1 Commits

Author SHA1 Message Date
xds
2452e5935f + targets 2025-11-20 12:37:06 +03:00
50 changed files with 421 additions and 1520 deletions

View File

@@ -68,7 +68,6 @@ dependencies {
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")

View File

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

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

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

View File

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

View File

@@ -23,7 +23,7 @@ class TransactionController (
@PostMapping("/_search")
fun getTransactions(@PathVariable spaceId: Int, @RequestBody filter: TransactionService.TransactionsFilter) : List<TransactionDTO>{
return transactionService.getTransactions(spaceId, filter).map { it.toDto() }
return transactionService.getTransactions(spaceId, filter,"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://app.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

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

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

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

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

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

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

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

View File

@@ -73,7 +73,7 @@ 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
where ro.space_id = :spaceId
order by ro.date, ro.id
""".trimIndent()
val params = mapOf("spaceId" to spaceId)
@@ -109,7 +109,7 @@ 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()
@@ -143,7 +143,7 @@ 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.date = :date and ro.is_deleted = false
where ro.date = :date
""".trimIndent()
val params = mapOf( "date" to date)
return jdbcTemplate.query(sql, params, operationRowMapper())
@@ -203,8 +203,7 @@ 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)

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

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

View File

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

View File

@@ -7,6 +7,8 @@ import space.luminic.finance.models.Category
import space.luminic.finance.models.Transaction
import space.luminic.finance.models.User
import space.luminic.finance.services.TransactionService
import java.time.LocalDate
import java.time.LocalDateTime
@Repository
class TransactionRepoImpl(
@@ -98,18 +100,10 @@ class TransactionRepoImpl(
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)
@@ -117,20 +111,16 @@ class TransactionRepoImpl(
filters.dateFrom?.let {
sql += " AND t.date >= :dateFrom"
params.put("dateFrom", it)
} ?: {
sql += " AND t.date >= :dateFrom"
params.put("dateFrom", LocalDate.now().minusMonths(1))
}
filters.dateTo?.let {
sql += " AND t.date <= :dateTo"
params.put("dateTo", it)
}
sql += 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 += """
ORDER BY t.date, t.id
OFFSET :offset ROWS
FETCH FIRST :limit ROWS ONLY"""

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

View File

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

View File

@@ -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

@@ -6,8 +6,6 @@ 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
@@ -74,7 +72,7 @@ class RecurrentOperationServiceImpl(
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()),
date = date.plusMonths(i.toLong()),
recurrentId = createdRecurrentId
)
)
@@ -112,7 +110,7 @@ class RecurrentOperationServiceImpl(
type = if (it.category?.type == Category.CategoryType.EXPENSE) Transaction.TransactionType.EXPENSE else Transaction.TransactionType.INCOME,
category = updatedOperation.category,
comment = operation.name,
amount = operation.amount,
amount = updatedOperation.amount,
date = LocalDate.of(
it.date.year,
it.date.monthValue,
@@ -128,11 +126,10 @@ class RecurrentOperationServiceImpl(
}
}
}
@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)
}

View File

@@ -4,14 +4,11 @@ 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 recurrentOperationService: RecurrentOperationService
) {
private val log = LoggerFactory.getLogger(Scheduler::class.java)
@@ -20,16 +17,4 @@ class Scheduler(
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,10 +2,9 @@ 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

View File

@@ -6,9 +6,6 @@ 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,11 +13,6 @@ 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)
}
@@ -74,7 +66,6 @@ class SpaceServiceImpl(
)
return spaceRepo.update(updatedSpace, userId)
}
@Transactional
override fun deleteSpace(spaceId: Int) {
spaceRepo.delete(spaceId)

View File

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

View File

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

View File

@@ -7,34 +7,20 @@ 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
filter: TransactionsFilter,
sortBy: String,
sortDirection: String
): List<Transaction>
fun getTransaction(spaceId: Int, transactionId: Int): Transaction

View File

@@ -1,18 +1,11 @@
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(
@@ -21,14 +14,12 @@ class TransactionServiceImpl(
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
@@ -62,17 +53,7 @@ class TransactionServiceImpl(
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
return transactionRepo.create(transaction, userId)
}
override fun batchCreate(spaceId: Int, transactions: List<TransactionDTO.CreateTransactionDTO>, createdById: Int?) {
@@ -104,22 +85,15 @@ class TransactionServiceImpl(
transactionId: Int,
transaction: TransactionDTO.UpdateTransactionDTO
): Int {
val userId = authService.getSecurityUserId()
val space = spaceService.getSpace(spaceId, null)
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 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,
@@ -135,34 +109,14 @@ class TransactionServiceImpl(
if ((existingTransaction.category == null && updatedTransaction.category != null) || (existingTransaction.category?.id != updatedTransaction.category?.id)) {
categorizeService.notifyThatCategorySelected(updatedTransaction)
}
val updatedTx = transactionRepo.update(updatedTransaction)
serviceScope.launch {
runCatching {
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)
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) {

View File

@@ -60,7 +60,7 @@ class CategorizeService(
listOf(
InlineKeyboardButton.WebApp(
"Открыть в WebApp",
WebAppInfo("https://app.luminic.space/transactions/${tx.id}/edit")
WebAppInfo("https://app.luminic.space/transactions/${tx.id}/edit?mode=from_bot")
)
)
)
@@ -85,7 +85,7 @@ class CategorizeService(
listOf(
InlineKeyboardButton.WebApp(
"Открыть в WebApp",
WebAppInfo("https://app.luminic.space/transactions/${tx.id}/edit")
WebAppInfo("https://app.luminic.space/transactions/${tx.id}/edit?mode=from_bot")
)
)
),
@@ -131,7 +131,7 @@ class CategorizeService(
listOf(
InlineKeyboardButton.WebApp(
"Открыть в WebApp",
WebAppInfo("https://app.luminic.space/transactions/${tx.id}/edit")
WebAppInfo("https://app.luminic.space/transactions/${tx.id}/edit?mode=from_bot")
)
)
),

View File

@@ -1,12 +1,9 @@
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
@@ -14,10 +11,7 @@ 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")
@@ -27,8 +21,7 @@ class DeepSeekCategorizationService(
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 client = OkHttpClient()
private val logger = LoggerFactory.getLogger(javaClass)
override fun suggestCategory(tx: Transaction, categories: List<Category>): CategorySuggestion {
@@ -81,181 +74,7 @@ class DeepSeekCategorizationService(
// 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
}
return CategorySuggestion(idStr.toInt(), )
}
}
}

View File

@@ -1,13 +1,10 @@
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

@@ -3,15 +3,15 @@ package space.luminic.finance.services.gpt
import okhttp3.*
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody.Companion.toRequestBody
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import space.luminic.finance.models.Category
import space.luminic.finance.models.DashboardData
import space.luminic.finance.models.Transaction
import java.time.LocalDate
@Service("qwenCategorizationService")
class QwenCategorizationService(
@@ -75,8 +75,4 @@ class QwenCategorizationService(
return CategorySuggestion(idStr.toInt(), name, confStr.toDouble())
}
}
override fun analyzePeriod(startDate: LocalDate, endDate: LocalDate, dashboardData: DashboardData): String {
TODO("Not yet implemented")
}
}

View File

@@ -16,7 +16,6 @@ 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
@@ -31,11 +30,10 @@ 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
@Qualifier("transactionsServiceTelegram") private val transactionService: TransactionService
) {
@@ -115,13 +113,13 @@ class BotService(
)
)
return InlineKeyboardMarkup.create(keyboard)
return InlineKeyboardMarkup.Companion.create(keyboard)
}
@Bean
fun bot(): Bot {
val bot = com.github.kotlintelegrambot.bot {
logLevel = if (profile == "prod") LogLevel.None else LogLevel.All()
logLevel = LogLevel.None
token = botToken
dispatch {
message(Filter.Text) {
@@ -239,9 +237,9 @@ class BotService(
)
} 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.sendMessage(ChatId.Companion.fromId(message.chat.id), text = "Кажется, мы еще не знакомы.")
bot.sendMessage(ChatId.Companion.fromId(message.chat.id), text = "Давайте зарегистрируемся? ")
bot.sendMessage(ChatId.Companion.fromId(message.chat.id), text = "")
}

View File

@@ -4,5 +4,5 @@ import space.luminic.finance.models.Space
interface SpaceService {
fun getSpaces(userId: Int): List<Space>
fun getSpace(spaceId: Int, userId: Int): Space
fun getSpace(spaceId: Int, userId: Int): Space?
}

View File

@@ -14,7 +14,7 @@ class SpaceServiceImpl(
return spaces
}
override fun getSpace(spaceId: Int, userId: Int): Space {
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,29 +1,18 @@
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())
private val categoryService: CategoryServiceImpl
): TransactionService {
override fun createTransaction(
spaceId: Int,
@@ -32,31 +21,23 @@ class TransactionsServiceImpl(
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)
val space = spaceService.getSpace(spaceId, userId)
val category = transaction.categoryId?.let { categoryService.getCategory(spaceId, it) }
val transaction = Transaction(
space = space,
type = transaction.type,
kind = transaction.kind,
category = category,
comment = transaction.comment,
amount = transaction.amount,
fees = transaction.fees,
date = transaction.date,
tgChatId = chatId,
tgMessageId = messageId,
)
return transactionRepo.create(transaction, userId)
}
}

View File

@@ -1,20 +1,19 @@
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
logging.level.org.springframework.data = DEBUG
logging.level.org.springframework.data.jpa = DEBUG
#logging.level.org.springframework.data.mongodb.core.ReactiveMongoTemplate=DEBUG
logging.level.org.springframework.security=DEBUG
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.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
management.endpoints.web.exposure.include=*
management.endpoint.health.show-details=always
@@ -24,6 +23,6 @@ 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,14 +1,59 @@
create table if not exists finance.period_analyze
DROP table if exists finance.goals cascade;
DROP table if exists finance.goals_components cascade;
DROP table if exists finance.goals_transactions cascade;
DROP table if exists finance.targets cascade;
DROP table if exists finance.targets_components cascade;
DROP table if exists finance.targets_transactions cascade;
CREATE TABLE finance.targets
(
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
space_id integer 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)
id integer generated by default as identity not null,
space_id INTEGER,
type SMALLINT,
name VARCHAR(255),
description VARCHAR(255),
amount DECIMAL,
until_date date,
created_by_id INTEGER,
created_at TIMESTAMP WITHOUT TIME ZONE,
updated_by_id INTEGER,
updated_at TIMESTAMP WITHOUT TIME ZONE,
CONSTRAINT pk_targets PRIMARY KEY (id)
);
ALTER TABLE finance.period_analyze
ADD CONSTRAINT FK_PERIOD_ANALYZE_ON_SPACE FOREIGN KEY (space_id) REFERENCES finance.spaces (id);
ALTER TABLE finance.targets
ADD CONSTRAINT FK_TARGETS_ON_CREATEDBY FOREIGN KEY (created_by_id) REFERENCES finance.users (id);
ALTER TABLE finance.targets
ADD CONSTRAINT FK_TARGETS_ON_SPACE FOREIGN KEY (space_id) REFERENCES finance.spaces (id);
ALTER TABLE finance.targets
ADD CONSTRAINT FK_TARGETS_ON_UPDATEDBY FOREIGN KEY (updated_by_id) REFERENCES finance.users (id);
CREATE TABLE finance.targets_transactions
(
target_id INTEGER NOT NULL,
transactions_id INTEGER NOT NULL
);
ALTER TABLE finance.targets_transactions
ADD CONSTRAINT fk_targettx_on_target FOREIGN KEY (target_id) REFERENCES finance.targets (id);
ALTER TABLE finance.targets_transactions
ADD CONSTRAINT fk_targettx_on_tx FOREIGN KEY (transactions_id) REFERENCES finance.transactions (id);
create table if not exists finance.targets_components
(
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
target_id INTEGER NOT NULL,
name VARCHAR(255) NOT NULL,
amount NUMERIC NOT NULL,
is_done BOOLEAN NOT NULL DEFAULT FALSE,
date TIMESTAMP WITH TIME ZONE NULL
);
alter table finance.targets_components
add constraint fk_target_on_components foreign key (target_id) references finance.targets (id);

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;