18 Commits

Author SHA1 Message Date
xds
2452e5935f + targets 2025-11-20 12:37:06 +03:00
xds
195bdd83f0 filters for transactions;
update transactions when recurrent updated
2025-11-18 00:34:02 +03:00
42cbf30bd8 Update Dockerfile 2025-11-17 15:11:46 +03:00
5803fc208b Update Dockerfile 2025-11-17 15:10:40 +03:00
a79dbffe3f recurrents 2025-11-17 15:10:25 +03:00
9d7c385654 Merge pull request 'recurrents' (#2) from recurrents into main
Reviewed-on: #2
2025-11-17 15:03:32 +03:00
xds
12afd1f90e recurrents 2025-11-17 15:02:47 +03:00
xds
d0cae182b7 build 2025-10-31 19:28:59 +03:00
xds
0f02b53bc0 build 2025-10-31 18:46:16 +03:00
xds
b08ab909c8 build 2025-10-31 18:45:28 +03:00
xds
a65f46aff3 build 2025-10-31 18:33:11 +03:00
xds
036ad00795 build 2025-10-31 18:29:31 +03:00
xds
1c3605623e build 2025-10-31 18:24:12 +03:00
xds
aaa12fcb86 build 2025-10-31 17:53:39 +03:00
xds
cef82c483f build 2025-10-31 17:46:35 +03:00
xds
e83e3a2b65 build 2025-10-31 17:40:57 +03:00
xds
c68e6afb8a build 2025-10-31 17:38:48 +03:00
xds
10b7c730ad build 2025-10-31 17:33:49 +03:00
71 changed files with 1998 additions and 922 deletions

View File

@@ -1,20 +1,14 @@
FROM eclipse-temurin:17-jre AS runtime FROM eclipse-temurin:17-jre AS runtime
WORKDIR /app WORKDIR /app
# (Опционально) установить curl для healthcheck
USER root USER root
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
RUN groupadd --system --gid 1001 app && useradd --system --gid app --uid 1001 --shell /bin/bash --create-home app RUN groupadd --system --gid 1001 app && useradd --system --gid app --uid 1001 --shell /bin/bash --create-home app
RUN mkdir -p /app/static && chown -R app:app /app RUN mkdir -p /app/static && chown -R app:app /app
USER app USER app
# ❗ копируем jar, собранный локально
COPY build/libs/*.jar /app/app.jar
ENV JAVA_TOOL_OPTIONS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0" ENV JAVA_TOOL_OPTIONS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0"
EXPOSE 8080 EXPOSE 8080
HEALTHCHECK --interval=20s --timeout=3s --retries=3 CMD curl -fsS http://localhost:8080/actuator/health || exit 1 HEALTHCHECK --interval=20s --timeout=3s --retries=3 CMD curl -fsS http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["java", "-jar", "/app/app.jar"] ENTRYPOINT ["java","-jar","/app/luminic-space-v2.jar"]

View File

@@ -29,13 +29,16 @@ configurations {
repositories { repositories {
mavenCentral() mavenCentral()
maven { url = uri("https://jitpack.io") }
} }
dependencies { dependencies {
// Spring // Spring
implementation("org.springframework.boot:spring-boot-starter-cache") implementation("org.springframework.boot:spring-boot-starter-cache")
implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.springframework.boot:spring-boot-starter-security")
implementation ("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13")
@@ -53,6 +56,12 @@ dependencies {
implementation("commons-logging:commons-logging:1.3.4") implementation("commons-logging:commons-logging:1.3.4")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.17.0")
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("io.micrometer:micrometer-registry-prometheus")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
@@ -67,10 +76,11 @@ dependencies {
implementation("io.micrometer:micrometer-registry-prometheus") implementation("io.micrometer:micrometer-registry-prometheus")
implementation("org.telegram:telegrambots:6.9.7.1") // implementation("org.telegram:telegrambots:6.9.7.1")
implementation("org.telegram:telegrambots-spring-boot-starter:6.9.7.1") // implementation("org.telegram:telegrambots-spring-boot-starter:6.9.7.1")
implementation("com.opencsv:opencsv:5.10") implementation("com.opencsv:opencsv:5.10")
implementation("io.github.kotlin-telegram-bot.kotlin-telegram-bot:telegram:6.3.0")
compileOnly("org.projectlombok:lombok") compileOnly("org.projectlombok:lombok")
annotationProcessor("org.projectlombok:lombok") annotationProcessor("org.projectlombok:lombok")

Binary file not shown.

10
deploy.sh Executable file
View File

@@ -0,0 +1,10 @@
#!/bin/bash
./gradlew bootJar || exit 1
scp build/libs/luminic-space-v2.jar root@213.226.71.138:/root/luminic/space/back
ssh root@213.226.71.138 "
cd /root/luminic/space/back &&
docker compose up -d --build &&
docker restart back-app-1
"

View File

@@ -4,6 +4,9 @@ networks:
services: services:
app: app:
image: back-app
volumes:
- ./luminic-space-v2.jar:/app/luminic-space-v2.jar
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
@@ -11,7 +14,6 @@ services:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/luminic-space-db SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/luminic-space-db
SPRING_DATASOURCE_USERNAME: luminicspace SPRING_DATASOURCE_USERNAME: luminicspace
SPRING_DATASOURCE_PASSWORD: LS1q2w3e4r! SPRING_DATASOURCE_PASSWORD: LS1q2w3e4r!
MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE: health,info
ports: ports:
- "8089:8089" - "8089:8089"
restart: unless-stopped restart: unless-stopped

View File

@@ -1,8 +1,8 @@
package space.luminic.finance.api package space.luminic.finance.api
import org.apache.commons.codec.digest.DigestUtils.sha256 import kotlinx.serialization.json.Json
import org.apache.commons.codec.digest.HmacUtils.hmacSha256 import kotlinx.serialization.json.JsonBuilder
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.core.context.SecurityContextHolder
@@ -13,6 +13,7 @@ import space.luminic.finance.dtos.UserDTO.RegisterUserDTO
import space.luminic.finance.mappers.UserMapper.toDto import space.luminic.finance.mappers.UserMapper.toDto
import space.luminic.finance.mappers.UserMapper.toTelegramMap import space.luminic.finance.mappers.UserMapper.toTelegramMap
import space.luminic.finance.services.AuthService import space.luminic.finance.services.AuthService
import java.net.URLDecoder
import java.security.MessageDigest import java.security.MessageDigest
import java.time.Instant import java.time.Instant
import javax.crypto.Mac import javax.crypto.Mac
@@ -27,28 +28,83 @@ class AuthController(
private val logger = LoggerFactory.getLogger(javaClass) private val logger = LoggerFactory.getLogger(javaClass)
fun verifyTelegramAuth(data: Map<String, String>, botToken: String): Boolean { fun verifyTelegramAuth(
val hash = data["hash"] ?: return false loginData: Map<String, String>? = null, // from login widget
webAppInitData: String? = null
): Boolean {
val dataCheckString = data // --- LOGIN WIDGET CHECK ---
.filterKeys { it != "hash" } if (loginData != null) {
.toSortedMap() val hash = loginData["hash"]
.map { "${it.key}=${it.value}" } if (hash != null) {
.joinToString("\n") val dataCheckString = loginData
.filterKeys { it != "hash" }
.toSortedMap()
.map { "${it.key}=${it.value}" }
.joinToString("\n")
val secretKey = sha256(botToken) val secretKey = MessageDigest.getInstance("SHA-256")
val hmacHex = hmacSha256(secretKey, dataCheckString) .digest(botToken.toByteArray())
if (hmacHex != hash) return false val hmac = Mac.getInstance("HmacSHA256").apply {
init(SecretKeySpec(secretKey, "HmacSHA256"))
}.doFinal(dataCheckString.toByteArray())
.joinToString("") { "%02x".format(it) }
val authDate = data["auth_date"]?.toLongOrNull() ?: return false val authDate = loginData["auth_date"]?.toLongOrNull() ?: return false
val now = Instant.now().epochSecond if (Instant.now().epochSecond - authDate > 3600) return false
// Опционально — запрет старых ответов (например, старше 1 часа) if (hmac == hash) return true
val maxAgeSeconds = 3600 }
if (now - authDate > maxAgeSeconds) return false }
return true // --- WEBAPP CHECK ---
// --- WEBAPP CHECK ---
if (webAppInitData != null) {
// Разбираем query string корректно (учитывая '=' внутри значения)
val pairs: Map<String, String> = webAppInitData.split("&")
.mapNotNull { part ->
val idx = part.indexOf('=')
if (idx <= 0) return@mapNotNull null
val k = part.substring(0, idx)
val v = part.substring(idx + 1)
k to URLDecoder.decode(v, Charsets.UTF_8.name())
}.toMap()
val receivedHash = pairs["hash"] ?: return false
// Строка для подписи: все поля КРОМЕ hash, отсортированные по ключу, в формате key=value с \n
val dataCheckString = pairs
.filterKeys { it != "hash" }
.toSortedMap()
.entries
.joinToString("\n") { (k, v) -> "$k=$v" }
// ВАЖНО: secret_key = HMAC_SHA256(message=botToken, key="WebAppData")
val secretKeyBytes = Mac.getInstance("HmacSHA256").apply {
init(SecretKeySpec("WebAppData".toByteArray(Charsets.UTF_8), "HmacSHA256"))
}.doFinal(botToken.toByteArray(Charsets.UTF_8))
// hash = HMAC_SHA256(message=data_check_string, key=secret_key)
val calcHashHex = Mac.getInstance("HmacSHA256").apply {
init(SecretKeySpec(secretKeyBytes, "HmacSHA256"))
}.doFinal(dataCheckString.toByteArray(Charsets.UTF_8))
.joinToString("") { "%02x".format(it) }
// опциональная проверка свежести
val authDate = pairs["auth_date"]?.toLongOrNull() ?: return false
val now = Instant.now().epochSecond
val ttl = 6 * 3600L // например, 6 часов для WebApp
val skew = 300L // допускаем до 5 минут будущего/прошлого
val diff = now - authDate
if (diff > ttl || diff < -skew) return false
if (calcHashHex == receivedHash) return true
}
return false
return false
} }
private fun sha256(input: String): ByteArray = private fun sha256(input: String): ByteArray =
@@ -62,6 +118,7 @@ class AuthController(
return hashBytes.joinToString("") { "%02x".format(it) } return hashBytes.joinToString("") { "%02x".format(it) }
} }
@GetMapping("/test") @GetMapping("/test")
fun test(): String { fun test(): String {
val authentication = SecurityContextHolder.getContext().authentication val authentication = SecurityContextHolder.getContext().authentication
@@ -80,16 +137,45 @@ class AuthController(
return authService.register(request.username, request.password, request.firstName).toDto() return authService.register(request.username, request.password, request.firstName).toDto()
} }
private val json = Json { ignoreUnknownKeys = true }
@PostMapping("/tg-login") @PostMapping("/tg-login")
fun tgLogin(@RequestBody tgUser: UserDTO.TelegramAuthDTO): String { fun tgLogin(@RequestBody tgUser: UserDTO.TelegramAuthDTO): Map<String, String> {
val ok = verifyTelegramAuth(tgUser.toTelegramMap(), botToken) // println(tgUser.hash)
if (!ok) throw IllegalArgumentException("Invalid Telegram login") // println(botToken)
return authService.tgAuth(tgUser) if (tgUser.initData == null) {
if (verifyTelegramAuth(
loginData = tgUser.toTelegramMap(),
)
) {
return mapOf("token" to authService.tgAuth(tgUser))
} else throw IllegalArgumentException("Invalid Telegram login")
} else {
if (verifyTelegramAuth(webAppInitData = tgUser.initData)) {
val params = tgUser.initData.split("&").associate {
val (k, v) = it.split("=", limit = 2)
k to URLDecoder.decode(v, "UTF-8")
}
val userJson = params["user"] ?: error("No user data")
val jsonUser = json.decodeFromString<UserDTO.TelegramUserData>(userJson)
val newUser = UserDTO.TelegramAuthDTO(
jsonUser.id,
jsonUser.first_name,
jsonUser.last_name,
jsonUser.username,
jsonUser.photo_url,
null,
hash = tgUser.hash,
initData = null,
)
return mapOf("token" to authService.tgAuth(newUser))
} else throw IllegalArgumentException("Invalid Telegram login")
}
} }
@GetMapping("/me") @GetMapping("/me")
fun getMe(): UserDTO { fun getMe(): UserDTO {
logger.info("Get Me") logger.info("Get Me")

View File

@@ -1,19 +0,0 @@
package space.luminic.finance.api
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import space.luminic.finance.dtos.GoalDTO
import space.luminic.finance.mappers.GoalMapper.toDto
import space.luminic.finance.services.GoalService
@RestController
@RequestMapping("/spaces/{spaceId}/goals")
class GoalController(private val goalService: GoalService) {
@GetMapping
fun findAll(@PathVariable spaceId: Int): List<GoalDTO> {
return goalService.findAllBySpaceId(spaceId).map { it.toDto() }
}
}

View File

@@ -27,7 +27,7 @@ class SpaceController(
@GetMapping("/{spaceId}") @GetMapping("/{spaceId}")
fun getSpace(@PathVariable spaceId: Int): SpaceDTO { fun getSpace(@PathVariable spaceId: Int): SpaceDTO {
return spaceService.getSpace(spaceId).toDto() return spaceService.getSpace(spaceId, null).toDto()
} }
@PostMapping @PostMapping

View File

@@ -0,0 +1,19 @@
package space.luminic.finance.api
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import space.luminic.finance.dtos.TargetDTO
import space.luminic.finance.mappers.TargetMapper.toDto
import space.luminic.finance.services.TargetService
@RestController
@RequestMapping("/spaces/{spaceId}/targets")
class TargetController(private val targetService: TargetService) {
@GetMapping
fun findAll(@PathVariable spaceId: Int): List<TargetDTO> {
return targetService.findAllBySpaceId(spaceId).map { it.toDto() }
}
}

View File

@@ -21,9 +21,9 @@ class TransactionController (
){ ){
@GetMapping @PostMapping("/_search")
fun getTransactions(@PathVariable spaceId: Int) : List<TransactionDTO>{ fun getTransactions(@PathVariable spaceId: Int, @RequestBody filter: TransactionService.TransactionsFilter) : List<TransactionDTO>{
return transactionService.getTransactions(spaceId, TransactionService.TransactionsFilter(),"date", "DESC").map { it.toDto() } return transactionService.getTransactions(spaceId, filter,"date", "DESC").map { it.toDto() }
} }
@GetMapping("/{transactionId}") @GetMapping("/{transactionId}")

View File

@@ -50,7 +50,7 @@ class SecurityConfig(
@Bean @Bean
fun corsConfigurationSource(): CorsConfigurationSource { fun corsConfigurationSource(): CorsConfigurationSource {
val cors = CorsConfiguration().apply { val cors = CorsConfiguration().apply {
allowedOrigins = listOf("https://luminic.space", "http://localhost:5173") allowedOrigins = listOf("https://app.luminic.space", "http://localhost:5173")
allowedMethods = listOf("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH") allowedMethods = listOf("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
allowedHeaders = listOf("*") allowedHeaders = listOf("*")
allowCredentials = true allowCredentials = true

View File

@@ -1,36 +1,37 @@
package space.luminic.finance.dtos package space.luminic.finance.dtos
import space.luminic.finance.models.Goal import space.luminic.finance.models.Target
import space.luminic.finance.models.Goal.GoalType import space.luminic.finance.models.Target.TargetType
import space.luminic.finance.models.Transaction import space.luminic.finance.models.Transaction
import java.math.BigDecimal import java.math.BigDecimal
import java.time.Instant import java.time.Instant
import java.time.LocalDate import java.time.LocalDate
data class GoalDTO( data class TargetDTO(
val id: Int, val id: Int,
val type: GoalType, val type: TargetType,
val name: String, val name: String,
val description: String? = null, val description: String? = null,
val amount: BigDecimal, val amount: BigDecimal,
val currentAmount: BigDecimal,
val date: LocalDate, val date: LocalDate,
val components: List<Goal.GoalComponent>, val components: List<Target.TargetComponent>,
val transactions: List<Transaction>, val transactions: List<Transaction>,
val createdBy: UserDTO, val createdBy: UserDTO,
val createdAt: Instant, val createdAt: Instant,
val updatedBy: UserDTO? = null, val updatedBy: UserDTO? = null,
val updatedAt: Instant? = null, val updatedAt: Instant? = null,
) { ) {
data class CreateGoalDTO( data class CreateTargetDTO(
val type: GoalType, val type: TargetType,
val name: String, val name: String,
val description: String?, val description: String?,
val amount: BigDecimal, val amount: BigDecimal,
val date: LocalDate val date: LocalDate
) )
data class UpdateGoalDTO( data class UpdateTargetDTO(
val type: GoalType, val type: TargetType,
val name: String, val name: String,
val description: String?, val description: String?,
val amount: BigDecimal, val amount: BigDecimal,

View File

@@ -11,7 +11,7 @@ data class TransactionDTO(
var parentId: Int? = null, var parentId: Int? = null,
val type: TransactionType = TransactionType.EXPENSE, val type: TransactionType = TransactionType.EXPENSE,
val kind: TransactionKind = TransactionKind.INSTANT, val kind: TransactionKind = TransactionKind.INSTANT,
val category: CategoryDTO, val category: CategoryDTO? = null,
val comment: String, val comment: String,
val amount: BigDecimal, val amount: BigDecimal,
val fees: BigDecimal = BigDecimal.ZERO, val fees: BigDecimal = BigDecimal.ZERO,
@@ -23,17 +23,18 @@ data class TransactionDTO(
data class CreateTransactionDTO( data class CreateTransactionDTO(
val type: TransactionType = TransactionType.EXPENSE, val type: TransactionType = TransactionType.EXPENSE,
val kind: TransactionKind = TransactionKind.INSTANT, val kind: TransactionKind = TransactionKind.INSTANT,
val categoryId: Int, val categoryId: Int? = null,
val comment: String, val comment: String,
val amount: BigDecimal, val amount: BigDecimal,
val fees: BigDecimal = BigDecimal.ZERO, val fees: BigDecimal = BigDecimal.ZERO,
val date: LocalDate, val date: LocalDate,
val recurrentId: Int? = null
) )
data class UpdateTransactionDTO( data class UpdateTransactionDTO(
val type: TransactionType = TransactionType.EXPENSE, val type: TransactionType = TransactionType.EXPENSE,
val kind: TransactionKind = TransactionKind.INSTANT, val kind: TransactionKind = TransactionKind.INSTANT,
val categoryId: Int, val categoryId: Int? = null,
val comment: String, val comment: String,
val amount: BigDecimal, val amount: BigDecimal,
val fees: BigDecimal = BigDecimal.ZERO, val fees: BigDecimal = BigDecimal.ZERO,

View File

@@ -1,8 +1,8 @@
package space.luminic.finance.dtos package space.luminic.finance.dtos
import java.util.Date import kotlinx.serialization.Serializable
data class UserDTO ( data class UserDTO(
var id: Int, var id: Int,
val username: String, val username: String,
var firstName: String, var firstName: String,
@@ -13,12 +13,12 @@ data class UserDTO (
) { ) {
data class AuthUserDTO ( data class AuthUserDTO(
var username: String, var username: String,
var password: String, var password: String,
) )
data class RegisterUserDTO ( data class RegisterUserDTO(
var username: String, var username: String,
var firstName: String, var firstName: String,
var password: String, var password: String,
@@ -26,15 +26,27 @@ data class UserDTO (
) )
data class TelegramAuthDTO( data class TelegramAuthDTO(
val id: Long, val id: Long?,
val first_name: String?, val first_name: String?,
val last_name: String?, val last_name: String?,
val username: String?, val username: String?,
val photo_url: String?, val photo_url: String?,
val auth_date: Long, val auth_date: Long?,
val hash: String val hash: String,
val initData: String?,
) )
@Serializable
class TelegramUserData(
val id: Long,
val first_name: String,
val last_name: String? = null,
val username: String? = null,
val photo_url: String? = null,
)
} }

View File

@@ -1,22 +1,26 @@
package space.luminic.finance.mappers package space.luminic.finance.mappers
import space.luminic.finance.dtos.GoalDTO import space.luminic.finance.dtos.TargetDTO
import space.luminic.finance.mappers.UserMapper.toDto import space.luminic.finance.mappers.UserMapper.toDto
import space.luminic.finance.models.Goal import space.luminic.finance.models.Target
object GoalMapper { object TargetMapper {
fun Goal.toDto() = GoalDTO( fun Target.toDto() = TargetDTO(
id = this.id ?: throw IllegalArgumentException("Goal id is not provided"), id = this.id ?: throw IllegalArgumentException("Target id is not provided"),
type = this.type, type = this.type,
name = this.name, name = this.name,
description = this.description,
amount = this.amount, amount = this.amount,
currentAmount = this.currentAmount,
date = this.untilDate, date = this.untilDate,
components = this.components, components = this.components,
transactions = this.transactions, transactions = this.transactions,
createdBy = (this.createdBy ?: throw IllegalArgumentException("created by not provided")).toDto(), createdBy = (this.createdBy ?: throw IllegalArgumentException("created by not provided")).toDto(),
createdAt = this.createdAt ?: throw IllegalArgumentException("created at not provided"), createdAt = this.createdAt ?: throw IllegalArgumentException("created at not provided"),
updatedBy = this.updatedBy?.toDto() , updatedBy = this.updatedBy?.toDto(),
updatedAt = this.updatedAt updatedAt = this.updatedAt,
)
)
} }

View File

@@ -11,7 +11,7 @@ object TransactionMapper {
parentId = this.parent?.id, parentId = this.parent?.id,
type = this.type, type = this.type,
kind = this.kind, kind = this.kind,
category = this.category.toDto(), category = this.category?.toDto(),
comment = this.comment, comment = this.comment,
amount = this.amount, amount = this.amount,
fees = this.fees, fees = this.fees,

View File

@@ -0,0 +1,16 @@
package space.luminic.finance.models
data class State(
val user: User,
val state: StateCode = StateCode.AWAIT_SPACE_SELECT,
val data: Map<String, String> = mapOf()
) {
enum class StateCode {
AWAIT_SPACE_SELECT,
SPACE_SELECTED,
AWAIT_TRANSACTION,
}
}

View File

@@ -8,30 +8,30 @@ import java.math.BigDecimal
import java.time.Instant import java.time.Instant
import java.time.LocalDate import java.time.LocalDate
data class Goal( data class Target(
var id: Int? = null, var id: Int? = null,
val space: Space? = null, val space: Space? = null,
val type: GoalType, val type: TargetType,
val name: String, val name: String,
val description: String? = null, val description: String? = null,
val amount: BigDecimal, val amount: BigDecimal,
val components: List<GoalComponent> = emptyList(), val components: List<TargetComponent> = emptyList(),
val transactions: List<Transaction> = emptyList(), val transactions: List<Transaction> = emptyList(),
val untilDate: LocalDate, val untilDate: LocalDate,
@CreatedBy var createdBy: User? = null, var createdBy: User? = null,
@CreatedDate var createdAt: Instant? = null, var createdAt: Instant? = null,
@LastModifiedBy var updatedBy: User? = null, var updatedBy: User? = null,
@LastModifiedDate var updatedAt: Instant? = null, var updatedAt: Instant? = null,
) { ) {
var currentAmount: BigDecimal = { var currentAmount: BigDecimal = {
this.transactions.sumOf { it.amount } this.transactions.sumOf { it.amount }
} as BigDecimal } as BigDecimal
data class GoalComponent( data class TargetComponent(
val id: Int? = null, val id: Int? = null,
val name: String, val name: String,
val amount: BigDecimal, val amount: BigDecimal,
@@ -39,8 +39,9 @@ data class Goal(
val date: LocalDate = LocalDate.now(), val date: LocalDate = LocalDate.now(),
) )
enum class GoalType(val displayName: String, val icon: String) { enum class TargetType(val displayName: String, val icon: String) {
AUTO("Авто", "🏎️"), AUTO("Авто", "🏎️"),
LEISURE("Досуг", "💃"),
VACATION("Отпуск", "🏖️"), VACATION("Отпуск", "🏖️"),
GOODS("Покупка", "🛍️"), GOODS("Покупка", "🛍️"),
OTHER("Прочее", "💸") OTHER("Прочее", "💸")

View File

@@ -14,8 +14,8 @@ data class Transaction(
var parent: Transaction? = null, var parent: Transaction? = null,
val type: TransactionType = TransactionType.EXPENSE, val type: TransactionType = TransactionType.EXPENSE,
val kind: TransactionKind = TransactionKind.INSTANT, val kind: TransactionKind = TransactionKind.INSTANT,
val category: Category, val category: Category? = null,
val comment: String, var comment: String,
val amount: BigDecimal, val amount: BigDecimal,
val fees: BigDecimal = BigDecimal.ZERO, val fees: BigDecimal = BigDecimal.ZERO,
val date: LocalDate = LocalDate.now(), val date: LocalDate = LocalDate.now(),
@@ -25,6 +25,9 @@ data class Transaction(
@CreatedDate var createdAt: Instant? = null, @CreatedDate var createdAt: Instant? = null,
@LastModifiedBy var updatedBy: User? = null, @LastModifiedBy var updatedBy: User? = null,
@LastModifiedDate var updatedAt: Instant? = null, @LastModifiedDate var updatedAt: Instant? = null,
val tgChatId: Long? = null,
val tgMessageId: Long? = null,
val recurrentId: Int? = null,
) { ) {

View File

@@ -0,0 +1,9 @@
package space.luminic.finance.repos
import space.luminic.finance.models.State
interface BotRepo {
fun getState(tgUserId: Long): State?
fun setState(userId: Int, stateCode: State.StateCode, stateData: Map<String, String>)
fun clearState(userId: Int)
}

View File

@@ -0,0 +1,114 @@
package space.luminic.finance.repos
import org.springframework.jdbc.core.ResultSetExtractor
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
import org.springframework.stereotype.Repository
import space.luminic.finance.models.State
import space.luminic.finance.models.User
@Repository
class BotRepoImpl(
private val jdbcTemplate: NamedParameterJdbcTemplate,
) : BotRepo {
override fun getState(tgUserId: Long): State? {
val sql = """
select
bs.user_id as bs_user_id,
u.username as u_username,
u.first_name as u_first_name,
bs.state_code as bs_state_code,
bsd.data_code as bs_data_code,
bsd.data_value as bs_data_value
from finance.bot_states bs
join finance.users u on u.id = bs.user_id
left join finance.bot_states_data bsd on bsd.user_id = bs.user_id
where u.tg_id = :user_id
""".trimIndent()
val params = mapOf("user_id" to tgUserId)
return jdbcTemplate.query(sql, params, ResultSetExtractor { rs ->
var user: User? = null
var stateCode: State.StateCode? = null
val data = mutableMapOf<String, String>()
while (rs.next()) {
if (user == null) {
user = User(
id = rs.getInt("bs_user_id"),
username = rs.getString("u_username"),
firstName = rs.getString("u_first_name")
)
stateCode = rs.getString("bs_state_code")?.let { raw ->
runCatching { State.StateCode.valueOf(raw) }
.getOrElse { State.StateCode.AWAIT_SPACE_SELECT }
}
}
val code = rs.getString("bs_data_code")
val value = rs.getString("bs_data_value")
if (code != null && value != null) {
data[code] = value
}
}
user?.let {
State(
user = it,
state = stateCode ?: State.StateCode.AWAIT_SPACE_SELECT,
data = data.toMap()
)
}
})
}
override fun setState(
userId: Int,
stateCode: State.StateCode,
stateData: Map<String, String>
) {
// 1) UPSERT state (по user_id)
val upsertStateSql = """
INSERT INTO finance.bot_states (user_id, state_code)
VALUES (:user_id, :state_code)
ON CONFLICT (user_id) DO UPDATE
SET state_code = EXCLUDED.state_code
""".trimIndent()
jdbcTemplate.update(
upsertStateSql,
mapOf(
"user_id" to userId,
// если в БД enum — чаще всего ок передать name(); если колонка TEXT/VARCHAR — тоже ок
"state_code" to stateCode.name
)
)
// 2) Обновление data: вариант A — апсерты (рекомендуется)
if (stateData.isNotEmpty()) {
val upsertDataSql = """
INSERT INTO finance.bot_states_data (user_id, data_code, data_value)
VALUES (:user_id, :data_code, :data_value)
ON CONFLICT (user_id, data_code) DO UPDATE
SET data_value = EXCLUDED.data_value
""".trimIndent()
val batch = stateData.map { (code, value) ->
mapOf(
"user_id" to userId,
"data_code" to code,
"data_value" to value
)
}.toTypedArray()
jdbcTemplate.batchUpdate(upsertDataSql, batch)
}
// Если тебе принципиально "перезаписывать" состояние данных (вариант B):
// np.update("DELETE FROM finance.bot_states_data WHERE user_id = :user_id", mapOf("user_id" to userId))
// затем обычный INSERT batch без ON CONFLICT.
}
override fun clearState(userId: Int) {
TODO("Not yet implemented")
}
}

View File

@@ -24,7 +24,7 @@ class CategoryRepoImpl(
} }
override fun findBySpaceId(spaceId: Int): List<Category> { override fun findBySpaceId(spaceId: Int): List<Category> {
val query = "select * from finance.categories where space_id = :space_id order by id" val query = "select * from finance.categories where space_id = :space_id order by name"
val params = mapOf("space_id" to spaceId) val params = mapOf("space_id" to spaceId)
return jdbcTemplate.query(query, params, categoryRowMapper()) return jdbcTemplate.query(query, params, categoryRowMapper())
} }

View File

@@ -1,20 +0,0 @@
package space.luminic.finance.repos
import space.luminic.finance.models.Goal
interface GoalRepo {
fun findAllBySpaceId(spaceId: Int) : List<Goal>
fun findBySpaceIdAndId(spaceId: Int, id: Int) : Goal?
fun create(goal: Goal, createdById: Int): Int
fun update(goal: Goal, updatedById: Int)
fun delete(spaceId: Int, id: Int)
fun getComponents(spaceId: Int, goalId: Int): List<Goal.GoalComponent>
fun getComponent(spaceId: Int, goalId: Int, id: Int): Goal.GoalComponent?
fun createComponent(goalId: Int, component: Goal.GoalComponent, createdById: Int): Int
fun updateComponent(goalId: Int, componentId: Int, component: Goal.GoalComponent, updatedById: Int)
fun deleteComponent(goalId: Int, componentId: Int)
fun assignTransaction(goalId: Int, transactionId: Int)
fun refuseTransaction(goalId: Int, transactionId: Int)
}

View File

@@ -5,7 +5,9 @@ import space.luminic.finance.models.RecurrentOperation
interface RecurrentOperationRepo { interface RecurrentOperationRepo {
fun findAllBySpaceId(spaceId: Int): List<RecurrentOperation> fun findAllBySpaceId(spaceId: Int): List<RecurrentOperation>
fun findBySpaceIdAndId(spaceId: Int, id: Int): RecurrentOperation? fun findBySpaceIdAndId(spaceId: Int, id: Int): RecurrentOperation?
fun findByDate( date: Int): List<RecurrentOperation>
fun create(operation: RecurrentOperation, createdById: Int): Int fun create(operation: RecurrentOperation, createdById: Int): Int
fun update(operation: RecurrentOperation, updatedById: Int) fun update(operation: RecurrentOperation, updatedById: Int)
fun delete(id: Int) fun delete(id: Int)
fun findRecurrentsToCreate(spaceId: Int): List<RecurrentOperation>
} }

View File

@@ -74,7 +74,7 @@ class RecurrentOperationRepoImpl(
join finance.categories c on ro.category_id = c.id join finance.categories c on ro.category_id = c.id
join finance.users r_created_by on ro.created_by_id = r_created_by.id join finance.users r_created_by on ro.created_by_id = r_created_by.id
where ro.space_id = :spaceId where ro.space_id = :spaceId
order by ro.date order by ro.date, ro.id
""".trimIndent() """.trimIndent()
val params = mapOf("spaceId" to spaceId) val params = mapOf("spaceId" to spaceId)
return jdbcTemplate.query(sql, params, operationRowMapper()) return jdbcTemplate.query(sql, params, operationRowMapper())
@@ -115,6 +115,40 @@ class RecurrentOperationRepoImpl(
return jdbcTemplate.query(sql, params, operationRowMapper()).firstOrNull() return jdbcTemplate.query(sql, params, operationRowMapper()).firstOrNull()
} }
override fun findByDate(
date: Int
): List<RecurrentOperation> {
val sql = """
select
ro.id as r_id,
ro.space_id AS r_space_id,
s.name AS s_name,
s.owner_id as s_owner_id,
su.username as su_username,
su.first_name AS su_first_name,
ro.category_id as r_category_id,
c.type AS c_type,
c.name AS c_name,
c.description AS c_description,
c.icon AS c_icon,
ro.name AS r_name,
ro.amount AS r_amount,
ro.date AS r_date,
ro.created_by_id as r_created_by_id,
r_created_by.username as r_created_by_username,
r_created_by.first_name as r_created_by_first_name,
ro.created_at as r_created_at
from finance.recurrent_operations ro
join finance.spaces s on ro.space_id = s.id
join finance.users su on s.owner_id = su.id
join finance.categories c on ro.category_id = c.id
join finance.users r_created_by on ro.created_by_id = r_created_by.id
where ro.date = :date
""".trimIndent()
val params = mapOf( "date" to date)
return jdbcTemplate.query(sql, params, operationRowMapper())
}
override fun create(operation: RecurrentOperation, createdById: Int): Int { override fun create(operation: RecurrentOperation, createdById: Int): Int {
val sql = """ val sql = """
insert into finance.recurrent_operations ( insert into finance.recurrent_operations (
@@ -175,4 +209,13 @@ class RecurrentOperationRepoImpl(
val params = mapOf("id" to id) val params = mapOf("id" to id)
jdbcTemplate.update(sql, params) jdbcTemplate.update(sql, params)
} }
override fun findRecurrentsToCreate(spaceId: Int): List<RecurrentOperation> {
val sql = """
select * from finance.transactions where space_id = :spaceId and t.date >
""".trimIndent()
TODO("Not ready")
}
} }

View File

@@ -0,0 +1,20 @@
package space.luminic.finance.repos
import space.luminic.finance.models.Target
interface TargetRepo {
fun findAllBySpaceId(spaceId: Int) : List<Target>
fun findBySpaceIdAndId(spaceId: Int, id: Int) : Target?
fun create(target: Target, createdById: Int): Int
fun update(target: Target, updatedById: Int)
fun delete(spaceId: Int, id: Int)
fun getComponents(spaceId: Int, targetId: Int): List<Target.TargetComponent>
fun getComponent(spaceId: Int, targetId: Int, id: Int): Target.TargetComponent?
fun createComponent(targetId: Int, component: Target.TargetComponent, createdById: Int): Int
fun updateComponent(targetId: Int, componentId: Int, component: Target.TargetComponent, updatedById: Int)
fun deleteComponent(targetId: Int, componentId: Int)
fun assignTransaction(targetId: Int, transactionId: Int)
fun refuseTransaction(targetId: Int, transactionId: Int)
}

View File

@@ -3,17 +3,17 @@ package space.luminic.finance.repos
import org.springframework.jdbc.core.RowMapper import org.springframework.jdbc.core.RowMapper
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
import space.luminic.finance.models.Goal import space.luminic.finance.models.Target
import space.luminic.finance.models.User import space.luminic.finance.models.User
@Repository @Repository
class GoalRepoImpl( class TargetRepoImpl(
private val jdbcTemplate: NamedParameterJdbcTemplate private val jdbcTemplate: NamedParameterJdbcTemplate
) : GoalRepo { ) : TargetRepo {
private val goalRowMapper = RowMapper { rs, _ -> private val targetRowMapper = RowMapper { rs, _ ->
Goal( Target(
id = rs.getInt("g_id"), id = rs.getInt("g_id"),
type = Goal.GoalType.valueOf(rs.getString("g_type")), type = Target.TargetType.valueOf(rs.getString("g_type")),
name = rs.getString("g_name"), name = rs.getString("g_name"),
description = rs.getString("g_description"), description = rs.getString("g_description"),
amount = rs.getBigDecimal("g_amount"), amount = rs.getBigDecimal("g_amount"),
@@ -30,7 +30,7 @@ class GoalRepoImpl(
} }
private val componentRowMapper = RowMapper { rs, _ -> private val componentRowMapper = RowMapper { rs, _ ->
Goal.GoalComponent( Target.TargetComponent(
id = rs.getInt("gc_id"), id = rs.getInt("gc_id"),
name = rs.getString("gc_name"), name = rs.getString("gc_name"),
amount = rs.getBigDecimal("gc_amount"), amount = rs.getBigDecimal("gc_amount"),
@@ -39,7 +39,7 @@ class GoalRepoImpl(
) )
} }
override fun findAllBySpaceId(spaceId: Int): List<Goal> { override fun findAllBySpaceId(spaceId: Int): List<Target> {
val sql = """ val sql = """
select select
g.id as g_id, g.id as g_id,
@@ -51,7 +51,7 @@ class GoalRepoImpl(
created_by.username as created_by_username, created_by.username as created_by_username,
created_by.first_name as created_by_first_name, created_by.first_name as created_by_first_name,
g.created_at as g_created_at g.created_at as g_created_at
from finance.goals g from finance.targets g
join finance.users created_by on g.created_by_id = created_by.id join finance.users created_by on g.created_by_id = created_by.id
where g.space_id = :spaceId where g.space_id = :spaceId
@@ -60,10 +60,10 @@ class GoalRepoImpl(
val params = mapOf( val params = mapOf(
"space_id" to spaceId, "space_id" to spaceId,
) )
return jdbcTemplate.query(sql, params, goalRowMapper) return jdbcTemplate.query(sql, params, targetRowMapper)
} }
override fun findBySpaceIdAndId(spaceId: Int, id: Int): Goal? { override fun findBySpaceIdAndId(spaceId: Int, id: Int): Target? {
val sql = """ val sql = """
select select
g.id as g_id, g.id as g_id,
@@ -75,7 +75,7 @@ class GoalRepoImpl(
created_by.username as created_by_username, created_by.username as created_by_username,
created_by.first_name as created_by_first_name, created_by.first_name as created_by_first_name,
g.created_at as g_created_at g.created_at as g_created_at
from finance.goals g from finance.targets g
join finance.users created_by on g.created_by_id = created_by.id join finance.users created_by on g.created_by_id = created_by.id
where g.space_id = :spaceId and g.id = :id where g.space_id = :spaceId and g.id = :id
@@ -85,12 +85,12 @@ class GoalRepoImpl(
"space_id" to spaceId, "space_id" to spaceId,
"id" to id, "id" to id,
) )
return jdbcTemplate.query(sql, params, goalRowMapper).firstOrNull() return jdbcTemplate.query(sql, params, targetRowMapper).firstOrNull()
} }
override fun create(goal: Goal, createdById: Int): Int { override fun create(target: Target, createdById: Int): Int {
val sql = """ val sql = """
insert into finance.goals( insert into finance.targets(
type, type,
name, name,
description, description,
@@ -108,19 +108,19 @@ class GoalRepoImpl(
returning id returning id
""".trimIndent() """.trimIndent()
val params = mapOf( val params = mapOf(
"type" to goal.type, "type" to target.type,
"name" to goal.name, "name" to target.name,
"description" to goal.description, "description" to target.description,
"amount" to goal.amount, "amount" to target.amount,
"until_date" to goal.untilDate, "until_date" to target.untilDate,
"created_by_id" to createdById "created_by_id" to createdById
) )
return jdbcTemplate.queryForObject(sql, params, Int::class.java)!! return jdbcTemplate.queryForObject(sql, params, Int::class.java)!!
} }
override fun update(goal: Goal, updatedById: Int) { override fun update(target: Target, updatedById: Int) {
val sql = """ val sql = """
update finance.goals set update finance.targets set
type = :type, type = :type,
name = :name, name = :name,
description = :description, description = :description,
@@ -131,12 +131,12 @@ class GoalRepoImpl(
where id = :id where id = :id
""".trimIndent() """.trimIndent()
val params = mapOf( val params = mapOf(
"id" to goal.id, "id" to target.id,
"type" to goal.type.name, "type" to target.type.name,
"name" to goal.name, "name" to target.name,
"description" to goal.description, "description" to target.description,
"amount" to goal.amount, "amount" to target.amount,
"until_date" to goal.untilDate, "until_date" to target.untilDate,
"updated_by_id" to updatedById "updated_by_id" to updatedById
) )
@@ -145,7 +145,7 @@ class GoalRepoImpl(
override fun delete(spaceId: Int, id: Int) { override fun delete(spaceId: Int, id: Int) {
val sql = """ val sql = """
delete from finance.goals where id = :id delete from finance.targets where id = :id
""".trimIndent() """.trimIndent()
val params = mapOf( val params = mapOf(
@@ -156,8 +156,8 @@ class GoalRepoImpl(
override fun getComponents( override fun getComponents(
spaceId: Int, spaceId: Int,
goalId: Int targetId: Int
): List<Goal.GoalComponent> { ): List<Target.TargetComponent> {
val sql = """ val sql = """
select select
gc.id as gc_id, gc.id as gc_id,
@@ -165,11 +165,11 @@ class GoalRepoImpl(
gc.amount as gc_amount, gc.amount as gc_amount,
gc.is_done as gc_is_done, gc.is_done as gc_is_done,
gc.date as gc_date gc.date as gc_date
from finance.goals_components gc from finance.targets_components gc
where gc.goal_id = :goal_id where gc.target_id = :target_id
""".trimIndent() """.trimIndent()
val params = mapOf( val params = mapOf(
"goal_id" to goalId "target_id" to targetId
) )
return jdbcTemplate.query(sql, params, componentRowMapper) return jdbcTemplate.query(sql, params, componentRowMapper)
@@ -177,9 +177,9 @@ class GoalRepoImpl(
override fun getComponent( override fun getComponent(
spaceId: Int, spaceId: Int,
goalId: Int, targetId: Int,
id: Int id: Int
): Goal.GoalComponent? { ): Target.TargetComponent? {
val sql = """ val sql = """
select select
gc.id as gc_id, gc.id as gc_id,
@@ -187,27 +187,27 @@ class GoalRepoImpl(
gc.amount as gc_amount, gc.amount as gc_amount,
gc.is_done as gc_is_done, gc.is_done as gc_is_done,
gc.date as gc_date gc.date as gc_date
from finance.goals_components gc from finance.targets_components gc
where gc.goal_id = :goal_id and gc.id = :id where gc.target_id = :target_id and gc.id = :id
""".trimIndent() """.trimIndent()
val params = mapOf( val params = mapOf(
"goal_id" to goalId, "target_id" to targetId,
"id" to id "id" to id
) )
return jdbcTemplate.query(sql, params, componentRowMapper).firstOrNull() return jdbcTemplate.query(sql, params, componentRowMapper).firstOrNull()
} }
override fun createComponent(goalId: Int, component: Goal.GoalComponent, createdById: Int): Int { override fun createComponent(targetId: Int, component: Target.TargetComponent, createdById: Int): Int {
val sql = """ val sql = """
insert into finance.goals_components( insert into finance.targets_components(
goal_id, target_id,
name, name,
amount, amount,
is_done, is_done,
date, date,
created_by_id created_by_id
) values ( ) values (
:goal_id, :target_id,
:name, :name,
:amount, :amount,
:is_done, :is_done,
@@ -216,7 +216,7 @@ class GoalRepoImpl(
returning id returning id
""".trimIndent() """.trimIndent()
val params = mapOf( val params = mapOf(
"goal_id" to goalId, "target_id" to targetId,
"name" to component.name, "name" to component.name,
"amount" to component.amount, "amount" to component.amount,
"is_done" to component.isDone, "is_done" to component.isDone,
@@ -226,17 +226,17 @@ class GoalRepoImpl(
return jdbcTemplate.queryForObject(sql, params, Int::class.java)!! return jdbcTemplate.queryForObject(sql, params, Int::class.java)!!
} }
override fun updateComponent(goalId: Int, componentId: Int, component: Goal.GoalComponent, updatedById: Int) { override fun updateComponent(targetId: Int, componentId: Int, component: Target.TargetComponent, updatedById: Int) {
val sql = """ val sql = """
update finance.goals_components set update finance.targets_components set
name = :name, name = :name,
amount = :amount, amount = :amount,
is_done = :is_done, is_done = :is_done,
updated_by_id = :updated_by_id updated_by_id = :updated_by_id
where goal_id = :goalId and id = :componentId where target_id = :targetId and id = :componentId
""".trimIndent() """.trimIndent()
val params = mapOf( val params = mapOf(
"goalId" to goalId, "targetId" to targetId,
"componentId" to componentId, "componentId" to componentId,
"name" to component.name, "name" to component.name,
"amount" to component.amount, "amount" to component.amount,
@@ -247,35 +247,35 @@ class GoalRepoImpl(
jdbcTemplate.update(sql, params) jdbcTemplate.update(sql, params)
} }
override fun deleteComponent(goalId: Int, componentId: Int) { override fun deleteComponent(targetId: Int, componentId: Int) {
val sql = """ val sql = """
delete from finance.goals_components where goal_id = :goalId and id = :componentId delete from finance.targets_components where target_id = :targetId and id = :componentId
""".trimIndent() """.trimIndent()
val params = mapOf( val params = mapOf(
"goalId" to goalId, "targetId" to targetId,
"componentId" to componentId "componentId" to componentId
) )
jdbcTemplate.update(sql, params) jdbcTemplate.update(sql, params)
} }
override fun assignTransaction(goalId: Int, transactionId: Int) { override fun assignTransaction(targetId: Int, transactionId: Int) {
val sql = """ val sql = """
insert into finance.goals_transactions(goal_id, transactions_id) insert into finance.targets_transactions(target_id, transactions_id)
values (:goal_id, :transaction_id) values (:targetId, :transaction_id)
""".trimIndent() """.trimIndent()
val params = mapOf( val params = mapOf(
"goal_id" to goalId, "targetId" to targetId,
"transaction_id" to transactionId "transaction_id" to transactionId
) )
jdbcTemplate.update(sql, params) jdbcTemplate.update(sql, params)
} }
override fun refuseTransaction(goalId: Int, transactionId: Int) { override fun refuseTransaction(targetId: Int, transactionId: Int) {
val sql = """ val sql = """
delete from finance.goals_transactions where goal_id = :goalId and transactions_id = :transactionId delete from finance.targets_transactions where target_id = :goalId and transactions_id = :transactionId
""".trimIndent() """.trimIndent()
val params = mapOf( val params = mapOf(
"goal_id" to goalId, "target_id" to targetId,
"transaction_id" to transactionId "transaction_id" to transactionId
) )
jdbcTemplate.update(sql, params) jdbcTemplate.update(sql, params)

View File

@@ -64,8 +64,8 @@ class TokenRepoImpl(
update finance.tokens set status = :status where token = :token update finance.tokens set status = :status where token = :token
""".trimIndent() """.trimIndent()
val params = mapOf( val params = mapOf(
"token" to token, "token" to token.token,
"status" to token.status "status" to token.status.name
) )
jdbcTemplate.update(sql, params) jdbcTemplate.update(sql, params)
return token return token

View File

@@ -1,12 +1,19 @@
package space.luminic.finance.repos package space.luminic.finance.repos
import space.luminic.finance.models.Transaction import space.luminic.finance.models.Transaction
import space.luminic.finance.services.TransactionService
interface TransactionRepo { interface TransactionRepo {
fun findAllBySpaceId(spaceId: Int): List<Transaction> fun findAllBySpaceId(spaceId: Int, filters: TransactionService.TransactionsFilter): List<Transaction>
fun findBySpaceIdAndId(spaceId: Int, id: Int): Transaction? fun findBySpaceIdAndId(spaceId: Int, id: Int): Transaction?
fun findBySpaceIdAndRecurrentId(spaceId: Int, recurrentId: Int): List<Transaction>
fun create(transaction: Transaction, userId: Int): Int fun create(transaction: Transaction, userId: Int): Int
fun createBatch(transactions: List<Transaction>, userId: Int)
fun update(transaction: Transaction): Int fun update(transaction: Transaction): Int
fun updateBatch(transactions: List<Transaction>, userId: Int)
fun delete(transactionId: Int) fun delete(transactionId: Int)
fun deleteByRecurrentId(spaceId: Int, recurrentId: Int)
fun setCategory(txId:Int, categoryId: Int)
} }

View File

@@ -6,6 +6,9 @@ import org.springframework.stereotype.Repository
import space.luminic.finance.models.Category import space.luminic.finance.models.Category
import space.luminic.finance.models.Transaction import space.luminic.finance.models.Transaction
import space.luminic.finance.models.User import space.luminic.finance.models.User
import space.luminic.finance.services.TransactionService
import java.time.LocalDate
import java.time.LocalDateTime
@Repository @Repository
class TransactionRepoImpl( class TransactionRepoImpl(
@@ -13,21 +16,26 @@ class TransactionRepoImpl(
) : TransactionRepo { ) : TransactionRepo {
private fun transactionRowMapper() = RowMapper { rs, _ -> private fun transactionRowMapper() = RowMapper { rs, _ ->
val category = if (rs.getString("c_id") == null) null else Category(
id = rs.getInt("c_id"),
type = Category.CategoryType.valueOf(rs.getString("c_type")),
name = rs.getString(("c_name")),
description = rs.getString(("c_description")),
icon = rs.getString("c_icon"),
isDeleted = rs.getBoolean(("c_is_deleted")),
createdAt = rs.getTimestamp("c_created_at").toInstant(),
updatedAt = rs.getTimestamp("c_updated_at")?.toInstant(),
)
val parent = if (rs.getInt("t_parent_id") != 0) findBySpaceIdAndId(
spaceId = rs.getInt("t_space_id"),
rs.getInt("t_parent_id")
) else null
Transaction( Transaction(
id = rs.getInt("t_id"), id = rs.getInt("t_id"),
parent = findBySpaceIdAndId(spaceId = rs.getInt("t_space_id"), rs.getInt("t_parent_id")), parent = parent,
type = Transaction.TransactionType.valueOf(rs.getString("t_type")), type = Transaction.TransactionType.valueOf(rs.getString("t_type")),
kind = Transaction.TransactionKind.valueOf(rs.getString("t_kind")), kind = Transaction.TransactionKind.valueOf(rs.getString("t_kind")),
category = Category( category = category,
id = rs.getInt("c_id"),
type = Category.CategoryType.valueOf(rs.getString("c_type")),
name = rs.getString(("c_name")),
description = rs.getString(("c_description")),
icon = rs.getString("c_icon"),
isDeleted = rs.getBoolean(("c_is_deleted")),
createdAt = rs.getTimestamp("c_created_at").toInstant(),
updatedAt = rs.getTimestamp("c_updated_at").toInstant(),
),
comment = rs.getString("t_comment"), comment = rs.getString("t_comment"),
amount = rs.getBigDecimal("t_amount"), amount = rs.getBigDecimal("t_amount"),
fees = rs.getBigDecimal("t_fees"), fees = rs.getBigDecimal("t_fees"),
@@ -41,11 +49,14 @@ class TransactionRepoImpl(
), ),
createdAt = rs.getTimestamp("t_created_at").toInstant(), createdAt = rs.getTimestamp("t_created_at").toInstant(),
updatedAt = rs.getTimestamp("t_updated_at").toInstant(), updatedAt = rs.getTimestamp("t_updated_at").toInstant(),
tgChatId = rs.getLong("tg_chat_id"),
tgMessageId = rs.getLong("tg_message_id"),
recurrentId = rs.getInt("t_recurrent_id"),
) )
} }
override fun findAllBySpaceId(spaceId: Int): List<Transaction> { override fun findAllBySpaceId(spaceId: Int, filters: TransactionService.TransactionsFilter): List<Transaction> {
val sql = """ var sql = """
SELECT SELECT
t.id AS t_id, t.id AS t_id,
t.parent_id AS t_parent_id, t.parent_id AS t_parent_id,
@@ -70,17 +81,50 @@ class TransactionRepoImpl(
c.updated_at AS c_updated_at, c.updated_at AS c_updated_at,
u.id AS u_id, u.id AS u_id,
u.username AS u_username, u.username AS u_username,
u.first_name AS u_first_name u.first_name AS u_first_name,
t.tg_chat_id AS tg_chat_id,
t.tg_message_id AS tg_message_id,
t.recurrent_id AS t_recurrent_id
FROM finance.transactions t FROM finance.transactions t
JOIN finance.categories c ON t.category_id = c.id LEFT JOIN finance.categories c ON t.category_id = c.id
JOIN finance.users u ON u.id = t.created_by_id JOIN finance.users u ON u.id = t.created_by_id
WHERE t.space_id = :spaceId and t.is_deleted = false WHERE t.space_id = :spaceId and t.is_deleted = false
ORDER BY t.date
""".trimIndent() """.trimIndent()
val params = mapOf( val params = mutableMapOf<String, Any?>(
"spaceId" to spaceId, "spaceId" to spaceId,
"offset" to filters.offset,
"limit" to filters.limit,
) )
filters.type?.let {
sql += " AND t.type = :type"
params.put("type", it.name)
}
filters.kind?.let {
sql += " AND t.kind = :kind"
params.put("kind", it.name)
}
filters.isDone?.let {
sql += " AND t.is_done = :isDone"
params.put("isDone", it)
}
filters.dateFrom?.let {
sql += " AND t.date >= :dateFrom"
params.put("dateFrom", it)
} ?: {
sql += " AND t.date >= :dateFrom"
params.put("dateFrom", LocalDate.now().minusMonths(1))
}
filters.dateTo?.let {
sql += " AND t.date <= :dateTo"
params.put("dateTo", it)
}
sql += """
ORDER BY t.date, t.id
OFFSET :offset ROWS
FETCH FIRST :limit ROWS ONLY"""
return jdbcTemplate.query(sql, params, transactionRowMapper()) return jdbcTemplate.query(sql, params, transactionRowMapper())
} }
@@ -99,6 +143,8 @@ class TransactionRepoImpl(
t.is_done AS t_is_done, t.is_done AS t_is_done,
t.created_at AS t_created_at, t.created_at AS t_created_at,
t.updated_at AS t_updated_at, t.updated_at AS t_updated_at,
t.tg_chat_id AS tg_chat_id,
t.tg_message_id AS tg_message_id,
c.id AS c_id, c.id AS c_id,
c.type AS c_type, c.type AS c_type,
c.name AS c_name, c.name AS c_name,
@@ -109,9 +155,10 @@ class TransactionRepoImpl(
c.updated_at AS c_updated_at, c.updated_at AS c_updated_at,
u.id AS u_id, u.id AS u_id,
u.username AS u_username, u.username AS u_username,
u.first_name AS u_first_name u.first_name AS u_first_name,
t.recurrent_id AS t_recurrent_id
FROM finance.transactions t FROM finance.transactions t
JOIN finance.categories c ON t.category_id = c.id LEFT JOIN finance.categories c ON t.category_id = c.id
JOIN finance.users u ON u.id = t.created_by_id JOIN finance.users u ON u.id = t.created_by_id
WHERE t.space_id = :spaceId and t.id = :id and t.is_deleted = false""".trimMargin() WHERE t.space_id = :spaceId and t.id = :id and t.is_deleted = false""".trimMargin()
val params = mapOf( val params = mapOf(
@@ -121,9 +168,52 @@ class TransactionRepoImpl(
return jdbcTemplate.query(sql, params, transactionRowMapper()).firstOrNull() return jdbcTemplate.query(sql, params, transactionRowMapper()).firstOrNull()
} }
override fun findBySpaceIdAndRecurrentId(
spaceId: Int,
recurrentId: Int
): List<Transaction> {
val sql = """SELECT
t.id AS t_id,
t.parent_id AS t_parent_id,
t.space_id AS t_space_id,
t.type AS t_type,
t.kind AS t_kind,
t.comment AS t_comment,
t.amount AS t_amount,
t.fees AS t_fees,
t.date AS t_date,
t.is_deleted AS t_is_deleted,
t.is_done AS t_is_done,
t.created_at AS t_created_at,
t.updated_at AS t_updated_at,
t.tg_chat_id AS tg_chat_id,
t.tg_message_id AS tg_message_id,
c.id AS c_id,
c.type AS c_type,
c.name AS c_name,
c.description AS c_description,
c.icon AS c_icon,
c.is_deleted AS c_is_deleted,
c.created_at AS c_created_at,
c.updated_at AS c_updated_at,
u.id AS u_id,
u.username AS u_username,
u.first_name AS u_first_name,
t.recurrent_id AS t_recurrent_id
FROM finance.transactions t
LEFT JOIN finance.categories c ON t.category_id = c.id
JOIN finance.users u ON u.id = t.created_by_id
WHERE t.space_id = :spaceId and t.recurrent_id = :recurrentId and t.is_deleted = false""".trimMargin()
val params = mapOf(
"spaceId" to spaceId,
"recurrentId" to recurrentId,
)
return jdbcTemplate.query(sql, params, transactionRowMapper())
}
override fun create(transaction: Transaction, userId: Int): Int { override fun create(transaction: Transaction, userId: Int): Int {
val sql = """ val sql = """
INSERT INTO finance.transactions (space_id, parent_id, type, kind, category_id, comment, amount, fees, date, is_deleted, is_done, created_by_id) VALUES ( INSERT INTO finance.transactions (space_id, parent_id, type, kind, category_id, comment, amount, fees, date, is_deleted, is_done, created_by_id, tg_chat_id, tg_message_id, recurrent_id) VALUES (
:spaceId, :spaceId,
:parentId, :parentId,
:type, :type,
@@ -135,7 +225,10 @@ class TransactionRepoImpl(
:date, :date,
:is_deleted, :is_deleted,
:is_done, :is_done,
:createdById) :createdById,
:tgChatId,
:tgMessageId,
:recurrentId)
returning id returning id
""".trimIndent() """.trimIndent()
val params = mapOf( val params = mapOf(
@@ -143,29 +236,58 @@ class TransactionRepoImpl(
"parentId" to transaction.parent?.id, "parentId" to transaction.parent?.id,
"type" to transaction.type.name, "type" to transaction.type.name,
"kind" to transaction.kind.name, "kind" to transaction.kind.name,
"categoryId" to transaction.category.id, "categoryId" to transaction.category?.id,
"comment" to transaction.comment, "comment" to transaction.comment,
"amount" to transaction.amount, "amount" to transaction.amount,
"fees" to transaction.fees, "fees" to transaction.fees,
"date" to transaction.date, "date" to transaction.date,
"is_deleted" to transaction.isDeleted, "is_deleted" to transaction.isDeleted,
"is_done" to transaction.isDone, "is_done" to transaction.isDone,
"createdById" to userId "createdById" to userId,
"tgChatId" to transaction.tgChatId,
"tgMessageId" to transaction.tgMessageId,
"recurrentId" to transaction.recurrentId,
) )
val createdTxId = jdbcTemplate.queryForObject(sql, params, Int::class.java) val createdTxId = jdbcTemplate.queryForObject(sql, params, Int::class.java)
transaction.id = createdTxId transaction.id = createdTxId
return createdTxId!! return createdTxId!!
} }
override fun createBatch(transactions: List<Transaction>, userId: Int) {
val sql = """
INSERT INTO finance.transactions (
space_id, parent_id, type, kind, category_id, comment, amount, fees, date,
is_deleted, is_done, created_by_id, tg_chat_id, tg_message_id, recurrent_id
) VALUES (
:spaceId, :parentId, :type, :kind, :categoryId, :comment, :amount, :fees, :date,
:is_deleted, :is_done, :createdById, :tgChatId, :tgMessageId, :recurrentId
)
""".trimIndent()
val batchValues = transactions.map { transaction ->
mapOf(
"spaceId" to transaction.space!!.id,
"parentId" to transaction.parent?.id,
"type" to transaction.type.name,
"kind" to transaction.kind.name,
"categoryId" to transaction.category?.id,
"comment" to transaction.comment,
"amount" to transaction.amount,
"fees" to transaction.fees,
"date" to transaction.date,
"is_deleted" to transaction.isDeleted,
"is_done" to transaction.isDone,
"createdById" to userId,
"tgChatId" to transaction.tgChatId,
"tgMessageId" to transaction.tgMessageId,
"recurrentId" to transaction.recurrentId
)
}.toTypedArray()
jdbcTemplate.batchUpdate(sql, batchValues)
}
override fun update(transaction: Transaction): Int { override fun update(transaction: Transaction): Int {
// val type: TransactionType = TransactionType.EXPENSE,
// val kind: TransactionKind = TransactionKind.INSTANT,
// val category: Int,
// val comment: String,
// val amount: BigDecimal,
// val fees: BigDecimal = BigDecimal.ZERO,
// val isDone: Boolean,
// val date: Instant
val sql = """ val sql = """
UPDATE finance.transactions UPDATE finance.transactions
@@ -183,7 +305,7 @@ class TransactionRepoImpl(
"id" to transaction.id, "id" to transaction.id,
"type" to transaction.type.name, "type" to transaction.type.name,
"kind" to transaction.kind.name, "kind" to transaction.kind.name,
"categoryId" to transaction.category.id, "categoryId" to transaction.category?.id,
"comment" to transaction.comment, "comment" to transaction.comment,
"amount" to transaction.amount, "amount" to transaction.amount,
"fees" to transaction.fees, "fees" to transaction.fees,
@@ -194,6 +316,39 @@ class TransactionRepoImpl(
return transaction.id!! return transaction.id!!
} }
override fun updateBatch(transactions: List<Transaction>, userId: Int) {
val sql = """
UPDATE finance.transactions
set type = :type,
kind = :kind,
category_id = :categoryId,
comment = :comment,
amount = :amount,
fees = :fees,
is_done = :is_done,
date = :date,
updated_by_id = :updatedById,
updated_at = now()
where id = :id
""".trimIndent()
val batchValues = transactions.map { transaction ->
mapOf(
"id" to transaction.id,
"type" to transaction.type.name,
"kind" to transaction.kind.name,
"categoryId" to transaction.category?.id,
"comment" to transaction.comment,
"amount" to transaction.amount,
"fees" to transaction.fees,
"date" to transaction.date,
"is_done" to transaction.isDone,
"updatedById" to userId,
)
}.toTypedArray()
jdbcTemplate.batchUpdate(sql, batchValues)
}
override fun delete(transactionId: Int) { override fun delete(transactionId: Int) {
val sql = """ val sql = """
update finance.transactions set is_deleted = true where id = :id update finance.transactions set is_deleted = true where id = :id
@@ -203,4 +358,27 @@ class TransactionRepoImpl(
) )
jdbcTemplate.update(sql, params) jdbcTemplate.update(sql, params)
} }
override fun deleteByRecurrentId(spaceId: Int, recurrentId: Int) {
val sql = """
update finance.transactions set is_deleted = true where recurrent_id = :recurrentId
""".trimIndent()
val params = mapOf(
"recurrentId" to recurrentId,
)
jdbcTemplate.update(sql, params)
}
override fun setCategory(txId: Int, categoryId: Int) {
val sql = """
UPDATE finance.transactions
SET category_id = :categoryId
where id = :txId
""".trimIndent()
val params = mapOf(
"categoryId" to categoryId,
"txId" to txId,
)
jdbcTemplate.update(sql, params)
}
} }

View File

@@ -53,12 +53,12 @@ class UserRepoImpl(
select * from finance.users u where tg_id = :tgId select * from finance.users u where tg_id = :tgId
""".trimIndent() """.trimIndent()
val params = mapOf("tgId" to tgId) val params = mapOf("tgId" to tgId)
return jdbcTemplate.queryForObject(sql, params, userRowMapper()) return jdbcTemplate.query(sql, params, userRowMapper()).firstOrNull()
} }
override fun create(user: User): User { override fun create(user: User): User {
val sql = val sql =
"insert into finance.users(username, first_name, tg_id, tg_user_name, photo_url, password, is_active, reg_date) values (:username, :firstname, :tg_id, :tg_user_name, :photo_url :password, :isActive, :regDate) returning ID" "insert into finance.users(username, first_name, tg_id, tg_user_name, photo_url, password, is_active, reg_date) values (:username, :firstname, :tg_id, :tg_user_name, :photo_url, :password, :isActive, :regDate) returning ID"
val params = mapOf( val params = mapOf(
"username" to user.username, "username" to user.username,
"firstname" to user.firstName, "firstname" to user.firstName,

View File

@@ -65,7 +65,7 @@ class AuthService(
fun tgAuth(tgUser: UserDTO.TelegramAuthDTO): String { fun tgAuth(tgUser: UserDTO.TelegramAuthDTO): String {
val user: User = try { val user: User = try {
tgLogin(tgUser.id) tgLogin(tgUser.id!!)
} catch (e: NotFoundException) { } catch (e: NotFoundException) {
registerTg(tgUser) registerTg(tgUser)
} }

View File

@@ -1,108 +0,0 @@
//package space.luminic.finance.services
//
//import kotlinx.coroutines.reactive.awaitSingle
//import org.bson.types.ObjectId
//import org.springframework.data.mongodb.core.ReactiveMongoTemplate
//import org.springframework.data.mongodb.core.aggregation.Aggregation.addFields
//import org.springframework.data.mongodb.core.aggregation.Aggregation.lookup
//import org.springframework.data.mongodb.core.aggregation.Aggregation.match
//import org.springframework.data.mongodb.core.aggregation.Aggregation.newAggregation
//import org.springframework.data.mongodb.core.aggregation.Aggregation.unwind
//import org.springframework.data.mongodb.core.aggregation.AggregationOperation
//import org.springframework.data.mongodb.core.aggregation.ConvertOperators
//import org.springframework.data.mongodb.core.query.Criteria
//import org.springframework.stereotype.Service
//import space.luminic.finance.dtos.CategoryDTO
//import space.luminic.finance.models.Category
//import space.luminic.finance.repos.CategoryEtalonRepo
//import space.luminic.finance.repos.CategoryRepo
//
//@Service
//class CategoryServiceMongoImpl(
// private val categoryRepo: CategoryRepo,
// private val categoryEtalonRepo: CategoryEtalonRepo,
// private val reactiveMongoTemplate: ReactiveMongoTemplate,
// private val authService: AuthService,
//) : CategoryService {
//
// private fun basicAggregation(spaceId: String): List<AggregationOperation> {
// val addFieldsAsOJ = addFields()
// .addField("createdByOI")
// .withValue(ConvertOperators.valueOf("createdById").convertToObjectId())
// .addField("updatedByOI")
// .withValue(ConvertOperators.valueOf("updatedById").convertToObjectId())
// .build()
// val lookupCreatedBy = lookup("users", "createdByOI", "_id", "createdBy")
// val unwindCreatedBy = unwind("createdBy")
//
// val lookupUpdatedBy = lookup("users", "updatedByOI", "_id", "updatedBy")
// val unwindUpdatedBy = unwind("updatedBy")
// val matchCriteria = mutableListOf<Criteria>()
// matchCriteria.add(Criteria.where("spaceId").`is`(spaceId))
// matchCriteria.add(Criteria.where("isDeleted").`is`(false))
// val matchStage = match(Criteria().andOperator(*matchCriteria.toTypedArray()))
//
// return listOf(addFieldsAsOJ, lookupCreatedBy, unwindCreatedBy, lookupUpdatedBy, unwindUpdatedBy, matchStage)
// }
//
// override suspend fun getCategories(spaceId: String): List<Category> {
// val basicAggregation = basicAggregation(spaceId)
// val aggregation = newAggregation(*basicAggregation.toTypedArray())
// return reactiveMongoTemplate.aggregate(aggregation, "categories", Category::class.java).collectList().awaitSingle()
// }
//
// override suspend fun getCategory(spaceId: String, id: String): Category {
// val basicAggregation = basicAggregation(spaceId)
// val match = match(Criteria.where("_id").`is`(ObjectId(id)))
// val aggregation = newAggregation(*basicAggregation.toTypedArray(), match)
// return reactiveMongoTemplate.aggregate(aggregation, "categories", Category::class.java).awaitSingle()
// }
//
//
// override suspend fun createCategory(
// spaceId: String,
// category: CategoryDTO.CreateCategoryDTO
// ): Category {
// val createdCategory = Category(
// spaceId = spaceId,
// type = category.type,
// name = category.name,
// icon = category.icon
// )
// return categoryRepo.save(createdCategory).awaitSingle()
// }
//
// override suspend fun updateCategory(
// spaceId: String,
// category: CategoryDTO.UpdateCategoryDTO
// ): Category {
// val existingCategory = getCategory(spaceId, category.id)
// val updatedCategory = existingCategory.copy(
// type = category.type,
// name = category.name,
// icon = category.icon,
// )
// return categoryRepo.save(updatedCategory).awaitSingle()
// }
//
// override suspend fun deleteCategory(spaceId: String, id: String) {
// val existingCategory = getCategory(spaceId, id)
// existingCategory.isDeleted = true
// categoryRepo.save(existingCategory).awaitSingle()
// }
//
// override suspend fun createCategoriesForSpace(spaceId: String): List<Category> {
// val etalonCategories = categoryEtalonRepo.findAll().collectList().awaitSingle()
// val toCreate = etalonCategories.map {
// Category(
// spaceId = spaceId,
// type = it.type,
// name = it.name,
// icon = it.icon
// )
// }
// return categoryRepo.saveAll(toCreate).collectList().awaitSingle()
// }
//
//
//}

View File

@@ -1,51 +0,0 @@
//package space.luminic.finance.services
//
//import kotlinx.coroutines.reactive.awaitSingle
//import org.springframework.stereotype.Service
//import space.luminic.finance.dtos.CurrencyDTO
//import space.luminic.finance.models.Currency
//import space.luminic.finance.models.CurrencyRate
//import space.luminic.finance.repos.CurrencyRateRepo
//import space.luminic.finance.repos.CurrencyRepo
//import java.math.BigDecimal
//import java.time.LocalDate
//
//@Service
//class CurrencyServiceMongoImpl(
// private val currencyRepo: CurrencyRepo,
// private val currencyRateRepo: CurrencyRateRepo
//) : CurrencyService {
//
// override suspend fun getCurrencies(): List<Currency> {
// return currencyRepo.findAll().collectList().awaitSingle()
// }
//
// override suspend fun getCurrency(currencyCode: String): Currency {
// return currencyRepo.findById(currencyCode).awaitSingle()
// }
//
// override suspend fun createCurrency(currency: CurrencyDTO): Currency {
// val createdCurrency = Currency(currency.code, currency.name, currency.symbol)
// return currencyRepo.save(createdCurrency).awaitSingle()
// }
//
// override suspend fun updateCurrency(currency: CurrencyDTO): Currency {
// val existingCurrency = currencyRepo.findById(currency.code).awaitSingle()
// val newCurrency = existingCurrency.copy(name = currency.name, symbol = currency.symbol)
// return currencyRepo.save(newCurrency).awaitSingle()
// }
//
// override suspend fun deleteCurrency(currencyCode: String) {
// currencyRepo.deleteById(currencyCode).awaitSingle()
// }
//
// override suspend fun createCurrencyRate(currencyCode: String): CurrencyRate {
// return currencyRateRepo.save(
// CurrencyRate(
// currencyCode = currencyCode,
// rate = BigDecimal(12.0),
// date = LocalDate.now(),
// )
// ).awaitSingle()
// }
//}

View File

@@ -1,22 +0,0 @@
package space.luminic.finance.services
import space.luminic.finance.dtos.GoalDTO
import space.luminic.finance.models.Goal
interface GoalService {
fun findAllBySpaceId(spaceId: Int): List<Goal>
fun findBySpaceIdAndId(spaceId: Int, id: Int): Goal
fun create(spaceId: Int,goal: GoalDTO.CreateGoalDTO): Int
fun update(spaceId: Int, goalId: Int, goal: GoalDTO.UpdateGoalDTO)
fun delete(spaceId: Int, id: Int)
fun getComponents(spaceId: Int, goalId: Int): List<Goal.GoalComponent>
fun getComponent(spaceId: Int, goalId: Int, id: Int): Goal.GoalComponent?
fun createComponent(spaceId: Int, goalId: Int, component: Goal.GoalComponent): Int
fun updateComponent(spaceId: Int, goalId: Int, component: Goal.GoalComponent)
fun deleteComponent(spaceId: Int, goalId: Int, id: Int)
fun assignTransaction(spaceId: Int, goalId: Int, transactionId: Int)
fun refuseTransaction(spaceId: Int,goalId: Int, transactionId: Int)
}

View File

@@ -1,140 +0,0 @@
package space.luminic.finance.services
import org.springframework.stereotype.Service
import space.luminic.finance.dtos.GoalDTO
import space.luminic.finance.models.Goal
import space.luminic.finance.models.NotFoundException
import space.luminic.finance.repos.GoalRepo
import space.luminic.finance.repos.SpaceRepo
import space.luminic.finance.repos.TransactionRepo
@Service
class GoalServiceImpl(
private val goalRepo: GoalRepo,
private val spaceRepo: SpaceRepo,
private val authService: AuthService,
private val transactionRepo: TransactionRepo
) : GoalService {
override fun findAllBySpaceId(spaceId: Int): List<Goal> {
val userId = authService.getSecurityUserId()
spaceRepo.findSpaceById(spaceId, userId)
return goalRepo.findAllBySpaceId(spaceId)
}
override fun findBySpaceIdAndId(spaceId: Int, id: Int): Goal {
val userId = authService.getSecurityUserId()
spaceRepo.findSpaceById(spaceId, userId)
return goalRepo.findBySpaceIdAndId(spaceId, userId) ?: throw NotFoundException("Goal $id not found")
}
override fun create(spaceId: Int, goal: GoalDTO.CreateGoalDTO): Int {
val userId = authService.getSecurityUserId()
spaceRepo.findSpaceById(spaceId, userId)
val creatingGoal = Goal(
type = goal.type,
name = goal.name,
amount = goal.amount,
untilDate = goal.date
)
return goalRepo.create(creatingGoal, userId)
}
override fun update(spaceId: Int, goalId: Int, goal: GoalDTO.UpdateGoalDTO) {
val userId = authService.getSecurityUserId()
spaceRepo.findSpaceById(spaceId, userId)
val existingGoal =
goalRepo.findBySpaceIdAndId(spaceId, goalId) ?: throw NotFoundException("Goal $goalId not found")
val updatedGoal = existingGoal.copy(
type = goal.type,
name = goal.name,
description = goal.description,
amount = goal.amount,
untilDate = goal.date
)
goalRepo.update(updatedGoal, userId)
}
override fun delete(spaceId: Int, id: Int) {
goalRepo.delete(spaceId, id)
}
override fun getComponents(
spaceId: Int,
goalId: Int
): List<Goal.GoalComponent> {
val userId = authService.getSecurityUserId()
spaceRepo.findSpaceById(spaceId, userId)
goalRepo.findBySpaceIdAndId(spaceId, goalId) ?: throw NotFoundException("Goal $goalId not found")
return goalRepo.getComponents(spaceId, goalId)
}
override fun getComponent(
spaceId: Int,
goalId: Int,
id: Int
): Goal.GoalComponent? {
val userId = authService.getSecurityUserId()
spaceRepo.findSpaceById(spaceId, userId)
goalRepo.findBySpaceIdAndId(spaceId, goalId) ?: throw NotFoundException("Goal $goalId not found")
return goalRepo.getComponent(spaceId, goalId, id)
}
override fun createComponent(
spaceId: Int,
goalId: Int,
component: Goal.GoalComponent
): Int {
val userId = authService.getSecurityUserId()
spaceRepo.findSpaceById(spaceId, userId)
goalRepo.findBySpaceIdAndId(spaceId, goalId) ?: throw NotFoundException("Goal $goalId not found")
return goalRepo.createComponent(goalId, component, userId)
}
override fun updateComponent(
spaceId: Int,
goalId: Int,
component: Goal.GoalComponent
) {
val userId = authService.getSecurityUserId()
spaceRepo.findSpaceById(spaceId, userId)
goalRepo.findBySpaceIdAndId(spaceId, goalId) ?: throw NotFoundException("Goal $goalId not found")
val existingComponent = goalRepo.getComponent(spaceId, goalId, component.id!!)
?: throw NotFoundException("Component $goalId not found")
val updatedComponent = existingComponent.copy(
name = component.name,
amount = component.amount,
isDone = component.isDone,
date = component.date
)
goalRepo.updateComponent(goalId, updatedComponent.id!!, updatedComponent, userId)
}
override fun deleteComponent(spaceId: Int, goalId: Int, id: Int) {
val userId = authService.getSecurityUserId()
spaceRepo.findSpaceById(spaceId, userId)
goalRepo.findBySpaceIdAndId(spaceId, goalId) ?: throw NotFoundException("Goal $goalId not found")
goalRepo.getComponent(spaceId, goalId, id) ?: throw NotFoundException("Component $goalId not found")
goalRepo.deleteComponent(goalId, id)
}
override fun assignTransaction(spaceId: Int, goalId: Int, transactionId: Int) {
val userId = authService.getSecurityUserId()
spaceRepo.findSpaceById(spaceId, userId)
goalRepo.findBySpaceIdAndId(spaceId, goalId) ?: throw NotFoundException("Goal $goalId not found")
transactionRepo.findBySpaceIdAndId(spaceId, transactionId) ?: throw NotFoundException(
"Transaction $transactionId not found"
)
goalRepo.assignTransaction(goalId, transactionId)
}
override fun refuseTransaction(spaceId: Int, goalId: Int, transactionId: Int) {
val userId = authService.getSecurityUserId()
spaceRepo.findSpaceById(spaceId, userId)
goalRepo.findBySpaceIdAndId(spaceId, goalId) ?: throw NotFoundException("Goal $goalId not found")
transactionRepo.findBySpaceIdAndId(spaceId, transactionId) ?: throw NotFoundException(
"Transaction $transactionId not found"
)
goalRepo.refuseTransaction(goalId, transactionId)
}
}

View File

@@ -10,4 +10,6 @@ interface RecurrentOperationService {
fun create(spaceId: Int, operation: RecurrentOperationDTO.CreateRecurrentOperationDTO): Int fun create(spaceId: Int, operation: RecurrentOperationDTO.CreateRecurrentOperationDTO): Int
fun update(spaceId: Int, operationId: Int, operation: RecurrentOperationDTO.UpdateRecurrentOperationDTO) fun update(spaceId: Int, operationId: Int, operation: RecurrentOperationDTO.UpdateRecurrentOperationDTO)
fun delete(spaceId: Int, id: Int) fun delete(spaceId: Int, id: Int)
fun createRecurrentTransactions()
} }

View File

@@ -1,19 +1,33 @@
package space.luminic.finance.services package space.luminic.finance.services
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import space.luminic.finance.dtos.RecurrentOperationDTO import space.luminic.finance.dtos.RecurrentOperationDTO
import space.luminic.finance.models.Category
import space.luminic.finance.models.NotFoundException import space.luminic.finance.models.NotFoundException
import space.luminic.finance.models.RecurrentOperation import space.luminic.finance.models.RecurrentOperation
import space.luminic.finance.models.Transaction
import space.luminic.finance.repos.RecurrentOperationRepo import space.luminic.finance.repos.RecurrentOperationRepo
import space.luminic.finance.repos.SpaceRepo import space.luminic.finance.repos.SpaceRepo
import space.luminic.finance.repos.TransactionRepo
import java.time.LocalDate
import kotlin.math.min
@Service @Service
class RecurrentOperationServiceImpl( class RecurrentOperationServiceImpl(
private val authService: AuthService, private val authService: AuthService,
private val spaceRepo: SpaceRepo, private val spaceRepo: SpaceRepo,
private val recurrentOperationRepo: RecurrentOperationRepo, private val recurrentOperationRepo: RecurrentOperationRepo,
private val categoryService: CategoryService private val categoryService: CategoryService,
): RecurrentOperationService { private val transactionRepo: TransactionRepo
) : RecurrentOperationService {
private val logger = LoggerFactory.getLogger(this.javaClass)
private val serviceScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
override fun findBySpaceId(spaceId: Int): List<RecurrentOperation> { override fun findBySpaceId(spaceId: Int): List<RecurrentOperation> {
val userId = authService.getSecurityUserId() val userId = authService.getSecurityUserId()
spaceRepo.findSpaceById(spaceId, userId) spaceRepo.findSpaceById(spaceId, userId)
@@ -26,12 +40,14 @@ class RecurrentOperationServiceImpl(
): RecurrentOperation { ): RecurrentOperation {
val userId = authService.getSecurityUserId() val userId = authService.getSecurityUserId()
spaceRepo.findSpaceById(spaceId, userId) spaceRepo.findSpaceById(spaceId, userId)
return recurrentOperationRepo.findBySpaceIdAndId(spaceId, id) ?: throw NotFoundException("Cannot find recurrent operation with id ${id}") return recurrentOperationRepo.findBySpaceIdAndId(spaceId, id)
?: throw NotFoundException("Cannot find recurrent operation with id ${id}")
} }
override fun create(spaceId: Int, operation: RecurrentOperationDTO.CreateRecurrentOperationDTO): Int { override fun create(spaceId: Int, operation: RecurrentOperationDTO.CreateRecurrentOperationDTO): Int {
val userId = authService.getSecurityUserId() val userId = authService.getSecurityUserId()
val space = spaceRepo.findSpaceById(spaceId, userId) ?: throw NotFoundException("Cannot find space with id ${spaceId}") val space =
spaceRepo.findSpaceById(spaceId, userId) ?: throw NotFoundException("Cannot find space with id ${spaceId}")
val category = categoryService.getCategory(spaceId, operation.categoryId) val category = categoryService.getCategory(spaceId, operation.categoryId)
val creatingOperation = RecurrentOperation( val creatingOperation = RecurrentOperation(
space = space, space = space,
@@ -40,14 +56,43 @@ class RecurrentOperationServiceImpl(
amount = operation.amount, amount = operation.amount,
date = operation.date date = operation.date
) )
return recurrentOperationRepo.create(creatingOperation, userId)
val createdRecurrentId = recurrentOperationRepo.create(creatingOperation, userId)
val transactionsToCreate = mutableListOf<Transaction>()
serviceScope.launch {
runCatching {
val now = LocalDate.now()
val date = now.withDayOfMonth(min(operation.date, now.lengthOfMonth()))
for (i in 1..12) {
transactionsToCreate.add(
Transaction(
space = space,
type = if (category.type == Category.CategoryType.EXPENSE) Transaction.TransactionType.EXPENSE else Transaction.TransactionType.INCOME,
kind = Transaction.TransactionKind.PLANNING,
category = category,
comment = creatingOperation.name,
amount = creatingOperation.amount,
date = date.plusMonths(i.toLong()),
recurrentId = createdRecurrentId
)
)
}
transactionRepo.createBatch(transactionsToCreate, userId)
// transactionService.batchCreate(spaceId, transactionsToCreate, userId)
}.onFailure {
logger.error("Error creating recurring operation", it)
}
}
return createdRecurrentId
} }
override fun update(spaceId: Int, operationId: Int, operation: RecurrentOperationDTO.UpdateRecurrentOperationDTO) { override fun update(spaceId: Int, operationId: Int, operation: RecurrentOperationDTO.UpdateRecurrentOperationDTO) {
val userId = authService.getSecurityUserId() val userId = authService.getSecurityUserId()
spaceRepo.findSpaceById(spaceId, userId) spaceRepo.findSpaceById(spaceId, userId)
val newCategory = categoryService.getCategory(spaceId, operation.categoryId) val newCategory = categoryService.getCategory(spaceId, operation.categoryId)
val existingOperation = recurrentOperationRepo.findBySpaceIdAndId(spaceId,operationId ) ?: throw NotFoundException("Cannot find operation with id $operationId") val existingOperation = recurrentOperationRepo.findBySpaceIdAndId(spaceId, operationId)
?: throw NotFoundException("Cannot find operation with id $operationId")
val updatedOperation = existingOperation.copy( val updatedOperation = existingOperation.copy(
category = newCategory, category = newCategory,
name = operation.name, name = operation.name,
@@ -55,7 +100,31 @@ class RecurrentOperationServiceImpl(
date = operation.date date = operation.date
) )
recurrentOperationRepo.update(updatedOperation, userId) recurrentOperationRepo.update(updatedOperation, userId)
serviceScope.launch {
val transactionsToUpdate = mutableListOf<Transaction>()
runCatching {
val txs = transactionRepo.findBySpaceIdAndRecurrentId(spaceId, operationId)
txs.forEach {
transactionsToUpdate.add(
it.copy(
type = if (it.category?.type == Category.CategoryType.EXPENSE) Transaction.TransactionType.EXPENSE else Transaction.TransactionType.INCOME,
category = updatedOperation.category,
comment = operation.name,
amount = updatedOperation.amount,
date = LocalDate.of(
it.date.year,
it.date.monthValue,
min(it.date.lengthOfMonth(), updatedOperation.date)
)
)
)
}
transactionRepo.updateBatch(transactionsToUpdate, userId)
}.onFailure {
logger.error("Error creating recurring operation", it)
}
}
} }
override fun delete(spaceId: Int, id: Int) { override fun delete(spaceId: Int, id: Int) {
@@ -63,4 +132,25 @@ class RecurrentOperationServiceImpl(
spaceRepo.findSpaceById(spaceId, userId) spaceRepo.findSpaceById(spaceId, userId)
recurrentOperationRepo.delete(id) recurrentOperationRepo.delete(id)
} }
override fun createRecurrentTransactions() {
val today = LocalDate.now()
val recurrents = recurrentOperationRepo.findByDate(today.dayOfMonth)
recurrents.forEach {
transactionRepo.create(
Transaction(
space = it.space,
type = if (it.category.type == Category.CategoryType.EXPENSE) Transaction.TransactionType.EXPENSE else Transaction.TransactionType.INCOME,
kind = Transaction.TransactionKind.PLANNING,
category = it.category,
comment = it.name,
amount = it.amount,
date = today.plusMonths(13),
recurrentId = it.id
), it.createdBy?.id!!
)
}
}
} }

View File

@@ -0,0 +1,20 @@
package space.luminic.finance.services
import org.slf4j.LoggerFactory
import org.springframework.scheduling.annotation.EnableScheduling
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
@EnableScheduling
@Service
class Scheduler(
private val recurrentOperationService: RecurrentOperationService
) {
private val log = LoggerFactory.getLogger(Scheduler::class.java)
@Scheduled(cron = "0 0 3 * * *")
fun createRecurrentAfter13Month() {
log.info("Creating recurrent after 13 month")
recurrentOperationService.createRecurrentTransactions()
}
}

View File

@@ -7,7 +7,7 @@ interface SpaceService {
fun checkSpace(spaceId: Int): Space fun checkSpace(spaceId: Int): Space
fun getSpaces(): List<Space> fun getSpaces(): List<Space>
fun getSpace(id: Int): Space fun getSpace(id: Int, userId: Int?): Space
fun createSpace(space: SpaceDTO.CreateSpaceDTO): Int fun createSpace(space: SpaceDTO.CreateSpaceDTO): Int
fun updateSpace(spaceId: Int, space: SpaceDTO.UpdateSpaceDTO): Int fun updateSpace(spaceId: Int, space: SpaceDTO.UpdateSpaceDTO): Int
fun deleteSpace(spaceId: Int) fun deleteSpace(spaceId: Int)

View File

@@ -1,8 +1,5 @@
package space.luminic.finance.services package space.luminic.finance.services
import org.springframework.cache.annotation.CacheEvict
import org.springframework.cache.annotation.Cacheable
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import space.luminic.finance.dtos.SpaceDTO import space.luminic.finance.dtos.SpaceDTO
@@ -17,7 +14,7 @@ class SpaceServiceImpl(
private val categoryService: CategoryService private val categoryService: CategoryService
) : SpaceService { ) : SpaceService {
override fun checkSpace(spaceId: Int): Space { override fun checkSpace(spaceId: Int): Space {
return getSpace(spaceId) return getSpace(spaceId, null)
} }
// @Cacheable(cacheNames = ["spaces"]) // @Cacheable(cacheNames = ["spaces"])
@@ -27,8 +24,9 @@ class SpaceServiceImpl(
return spaces return spaces
} }
override fun getSpace(id: Int): Space {
val user = authService.getSecurityUserId() override fun getSpace(id: Int, userId: Int?): Space {
val user = userId ?: authService.getSecurityUserId()
val space = spaceRepo.findSpaceById(id, user) ?: throw NotFoundException("Space with id $id not found") val space = spaceRepo.findSpaceById(id, user) ?: throw NotFoundException("Space with id $id not found")
return space return space
@@ -56,7 +54,7 @@ class SpaceServiceImpl(
space: SpaceDTO.UpdateSpaceDTO space: SpaceDTO.UpdateSpaceDTO
): Int { ): Int {
val userId = authService.getSecurityUserId() val userId = authService.getSecurityUserId()
val existingSpace = getSpace(spaceId) val existingSpace = getSpace(spaceId, null)
val updatedSpace = Space( val updatedSpace = Space(
id = existingSpace.id, id = existingSpace.id,
name = space.name, name = space.name,

View File

@@ -1,145 +0,0 @@
//package space.luminic.finance.services
//
//import com.mongodb.client.model.Aggregates.sort
//import kotlinx.coroutines.reactive.awaitFirst
//import kotlinx.coroutines.reactive.awaitFirstOrNull
//import kotlinx.coroutines.reactive.awaitSingle
//import org.springframework.data.domain.Sort
//import org.springframework.data.mongodb.core.ReactiveMongoTemplate
//import org.springframework.data.mongodb.core.aggregation.Aggregation.*
//import org.springframework.data.mongodb.core.aggregation.AggregationOperation
//
//import org.springframework.data.mongodb.core.aggregation.ConvertOperators
//import org.springframework.data.mongodb.core.aggregation.VariableOperators
//import org.springframework.data.mongodb.core.query.Criteria
//import org.springframework.stereotype.Service
//import space.luminic.finance.dtos.SpaceDTO
//import space.luminic.finance.models.NotFoundException
//import space.luminic.finance.models.Space
//import space.luminic.finance.models.User
//import space.luminic.finance.repos.SpaceRepo
//
//@Service
//class SpaceServiceMongoImpl(
// private val authService: AuthService,
// private val spaceRepo: SpaceRepo,
// private val mongoTemplate: ReactiveMongoTemplate,
//) : SpaceService {
//
// private fun basicAggregation(user: User): List<AggregationOperation> {
// val addFieldsAsOJ = addFields()
// .addField("createdByOI")
// .withValue(ConvertOperators.valueOf("createdById").convertToObjectId())
// .addField("updatedByOI")
// .withValue(ConvertOperators.valueOf("updatedById").convertToObjectId())
// .build()
// val lookupCreatedBy = lookup("users", "createdByOI", "_id", "createdBy")
// val unwindCreatedBy = unwind("createdBy")
//
// val lookupUpdatedBy = lookup("users", "updatedByOI", "_id", "updatedBy")
// val unwindUpdatedBy = unwind("updatedBy")
//
//
//
// val matchCriteria = mutableListOf<Criteria>()
// matchCriteria.add(
// Criteria().orOperator(
// Criteria.where("ownerId").`is`(user.id),
// Criteria.where("participantsIds").`is`(user.id)
// )
// )
// matchCriteria.add(Criteria.where("isDeleted").`is`(false))
// val matchStage = match(Criteria().andOperator(*matchCriteria.toTypedArray()))
//
// return listOf(addFieldsAsOJ, lookupCreatedBy, unwindCreatedBy, lookupUpdatedBy, unwindUpdatedBy, matchStage)
// }
//
// private fun ownerAndParticipantsLookups(): List<AggregationOperation>{
// val addOwnerAsOJ = addFields()
// .addField("ownerIdAsObjectId")
// .withValue(ConvertOperators.valueOf("ownerId").convertToObjectId())
// .addField("participantsIdsAsObjectId")
// .withValue(
// VariableOperators.Map.itemsOf("participantsIds")
// .`as`("id")
// .andApply(
// ConvertOperators.valueOf("$\$id").convertToObjectId()
// )
// )
// .build()
// val lookupOwner = lookup("users", "ownerIdAsObjectId", "_id", "owner")
// val unwindOwner = unwind("owner")
// val lookupUsers = lookup("users", "participantsIdsAsObjectId", "_id", "participants")
// return listOf(addOwnerAsOJ, lookupOwner, unwindOwner, lookupUsers)
// }
//
// override suspend fun checkSpace(spaceId: String): Space {
// val user = authService.getSecurityUser()
// val space = getSpace(spaceId)
//
// // Проверяем доступ пользователя к пространству
// return if (space.participants!!.none { it.id.toString() == user.id }) {
// throw IllegalArgumentException("User does not have access to this Space")
// } else space
// }
//
// override suspend fun getSpaces(): List<Space> {
// val user = authService.getSecurityUser()
// val basicAggregation = basicAggregation(user)
// val ownerAndParticipantsLookup = ownerAndParticipantsLookups()
//
// val sort = sort(Sort.by(Sort.Direction.DESC, "createdAt"))
// val aggregation = newAggregation(
// *basicAggregation.toTypedArray(),
// *ownerAndParticipantsLookup.toTypedArray(),
// sort,
// )
// return mongoTemplate.aggregate(aggregation, "spaces", Space::class.java).collectList().awaitSingle()
// }
//
// override suspend fun getSpace(id: String): Space {
// val user = authService.getSecurityUser()
// val basicAggregation = basicAggregation(user)
// val ownerAndParticipantsLookup = ownerAndParticipantsLookups()
//
// val aggregation = newAggregation(
// *basicAggregation.toTypedArray(),
// *ownerAndParticipantsLookup.toTypedArray(),
// )
// return mongoTemplate.aggregate(aggregation, "spaces", Space::class.java).awaitFirstOrNull()
// ?: throw NotFoundException("Space not found")
//
// }
//
// override suspend fun createSpace(space: SpaceDTO.CreateSpaceDTO): Space {
// val owner = authService.getSecurityUser()
// val createdSpace = Space(
// name = space.name,
// ownerId = owner.id!!,
//
// participantsIds = listOf(owner.id!!),
//
//
// )
// createdSpace.owner = owner
// createdSpace.participants?.toMutableList()?.add(owner)
// val savedSpace = spaceRepo.save(createdSpace).awaitSingle()
// return savedSpace
// }
//
// override suspend fun updateSpace(spaceId: String, space: SpaceDTO.UpdateSpaceDTO): Space {
// val existingSpace = spaceRepo.findById(spaceId).awaitFirstOrNull() ?: throw NotFoundException("Space not found")
// val updatedSpace = existingSpace.copy(
// name = space.name,
// )
// updatedSpace.owner = existingSpace.owner
// updatedSpace.participants = existingSpace.participants
// return spaceRepo.save(updatedSpace).awaitFirst()
// }
//
// override suspend fun deleteSpace(spaceId: String) {
// val space = spaceRepo.findById(spaceId).awaitFirstOrNull() ?: throw NotFoundException("Space not found")
// space.isDeleted = true
// spaceRepo.save(space).awaitFirst()
// }
//}

View File

@@ -0,0 +1,22 @@
package space.luminic.finance.services
import space.luminic.finance.dtos.TargetDTO
import space.luminic.finance.models.Target
interface TargetService {
fun findAllBySpaceId(spaceId: Int): List<Target>
fun findBySpaceIdAndId(spaceId: Int, id: Int): Target
fun create(spaceId: Int,target: TargetDTO.CreateTargetDTO): Int
fun update(spaceId: Int, targetId: Int, target: TargetDTO.UpdateTargetDTO)
fun delete(spaceId: Int, id: Int)
fun getComponents(spaceId: Int, targetId: Int): List<Target.TargetComponent>
fun getComponent(spaceId: Int, targetId: Int, id: Int): Target.TargetComponent?
fun createComponent(spaceId: Int, targetId: Int, component: Target.TargetComponent): Int
fun updateComponent(spaceId: Int, targetId: Int, component: Target.TargetComponent)
fun deleteComponent(spaceId: Int, targetId: Int, id: Int)
fun assignTransaction(spaceId: Int, targetId: Int, transactionId: Int)
fun refuseTransaction(spaceId: Int,targetId: Int, transactionId: Int)
}

View File

@@ -0,0 +1,140 @@
package space.luminic.finance.services
import org.springframework.stereotype.Service
import space.luminic.finance.dtos.TargetDTO
import space.luminic.finance.models.Target
import space.luminic.finance.models.NotFoundException
import space.luminic.finance.repos.TargetRepo
import space.luminic.finance.repos.SpaceRepo
import space.luminic.finance.repos.TransactionRepo
@Service
class TargetServiceImpl(
private val targetRepo: TargetRepo,
private val spaceRepo: SpaceRepo,
private val authService: AuthService,
private val transactionRepo: TransactionRepo
) : TargetService {
override fun findAllBySpaceId(spaceId: Int): List<Target> {
val userId = authService.getSecurityUserId()
spaceRepo.findSpaceById(spaceId, userId)
return targetRepo.findAllBySpaceId(spaceId)
}
override fun findBySpaceIdAndId(spaceId: Int, id: Int): Target {
val userId = authService.getSecurityUserId()
spaceRepo.findSpaceById(spaceId, userId)
return targetRepo.findBySpaceIdAndId(spaceId, userId) ?: throw NotFoundException("Goal $id not found")
}
override fun create(spaceId: Int, target: TargetDTO.CreateTargetDTO): Int {
val userId = authService.getSecurityUserId()
spaceRepo.findSpaceById(spaceId, userId)
val creatingTarget = Target(
type = target.type,
name = target.name,
amount = target.amount,
untilDate = target.date
)
return targetRepo.create(creatingTarget, userId)
}
override fun update(spaceId: Int, targetId: Int, target: TargetDTO.UpdateTargetDTO) {
val userId = authService.getSecurityUserId()
spaceRepo.findSpaceById(spaceId, userId)
val existingGoal =
targetRepo.findBySpaceIdAndId(spaceId, targetId) ?: throw NotFoundException("Goal $targetId not found")
val updatedGoal = existingGoal.copy(
type = target.type,
name = target.name,
description = target.description,
amount = target.amount,
untilDate = target.date
)
targetRepo.update(updatedGoal, userId)
}
override fun delete(spaceId: Int, id: Int) {
targetRepo.delete(spaceId, id)
}
override fun getComponents(
spaceId: Int,
targetId: Int
): List<Target.TargetComponent> {
val userId = authService.getSecurityUserId()
spaceRepo.findSpaceById(spaceId, userId)
targetRepo.findBySpaceIdAndId(spaceId, targetId) ?: throw NotFoundException("Goal $targetId not found")
return targetRepo.getComponents(spaceId, targetId)
}
override fun getComponent(
spaceId: Int,
targetId: Int,
id: Int
): Target.TargetComponent? {
val userId = authService.getSecurityUserId()
spaceRepo.findSpaceById(spaceId, userId)
targetRepo.findBySpaceIdAndId(spaceId, targetId) ?: throw NotFoundException("Target $targetId not found")
return targetRepo.getComponent(spaceId, targetId, id)
}
override fun createComponent(
spaceId: Int,
targetId: Int,
component: Target.TargetComponent
): Int {
val userId = authService.getSecurityUserId()
spaceRepo.findSpaceById(spaceId, userId)
targetRepo.findBySpaceIdAndId(spaceId, targetId) ?: throw NotFoundException("Target $targetId not found")
return targetRepo.createComponent(targetId, component, userId)
}
override fun updateComponent(
spaceId: Int,
targetId: Int,
component: Target.TargetComponent
) {
val userId = authService.getSecurityUserId()
spaceRepo.findSpaceById(spaceId, userId)
targetRepo.findBySpaceIdAndId(spaceId, targetId) ?: throw NotFoundException("Target $targetId not found")
val existingComponent = targetRepo.getComponent(spaceId, targetId, component.id!!)
?: throw NotFoundException("Component $targetId not found")
val updatedComponent = existingComponent.copy(
name = component.name,
amount = component.amount,
isDone = component.isDone,
date = component.date
)
targetRepo.updateComponent(targetId, updatedComponent.id!!, updatedComponent, userId)
}
override fun deleteComponent(spaceId: Int, targetId: Int, id: Int) {
val userId = authService.getSecurityUserId()
spaceRepo.findSpaceById(spaceId, userId)
targetRepo.findBySpaceIdAndId(spaceId, targetId) ?: throw NotFoundException("Target $targetId not found")
targetRepo.getComponent(spaceId, targetId, id) ?: throw NotFoundException("Component $targetId not found")
targetRepo.deleteComponent(targetId, id)
}
override fun assignTransaction(spaceId: Int, targetId: Int, transactionId: Int) {
val userId = authService.getSecurityUserId()
spaceRepo.findSpaceById(spaceId, userId)
targetRepo.findBySpaceIdAndId(spaceId, targetId) ?: throw NotFoundException("Target $targetId not found")
transactionRepo.findBySpaceIdAndId(spaceId, transactionId) ?: throw NotFoundException(
"Transaction $transactionId not found"
)
targetRepo.assignTransaction(targetId, transactionId)
}
override fun refuseTransaction(spaceId: Int, targetId: Int, transactionId: Int) {
val userId = authService.getSecurityUserId()
spaceRepo.findSpaceById(spaceId, userId)
targetRepo.findBySpaceIdAndId(spaceId, targetId) ?: throw NotFoundException("Target $targetId not found")
transactionRepo.findBySpaceIdAndId(spaceId, transactionId) ?: throw NotFoundException(
"Transaction $transactionId not found"
)
targetRepo.refuseTransaction(targetId, transactionId)
}
}

View File

@@ -7,13 +7,26 @@ import java.time.LocalDate
interface TransactionService { interface TransactionService {
data class TransactionsFilter( data class TransactionsFilter(
val type: Transaction.TransactionType? = null,
val kind: Transaction.TransactionKind? = null,
val dateFrom: LocalDate? = null, val dateFrom: LocalDate? = null,
val dateTo: LocalDate? = null, val dateTo: LocalDate? = null,
val isDone: Boolean? = null,
val offset: Int = 0,
val limit: Int = 10,
) )
fun getTransactions(spaceId: Int, filter: TransactionsFilter, sortBy: String, sortDirection: String): List<Transaction> fun getTransactions(
fun getTransaction(spaceId: Int, transactionId: Int): Transaction spaceId: Int,
fun createTransaction(spaceId: Int, transaction: TransactionDTO.CreateTransactionDTO): Int filter: TransactionsFilter,
fun updateTransaction(spaceId: Int, transactionId: Int, transaction: TransactionDTO.UpdateTransactionDTO): Int sortBy: String,
sortDirection: String
): List<Transaction>
fun getTransaction(spaceId: Int, transactionId: Int): Transaction
fun createTransaction(spaceId: Int, transaction: TransactionDTO.CreateTransactionDTO): Int
fun batchCreate(spaceId: Int, transactions: List<TransactionDTO.CreateTransactionDTO>, createdById: Int?)
fun updateTransaction(spaceId: Int, transactionId: Int, transaction: TransactionDTO.UpdateTransactionDTO): Int
fun deleteTransaction(spaceId: Int, transactionId: Int) fun deleteTransaction(spaceId: Int, transactionId: Int)
fun deleteByRecurrentId(spaceId: Int, recurrentId: Int)
} }

View File

@@ -5,6 +5,7 @@ import space.luminic.finance.dtos.TransactionDTO
import space.luminic.finance.models.NotFoundException import space.luminic.finance.models.NotFoundException
import space.luminic.finance.models.Transaction import space.luminic.finance.models.Transaction
import space.luminic.finance.repos.TransactionRepo import space.luminic.finance.repos.TransactionRepo
import space.luminic.finance.services.gpt.CategorizeService
@Service @Service
class TransactionServiceImpl( class TransactionServiceImpl(
@@ -12,6 +13,7 @@ class TransactionServiceImpl(
private val categoryService: CategoryService, private val categoryService: CategoryService,
private val transactionRepo: TransactionRepo, private val transactionRepo: TransactionRepo,
private val authService: AuthService, private val authService: AuthService,
private val categorizeService: CategorizeService,
) : TransactionService { ) : TransactionService {
override fun getTransactions( override fun getTransactions(
spaceId: Int, spaceId: Int,
@@ -19,14 +21,15 @@ class TransactionServiceImpl(
sortBy: String, sortBy: String,
sortDirection: String sortDirection: String
): List<Transaction> { ): List<Transaction> {
return transactionRepo.findAllBySpaceId(spaceId) val transactions = transactionRepo.findAllBySpaceId(spaceId, filter)
return transactions
} }
override fun getTransaction( override fun getTransaction(
spaceId: Int, spaceId: Int,
transactionId: Int transactionId: Int
): Transaction { ): Transaction {
spaceService.getSpace(spaceId) spaceService.getSpace(spaceId, null)
return transactionRepo.findBySpaceIdAndId(spaceId, transactionId) return transactionRepo.findBySpaceIdAndId(spaceId, transactionId)
?: throw NotFoundException("Transaction with id $transactionId not found") ?: throw NotFoundException("Transaction with id $transactionId not found")
} }
@@ -36,8 +39,9 @@ class TransactionServiceImpl(
transaction: TransactionDTO.CreateTransactionDTO transaction: TransactionDTO.CreateTransactionDTO
): Int { ): Int {
val userId = authService.getSecurityUserId() val userId = authService.getSecurityUserId()
val space = spaceService.getSpace(spaceId) val space = spaceService.getSpace(spaceId, null)
val category = categoryService.getCategory(spaceId, transaction.categoryId)
val category = transaction.categoryId?.let { categoryService.getCategory(spaceId, it) }
val transaction = Transaction( val transaction = Transaction(
space = space, space = space,
type = transaction.type, type = transaction.type,
@@ -47,26 +51,43 @@ class TransactionServiceImpl(
amount = transaction.amount, amount = transaction.amount,
fees = transaction.fees, fees = transaction.fees,
date = transaction.date, date = transaction.date,
recurrentId = transaction.recurrentId,
) )
return transactionRepo.create(transaction, userId) return transactionRepo.create(transaction, userId)
} }
override fun batchCreate(spaceId: Int, transactions: List<TransactionDTO.CreateTransactionDTO>, createdById: Int?) {
val userId = createdById ?: authService.getSecurityUserId()
val space = spaceService.getSpace(spaceId, userId)
val transactionsToCreate = mutableListOf<Transaction>()
transactions.forEach { transaction ->
val category = transaction.categoryId?.let { categoryService.getCategory(spaceId, it) }
transactionsToCreate.add(
Transaction(
space = space,
type = transaction.type,
kind = transaction.kind,
category = category,
comment = transaction.comment,
amount = transaction.amount,
fees = transaction.fees,
date = transaction.date,
recurrentId = transaction.recurrentId,
)
)
}
transactionRepo.createBatch(transactionsToCreate, userId)
}
override fun updateTransaction( override fun updateTransaction(
spaceId: Int, spaceId: Int,
transactionId: Int, transactionId: Int,
transaction: TransactionDTO.UpdateTransactionDTO transaction: TransactionDTO.UpdateTransactionDTO
): Int { ): Int {
val space = spaceService.getSpace(spaceId) val space = spaceService.getSpace(spaceId, null)
val existingTransaction = getTransaction(space.id!!, transactionId) val existingTransaction = getTransaction(space.id!!, transactionId)
val newCategory = categoryService.getCategory(spaceId, transaction.categoryId) val newCategory = transaction.categoryId?.let { categoryService.getCategory(spaceId, it) }
// val id: Int,
// val type: TransactionType = TransactionType.EXPENSE,
// val kind: TransactionKind = TransactionKind.INSTANT,
// val category: Int,
// val comment: String,
// val amount: BigDecimal,
// val fees: BigDecimal = BigDecimal.ZERO,
// val date: Instant
val updatedTransaction = Transaction( val updatedTransaction = Transaction(
id = existingTransaction.id, id = existingTransaction.id,
space = existingTransaction.space, space = existingTransaction.space,
@@ -81,16 +102,25 @@ class TransactionServiceImpl(
isDeleted = existingTransaction.isDeleted, isDeleted = existingTransaction.isDeleted,
isDone = transaction.isDone, isDone = transaction.isDone,
createdBy = existingTransaction.createdBy, createdBy = existingTransaction.createdBy,
createdAt = existingTransaction.createdAt createdAt = existingTransaction.createdAt,
tgChatId = existingTransaction.tgChatId,
tgMessageId = existingTransaction.tgMessageId,
) )
if ((existingTransaction.category == null && updatedTransaction.category != null) || (existingTransaction.category?.id != updatedTransaction.category?.id)) {
categorizeService.notifyThatCategorySelected(updatedTransaction)
}
return transactionRepo.update(updatedTransaction) return transactionRepo.update(updatedTransaction)
} }
override fun deleteTransaction(spaceId: Int, transactionId: Int) { override fun deleteTransaction(spaceId: Int, transactionId: Int) {
val space = spaceService.getSpace(spaceId) val space = spaceService.getSpace(spaceId, null)
getTransaction(space.id!!, transactionId) getTransaction(space.id!!, transactionId)
transactionRepo.delete(transactionId) transactionRepo.delete(transactionId)
} }
override fun deleteByRecurrentId(spaceId: Int, recurrentId: Int) {
TODO("Not yet implemented")
}
} }

View File

@@ -1,185 +0,0 @@
//package space.luminic.finance.services
//
//import kotlinx.coroutines.reactive.awaitFirstOrNull
//import kotlinx.coroutines.reactive.awaitSingle
//import org.bson.types.ObjectId
//import org.springframework.data.domain.Sort
//import org.springframework.data.domain.Sort.Direction
//import org.springframework.data.mongodb.core.ReactiveMongoTemplate
//import org.springframework.data.mongodb.core.aggregation.Aggregation.addFields
//import org.springframework.data.mongodb.core.aggregation.Aggregation.lookup
//import org.springframework.data.mongodb.core.aggregation.Aggregation.match
//import org.springframework.data.mongodb.core.aggregation.Aggregation.newAggregation
//import org.springframework.data.mongodb.core.aggregation.Aggregation.sort
//import org.springframework.data.mongodb.core.aggregation.Aggregation.unwind
//import org.springframework.data.mongodb.core.aggregation.AggregationOperation
//import org.springframework.data.mongodb.core.aggregation.ConvertOperators
//import org.springframework.data.mongodb.core.query.Criteria
//import org.springframework.stereotype.Service
//import space.luminic.finance.dtos.TransactionDTO
//import space.luminic.finance.models.NotFoundException
//import space.luminic.finance.models.Transaction
//import space.luminic.finance.repos.TransactionRepo
//
//@Service
//class TransactionServiceMongoImpl(
// private val mongoTemplate: ReactiveMongoTemplate,
// private val transactionRepo: TransactionRepo,
// private val categoryService: CategoryService,
//) : TransactionService {
//
//
// private fun basicAggregation(spaceId: String): List<AggregationOperation> {
// val addFieldsOI = addFields()
// .addField("createdByOI")
// .withValue(ConvertOperators.valueOf("createdById").convertToObjectId())
// .addField("updatedByOI")
// .withValue(ConvertOperators.valueOf("updatedById").convertToObjectId())
// .addField("fromAccountIdOI")
// .withValue(ConvertOperators.valueOf("fromAccountId").convertToObjectId())
// .addField("toAccountIdOI")
// .withValue(ConvertOperators.valueOf("toAccountId").convertToObjectId())
// .addField("categoryIdOI")
// .withValue(ConvertOperators.valueOf("categoryId").convertToObjectId())
// .build()
//
// val lookupFromAccount = lookup("accounts", "fromAccountIdOI", "_id", "fromAccount")
// val unwindFromAccount = unwind("fromAccount")
// val lookupToAccount = lookup("accounts", "toAccountIdOI", "_id", "toAccount")
// val unwindToAccount = unwind("toAccount", true)
//
// val lookupCategory = lookup("categories", "categoryIdOI", "_id", "category")
// val unwindCategory = unwind("category")
//
// val lookupCreatedBy = lookup("users", "createdByOI", "_id", "createdBy")
// val unwindCreatedBy = unwind("createdBy")
//
// val lookupUpdatedBy = lookup("users", "updatedByOI", "_id", "updatedBy")
// val unwindUpdatedBy = unwind("updatedBy")
// val matchCriteria = mutableListOf<Criteria>()
// matchCriteria.add(Criteria.where("spaceId").`is`(spaceId))
// val matchStage = match(Criteria().andOperator(*matchCriteria.toTypedArray()))
//
// return listOf(
// matchStage,
// addFieldsOI,
// lookupFromAccount,
// unwindFromAccount,
// lookupToAccount,
// unwindToAccount,
// lookupCategory,
// unwindCategory,
// lookupCreatedBy,
// unwindCreatedBy,
// lookupUpdatedBy,
// unwindUpdatedBy
// )
// }
//
// override suspend fun getTransactions(
// spaceId: String,
// filter: TransactionService.TransactionsFilter,
// sortBy: String,
// sortDirection: String
// ): List<Transaction> {
// val allowedSortFields = setOf("date", "amount", "category.name", "createdAt")
// require(sortBy in allowedSortFields) { "Invalid sort field: $sortBy" }
//
// val direction = when (sortDirection.uppercase()) {
// "ASC" -> Direction.ASC
// "DESC" -> Direction.DESC
// else -> throw IllegalArgumentException("Sort direction must be 'ASC' or 'DESC'")
// }
// val basicAggregation = basicAggregation(spaceId)
//
// val sort = sort(Sort.by(direction, sortBy))
// val matchCriteria = mutableListOf<Criteria>()
// filter.dateFrom?.let { matchCriteria.add(Criteria.where("date").gte(it)) }
// filter.dateTo?.let { matchCriteria.add(Criteria.where("date").lte(it)) }
// val matchStage = match(Criteria().andOperator(*matchCriteria.toTypedArray()))
// val aggregation =
// newAggregation(
// matchStage,
// *basicAggregation.toTypedArray(),
// sort
// )
//
// return mongoTemplate.aggregate(aggregation, "transactions", Transaction::class.java)
// .collectList()
// .awaitSingle()
// }
//
// override suspend fun getTransaction(
// spaceId: String,
// transactionId: String
// ): Transaction {
// val matchCriteria = mutableListOf<Criteria>()
// matchCriteria.add(Criteria.where("spaceId").`is`(spaceId))
// matchCriteria.add(Criteria.where("_id").`is`(ObjectId(transactionId)))
// val matchStage = match(Criteria().andOperator(*matchCriteria.toTypedArray()))
//
// val aggregation =
// newAggregation(
// matchStage,
// )
// return mongoTemplate.aggregate(aggregation, "transactions", Transaction::class.java)
// .awaitFirstOrNull() ?: throw NotFoundException("Transaction with ID $transactionId not found")
// }
//
// override suspend fun createTransaction(
// spaceId: String,
// transaction: TransactionDTO.CreateTransactionDTO
// ): Transaction {
// if (transaction.type == Transaction.TransactionType.TRANSFER && transaction.toAccountId == null) {
// throw IllegalArgumentException("Cannot create a transaction with type TRANSFER without a toAccountId")
// }
// val category = categoryService.getCategory(spaceId, transaction.categoryId)
// if (transaction.type != Transaction.TransactionType.TRANSFER && transaction.type.name != category.type.name) {
// throw IllegalArgumentException("Transaction type should match with category type")
// }
// val transaction = Transaction(
// spaceId = spaceId,
// type = transaction.type,
// kind = transaction.kind,
// categoryId = transaction.categoryId,
// comment = transaction.comment,
// amount = transaction.amount,
// fees = transaction.fees,
// date = transaction.date,
// fromAccountId = transaction.fromAccountId,
// toAccountId = transaction.toAccountId,
// )
// return transactionRepo.save(transaction).awaitSingle()
// }
//
// override suspend fun updateTransaction(
// spaceId: String,
// transaction: TransactionDTO.UpdateTransactionDTO
// ): Transaction {
// if (transaction.type == Transaction.TransactionType.TRANSFER && transaction.toAccountId == null) {
// throw IllegalArgumentException("Cannot edit a transaction with type TRANSFER without a toAccountId")
// }
// val exitingTx = getTransaction(spaceId, transaction.id)
// val transaction = exitingTx.copy(
// spaceId = exitingTx.spaceId,
// type = transaction.type,
// kind = transaction.kind,
// categoryId = transaction.category,
// comment = transaction.comment,
// amount = transaction.amount,
// fees = transaction.fees,
// date = transaction.date,
// fromAccountId = transaction.fromAccountId,
// toAccountId = transaction.toAccountId,
// )
// return transactionRepo.save(transaction).awaitSingle()
// }
//
// override suspend fun deleteTransaction(spaceId: String, transactionId: String) {
// val transaction = getTransaction(spaceId, transactionId)
// transaction.isDeleted = true
// transactionRepo.save(transaction).awaitSingle()
// }
//
//
//}

View File

@@ -0,0 +1,26 @@
package space.luminic.finance.services.gpt
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
@Service
class CategorizeBotScheduler(
private val seeder: CategoryJobSeeder,
private val picker: CategoryJobRepo,
private val service: CategorizeService
) {
@Scheduled(cron = "* * * * * *")
fun work() {
val jobs = picker.pickBatch(limit = 50)
if (jobs.isEmpty()) return
service.processBatch(jobs)
}
@Scheduled(cron = "* * * * * *")
fun createJob(){
seeder.seedMissing()
}
}

View File

@@ -0,0 +1,144 @@
package space.luminic.finance.services.gpt
import com.github.kotlintelegrambot.Bot
import com.github.kotlintelegrambot.entities.ChatId
import com.github.kotlintelegrambot.entities.InlineKeyboardMarkup
import com.github.kotlintelegrambot.entities.Message
import com.github.kotlintelegrambot.entities.ParseMode
import com.github.kotlintelegrambot.entities.keyboard.InlineKeyboardButton
import com.github.kotlintelegrambot.entities.keyboard.WebAppInfo
import com.github.kotlintelegrambot.entities.reaction.ReactionType
import com.github.kotlintelegrambot.types.TelegramBotResult
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import space.luminic.finance.models.Transaction
import space.luminic.finance.repos.CategoryRepo
import space.luminic.finance.repos.TransactionRepo
enum class JobStatus { NEW, PROCESSING, DONE, FAILED }
data class CategoryResult(val categoryId: Int)
@Service
class CategorizeService(
private val transactionRepo: TransactionRepo,
@Qualifier("dsCategorizationService") private val gpt: GptClient,
@Value("\${app.categorize.parallel:4}") private val parallel: Int,
private val categoriesRepo: CategoryRepo,
private val categoryJobRepo: CategoryJobRepo,
private val bot: Bot
) {
private val exec = java.util.concurrent.Executors.newFixedThreadPool(parallel)
fun processBatch(jobs: List<CategoryJob>) {
jobs.forEach { job ->
exec.submit {
runCatching {
val tx = transactionRepo.findBySpaceIdAndId(job.spaceId, job.txId)
?: throw IllegalArgumentException("Transaction ${job.txId} not found")
val res = gpt.suggestCategory(
tx,
categoriesRepo.findBySpaceId(job.spaceId)
) // тут твой вызов GPT
var message: TelegramBotResult<Message>? = null
if (res.categoryId == 0) {
if (tx.tgChatId != null && tx.tgMessageId != null) {
bot.setMessageReaction(
ChatId.fromId(tx.tgChatId),
tx.tgMessageId,
listOf(ReactionType.Emoji("💔")),
isBig = false
)
message = bot.sendMessage(
ChatId.fromId(tx.tgChatId),
replyToMessageId = tx.tgMessageId,
text = "К сожалению, мы не смогли распознать категорию.\n\nПопробуйте выставить ее самостоятельно.",
replyMarkup = InlineKeyboardMarkup.create(
listOf(
InlineKeyboardButton.WebApp(
"Открыть в WebApp",
WebAppInfo("https://app.luminic.space/transactions/${tx.id}/edit?mode=from_bot")
)
)
)
)
}
} else {
transactionRepo.setCategory(job.txId, res.categoryId)
if (tx.tgChatId != null && tx.tgMessageId != null) {
bot.setMessageReaction(
ChatId.fromId(tx.tgChatId),
tx.tgMessageId,
listOf(ReactionType.Emoji("👌")),
isBig = false
)
val category = categoriesRepo.findBySpaceIdAndId(job.spaceId, res.categoryId)
if (category != null) {
message = bot.sendMessage(
ChatId.fromId(tx.tgChatId),
replyToMessageId = tx.tgMessageId,
text = "Определили категорию: <b>${category.name}</b>.\n\nЕсли это не так, исправьте это в WebApp.",
replyMarkup = InlineKeyboardMarkup.create(
listOf(
InlineKeyboardButton.WebApp(
"Открыть в WebApp",
WebAppInfo("https://app.luminic.space/transactions/${tx.id}/edit?mode=from_bot")
)
)
),
parseMode = ParseMode.HTML
)
}
}
}
if (message != null) {
categoryJobRepo.successJob(
job.id,
message.get().chat.id,
message.get().messageId
)
} else {
categoryJobRepo.successJob(job.id)
}
}.onFailure { e ->
print(e.localizedMessage)
categoryJobRepo.failJob(job.id, e.localizedMessage)
}
}
}
}
fun notifyThatCategorySelected(tx: Transaction) {
val job = categoryJobRepo.getJobByTxId(tx.id!!)
job?.let {
if (tx.tgChatId != null && tx.tgMessageId != null) {
bot.setMessageReaction(
ChatId.fromId(tx.tgChatId),
tx.tgMessageId,
listOf(ReactionType.Emoji("👌"))
)
}
if (it.chatId != null && it.messageId != null) {
bot.editMessageText(
ChatId.fromId(it.chatId),
messageId = it.messageId,
text = "Выбрана: <b>${tx.category!!.name}</b>.\n\nЕсли это не так, измените это в WebApp.",
replyMarkup = InlineKeyboardMarkup.create(
listOf(
InlineKeyboardButton.WebApp(
"Открыть в WebApp",
WebAppInfo("https://app.luminic.space/transactions/${tx.id}/edit?mode=from_bot")
)
)
),
parseMode = ParseMode.HTML
)
}
}
}
}

View File

@@ -0,0 +1,97 @@
package space.luminic.finance.services.gpt
import com.github.kotlintelegrambot.Bot
import com.github.kotlintelegrambot.entities.ChatId
import com.github.kotlintelegrambot.entities.reaction.ReactionType
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
import org.springframework.stereotype.Repository
import org.springframework.transaction.annotation.Transactional
data class CategoryJob(
val id: Long,
val spaceId: Int,
val txId: Int,
val attempts: Int,
val chatId: Long? = null,
val messageId: Long? = null
)
@Repository
class CategoryJobRepo(
private val np: NamedParameterJdbcTemplate,
private val bot: Bot
) {
@Transactional
fun pickBatch(limit: Int = 50): List<CategoryJob> {
val selectSql = """
SELECT cj.id as cj_id, cj.tx_id as cj_tx_id, cj.attempts as cj_attempts, s.id as s_id
FROM finance.category_jobs cj
JOIN finance.transactions t on cj.tx_id = t.id
JOIN finance.spaces s on t.space_id = s.id
WHERE status IN ('NEW', 'FAILED')
ORDER BY cj.created_at
FOR UPDATE SKIP LOCKED
LIMIT :limit
""".trimIndent()
val jobs = np.query(selectSql, mapOf("limit" to limit)) { rs, _ ->
CategoryJob(
id = rs.getLong("cj_id"),
spaceId = rs.getInt("s_id"),
txId = rs.getInt("cj_tx_id"),
attempts = rs.getInt("cj_attempts")
)
}
if (jobs.isNotEmpty()) {
val updateSql = """
UPDATE finance.category_jobs
SET status = 'PROCESSING',
attempts = attempts + 1,
started_at = NOW()
WHERE id IN (:ids)
""".trimIndent()
np.update(updateSql, mapOf("ids" to jobs.map { it.id }))
}
return jobs
}
@Transactional
fun successJob(id: Long, chatId: Long? = null, messageId: Long? = null) {
val sql =
"""UPDATE finance.category_jobs SET status = 'DONE', finished_at = now(), tg_chat_id = :chatId, tg_message_id = :messageId WHERE id = :id """.trimIndent()
np.update(sql, mapOf("id" to id, "chatId" to chatId, "messageId" to messageId))
}
@Transactional
fun failJob(id: Long, errorMessage: String?) {
val sql =
"""UPDATE finance.category_jobs SET status = 'FAILED', last_error = :message WHERE id = :id """.trimIndent()
np.update(sql, mapOf("id" to id, "errorMessage" to errorMessage))
}
@Transactional
fun getJobByTxId(txId: Int): CategoryJob? {
val selectSql = """
SELECT cj.id as cj_id, cj.tx_id as cj_tx_id, cj.attempts as cj_attempts, s.id as s_id, cj.tg_chat_id as cj_chat_id, cj.tg_message_id as cj_message_id
FROM finance.category_jobs cj
JOIN finance.transactions t on cj.tx_id = t.id
JOIN finance.spaces s on t.space_id = s.id
WHERE cj.tx_id = :txId
""".trimIndent()
val jobs = np.query(selectSql, mapOf("txId" to txId), { rs, _ ->
CategoryJob(
id = rs.getLong("cj_id"),
spaceId = rs.getInt("s_id"),
txId = rs.getInt("cj_tx_id"),
attempts = rs.getInt("cj_attempts"),
chatId = rs.getLong("cj_chat_id"),
messageId = rs.getLong("cj_message_id")
)
})
return jobs.firstOrNull()
}
}

View File

@@ -0,0 +1,31 @@
package space.luminic.finance.services.gpt
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
import org.springframework.stereotype.Repository
import org.springframework.transaction.annotation.Transactional
@Repository
class CategoryJobSeeder(private val np: NamedParameterJdbcTemplate) {
/**
* Создаёт задачи для всех транзакций без категории.
* Ограничь лимит, чтобы не захлестнуть очередь.
*/
@Transactional
fun seedMissing(limit: Int = 1000) : Int {
val sql = """
INSERT INTO finance.category_jobs (tx_id)
SELECT t.id
FROM finance.transactions t
WHERE t.category_id IS NULL
AND t.is_deleted = false
AND NOT EXISTS (
SELECT 1 FROM finance.category_jobs j WHERE j.tx_id = t.id
)
ORDER BY t.date DESC
LIMIT :limit
ON CONFLICT (tx_id) DO NOTHING
""".trimIndent()
return np.update(sql, mapOf("limit" to limit))
}
}

View File

@@ -0,0 +1,80 @@
package space.luminic.finance.services.gpt
import okhttp3.*
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody.Companion.toRequestBody
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import space.luminic.finance.models.Category
import space.luminic.finance.models.Transaction
@Service("dsCategorizationService")
class DeepSeekCategorizationService(
@Value("\${ds.api_key}") private val apiKey: String,
) : GptClient {
private val endpoint = "https://api.deepseek.com/v1"
private val mapper = jacksonObjectMapper()
private val client = OkHttpClient()
private val logger = LoggerFactory.getLogger(javaClass)
override fun suggestCategory(tx: Transaction, categories: List<Category>): CategorySuggestion {
val catList = categories.joinToString("\n") { "- ${it.id}: ${it.name}" }
val txInfo = """
{ \"amount\": ${tx.amount}, \"comment\": \"${tx.comment}\", \"date\":${tx.date}\" }
""".trimIndent()
val prompt = """
Пользователь имеет следующие категории:
$catList
Задача:
1. Определите наиболее подходящую категорию из списка выше для транзакции пользователя.
2. Верните ответ в формате: ID категории", например 3.
3. Если ни одна категория из списка не подходит, верните: 0.
Ответ должен быть кратким, одной строкой, без дополнительных пояснений.
""".trimIndent()
val body = mapOf(
"model" to "deepseek-chat",
"messages" to listOf(
mapOf("role" to "assistant", "content" to prompt),
mapOf("role" to "user", "content" to txInfo)
)
)
val jsonBody = mapper.writeValueAsString(body)
val requestBody = jsonBody.toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull())
val request = Request.Builder()
.url("${endpoint}/chat/completions")
.addHeader("Authorization", "Bearer $apiKey")
.post(requestBody)
.build()
println(request)
logger.info(request.toString())
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) error("Qwen error: ${response.code} ${response.body?.string()}")
val bodyStr = response.body?.string().orEmpty()
// Берём content из choices[0].message.content
val root = mapper.readTree(bodyStr)
val text = root["choices"]?.get(0)?.get("message")?.get("content")?.asText()
?: error("No choices[0].message.content in response")
// Парсим "ID Название (вероятность)"
// val regex = Regex("""^\s*(\d+)\s*[-]\s*(.+?)\s*\((0(?:\.\d+)?|1(?:\.0)?)\)\s*$""")
// val match = regex.find(text.trim()) ?: error("Bad format: '$text'")
// val (idStr, name, confStr) = match.destructured
val idStr = text
return CategorySuggestion(idStr.toInt(), )
}
}
}

View File

@@ -0,0 +1,10 @@
package space.luminic.finance.services.gpt
import space.luminic.finance.models.Category
import space.luminic.finance.models.Transaction
data class CategorySuggestion(val categoryId: Int, val categoryName: String? = null, val confidence: Double? = null)
interface GptClient {
fun suggestCategory(tx: Transaction, categories: List<Category>): CategorySuggestion
}

View File

@@ -0,0 +1,78 @@
package space.luminic.finance.services.gpt
import okhttp3.*
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody.Companion.toRequestBody
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import space.luminic.finance.models.Category
import space.luminic.finance.models.Transaction
@Service("qwenCategorizationService")
class QwenCategorizationService(
@Value("\${qwen.api_key}") private val apiKey: String,
) : GptClient {
private val endpoint = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
private val mapper = jacksonObjectMapper()
private val client = OkHttpClient()
private val logger = LoggerFactory.getLogger(javaClass)
override fun suggestCategory(tx: Transaction, categories: List<Category>): CategorySuggestion {
val catList = categories.joinToString("\n") { "- ${it.id}: ${it.name}" }
val txInfo = """
{ \"amount\": ${tx.amount}, \"comment\": \"${tx.comment}\", \"date\":${tx.date}\" }
""".trimIndent()
val prompt = """
Пользователь имеет следующие категории:
$catList
Задача:
1. Определите наиболее подходящую категорию из списка выше для транзакции пользователя.
2. Верните ответ в формате: "ID категории имя категории (вероятность)", например "3 Продукты (0.87)".
3. Если ни одна категория из списка не подходит, верните: "0 Другое (вероятность)".
Ответ должен быть кратким, одной строкой, без дополнительных пояснений.
""".trimIndent()
val body = mapOf(
"model" to "qwen-plus",
"messages" to listOf(
mapOf("role" to "assistant", "content" to prompt),
mapOf("role" to "user", "content" to txInfo)
)
)
val jsonBody = mapper.writeValueAsString(body)
val requestBody = jsonBody.toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull())
val request = Request.Builder()
.url("${endpoint}/chat/completions")
.addHeader("Authorization", "Bearer $apiKey")
.post(requestBody)
.build()
println(request)
logger.info(request.toString())
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) error("Qwen error: ${response.code} ${response.body?.string()}")
val bodyStr = response.body?.string().orEmpty()
// Берём content из choices[0].message.content
val root = mapper.readTree(bodyStr)
val text = root["choices"]?.get(0)?.get("message")?.get("content")?.asText()
?: error("No choices[0].message.content in response")
// Парсим "ID Название (вероятность)"
val regex = Regex("""^\s*(\d+)\s*[-]\s*(.+?)\s*\((0(?:\.\d+)?|1(?:\.0)?)\)\s*$""")
val match = regex.find(text.trim()) ?: error("Bad format: '$text'")
val (idStr, name, confStr) = match.destructured
return CategorySuggestion(idStr.toInt(), name, confStr.toDouble())
}
}
}

View File

@@ -0,0 +1,253 @@
package space.luminic.finance.services.telegram
import com.github.kotlintelegrambot.Bot
import com.github.kotlintelegrambot.dispatch
import com.github.kotlintelegrambot.dispatcher.callbackQuery
import com.github.kotlintelegrambot.dispatcher.command
import com.github.kotlintelegrambot.dispatcher.message
import com.github.kotlintelegrambot.entities.ChatId
import com.github.kotlintelegrambot.entities.InlineKeyboardMarkup
import com.github.kotlintelegrambot.entities.ParseMode
import com.github.kotlintelegrambot.entities.keyboard.InlineKeyboardButton
import com.github.kotlintelegrambot.entities.keyboard.WebAppInfo
import com.github.kotlintelegrambot.entities.reaction.ReactionType
import com.github.kotlintelegrambot.extensions.filters.Filter
import com.github.kotlintelegrambot.logging.LogLevel
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import space.luminic.finance.dtos.TransactionDTO
import space.luminic.finance.models.NotFoundException
import space.luminic.finance.models.State
import space.luminic.finance.models.Transaction
import space.luminic.finance.models.User
import space.luminic.finance.repos.BotRepo
import space.luminic.finance.services.UserService
import java.time.LocalDate
@Service
class BotService(
@Value("\${telegram.bot.token}") private val botToken: String,
private val userService: UserService,
@Qualifier("spaceServiceTelegram") private val spaceService: SpaceService,
private val botRepo: BotRepo,
@Qualifier("transactionsServiceTelegram") private val transactionService: TransactionService
) {
private fun buildSpaceSelector(userId: Int): InlineKeyboardMarkup {
val spaces = spaceService.getSpaces(userId)
val keyboard = mutableListOf<List<InlineKeyboardButton>>()
val row = mutableListOf<InlineKeyboardButton>()
if (spaces.isNotEmpty()) {
for ((index, space) in spaces.withIndex()) {
val button =
InlineKeyboardButton.CallbackData(text = space.name, callbackData = "select_space_${space.id}")
row.add(button)
// Если 2 кнопки в строке — отправляем строку и очищаем
if (row.size == 2) {
keyboard.add(ArrayList(row))
row.clear()
}
}
// Если осталась 1 кнопка — добавляем последнюю строку
if (row.isNotEmpty()) {
keyboard.add(ArrayList(row))
}
} else {
row.add(InlineKeyboardButton.CallbackData("Создать пространство!", callbackData = "create_space"))
keyboard.add(ArrayList(row))
}
return InlineKeyboardMarkup.Companion.create(keyboard)
}
@Transactional
fun selectSpace(tgUserId: Long, selectedSpaceId: Int) {
val user = userService.getUserByTelegramId(tgUserId)
botRepo.setState(
user.id!!, State.StateCode.SPACE_SELECTED, mapOf(
"selected_space" to selectedSpaceId.toString(),
)
)
}
private fun buildRegister() {
}
private fun buildMenu(tgUserId: Long): InlineKeyboardMarkup {
val user = userService.getUserByTelegramId(tgUserId)
val userId = requireNotNull(user.id) { "User must have id" }
val state = botRepo.getState(tgUserId)
val spaceId = state?.data?.get("selected_space")?.toIntOrNull()
val space = spaceId?.let { id -> spaceService.getSpace(spaceId, userId) }
val keyboard = mutableListOf<List<InlineKeyboardButton>>()
// Кнопка с названием выбранного space (или плейсхолдером)
keyboard.add(
listOf(
InlineKeyboardButton.CallbackData(
text = space?.name ?: "Select space",
"select_space"
)
)
)
// Если нужен второй баттон — сделай другой смысл/текст; иначе этот блок можно убрать
keyboard.add(
listOf(
InlineKeyboardButton.WebApp(
text = "Открыть WebApp",
webApp = WebAppInfo(url = "https://app.luminic.space")
)
)
)
return InlineKeyboardMarkup.Companion.create(keyboard)
}
@Bean
fun bot(): Bot {
val bot = com.github.kotlintelegrambot.bot {
logLevel = LogLevel.None
token = botToken
dispatch {
message(Filter.Text) {
val fromId = message.from?.id ?: throw IllegalArgumentException("user is empty")
val user = userService.getUserByTelegramId(fromId)
val state = botRepo.getState(message.from?.id ?: throw IllegalArgumentException("user is empty"))
when (state?.state) {
State.StateCode.SPACE_SELECTED -> {
try {
val parts = message.text!!.trim().split(" ", limit = 2)
if (parts.isEmpty()) {
bot.sendMessage(
chatId = ChatId.fromId(message.chat.id),
text = "Введите сумму и комментарий, например: `250 обед`",
parseMode = ParseMode.MARKDOWN
)
}
val amount = parts[0].toIntOrNull()
?: throw IllegalArgumentException("Сумма транзакции не число!")
if (amount <= 0) {
throw IllegalArgumentException("Сумма не может быть меньше 1.")
}
val comment = parts.getOrNull(1)?.trim().orEmpty()
if (comment.isEmpty()) throw IllegalArgumentException("Комментарий не может быть пустым.")
// bot.sendMessage(
// chatId = ChatId.fromId(message.chat.id),
// text = "Принято: сумма = $amount, комментарий = \"$comment\""
// )
try {
transactionService.createTransaction(
state.data["selected_space"]?.toInt()
?: throw IllegalArgumentException("selected space is empty"),
user.id!!,
TransactionDTO.CreateTransactionDTO(
Transaction.TransactionType.EXPENSE,
Transaction.TransactionKind.INSTANT,
comment = comment,
amount = amount.toBigDecimal(),
date = LocalDate.now(),
),
message.chat.id,
message.messageId
)
bot.setMessageReaction(
chatId = ChatId.fromId(message.chat.id),
messageId = message.messageId,
reaction = listOf(ReactionType.Emoji("🤝")),
isBig = false
)
} catch (e: IllegalArgumentException) {
bot.sendMessage(
ChatId.Companion.fromId(message.chat.id),
text = "Кажется у вас не выбран Space",
replyMarkup = buildSpaceSelector(user.id!!)
)
}
} catch (e: IllegalArgumentException) {
bot.sendMessage(
chatId = ChatId.Companion.fromId(message.chat.id),
text = "Ошибка: ${e.message}"
)
}
}
else -> {}
}
}
callbackQuery {
if (callbackQuery.data.startsWith("select_space_")) {
val spaceId = callbackQuery.data.substringAfter("select_space_").toInt()
println(spaceId)
try {
selectSpace(callbackQuery.from.id, spaceId)
bot.editMessageText(
chatId = ChatId.Companion.fromId(callbackQuery.message!!.chat.id),
messageId = callbackQuery.message!!.messageId,
text = "Успешно!\n\nМы готовы принимать Ваши транзакции.\n\nПросто пишите их в формате:\n\n <i>сумма комментарий</i>\n\n <b>Первой обязательно должна быть сумма!</b>",
parseMode = ParseMode.HTML,
replyMarkup = buildMenu(callbackQuery.from.id)
)
} catch (e: NotFoundException) {
e.printStackTrace()
bot.sendMessage(
ChatId.Companion.fromId(callbackQuery.message!!.chat.id),
text = "Мы кажется не знакомы"
)
}
} else if (callbackQuery.data.equals("select_space", ignoreCase = true)) {
bot.editMessageText(
ChatId.Companion.fromId(callbackQuery.message!!.chat.id),
callbackQuery.message!!.messageId,
text = "Выберите новое пространство",
replyMarkup = buildSpaceSelector(
userService.getUserByTelegramId(callbackQuery.from.id).id!!
)
)
}
}
command("start") {
val user: User
try {
user = userService.getUserByTelegramId(
message.from?.id ?: throw IllegalArgumentException("User not found")
)
bot.sendMessage(
ChatId.Companion.fromId(message.chat.id),
text = "Привет!\n\nРады тебя снова видеть!\n\nНачнем с выбора пространства:",
replyMarkup = buildSpaceSelector(user.id!!)
)
} catch (e: NotFoundException) {
bot.sendMessage(ChatId.Companion.fromId(message.chat.id), text = "Кажется, мы еще не знакомы.")
bot.sendMessage(ChatId.Companion.fromId(message.chat.id), text = "Давайте зарегистрируемся? ")
bot.sendMessage(ChatId.Companion.fromId(message.chat.id), text = "")
}
}
}
}
bot.startPolling()
return bot
}
}

View File

@@ -0,0 +1,8 @@
package space.luminic.finance.services.telegram
import space.luminic.finance.models.Space
interface SpaceService {
fun getSpaces(userId: Int): List<Space>
fun getSpace(spaceId: Int, userId: Int): Space?
}

View File

@@ -0,0 +1,23 @@
package space.luminic.finance.services.telegram
import org.springframework.stereotype.Service
import space.luminic.finance.models.NotFoundException
import space.luminic.finance.models.Space
import space.luminic.finance.repos.SpaceRepo
@Service("spaceServiceTelegram")
class SpaceServiceImpl(
private val spaceRepo: SpaceRepo
) : SpaceService {
override fun getSpaces(userId: Int): List<Space> {
val spaces = spaceRepo.findSpacesAvailableForUser(userId)
return spaces
}
override fun getSpace(spaceId: Int, userId: Int): Space? {
val space =
spaceRepo.findSpaceById(spaceId, userId) ?: throw NotFoundException("Space with id $spaceId not found")
return space
}
}

View File

@@ -0,0 +1,8 @@
package space.luminic.finance.services.telegram
import space.luminic.finance.dtos.TransactionDTO
interface TransactionService {
fun createTransaction(spaceId: Int, userId: Int, transaction: TransactionDTO.CreateTransactionDTO, chatId: Long, messageId: Long ): Int
}

View File

@@ -0,0 +1,43 @@
package space.luminic.finance.services.telegram
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.stereotype.Service
import space.luminic.finance.dtos.TransactionDTO
import space.luminic.finance.models.Transaction
import space.luminic.finance.repos.TransactionRepo
import space.luminic.finance.services.CategoryServiceImpl
@Service("transactionsServiceTelegram")
class TransactionsServiceImpl(
private val transactionRepo: TransactionRepo,
@Qualifier("spaceServiceTelegram") private val spaceService: SpaceService,
private val categoryService: CategoryServiceImpl
): TransactionService {
override fun createTransaction(
spaceId: Int,
userId: Int,
transaction: TransactionDTO.CreateTransactionDTO,
chatId: Long,
messageId: Long
): Int {
val space = spaceService.getSpace(spaceId, userId)
val category = transaction.categoryId?.let { categoryService.getCategory(spaceId, it) }
val transaction = Transaction(
space = space,
type = transaction.type,
kind = transaction.kind,
category = category,
comment = transaction.comment,
amount = transaction.amount,
fees = transaction.fees,
date = transaction.date,
tgChatId = chatId,
tgMessageId = messageId,
)
return transactionRepo.create(transaction, userId)
}
}

View File

@@ -9,16 +9,16 @@ logging.level.org.springframework.security = DEBUG
#logging.level.org.springframework.data.mongodb.code = DEBUG #logging.level.org.springframework.data.mongodb.code = DEBUG
logging.level.org.springframework.web.reactive=DEBUG logging.level.org.springframework.web.reactive=DEBUG
logging.level.org.mongodb.driver.protocol.command = DEBUG logging.level.org.mongodb.driver.protocol.command = DEBUG
logging.level.org.springframework.jdbc.core=DEBUG logging.level.org.springframework.jdbc.core=INFO
logging.level.org.springframework.jdbc.core.StatementCreatorUtils=TRACE logging.level.org.springframework.jdbc.core.StatementCreatorUtils=INFO
logging.level.org.springframework.jdbc=DEBUG logging.level.org.springframework.jdbc=INFO
logging.level.org.springframework.jdbc.datasource=DEBUG logging.level.org.springframework.jdbc.datasource=INFO
logging.level.org.springframework.jdbc.support=DEBUG logging.level.org.springframework.jdbc.support=INFO
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE
management.endpoints.web.exposure.include=* management.endpoints.web.exposure.include=*
management.endpoint.health.show-details=always management.endpoint.health.show-details=always
telegram.bot.token=6972242509:AAGyXuL3T-BNE4XMoo_qvtaYxw_SuiS_dDs # vector test
telegram.bot.token=8127199836:AAEPepyKDAf8PvFpw-fpxBXUuPdx_LS20fI
nlp.address=http://127.0.0.1:8000 nlp.address=http://127.0.0.1:8000

View File

@@ -1,5 +1,3 @@
spring.application.name=budger-app
spring.data.mongodb.uri=mongodb://budger-app:BA1q2w3e4r!@luminic.space:27017/budger-app-v2?authSource=admin&minPoolSize=10&maxPoolSize=100
logging.level.org.springframework.web=INFO logging.level.org.springframework.web=INFO
logging.level.org.springframework.data = INFO logging.level.org.springframework.data = INFO
@@ -9,15 +7,12 @@ logging.level.org.springframework.data.mongodb.code = INFO
logging.level.org.springframework.web.reactive=INFO logging.level.org.springframework.web.reactive=INFO
logging.level.org.mongodb.driver.protocol.command = INFO logging.level.org.mongodb.driver.protocol.command = INFO
#management.endpoints.web.exposure.include=*
#management.endpoint.metrics.access=read_only
telegram.bot.token = 7999296388:AAGXPE5r0yt3ZFehBoUh8FGm5FBbs9pYIks telegram.bot.token = 7999296388:AAGXPE5r0yt3ZFehBoUh8FGm5FBbs9pYIks
nlp.address=https://nlp.luminic.space nlp.address=https://nlp.luminic.space
spring.datasource.url=jdbc:postgresql://postgresql:5432/luminic-space-db #spring.datasource.url=jdbc:postgresql://postgresql:5432/luminic-space-db
spring.datasource.url=jdbc:postgresql://213.226.71.138:5432/luminic-space-db
spring.datasource.username=luminicspace spring.datasource.username=luminicspace
spring.datasource.password=LS1q2w3e4r! spring.datasource.password=LS1q2w3e4r!

View File

@@ -17,17 +17,22 @@ spring.servlet.multipart.max-request-size=10MB
storage.location: static storage.location: static
spring.jackson.default-property-inclusion=non_null spring.jackson.default-property-inclusion=non_null
# Expose prometheus, health, and info endpoints management.endpoints.web.exposure.include=health,info,prometheus
#management.endpoints.web.exposure.include=prometheus,health,info
management.endpoints.web.exposure.include=*
# Enable Prometheus metrics export #management.endpoint.prometheus.access=unrestricted
management.endpoint.prometheus.enabled=true
management.prometheus.metrics.export.enabled=true management.prometheus.metrics.export.enabled=true
management.metrics.tags.application=luminic-app
telegram.bot.username = expenses_diary_bot telegram.bot.username = expenses_diary_bot
spring.flyway.enabled=true spring.flyway.enabled=true
spring.flyway.locations=classpath:db/migration spring.flyway.locations=classpath:db/migration
spring.flyway.baseline-on-migrate= false spring.flyway.baseline-on-migrate= false
spring.flyway.schemas=finance spring.flyway.schemas=finance
spring.jpa.properties.hibernate.default_schema=finance spring.jpa.properties.hibernate.default_schema=finance
spring.jpa.properties.hibernate.default_batch_fetch_size=50 spring.jpa.properties.hibernate.default_batch_fetch_size=50
qwen.api_key=sk-991942d15b424cc89513498bb2946045
ds.api_key=sk-b5949728e79747f08af0a1d65bc6a7a2

View File

@@ -0,0 +1,2 @@
ALTER TABLE finance.users
ADD CONSTRAINT uq_users_username UNIQUE (username);

View File

@@ -0,0 +1,21 @@
create table if not exists finance.bot_states
(
user_id integer primary key,
state_code varchar not null
);
create table if not exists finance.bot_states_data
(
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
user_id integer not null,
data_code varchar(255) not null,
data_value varchar(255) not null,
CONSTRAINT pk_bot_states_data PRIMARY KEY (id)
);
ALTER TABLE finance.bot_states
ADD CONSTRAINT FK_STATE_ON_USER FOREIGN KEY (user_id) REFERENCES finance.users (id);
ALTER TABLE finance.bot_states
ADD CONSTRAINT FK_STATE_DATA_ON_USER FOREIGN KEY (user_id) REFERENCES finance.users (id);

View File

@@ -0,0 +1,3 @@
-- уникальное ограничение под ON CONFLICT (user_id, data_code)
ALTER TABLE finance.bot_states_data
ADD CONSTRAINT ux_bot_states_data_user_code UNIQUE (user_id, data_code);

View File

@@ -0,0 +1,3 @@
-- уникальное ограничение под ON CONFLICT (user_id, data_code)
ALTER TABLE finance.transactions
ALTER COLUMN category_id DROP NOT NULL;

View File

@@ -0,0 +1,19 @@
-- Очередь классификации категорий
CREATE TABLE IF NOT EXISTS finance.category_jobs (
id BIGSERIAL PRIMARY KEY,
tx_id INT NOT NULL UNIQUE, -- одна задача на транзакцию
status TEXT NOT NULL DEFAULT 'NEW', -- NEW | PROCESSING | DONE | FAILED
attempts INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
last_error TEXT
);
-- Быстрые выборки по статусу
CREATE INDEX IF NOT EXISTS ix_category_jobs_status ON finance.category_jobs(status);
-- (опционально) защита от «зависших» задач
CREATE INDEX IF NOT EXISTS ix_category_jobs_processing_time
ON finance.category_jobs(started_at)
WHERE status = 'PROCESSING';

View File

@@ -0,0 +1,3 @@
alter table finance.transactions
add column tg_chat_id bigint null,
add column tg_message_id bigint null;

View File

@@ -0,0 +1,3 @@
alter table finance.category_jobs
add column tg_chat_id bigint null,
add column tg_message_id bigint null;

View File

@@ -0,0 +1,5 @@
ALTER TABLE finance.recurrent_operations
ADD CONSTRAINT recurrent_operations_pk PRIMARY KEY (id);
alter table finance.transactions
add column recurrent_id integer null,
ADD CONSTRAINT FK_RECURRENTS FOREIGN KEY (recurrent_id) REFERENCES finance.recurrent_operations (id);

View File

@@ -0,0 +1,59 @@
DROP table if exists finance.goals cascade;
DROP table if exists finance.goals_components cascade;
DROP table if exists finance.goals_transactions cascade;
DROP table if exists finance.targets cascade;
DROP table if exists finance.targets_components cascade;
DROP table if exists finance.targets_transactions cascade;
CREATE TABLE finance.targets
(
id integer generated by default as identity not null,
space_id INTEGER,
type SMALLINT,
name VARCHAR(255),
description VARCHAR(255),
amount DECIMAL,
until_date date,
created_by_id INTEGER,
created_at TIMESTAMP WITHOUT TIME ZONE,
updated_by_id INTEGER,
updated_at TIMESTAMP WITHOUT TIME ZONE,
CONSTRAINT pk_targets PRIMARY KEY (id)
);
ALTER TABLE finance.targets
ADD CONSTRAINT FK_TARGETS_ON_CREATEDBY FOREIGN KEY (created_by_id) REFERENCES finance.users (id);
ALTER TABLE finance.targets
ADD CONSTRAINT FK_TARGETS_ON_SPACE FOREIGN KEY (space_id) REFERENCES finance.spaces (id);
ALTER TABLE finance.targets
ADD CONSTRAINT FK_TARGETS_ON_UPDATEDBY FOREIGN KEY (updated_by_id) REFERENCES finance.users (id);
CREATE TABLE finance.targets_transactions
(
target_id INTEGER NOT NULL,
transactions_id INTEGER NOT NULL
);
ALTER TABLE finance.targets_transactions
ADD CONSTRAINT fk_targettx_on_target FOREIGN KEY (target_id) REFERENCES finance.targets (id);
ALTER TABLE finance.targets_transactions
ADD CONSTRAINT fk_targettx_on_tx FOREIGN KEY (transactions_id) REFERENCES finance.transactions (id);
create table if not exists finance.targets_components
(
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
target_id INTEGER NOT NULL,
name VARCHAR(255) NOT NULL,
amount NUMERIC NOT NULL,
is_done BOOLEAN NOT NULL DEFAULT FALSE,
date TIMESTAMP WITH TIME ZONE NULL
);
alter table finance.targets_components
add constraint fk_target_on_components foreign key (target_id) references finance.targets (id);