3 Commits

Author SHA1 Message Date
xds
90f1a3ce08 build 2025-10-31 17:38:48 +03:00
xds
f1b3fb34bd build 2025-10-31 17:33:49 +03:00
xds
5d8bfa236d build 2025-10-31 17:24:29 +03:00
75 changed files with 658 additions and 2833 deletions

View File

@@ -1,14 +1,20 @@
FROM eclipse-temurin:17-jre AS runtime
WORKDIR /app
# (Опционально) установить curl для healthcheck
USER root
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 mkdir -p /app/static && chown -R app:app /app
USER app
# ❗ копируем jar, собранный локально
COPY build/libs/*.jar /app/app.jar
ENV JAVA_TOOL_OPTIONS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0"
EXPOSE 8080
HEALTHCHECK --interval=20s --timeout=3s --retries=3 CMD curl -fsS http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["java","-jar","/app/luminic-space-v2.jar"]
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

View File

@@ -29,11 +29,8 @@ configurations {
repositories {
mavenCentral()
maven { url = uri("https://jitpack.io") }
}
dependencies {
// Spring
implementation("org.springframework.boot:spring-boot-starter-cache")
@@ -56,19 +53,12 @@ dependencies {
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("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:1.7.3")
implementation("org.jetbrains.kotlin.plugin.jpa:org.jetbrains.kotlin.plugin.jpa.gradle.plugin:1.9.25")
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.17.0")
implementation("io.jsonwebtoken:jjwt-api:0.11.5")
implementation("io.jsonwebtoken:jjwt-impl:0.11.5")
@@ -77,11 +67,10 @@ dependencies {
implementation("io.micrometer:micrometer-registry-prometheus")
// implementation("org.telegram:telegrambots:6.9.7.1")
// implementation("org.telegram:telegrambots-spring-boot-starter:6.9.7.1")
implementation("org.telegram:telegrambots:6.9.7.1")
implementation("org.telegram:telegrambots-spring-boot-starter:6.9.7.1")
implementation("com.opencsv:opencsv:5.10")
implementation("io.github.kotlin-telegram-bot.kotlin-telegram-bot:telegram:6.3.0")
compileOnly("org.projectlombok:lombok")
annotationProcessor("org.projectlombok:lombok")

Binary file not shown.

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
package space.luminic.finance.api
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonBuilder
import org.apache.commons.codec.digest.DigestUtils.sha256
import org.apache.commons.codec.digest.HmacUtils.hmacSha256
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.security.core.context.SecurityContextHolder
@@ -13,7 +13,6 @@ import space.luminic.finance.dtos.UserDTO.RegisterUserDTO
import space.luminic.finance.mappers.UserMapper.toDto
import space.luminic.finance.mappers.UserMapper.toTelegramMap
import space.luminic.finance.services.AuthService
import java.net.URLDecoder
import java.security.MessageDigest
import java.time.Instant
import javax.crypto.Mac
@@ -28,83 +27,28 @@ class AuthController(
private val logger = LoggerFactory.getLogger(javaClass)
fun verifyTelegramAuth(
loginData: Map<String, String>? = null, // from login widget
webAppInitData: String? = null
): Boolean {
fun verifyTelegramAuth(data: Map<String, String>, botToken: String): Boolean {
val hash = data["hash"] ?: return false
// --- LOGIN WIDGET CHECK ---
if (loginData != null) {
val hash = loginData["hash"]
if (hash != null) {
val dataCheckString = loginData
val dataCheckString = data
.filterKeys { it != "hash" }
.toSortedMap()
.map { "${it.key}=${it.value}" }
.joinToString("\n")
val secretKey = MessageDigest.getInstance("SHA-256")
.digest(botToken.toByteArray())
val secretKey = sha256(botToken)
val hmacHex = hmacSha256(secretKey, dataCheckString)
val hmac = Mac.getInstance("HmacSHA256").apply {
init(SecretKeySpec(secretKey, "HmacSHA256"))
}.doFinal(dataCheckString.toByteArray())
.joinToString("") { "%02x".format(it) }
if (hmacHex != hash) return false
val authDate = loginData["auth_date"]?.toLongOrNull() ?: return false
if (Instant.now().epochSecond - authDate > 3600) return false
if (hmac == hash) 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 authDate = data["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
}
// Опционально — запрет старых ответов (например, старше 1 часа)
val maxAgeSeconds = 3600
if (now - authDate > maxAgeSeconds) return false
return false
return false
return true
}
private fun sha256(input: String): ByteArray =
@@ -118,7 +62,6 @@ class AuthController(
return hashBytes.joinToString("") { "%02x".format(it) }
}
@GetMapping("/test")
fun test(): String {
val authentication = SecurityContextHolder.getContext().authentication
@@ -137,43 +80,14 @@ class AuthController(
return authService.register(request.username, request.password, request.firstName).toDto()
}
private val json = Json { ignoreUnknownKeys = true }
@PostMapping("/tg-login")
fun tgLogin(@RequestBody tgUser: UserDTO.TelegramAuthDTO): Map<String, String> {
// println(tgUser.hash)
// println(botToken)
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")
fun tgLogin(@RequestBody tgUser: UserDTO.TelegramAuthDTO): String {
val ok = verifyTelegramAuth(tgUser.toTelegramMap(), botToken)
if (!ok) throw IllegalArgumentException("Invalid Telegram login")
return authService.tgAuth(tgUser)
}
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")

View File

@@ -1,35 +0,0 @@
package space.luminic.finance.api
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType
import io.swagger.v3.oas.annotations.security.SecurityScheme
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.RequestParam
import org.springframework.web.bind.annotation.RestController
import space.luminic.finance.dtos.DashboardDataDTO
import space.luminic.finance.mappers.DashboardDataMapper.toDto
import space.luminic.finance.models.DashboardData
import space.luminic.finance.services.DashboardService
import java.time.LocalDate
@RestController
@RequestMapping("/spaces/{spaceId}/dashboard")
@SecurityScheme(
name = "bearerAuth",
type = SecuritySchemeType.HTTP,
bearerFormat = "JWT",
scheme = "bearer"
)
class DashboardController(private val dashboardService: DashboardService) {
@GetMapping
fun getDashboardData(
@PathVariable spaceId: Int,
@RequestParam startDate: LocalDate,
@RequestParam endDate: LocalDate
): DashboardDataDTO {
return dashboardService.getDashboardData(spaceId, startDate, endDate).toDto()
}
}

View File

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

View File

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

View File

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

View File

@@ -1,36 +0,0 @@
package space.luminic.finance.dtos
import space.luminic.finance.models.AISummaryData
import java.time.LocalDate
data class DashboardDataDTO(
val analyzedText: AISummaryData? = null,
val totalExpense: Int,
val totalIncome: Int,
val balance: Int,
val categories: List<DashboardCategoryDTO>,
val upcomingTransactions: List<TransactionDTO>,
val recentTransactions: List<TransactionDTO>,
val weeks: List<DashboardWeeksDTO>
)
data class DashboardWeeksDTO(
val startDate: LocalDate,
val endDate: LocalDate,
val expenseSum: Int,
val categories: List<WeekCategoryDTO>
)
data class WeekCategoryDTO(
val categoryId: Int?,
val categoryName: String?,
val categoryIcon: String?,
val sum: Int? = 0
)
data class DashboardCategoryDTO (
val category: CategoryDTO,
val currentPeriodAmount: Int,
val previousPeriodAmount: Int,
val changeDiff: Double,
val changeDiffPercentage: Double,
)

View File

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

View File

@@ -1,6 +1,6 @@
package space.luminic.finance.dtos
import kotlinx.serialization.Serializable
import java.util.Date
data class UserDTO (
var id: Int,
@@ -26,25 +26,13 @@ data class UserDTO(
)
data class TelegramAuthDTO(
val id: Long?,
val id: Long,
val first_name: String?,
val last_name: String?,
val username: String?,
val photo_url: String?,
val auth_date: Long?,
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,
val auth_date: Long,
val hash: String
)

View File

@@ -1,46 +0,0 @@
package space.luminic.finance.mappers
import space.luminic.finance.dtos.DashboardCategoryDTO
import space.luminic.finance.dtos.DashboardDataDTO
import space.luminic.finance.dtos.DashboardWeeksDTO
import space.luminic.finance.dtos.WeekCategoryDTO
import space.luminic.finance.mappers.CategoryMapper.toDto
import space.luminic.finance.mappers.TransactionMapper.toDto
import space.luminic.finance.models.DashboardCategory
import space.luminic.finance.models.DashboardData
import space.luminic.finance.models.DashboardWeeks
import space.luminic.finance.models.WeekCategory
object DashboardDataMapper {
fun DashboardData.toDto() = DashboardDataDTO(
analyzedText = this.analyzedText,
totalExpense = this.totalExpense,
totalIncome = this.totalIncome,
balance = this.balance,
categories = this.categories.map { it.toDto() },
upcomingTransactions = this.upcomingTransactions.map { it.toDto() },
recentTransactions = this.recentTransactions.map { it.toDto() },
weeks = this.weeks.map { it.toDto() },
)
fun DashboardWeeks.toDto() = DashboardWeeksDTO(
startDate = this.startDate,
endDate = this.endDate,
expenseSum = this.expenseSum,
categories = this.categories.map { it.toDto() },
)
fun WeekCategory.toDto() = WeekCategoryDTO(
categoryId = this.categoryId,
categoryName = this.categoryName,
categoryIcon = this.categoryIcon,
sum = this.sum
)
fun DashboardCategory.toDto() = DashboardCategoryDTO(
category = this.category.toDto(),
currentPeriodAmount = this.currentPeriodAmount,
previousPeriodAmount = this.previousPeriodAmount,
changeDiff = this.changeDiff,
changeDiffPercentage = this.changeDiffPercentage,
)
}

View File

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

View File

@@ -1,56 +0,0 @@
package space.luminic.finance.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import lombok.AllArgsConstructor
import java.time.LocalDate
data class DashboardData(
var analyzedText: AISummaryData? = null,
val totalExpense: Int,
val totalIncome: Int,
val balance: Int,
val prevTotalExpense: Int,
val prevTotalIncome: Int,
val prevBalance: Int,
val prevCurIncomeChange: Double,
val prevCurExpenseChange: Double,
val categories: List<DashboardCategory>,
val upcomingTransactions: List<Transaction>,
val recentTransactions: List<Transaction>,
val weeks: List<DashboardWeeks>
)
@AllArgsConstructor
data class AISummaryData(
//) "Общая оценка периода"
//2) "Анализ по категориям"
//3) "Ключевые инсайты"
//4) "Рекомендации"
val common: String,
val categoryAnalysis: String,
val keyInsights: String,
val recommendations: String,
)
data class DashboardWeeks(
val startDate: LocalDate,
val endDate: LocalDate,
val expenseSum: Int,
val categories: List<WeekCategory>
)
@Serializable
data class WeekCategory(
@SerialName("category_id") val categoryId: Int?,
@SerialName("category_name") val categoryName: String?,
@SerialName("category_icon") val categoryIcon: String?,
val sum: Int? = 0
)
data class DashboardCategory(
val category: Category,
val currentPeriodAmount: Int,
val previousPeriodAmount: Int,
val changeDiff: Double,
val changeDiffPercentage: Double,
)

View File

@@ -1,16 +0,0 @@
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

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

View File

@@ -1,9 +0,0 @@
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

@@ -1,114 +0,0 @@
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> {
val query = "select * from finance.categories where space_id = :space_id order by name"
val query = "select * from finance.categories where space_id = :space_id order by id"
val params = mapOf("space_id" to spaceId)
return jdbcTemplate.query(query, params, categoryRowMapper())
}

View File

@@ -1,14 +0,0 @@
package space.luminic.finance.repos
import space.luminic.finance.models.AISummaryData
import space.luminic.finance.models.DashboardData
import java.time.LocalDate
import java.time.LocalDateTime
interface DashboardRepo {
fun getData(spaceId: Int, startDate: LocalDate, endDate: LocalDate): DashboardData
fun savePeriodAnalyze(spaceId: Int, startDate: LocalDate, endDate: LocalDate, analyzedText: String)
fun getPeriodAnalyzedText(spaceId: Int, startDate: LocalDate, endDate: LocalDate): AISummaryData?
fun getAnalyzeLastRun(): LocalDateTime?
fun createRun(spaces: List<Int>)
}

View File

@@ -1,367 +0,0 @@
package space.luminic.finance.repos
import kotlinx.serialization.json.Json
import org.springframework.dao.EmptyResultDataAccessException
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
import org.springframework.stereotype.Repository
import space.luminic.finance.models.*
import space.luminic.finance.services.TransactionService
import java.time.LocalDate
import java.time.LocalDateTime
@Repository
class DashboardRepoImpl(
private val transactionRepo: TransactionRepo,
private val jdbcTemplate: NamedParameterJdbcTemplate
) : DashboardRepo {
override fun getData(
spaceId: Int,
startDate: LocalDate,
endDate: LocalDate
): DashboardData {
val getSumsSql = """
WITH bounds AS (SELECT :startDate::date AS cur_start,
:endDate::date AS cur_end,
(:startDate::date - INTERVAL '1 month') AS prev_start,
(:endDate::date - INTERVAL '1 month') AS prev_end),
sums_cur_period AS (SELECT COALESCE(SUM(amount) FILTER (WHERE type = 'EXPENSE'), 0) AS sum_expense,
COALESCE(SUM(amount) FILTER (WHERE type = 'INCOME'), 0) AS sum_income
FROM finance.transactions
CROSS JOIN bounds b
WHERE space_id = :spaceId
AND kind = 'INSTANT'
AND is_deleted = false
AND date BETWEEN b.cur_start AND b.cur_end),
sums_prev_period AS (SELECT COALESCE(SUM(amount) FILTER (WHERE type = 'EXPENSE'), 0) AS sum_expense,
COALESCE(SUM(amount) FILTER (WHERE type = 'INCOME'), 0) AS sum_income
FROM finance.transactions
CROSS JOIN bounds b
WHERE space_id = :spaceId
AND kind = 'INSTANT'
AND is_deleted = false
AND date BETWEEN b.prev_start AND b.prev_end)
SELECT cur.sum_expense AS cur_sum_expense,
cur.sum_income AS cur_sum_income,
cur.sum_income - cur.sum_expense AS cur_balance,
prev.sum_expense AS prev_sum_expense,
prev.sum_income AS prev_sum_income,
prev.sum_income - prev.sum_expense AS prev_balance,
CASE
WHEN prev.sum_expense != 0 THEN (cur.sum_expense / prev.sum_expense) * 100
ELSE 0 END as prev_cur_expense_change,
CASE
WHEN prev.sum_income != 0 THEN (cur.sum_income / prev.sum_income) * 100
ELSE 0 END as prev_cur_income_change
FROM sums_cur_period cur
CROSS JOIN sums_prev_period prev;
""".trimIndent()
val params = mutableMapOf<String, Any>(
"spaceId" to spaceId,
"startDate" to startDate,
"endDate" to endDate,
)
val expenseIncomeSumResult = jdbcTemplate.queryForObject(getSumsSql, params) { rs, _ ->
mapOf(
"cur_sum_expense" to rs.getDouble("cur_sum_expense"),
"cur_sum_income" to rs.getDouble("cur_sum_income"),
"cur_balance" to rs.getDouble("cur_balance"),
"prev_sum_expense" to rs.getDouble("prev_sum_expense"),
"prev_sum_income" to rs.getDouble("prev_sum_income"),
"prev_balance" to rs.getDouble("prev_balance"),
"prev_cur_expense_change" to rs.getDouble("prev_cur_expense_change"),
"prev_cur_income_change" to rs.getDouble("prev_cur_income_change"),
)
}
val getCatsSql = """
WITH bounds AS (
SELECT
:startDate::date AS cur_start,
:endDate::date AS cur_end,
(:startDate::date - INTERVAL '1 month') AS prev_start,
(:endDate::date - INTERVAL '1 month') AS prev_end
),
agg AS (
SELECT
c.*,
COALESCE(
SUM(
CASE
WHEN t.date BETWEEN b.cur_start AND b.cur_end
THEN t.amount
END
),
0
) AS sum_this_period,
COALESCE(
SUM(
CASE
WHEN t.date BETWEEN b.prev_start AND b.prev_end
THEN t.amount
END
),
0
) AS sum_previous_period
FROM finance.categories c
CROSS JOIN bounds b
LEFT JOIN finance.transactions t
ON t.category_id = c.id
where t.space_id = :spaceId and t.kind = 'INSTANT'
AND t.is_deleted = false
GROUP BY
c.id, c.name
)
SELECT
agg.*,
agg.sum_this_period - agg.sum_previous_period AS diff_period,
CASE
WHEN agg.sum_previous_period = 0 THEN NULL -- или 0, если так удобнее
ELSE (agg.sum_this_period::numeric / agg.sum_previous_period::numeric - 1) * 100
END AS diff_percent
FROM agg
ORDER BY agg.name;
""".trimIndent()
val resultCatsSql = jdbcTemplate.query(getCatsSql, params) { rs, _ ->
DashboardCategory(
Category(
id = rs.getInt("id"),
type = Category.CategoryType.valueOf(rs.getString("type")),
name = rs.getString("name"),
description = rs.getString("description"),
icon = rs.getString("icon"),
isDeleted = rs.getBoolean("is_deleted"),
createdAt = rs.getTimestamp("created_at").toInstant(),
updatedAt = if (rs.getTimestamp("updated_at") != null) rs.getTimestamp("updated_at")
.toInstant() else null
),
rs.getInt("sum_this_period"),
rs.getInt("sum_previous_period"),
rs.getDouble("diff_period"),
rs.getDouble("diff_percent"),
)
}
val weeksSql = """WITH bounds AS (
SELECT
date_trunc('week', current_date)::date AS cur_week_start
),
weeks AS (
-- 4 недели: текущая + 3 предыдущие
SELECT
(cur_week_start - (n * INTERVAL '1 week'))::date AS week_start
FROM bounds,
generate_series(0, 3) AS g(n)
),
tx AS (
SELECT
date_trunc('week', t.date)::date AS week_start,
t.amount,
c.id AS category_id,
c.name AS category_name,
c.icon AS category_icon
FROM finance.transactions t
JOIN finance.categories c ON c.id = t.category_id
JOIN bounds b ON TRUE
WHERE t.type = 'EXPENSE'
AND t.kind = 'INSTANT'
AND t.is_deleted = false
AND t.date >= b.cur_week_start - INTERVAL '3 weeks'
AND t.date < b.cur_week_start + INTERVAL '1 week'
),
weekly_by_cat AS (
SELECT
w.week_start,
tx.category_id,
tx.category_name,
tx.category_icon,
SUM(tx.amount) AS category_sum
FROM weeks w
LEFT JOIN tx
ON tx.week_start = w.week_start
GROUP BY
w.week_start, tx.category_id, tx.category_name, tx.category_icon
),
weekly_totals AS (
SELECT
week_start,
COALESCE(SUM(category_sum), 0) AS week_expense_sum
FROM weekly_by_cat
GROUP BY week_start
),
ranked AS (
SELECT
wbc.*,
ROW_NUMBER() OVER (
PARTITION BY week_start
ORDER BY category_sum DESC
) AS rn
FROM weekly_by_cat wbc
)
SELECT
w.week_start,
(w.week_start + INTERVAL '6 days')::date AS week_end,
wt.week_expense_sum,
CASE
WHEN COUNT(r.*) FILTER (WHERE r.rn <= 5) = 0 THEN '[]'::jsonb
ELSE jsonb_agg(
jsonb_build_object(
'category_id', r.category_id,
'category_name', r.category_name,
'category_icon', r.category_icon,
'sum', r.category_sum
)
ORDER BY r.category_sum DESC
)
END AS top_5_categories
FROM weeks w
LEFT JOIN weekly_totals wt ON wt.week_start = w.week_start
LEFT JOIN ranked r
ON r.week_start = w.week_start
AND r.rn <= 5
GROUP BY
w.week_start,
wt.week_expense_sum
ORDER BY
w.week_start"""
val weeks = jdbcTemplate.query(weeksSql, params) { rs, _ ->
val weekStart = rs.getDate("week_start").toLocalDate()
val weekEnd = rs.getDate("week_end").toLocalDate()
val expenseSum = rs.getBigDecimal("week_expense_sum")?.toInt() ?: 0
// или getInt, если в БД тип точно int/numeric(…)
val categoriesJson = rs.getString("top_5_categories") ?: "[]"
val categories: List<WeekCategory> =
Json.decodeFromString(categoriesJson) // тип T выведется из переменной
DashboardWeeks(
startDate = weekStart,
endDate = weekEnd,
expenseSum = expenseSum,
categories = categories
)
}
return DashboardData(
totalExpense = expenseIncomeSumResult?.get("cur_sum_expense")?.toInt() ?: 0,
totalIncome = expenseIncomeSumResult?.get("cur_sum_income")?.toInt() ?: 0,
balance = expenseIncomeSumResult?.get("cur_balance")?.toInt() ?: 0,
prevTotalExpense = expenseIncomeSumResult?.get("cur_balance")?.toInt() ?: 0,
prevTotalIncome = expenseIncomeSumResult?.get("cur_balance")?.toInt() ?: 0,
prevBalance = expenseIncomeSumResult?.get("cur_balance")?.toInt() ?: 0,
prevCurIncomeChange = expenseIncomeSumResult?.get("cur_balance") ?: 0.0,
prevCurExpenseChange = expenseIncomeSumResult?.get("cur_balance") ?: 0.0,
categories = resultCatsSql,
upcomingTransactions = transactionRepo.findAllBySpaceId(
spaceId,
TransactionService.TransactionsFilter(
kind = Transaction.TransactionKind.PLANNING,
dateFrom = startDate,
dateTo = endDate,
isDone = false,
limit = 5
)
),
recentTransactions = transactionRepo.findAllBySpaceId(
spaceId,
TransactionService.TransactionsFilter(
kind = Transaction.TransactionKind.INSTANT,
dateFrom = startDate,
dateTo = endDate,
limit = 5,
sorts = listOf(mapOf("sortBy" to "date", "sortDirection" to "DESC"))
)
),
weeks = weeks,
)
}
override fun savePeriodAnalyze(
spaceId: Int,
startDate: LocalDate,
endDate: LocalDate,
analyzedText: String
) {
val sql = """
INSERT INTO finance.period_analyze (
space_id,
period_start,
period_end,
analyze_text
)
VALUES (
:spaceId,
:periodStart,
:periodEnd,
:analyzeText::json
)
ON CONFLICT (space_id, period_start, period_end)
DO UPDATE SET
analyze_text = EXCLUDED.analyze_text::json,
last_analyze_at = now();
""".trimIndent()
val params = mapOf(
"spaceId" to spaceId,
"periodStart" to startDate,
"periodEnd" to endDate,
"analyzeText" to analyzedText
)
jdbcTemplate.update(sql, params)
}
override fun getPeriodAnalyzedText(
spaceId: Int,
startDate: LocalDate,
endDate: LocalDate
): AISummaryData? {
val sql =
"""SELECT analyze_text->>'common' AS common,
analyze_text->>'categoryAnalysis' AS category_analysis,
analyze_text->>'keyInsights' AS key_insights,
analyze_text->>'recommendations' AS recommendations
from finance.period_analyze WHERE space_id = :spaceId AND period_start = :periodStart AND period_end = :periodEnd"""
val params = mapOf(
"spaceId" to spaceId,
"periodStart" to startDate,
"periodEnd" to endDate
)
return try {
return jdbcTemplate
.query(sql, params) { rs, _ ->
AISummaryData(
common = rs.getString("common"),
categoryAnalysis = rs.getString("category_analysis"),
keyInsights = rs.getString("key_insights"),
recommendations = rs.getString("recommendations")
)
}
.firstOrNull()
} catch (e: EmptyResultDataAccessException) {
null
}
}
override fun getAnalyzeLastRun(): LocalDateTime? {
val sql = """
SELECT MAX(run_at) AS last_run
FROM finance.analyze_runs
"""
return jdbcTemplate
.query(sql) { rs, _ ->
rs.getTimestamp("last_run")?.toLocalDateTime()
}
.firstOrNull()
}
override fun createRun(spaces: List<Int>) {
val sql = """INSERT INTO finance.analyze_runs VALUES (now(), :spaces);"""
val params = mapOf(
"spaces" to spaces.joinToString(",")
)
jdbcTemplate.update(sql, params)
}
}

View File

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

View File

@@ -73,8 +73,8 @@ class RecurrentOperationRepoImpl(
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.space_id = :spaceId and ro.is_deleted = false
order by ro.date, ro.id
where ro.space_id = :spaceId
order by ro.date
""".trimIndent()
val params = mapOf("spaceId" to spaceId)
return jdbcTemplate.query(sql, params, operationRowMapper())
@@ -109,46 +109,12 @@ class RecurrentOperationRepoImpl(
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.space_id = :spaceId and ro.id = :id and ro.is_deleted = false;
where ro.space_id = :spaceId and ro.id = :id
""".trimIndent()
val params = mapOf("spaceId" to spaceId, "id" to id)
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 and ro.is_deleted = false
""".trimIndent()
val params = mapOf( "date" to date)
return jdbcTemplate.query(sql, params, operationRowMapper())
}
override fun create(operation: RecurrentOperation, createdById: Int): Int {
val sql = """
insert into finance.recurrent_operations (
@@ -203,20 +169,10 @@ class RecurrentOperationRepoImpl(
override fun delete(id: Int) {
val sql = """
update finance.recurrent_operations
set is_deleted = true
delete from finance.recurrent_operations
where id = :id
""".trimIndent()
val params = mapOf("id" to id)
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

@@ -2,11 +2,9 @@ package space.luminic.finance.repos
import org.springframework.stereotype.Repository
import space.luminic.finance.models.Space
import java.time.LocalDateTime
@Repository
interface SpaceRepo {
fun findSpacesForScheduling(lastRun: LocalDateTime): List<Space>
fun findSpacesAvailableForUser(userId: Int): List<Space>
fun findSpaceById(id: Int, userId: Int): Space?
fun create(space: Space, createdById: Int): Int

View File

@@ -6,7 +6,6 @@ import org.springframework.stereotype.Repository
import space.luminic.finance.dtos.SpaceDTO
import space.luminic.finance.models.Space
import space.luminic.finance.models.User
import java.time.LocalDateTime
@Repository
class SpaceRepoImpl(
@@ -39,8 +38,7 @@ class SpaceRepoImpl(
owner = User(
rs.getInt("s_owner_id"),
rs.getString("s_owner_username"),
rs.getString("s_owner_firstname"),
tgId = rs.getLong("s_owner_tg_id"),
rs.getString("s_owner_firstname")
),
participant = User(rs.getInt("sp_uid"), rs.getString("sp_username"), rs.getString("sp_first_name")),
createdAt = rs.getTimestamp("s_created_at").toInstant(),
@@ -85,44 +83,6 @@ class SpaceRepoImpl(
return spaceMap.map { it.value }
}
override fun findSpacesForScheduling(lastRun: LocalDateTime): List<Space> {
val sql = """
select s.id as s_id,
s.name as s_name,
s.created_at as s_created_at,
true as s_is_owner,
s.owner_id as s_owner_id,
ou.username as s_owner_username,
ou.first_name as s_owner_firstname,
ou.tg_id as s_owner_tg_id,
sp.participants_id as sp_uid,
u.username as sp_username,
u.first_name as sp_first_name,
s.created_at as s_created_at,
s.created_by_id as s_created_by,
cau.username as s_created_by_username,
cau.first_name as s_created_by_firstname,
s.updated_at as s_updated_at,
s.updated_by_id as s_updated_by,
uau.username as s_updated_by_username,
uau.first_name as s_updated_by_firstname
from finance.spaces s
join finance.users ou on s.owner_id = ou.id
join finance.spaces_participants sp on sp.space_id = s.id
join finance.users u on sp.participants_id = u.id
left join finance.users cau on s.created_by_id = cau.id
left join finance.users uau on s.updated_by_id = uau.id
left join finance.transactions t on t.space_id = s.id
where s.is_deleted = false and t.created_at >= :lastRun
group by s.id, ou.username, ou.first_name, ou.tg_id, sp.participants_id, u.username, u.first_name, cau.username, cau.first_name,
uau.username, uau.first_name;
""".trimMargin()
val params = mapOf("lastRun" to lastRun)
val spaces = jdbcTemplate.query(sql, params, shortRowMapper())
return collectParticipants(spaces)
}
override fun findSpacesAvailableForUser(userId: Int): List<Space> {
val sql = """
@@ -133,7 +93,6 @@ class SpaceRepoImpl(
s.owner_id as s_owner_id,
ou.username as s_owner_username,
ou.first_name as s_owner_firstname,
ou.tg_id as s_owner_tg_id,
sp.participants_id as sp_uid,
u.username as sp_username,
u.first_name as sp_first_name,
@@ -155,7 +114,7 @@ class SpaceRepoImpl(
where (s.owner_id = :user_id
or sp.participants_id = :user_id)
and s.is_deleted = false
group by s.id, ou.username, ou.first_name, ou.tg_id, sp.participants_id, u.username, u.first_name, cau.username, cau.first_name,
group by s.id, ou.username, ou.first_name, sp.participants_id, u.username, u.first_name, cau.username, cau.first_name,
uau.username, uau.first_name;
""".trimMargin()
val params = mapOf(
@@ -174,7 +133,6 @@ class SpaceRepoImpl(
s.owner_id as s_owner_id,
ou.username as s_owner_username,
ou.first_name as s_owner_firstname,
ou.tg_id as s_owner_tg_id,
sp.participants_id as sp_uid,
u.username as sp_username,
u.first_name as sp_first_name,
@@ -196,7 +154,7 @@ from finance.spaces s
where (s.owner_id = :user_id
or sp.participants_id = :user_id)
and s.is_deleted = false and s.id = :spaceId
group by s.id, ou.username, ou.first_name, ou.tg_id, sp.participants_id, u.username, u.first_name, cau.username, cau.first_name,
group by s.id, ou.username, ou.first_name, sp.participants_id, u.username, u.first_name, cau.username, cau.first_name,
uau.username, uau.first_name;
""".trimMargin()
val params = mapOf(

View File

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

View File

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

View File

@@ -6,7 +6,6 @@ import org.springframework.stereotype.Repository
import space.luminic.finance.models.Category
import space.luminic.finance.models.Transaction
import space.luminic.finance.models.User
import space.luminic.finance.services.TransactionService
@Repository
class TransactionRepoImpl(
@@ -14,7 +13,12 @@ class TransactionRepoImpl(
) : TransactionRepo {
private fun transactionRowMapper() = RowMapper { rs, _ ->
val category = if (rs.getString("c_id") == null) null else Category(
Transaction(
id = rs.getInt("t_id"),
parent = findBySpaceIdAndId(spaceId = rs.getInt("t_space_id"), rs.getInt("t_parent_id")),
type = Transaction.TransactionType.valueOf(rs.getString("t_type")),
kind = Transaction.TransactionKind.valueOf(rs.getString("t_kind")),
category = Category(
id = rs.getInt("c_id"),
type = Category.CategoryType.valueOf(rs.getString("c_type")),
name = rs.getString(("c_name")),
@@ -22,18 +26,8 @@ class TransactionRepoImpl(
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(
id = rs.getInt("t_id"),
parent = parent,
type = Transaction.TransactionType.valueOf(rs.getString("t_type")),
kind = Transaction.TransactionKind.valueOf(rs.getString("t_kind")),
category = category,
updatedAt = rs.getTimestamp("c_updated_at").toInstant(),
),
comment = rs.getString("t_comment"),
amount = rs.getBigDecimal("t_amount"),
fees = rs.getBigDecimal("t_fees"),
@@ -47,14 +41,11 @@ class TransactionRepoImpl(
),
createdAt = rs.getTimestamp("t_created_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, filters: TransactionService.TransactionsFilter): List<Transaction> {
var sql = """
override fun findAllBySpaceId(spaceId: Int): List<Transaction> {
val sql = """
SELECT
t.id AS t_id,
t.parent_id AS t_parent_id,
@@ -79,62 +70,17 @@ class TransactionRepoImpl(
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.tg_chat_id AS tg_chat_id,
t.tg_message_id AS tg_message_id,
t.recurrent_id AS t_recurrent_id
u.first_name AS u_first_name
FROM finance.transactions t
LEFT JOIN finance.categories c ON t.category_id = c.id
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.is_deleted = false
ORDER BY t.date
""".trimIndent()
val params = mutableMapOf<String, Any?>(
val params = mapOf(
"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.query?.let {
sql += " AND lower(t.comment) LIKE ('%${it.lowercase()}%')"
params["query"] = it.lowercase()
}
filters.kind?.let {
sql += " AND t.kind = :kind"
params.put("kind", it.name)
}
filters.categoriesIds?.let {
sql += " AND t.category_id in (:categoriesIds)"
params.put("categoriesIds", it)
}
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)
}
filters.dateTo?.let {
sql += " AND t.date <= :dateTo"
params.put("dateTo", it)
}
sql += if (filters.sorts.isNotEmpty()) {
var orderStatement = " ORDER BY "
orderStatement += filters.sorts.joinToString(",") { map ->
map.entries.joinToString(" ") { (_, v) -> v }
}
orderStatement
} else " ORDER BY t.date DESC, t.id"
sql += """
OFFSET :offset ROWS
FETCH FIRST :limit ROWS ONLY"""
return jdbcTemplate.query(sql, params, transactionRowMapper())
}
@@ -153,8 +99,6 @@ class TransactionRepoImpl(
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,
@@ -165,10 +109,9 @@ class TransactionRepoImpl(
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
u.first_name AS u_first_name
FROM finance.transactions t
LEFT JOIN finance.categories c ON t.category_id = c.id
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.id = :id and t.is_deleted = false""".trimMargin()
val params = mapOf(
@@ -178,52 +121,9 @@ class TransactionRepoImpl(
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 {
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 (
INSERT INTO finance.transactions (space_id, parent_id, type, kind, category_id, comment, amount, fees, date, is_deleted, is_done, created_by_id) VALUES (
:spaceId,
:parentId,
:type,
@@ -235,10 +135,7 @@ class TransactionRepoImpl(
:date,
:is_deleted,
:is_done,
:createdById,
:tgChatId,
:tgMessageId,
:recurrentId)
:createdById)
returning id
""".trimIndent()
val params = mapOf(
@@ -246,58 +143,29 @@ class TransactionRepoImpl(
"parentId" to transaction.parent?.id,
"type" to transaction.type.name,
"kind" to transaction.kind.name,
"categoryId" to transaction.category?.id,
"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,
"createdById" to userId
)
val createdTxId = jdbcTemplate.queryForObject(sql, params, Int::class.java)
transaction.id = 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 {
// 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 = """
UPDATE finance.transactions
@@ -315,7 +183,7 @@ class TransactionRepoImpl(
"id" to transaction.id,
"type" to transaction.type.name,
"kind" to transaction.kind.name,
"categoryId" to transaction.category?.id,
"categoryId" to transaction.category.id,
"comment" to transaction.comment,
"amount" to transaction.amount,
"fees" to transaction.fees,
@@ -326,39 +194,6 @@ class TransactionRepoImpl(
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) {
val sql = """
update finance.transactions set is_deleted = true where id = :id
@@ -368,27 +203,4 @@ class TransactionRepoImpl(
)
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
""".trimIndent()
val params = mapOf("tgId" to tgId)
return jdbcTemplate.query(sql, params, userRowMapper()).firstOrNull()
return jdbcTemplate.queryForObject(sql, params, userRowMapper())
}
override fun create(user: User): User {
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(
"username" to user.username,
"firstname" to user.firstName,

View File

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

View File

@@ -0,0 +1,108 @@
//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

@@ -0,0 +1,51 @@
//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,9 +0,0 @@
package space.luminic.finance.services
import space.luminic.finance.models.DashboardData
import java.time.LocalDate
interface DashboardService {
fun getDashboardData(spaceId: Int, startDate: LocalDate, endDate: LocalDate): DashboardData
fun analyzePeriodScheduled()
}

View File

@@ -1,59 +0,0 @@
package space.luminic.finance.services
import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.stereotype.Service
import space.luminic.finance.models.AISummaryData
import space.luminic.finance.models.DashboardData
import space.luminic.finance.repos.DashboardRepo
import space.luminic.finance.services.gpt.GptClient
import java.time.LocalDate
@Service
class DashboardServiceImpl(
private val authService: AuthService,
private val spaceService: SpaceService,
private val dashboardRepo: DashboardRepo,
@Qualifier("dsCategorizationService") private val gptClient: GptClient
) : DashboardService {
private val om = ObjectMapper()
override fun getDashboardData(spaceId: Int, startDate: LocalDate, endDate: LocalDate): DashboardData {
val userId = authService.getSecurityUserId()
spaceService.getSpace(spaceId, userId)
val data = dashboardRepo.getData(spaceId, startDate, endDate)
data.analyzedText = dashboardRepo.getPeriodAnalyzedText(spaceId, startDate, endDate)
return data
}
override fun analyzePeriodScheduled() {
val today = LocalDate.now()
val startDate = if (today.dayOfMonth < 10) {
today.minusMonths(1).withDayOfMonth(10)
} else {
today.withDayOfMonth(10)
}
val endDate = if (today.dayOfMonth >= 10) {
today.plusMonths(1).withDayOfMonth(9)
} else {
today.withDayOfMonth(9)
}
val lastRun = dashboardRepo.getAnalyzeLastRun()
val spaces = spaceService.getSpacesForScheduling(lastRun)
spaces.forEach { space ->
val data = dashboardRepo.getData(space.id!!, startDate, endDate)
dashboardRepo.savePeriodAnalyze(
space.id!!,
startDate,
endDate,
gptClient.analyzePeriod(startDate, endDate, data)
)
dashboardRepo.createRun(spaces.map { it.id!! })
}
}
}

View File

@@ -1,19 +0,0 @@
package space.luminic.finance.services
import com.github.kotlintelegrambot.entities.ReplyMarkup
import com.github.kotlintelegrambot.entities.inputmedia.MediaGroup
import space.luminic.finance.models.Space
import space.luminic.finance.models.Transaction
interface NotificationService {
fun sendDailyReminder()
fun sendTXNotification(action: TxActionType, space: Space, userId: Int, tx: Transaction, tx2: Transaction? = null)
fun sendTextMessage(chatId: Long, message: String, replyMarkup: ReplyMarkup? = null)
fun sendMediaGroup(chatId: Long, group: MediaGroup)
}
enum class TxActionType {
CREATE,
UPDATE,
DELETE,
}

View File

@@ -1,145 +0,0 @@
package space.luminic.finance.services
import com.github.kotlintelegrambot.Bot
import com.github.kotlintelegrambot.entities.ChatId
import com.github.kotlintelegrambot.entities.InlineKeyboardMarkup
import com.github.kotlintelegrambot.entities.ReplyMarkup
import com.github.kotlintelegrambot.entities.inputmedia.MediaGroup
import com.github.kotlintelegrambot.entities.keyboard.InlineKeyboardButton
import com.github.kotlintelegrambot.entities.keyboard.WebAppInfo
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.context.annotation.Lazy
import space.luminic.finance.models.Space
import space.luminic.finance.models.Transaction
import java.time.format.DateTimeFormatter
@Service
class NotificationServiceImpl(private val userService: UserService, private val bot: Bot,) : NotificationService {
private val logger = LoggerFactory.getLogger(this.javaClass)
private fun createWebAppButton(spaceId: Int? = null, txId: Int? = null): InlineKeyboardMarkup =
spaceId?.let { spaceId ->
txId?.let { txId ->
InlineKeyboardMarkup.create(
listOf(
InlineKeyboardButton.WebApp(
"Открыть в WebApp",
WebAppInfo("https://app.luminic.space/transactions/${txId}/edit?mode=from_bot&space=${spaceId}")
)
)
)
} ?: InlineKeyboardMarkup.create(
listOf(
InlineKeyboardButton.WebApp(
"Открыть в WebApp",
WebAppInfo("https://app.luminic.space/transactions?mode=from_bot&space=${spaceId}")
)
)
)
} ?: InlineKeyboardMarkup.create(
listOf(
InlineKeyboardButton.WebApp(
"Открыть в WebApp",
WebAppInfo("https://app.luminic.space/transactions")
)
)
)
override fun sendDailyReminder() {
val text = "🤑 Время заполнять траты!"
val users = userService.getUsers()
for (user in users) {
user.tgId?.let {
sendTextMessage(it, text, createWebAppButton())
}
}
}
override fun sendTXNotification(
action: TxActionType,
space: Space,
userId: Int,
tx: Transaction,
tx2: Transaction?
) {
val user = userService.getById(userId)
when (action) {
TxActionType.CREATE -> {
val text = "${user.firstName} создал транзакцию ${tx.comment} c суммой ${tx.amount} и датой ${tx.date}"
space.owner.tgId?.let { sendTextMessage(it, text, createWebAppButton(space.id, tx.id)) }
}
TxActionType.UPDATE -> {
tx2?.let { tx2 ->
val changes = mutableListOf<String>()
if (tx.type != tx2.type) {
changes.add("Тип: ${tx.type.name}${tx2.type.name}")
}
if (tx.kind != tx2.kind) {
changes.add("Вид: ${tx.kind.name}${tx2.kind.name}")
}
if (tx.category != tx2.category) {
tx.category?.let { oldCategory ->
tx2.category?.let { newCategory ->
if (oldCategory.id != newCategory.id) {
changes.add("Категория: ${oldCategory.name}${newCategory.name}")
}
} ?: changes.add("Удалена категория. Прежняя: ${oldCategory.name}")
} ?: {
tx2.category?.let { newCategory ->
changes.add("Установлена новая категория ${newCategory.name}")
}
}
}
if (tx.comment != tx2.comment) {
changes.add("Комментарий: ${tx.comment}${tx2.comment}")
}
if (tx.amount != tx2.amount) {
changes.add("Сумма: ${tx.amount}${tx2.amount}")
}
if (tx.date.toEpochDay() != tx2.date.toEpochDay()) {
changes.add(
"Сумма: ${
tx.date.format(DateTimeFormatter.ofPattern("dd.MM.yyyy"))
} ${
tx2.date.format(
DateTimeFormatter.ofPattern("dd.MM.yyyy")
)
}"
)
}
var text = "${user.firstName} обновил транзакцию ${tx.comment}\n\n"
text += changes.joinToString("\n") { it }
space.owner.tgId?.let { sendTextMessage(it, text, createWebAppButton(space.id, tx.id)) }
} ?: logger.warn("No tx2 provided when update")
}
TxActionType.DELETE -> {
val text = "${user.firstName} удалил транзакцию ${tx.comment} c суммой ${tx.amount} и датой ${tx.date}"
space.owner.tgId?.let { sendTextMessage(it, text, createWebAppButton(space.id, tx.id)) }
}
}
}
override fun sendTextMessage(
chatId: Long,
message: String,
replyMarkup: ReplyMarkup?
) {
bot.sendMessage(ChatId.fromId(chatId), message, replyMarkup = replyMarkup)
}
override fun sendMediaGroup(
chatId: Long,
group: MediaGroup
) {
TODO("Not yet implemented")
}
}

View File

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

View File

@@ -1,35 +1,19 @@
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.transaction.annotation.Isolation
import org.springframework.transaction.annotation.Transactional
import space.luminic.finance.dtos.RecurrentOperationDTO
import space.luminic.finance.models.Category
import space.luminic.finance.models.NotFoundException
import space.luminic.finance.models.RecurrentOperation
import space.luminic.finance.models.Transaction
import space.luminic.finance.repos.RecurrentOperationRepo
import space.luminic.finance.repos.SpaceRepo
import space.luminic.finance.repos.TransactionRepo
import java.time.LocalDate
import kotlin.math.min
@Service
class RecurrentOperationServiceImpl(
private val authService: AuthService,
private val spaceRepo: SpaceRepo,
private val recurrentOperationRepo: RecurrentOperationRepo,
private val categoryService: CategoryService,
private val transactionRepo: TransactionRepo
private val categoryService: CategoryService
): RecurrentOperationService {
private val logger = LoggerFactory.getLogger(this.javaClass)
private val serviceScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
override fun findBySpaceId(spaceId: Int): List<RecurrentOperation> {
val userId = authService.getSecurityUserId()
spaceRepo.findSpaceById(spaceId, userId)
@@ -42,14 +26,12 @@ class RecurrentOperationServiceImpl(
): RecurrentOperation {
val userId = authService.getSecurityUserId()
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 {
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 creatingOperation = RecurrentOperation(
space = space,
@@ -58,43 +40,14 @@ class RecurrentOperationServiceImpl(
amount = operation.amount,
date = operation.date
)
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 = if (now.dayOfMonth < 10 && operation.date > 10) date.plusMonths((i-1).toLong()) else 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
return recurrentOperationRepo.create(creatingOperation, userId)
}
override fun update(spaceId: Int, operationId: Int, operation: RecurrentOperationDTO.UpdateRecurrentOperationDTO) {
val userId = authService.getSecurityUserId()
spaceRepo.findSpaceById(spaceId, userId)
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(
category = newCategory,
name = operation.name,
@@ -102,58 +55,12 @@ class RecurrentOperationServiceImpl(
date = operation.date
)
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 = operation.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)
}
}
}
@Transactional(isolation = Isolation.READ_UNCOMMITTED)
override fun delete(spaceId: Int, id: Int) {
val userId = authService.getSecurityUserId()
spaceRepo.findSpaceById(spaceId, userId)?: throw NotFoundException("Cannot find space with id $id")
transactionRepo.deleteByRecurrentId(spaceId, id)
spaceRepo.findSpaceById(spaceId, userId)
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

@@ -1,35 +0,0 @@
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
import java.util.concurrent.TimeUnit
@EnableScheduling
@Service
class Scheduler(
private val recurrentOperationService: RecurrentOperationService,
private val notificationService: NotificationService,
private val dashboardService: DashboardService
) {
private val log = LoggerFactory.getLogger(Scheduler::class.java)
@Scheduled(cron = "0 0 3 * * *")
fun createRecurrentAfter13Month() {
log.info("Creating recurrent after 13 month")
recurrentOperationService.createRecurrentTransactions()
}
@Scheduled(cron = "0 30 16 * * *")
fun sendDailyReminders() {
log.info("Sending daily reminders")
notificationService.sendDailyReminder()
}
// @Scheduled(cron = "0 0 */3 * * *")
@Scheduled(fixedRate = 3, timeUnit =TimeUnit.HOURS)
fun analyzePeriodScheduled() {
dashboardService.analyzePeriodScheduled()
}
}

View File

@@ -2,13 +2,12 @@ package space.luminic.finance.services
import space.luminic.finance.dtos.SpaceDTO
import space.luminic.finance.models.Space
import java.time.LocalDateTime
interface SpaceService {
fun getSpacesForScheduling(lastRun: LocalDateTime? = null): List<Space>
fun checkSpace(spaceId: Int): Space
fun getSpaces(): List<Space>
fun getSpace(id: Int, userId: Int?): Space
fun getSpace(id: Int): Space
fun createSpace(space: SpaceDTO.CreateSpaceDTO): Int
fun updateSpace(spaceId: Int, space: SpaceDTO.UpdateSpaceDTO): Int
fun deleteSpace(spaceId: Int)

View File

@@ -1,14 +1,14 @@
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.transaction.annotation.Transactional
import space.luminic.finance.dtos.SpaceDTO
import space.luminic.finance.models.NotFoundException
import space.luminic.finance.models.Space
import space.luminic.finance.repos.SpaceRepo
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneOffset
@Service
class SpaceServiceImpl(
@@ -16,13 +16,8 @@ class SpaceServiceImpl(
private val spaceRepo: SpaceRepo,
private val categoryService: CategoryService
) : SpaceService {
override fun getSpacesForScheduling(lastRun: LocalDateTime?): List<Space> {
val lastRunDate = lastRun ?: LocalDateTime.ofInstant(Instant.EPOCH, ZoneOffset.UTC)
return spaceRepo.findSpacesForScheduling(lastRunDate)
}
override fun checkSpace(spaceId: Int): Space {
return getSpace(spaceId, null)
return getSpace(spaceId)
}
// @Cacheable(cacheNames = ["spaces"])
@@ -32,9 +27,8 @@ class SpaceServiceImpl(
return spaces
}
override fun getSpace(id: Int, userId: Int?): Space {
val user = userId ?: authService.getSecurityUserId()
override fun getSpace(id: Int): Space {
val user = authService.getSecurityUserId()
val space = spaceRepo.findSpaceById(id, user) ?: throw NotFoundException("Space with id $id not found")
return space
@@ -62,7 +56,7 @@ class SpaceServiceImpl(
space: SpaceDTO.UpdateSpaceDTO
): Int {
val userId = authService.getSecurityUserId()
val existingSpace = getSpace(spaceId, null)
val existingSpace = getSpace(spaceId)
val updatedSpace = Space(
id = existingSpace.id,
name = space.name,
@@ -74,7 +68,6 @@ class SpaceServiceImpl(
)
return spaceRepo.update(updatedSpace, userId)
}
@Transactional
override fun deleteSpace(spaceId: Int) {
spaceRepo.delete(spaceId)

View File

@@ -0,0 +1,145 @@
//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

@@ -7,40 +7,13 @@ import java.time.LocalDate
interface TransactionService {
data class TransactionsFilter(
val query : String? = null,
val type: Transaction.TransactionType? = null,
val kind: Transaction.TransactionKind? = null,
val categoriesIds: Set<Int>? = null,
val dateFrom: LocalDate? = null,
val dateTo: LocalDate? = null,
val isDone: Boolean? = null,
val offset: Int = 0,
val limit: Int = 10,
val sorts: List<Map<String, String>> = listOf(
mapOf(
"sortBy" to "t.created_at",
"sortDirection" to SortDirection.DESC.name
),
mapOf(
"sortBy" to "t.id",
"sortDirection" to SortDirection.ASC.name
)
),
)
enum class SortDirection {
ASC, DESC
}
fun getTransactions(
spaceId: Int,
filter: TransactionsFilter
): List<Transaction>
fun getTransactions(spaceId: Int, filter: TransactionsFilter, 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 deleteByRecurrentId(spaceId: Int, recurrentId: Int)
}

View File

@@ -1,18 +1,10 @@
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 space.luminic.finance.dtos.TransactionDTO
import space.luminic.finance.models.NotFoundException
import space.luminic.finance.models.Transaction
import space.luminic.finance.repos.TransactionRepo
import space.luminic.finance.services.gpt.CategorizeService
import java.time.LocalDate
import java.time.LocalDateTime
@Service
class TransactionServiceImpl(
@@ -20,25 +12,21 @@ class TransactionServiceImpl(
private val categoryService: CategoryService,
private val transactionRepo: TransactionRepo,
private val authService: AuthService,
private val categorizeService: CategorizeService,
private val notificationService: NotificationService,
) : TransactionService {
private val logger = LoggerFactory.getLogger(this.javaClass)
private val serviceScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
override fun getTransactions(
spaceId: Int,
filter: TransactionService.TransactionsFilter
filter: TransactionService.TransactionsFilter,
sortBy: String,
sortDirection: String
): List<Transaction> {
val transactions = transactionRepo.findAllBySpaceId(spaceId, filter)
return transactions
return transactionRepo.findAllBySpaceId(spaceId)
}
override fun getTransaction(
spaceId: Int,
transactionId: Int
): Transaction {
spaceService.getSpace(spaceId, null)
spaceService.getSpace(spaceId)
return transactionRepo.findBySpaceIdAndId(spaceId, transactionId)
?: throw NotFoundException("Transaction with id $transactionId not found")
}
@@ -48,9 +36,8 @@ class TransactionServiceImpl(
transaction: TransactionDTO.CreateTransactionDTO
): Int {
val userId = authService.getSecurityUserId()
val space = spaceService.getSpace(spaceId, null)
val category = transaction.categoryId?.let { categoryService.getCategory(spaceId, it) }
val space = spaceService.getSpace(spaceId)
val category = categoryService.getCategory(spaceId, transaction.categoryId)
val transaction = Transaction(
space = space,
type = transaction.type,
@@ -60,43 +47,8 @@ class TransactionServiceImpl(
amount = transaction.amount,
fees = transaction.fees,
date = transaction.date,
recurrentId = transaction.recurrentId,
)
val createdTx = transactionRepo.create(transaction, userId)
serviceScope.launch {
runCatching {
if (space.owner.id != userId) {
notificationService.sendTXNotification(TxActionType.CREATE, space, userId, transaction)
}
}.onFailure {
logger.error("Error while creating transaction", it)
}
}
return createdTx
}
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)
return transactionRepo.create(transaction, userId)
}
override fun updateTransaction(
@@ -104,22 +56,23 @@ class TransactionServiceImpl(
transactionId: Int,
transaction: TransactionDTO.UpdateTransactionDTO
): Int {
val userId = authService.getSecurityUserId()
val space = spaceService.getSpace(spaceId, null)
val space = spaceService.getSpace(spaceId)
val existingTransaction = getTransaction(space.id!!, transactionId)
val newCategory = transaction.categoryId?.let { categoryService.getCategory(spaceId, it) }
val today = LocalDate.now()
val newKind = if (
!existingTransaction.isDone &&
transaction.isDone &&
(today.isAfter(transaction.date) || today.isEqual(transaction.date))
) Transaction.TransactionKind.INSTANT else transaction.kind
val newCategory = categoryService.getCategory(spaceId, transaction.categoryId)
// 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(
id = existingTransaction.id,
space = existingTransaction.space,
parent = existingTransaction.parent,
type = transaction.type,
kind = newKind,
kind = transaction.kind,
category = newCategory,
comment = transaction.comment,
amount = transaction.amount,
@@ -128,45 +81,16 @@ class TransactionServiceImpl(
isDeleted = existingTransaction.isDeleted,
isDone = transaction.isDone,
createdBy = existingTransaction.createdBy,
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)
}
val updatedTx = transactionRepo.update(updatedTransaction)
serviceScope.launch {
runCatching {
createdAt = existingTransaction.createdAt
notificationService.sendTXNotification(
TxActionType.UPDATE,
space,
userId,
existingTransaction,
updatedTransaction
)
}.onFailure {
logger.error("Error while send transaction update notification", it)
}
}
return updatedTx
return transactionRepo.update(updatedTransaction)
}
override fun deleteTransaction(spaceId: Int, transactionId: Int) {
val userId = authService.getSecurityUserId()
val space = spaceService.getSpace(spaceId, null)
val tx = getTransaction(space.id!!, transactionId)
val space = spaceService.getSpace(spaceId)
getTransaction(space.id!!, transactionId)
transactionRepo.delete(transactionId)
serviceScope.launch {
runCatching {
notificationService.sendTXNotification(TxActionType.DELETE, space, userId, tx)
}.onFailure { logger.error("Error while transaction delete notification", it) }
}
}
override fun deleteByRecurrentId(spaceId: Int, recurrentId: Int) {
TODO("Not yet implemented")
}
}

View File

@@ -0,0 +1,185 @@
//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

@@ -1,26 +0,0 @@
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

@@ -1,144 +0,0 @@
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")
)
)
)
)
}
} 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")
)
)
),
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")
)
)
),
parseMode = ParseMode.HTML
)
}
}
}
}

View File

@@ -1,97 +0,0 @@
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

@@ -1,31 +0,0 @@
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

@@ -1,261 +0,0 @@
package space.luminic.finance.services.gpt
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import okhttp3.*
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
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.DashboardData
import space.luminic.finance.models.Transaction
import java.time.LocalDate
import java.util.concurrent.TimeUnit
@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().newBuilder().callTimeout(5, TimeUnit.MINUTES).readTimeout(1, TimeUnit.MINUTES).build()
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())
}
}
override fun analyzePeriod(startDate: LocalDate, endDate: LocalDate, dashboardData: DashboardData): String {
mapper.registerModule(JavaTimeModule());
if (dashboardData.totalIncome == 0 && dashboardData.totalExpense == 0){
return "Начните записывать траты и поступления и мы начнем их анализировать!"
} else {
var prompt = """
You are a personal finance analyst.
Your task is to analyze the users budget period and provide:
1) A summary,
2) Key insights,
3) Explanations of deviations,
4) Actionable recommendations.
The answer MUST be written in Russian.
IMPORTANT: The users budget is calculated in custom periods, not strict calendar months. A period is usually about a month, for example: starts on 10 November and ends on 9 December. The period may be ongoing or already finished.
Here is the data for the current budget period:
Period label (for display, may be month-like): {{period}}
Current income: {{income}}
Current expense: {{expense}}
Net result: {{net}}
Comparison with the previous period:
- Income: {{income_change_percent}}%
- Expense: {{expense_change_percent}}%
- Net result change: {{net_change_value}} ({{net_change_percent}}%)
Expense categories:
{{categories_list}}
(format for categories_list example:
"Еда — план {{plan}}, факт {{actual}}, отклонение {{diff}} ({{diff_percent}}%)"
one category per line)
Response formatting requirements:
1. The final response MUST be valid json.:
2. Use a HTML text formatting like (<b>, <i>, <u> only) in sections.
"common" - Общая оценка периода
"categoryAnalysis" - Анализ по категориям
"keyInsights" - Ключевые инсайты
"recommendations" - Рекомендации
{ "common": "string",
"categoryAnalysis": "string",
"keyInsights": "string",
"recommendations": "string",
}
Content requirements for each section (in Russian):
Section 1: "Общая оценка периода"
- Give a short 12 sentence overview of how the budget period is going financially (баланс, общая тенденция, спокойный/напряжённый период и т.п.).
- Mention overall direction compared to the previous period (лучше/хуже/на том же уровне), but do NOT restate all numeric values exactly.
- If the period is still ongoing, clearly indicate that the evaluation is preliminary (e.g. "на данный момент", "пока", "в текущем периоде").
Section 2: "Анализ по категориям"
- Explain which 24 categories had the biggest impact on the budget (самые крупные, самые проблемные или самые улучшившиеся).
- Опиши, как изменились привычки в этих категориях (больше/меньше, чем обычно или чем в прошлом периоде, почему это может быть так).
- Use a list-style text with "-" at the start of lines for key category observations.
Section 3: "Ключевые инсайты"
- Highlight 24 important insights about the users behavior and spending patterns in this period.
- For each insight, briefly explain the possible cause or context (например, рост доставки еды, больше поездок, крупные единовременные покупки).
- Use "-" bullets (text-only, not HTML list tags).
Section 4: "Рекомендации"
- Provide 35 concrete, actionable recommendations the user can apply in the next period or in the remaining part of the current period.
- Recommendations must be practical actions (e.g., "ограничить спонтанные покупки по одной категории", "установить лимит", "вынести обязательные платежи в начало периода").
- VERY IMPORTANT: Do NOT repeat exact numeric values from the input in the recommendations. Focus on actions and относительные формулировки (уменьшить, зафиксировать, перенести, разделить и т.п.).
- Use "-" bullets (text-only, not HTML list tags).
General style requirements:
- The tone should be friendly, professional, and supportive, without blaming the user.
- Keep the writing structured, concise, and avoid filler.
- Do NOT invent completely unrealistic events; base your reasoning on the provided data patterns.
CONTEXT:
today: {{TODAY}}
period starts: {{START_DATE}}
period ends: {{END_DATE}}
Dates are provided in ISO format (YYYY-MM-DD).
PERIOD STATUS LOGIC (VERY IMPORTANT):
- If {{TODAY}} < {{START_DATE}}:
- Treat the period as future/planned.
- Do NOT write as if the period has already started or finished.
- Use formulations like "запланированный период", "ожидаемые траты".
- If {{START_DATE}} <= {{TODAY}} <= {{END_DATE}}:
- Treat the period as ongoing.
- Do NOT say or imply that the period has already finished or "завершился".
- Avoid phrases like "по итогам периода" or "период завершился".
- Use formulations like "в текущем периоде", "на данный момент", "пока".
- If {{TODAY}} > {{END_DATE}}:
- Treat the period as completed.
- You MAY use phrases like "по итогам периода", "период завершился", "за весь период".
REMINDER:
- Always check the relationship between today, period start and period end when choosing wording (ongoing vs completed vs future).
- The final response MUST be in Russian.
- The final response MUST be valid json with fields:
"common" - Общая оценка периода
"categoryAnalysis" - Анализ по категориям
"keyInsights" - Ключевые инсайты
"recommendations" - Рекомендации
{ "common": "string",
"categoryAnalysis": "string",
"keyInsights": "string",
"recommendations": "string",
}
""".trimIndent()
prompt = prompt.replace("{{TODAY}}", LocalDate.now().toString())
prompt = prompt.replace("{{period}}", "$startDate - $endDate")
prompt = prompt.replace("{{income}}", dashboardData.totalIncome.toString())
prompt = prompt.replace("{{expense}}", dashboardData.totalExpense.toString())
prompt = prompt.replace("{{net}}", dashboardData.balance.toString())
prompt = prompt.replace("{{income_change_percent}}", dashboardData.prevCurIncomeChange.toString())
prompt = prompt.replace("{{expense_change_percent}}", dashboardData.prevCurExpenseChange.toString())
prompt =
prompt.replace("{{net_change_value}}", (dashboardData.balance - dashboardData.prevBalance).toString())
prompt = prompt.replace(
"{{net_change_percent}}", if (dashboardData.prevBalance != 0)
((dashboardData.balance / dashboardData.prevBalance) * 100).toString() else "0"
)
prompt = prompt.replace(
"{{categories_list}}",
dashboardData.categories.joinToString(",") { mapper.writeValueAsString(it) })
prompt = prompt.replace("{{START_DATE}}", startDate.toString())
prompt = prompt.replace("{{END_DATE}}", endDate.toString())
val body = mapOf(
"model" to "deepseek-chat",
"messages" to listOf(
// mapOf("role" to "assistant", "content" to prompt),
mapOf("role" to "user", "content" to prompt)
)
)
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()
logger.info("start analyze period $startDate - $endDate ")
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
logger.debug("got anazyled text: $text")
val idStr = text.replace("```json", "").replace("```", "")
logger.info("stopped analyze period $startDate - $endDate ")
return idStr
}
}
}
}

View File

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

View File

@@ -1,82 +0,0 @@
package space.luminic.finance.services.gpt
import okhttp3.*
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody.Companion.toRequestBody
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import space.luminic.finance.models.Category
import space.luminic.finance.models.DashboardData
import space.luminic.finance.models.Transaction
import java.time.LocalDate
@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())
}
}
override fun analyzePeriod(startDate: LocalDate, endDate: LocalDate, dashboardData: DashboardData): String {
TODO("Not yet implemented")
}
}

View File

@@ -1,255 +0,0 @@
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.context.annotation.Lazy
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,
@Value("\${spring.profiles.active}") private val profile: String,
private val userService: UserService,
@Qualifier("spaceServiceTelegram") private val spaceService: SpaceService,
private val botRepo: BotRepo,
@Lazy @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.create(keyboard)
}
@Bean
fun bot(): Bot {
val bot = com.github.kotlintelegrambot.bot {
logLevel = if (profile == "prod") LogLevel.None else LogLevel.All()
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.fromId(message.chat.id), text = "Кажется, мы еще не знакомы.")
bot.sendMessage(ChatId.fromId(message.chat.id), text = "Давайте зарегистрируемся? ")
bot.sendMessage(ChatId.fromId(message.chat.id), text = "")
}
}
}
}
bot.startPolling()
return bot
}
}

View File

@@ -1,8 +0,0 @@
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

@@ -1,23 +0,0 @@
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

@@ -1,8 +0,0 @@
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

@@ -1,62 +0,0 @@
package space.luminic.finance.services.telegram
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.slf4j.LoggerFactory
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
import space.luminic.finance.services.NotificationService
import space.luminic.finance.services.TxActionType
@Service("transactionsServiceTelegram")
class TransactionsServiceImpl(
private val transactionRepo: TransactionRepo,
@Qualifier("spaceServiceTelegram") private val spaceService: SpaceService,
private val categoryService: CategoryServiceImpl,
private val notificationService: NotificationService
) : TransactionService {
private val logger = LoggerFactory.getLogger(this.javaClass)
private val serviceScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
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,
)
serviceScope.launch {
runCatching {
if (space.owner.id != userId) {
notificationService.sendTXNotification(TxActionType.CREATE, space, userId, transaction)
}
}.onFailure {
logger.error("Error while transaction notification", it)
}
}
return transactionRepo.create(transaction, userId)
}
}

View File

@@ -1,5 +1,6 @@
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=DEBUG
logging.level.org.springframework.data = DEBUG
logging.level.org.springframework.data.jpa = DEBUG
@@ -8,22 +9,20 @@ logging.level.org.springframework.security=DEBUG
#logging.level.org.springframework.data.mongodb.code = DEBUG
logging.level.org.springframework.web.reactive=DEBUG
logging.level.org.mongodb.driver.protocol.command = DEBUG
logging.level.org.springframework.jdbc.core=INFO
logging.level.org.springframework.jdbc.core.StatementCreatorUtils=INFO
logging.level.org.springframework.jdbc=INFO
logging.level.org.springframework.jdbc.datasource=INFO
logging.level.org.springframework.jdbc.support=INFO
logging.level.okhttp3=INFO
logging.level.space.luminic=DEBUG
logging.level.org.springframework.jdbc.core=DEBUG
logging.level.org.springframework.jdbc.core.StatementCreatorUtils=TRACE
logging.level.org.springframework.jdbc=DEBUG
logging.level.org.springframework.jdbc.datasource=DEBUG
logging.level.org.springframework.jdbc.support=DEBUG
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE
management.endpoints.web.exposure.include=*
management.endpoint.health.show-details=always
# vector test
telegram.bot.token=8127199836:AAEPepyKDAf8PvFpw-fpxBXUuPdx_LS20fI
telegram.bot.token=6972242509:AAGyXuL3T-BNE4XMoo_qvtaYxw_SuiS_dDs
nlp.address=http://127.0.0.1:8000
spring.datasource.url=jdbc:postgresql://31.59.58.220:5432/luminic-space-db
spring.datasource.url=jdbc:postgresql://213.226.71.138:5432/luminic-space-db
spring.datasource.username=luminicspace
spring.datasource.password=LS1q2w3e4r!

View File

@@ -1,3 +1,5 @@
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.data = INFO
@@ -7,12 +9,15 @@ logging.level.org.springframework.data.mongodb.code = INFO
logging.level.org.springframework.web.reactive=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
nlp.address=https://nlp.luminic.space
#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.url=jdbc:postgresql://postgresql:5432/luminic-space-db
spring.datasource.username=luminicspace
spring.datasource.password=LS1q2w3e4r!

View File

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

View File

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

View File

@@ -1,21 +0,0 @@
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

@@ -1,3 +0,0 @@
-- уникальное ограничение под 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

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

View File

@@ -1,19 +0,0 @@
-- Очередь классификации категорий
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

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

View File

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

View File

@@ -1,5 +0,0 @@
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

@@ -1,14 +0,0 @@
create table if not exists finance.period_analyze
(
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
space_id integer not null,
period_start date not null,
period_end date not null,
analyze_text text not null,
first_analyze_at timestamp without time zone not null default now(),
last_analyze_at timestamp without time zone not null default now(),
CONSTRAINT pk_period_analyze PRIMARY KEY (id)
);
ALTER TABLE finance.period_analyze
ADD CONSTRAINT FK_PERIOD_ANALYZE_ON_SPACE FOREIGN KEY (space_id) REFERENCES finance.spaces (id);

View File

@@ -1,2 +0,0 @@
CREATE UNIQUE INDEX IF NOT EXISTS period_analyze_unique_idx
ON finance.period_analyze (space_id, period_start, period_end);

View File

@@ -1,2 +0,0 @@
alter table finance.period_analyze
alter column analyze_text set data type json USING analyze_text::json;

View File

@@ -1,4 +0,0 @@
create table if not exists finance.analyze_runs (
run_at timestamp without time zone primary key,
involved_spaces varchar not null
)

View File

@@ -1,2 +0,0 @@
alter table finance.recurrent_operations
add column is_deleted boolean default false;