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-core:1.7.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:1.7.3") 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("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-api:0.11.5")
implementation("io.jsonwebtoken:jjwt-impl:0.11.5") implementation("io.jsonwebtoken:jjwt-impl:0.11.5")

View File

@@ -1,10 +1,10 @@
#!/bin/bash #!/bin/bash
./gradlew bootJar || exit 1 ./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 " ssh root@213.226.71.138 "
cd /root/luminic/app/back && cd /root/luminic/space/back &&
docker compose up -d --build && docker compose up -d --build &&
docker restart back-app-1 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") @PostMapping("/_search")
fun getTransactions(@PathVariable spaceId: Int, @RequestBody filter: TransactionService.TransactionsFilter) : List<TransactionDTO>{ 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}") @GetMapping("/{transactionId}")

View File

@@ -50,7 +50,7 @@ class SecurityConfig(
@Bean @Bean
fun corsConfigurationSource(): CorsConfigurationSource { fun corsConfigurationSource(): CorsConfigurationSource {
val cors = CorsConfiguration().apply { val cors = CorsConfiguration().apply {
allowedOrigins = listOf("https://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") allowedMethods = listOf("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
allowedHeaders = listOf("*") allowedHeaders = listOf("*")
allowCredentials = true 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 package space.luminic.finance.dtos
import space.luminic.finance.models.Goal import space.luminic.finance.models.Target
import space.luminic.finance.models.Goal.GoalType import space.luminic.finance.models.Target.TargetType
import space.luminic.finance.models.Transaction import space.luminic.finance.models.Transaction
import java.math.BigDecimal import java.math.BigDecimal
import java.time.Instant import java.time.Instant
import java.time.LocalDate import java.time.LocalDate
data class GoalDTO( data class TargetDTO(
val id: Int, val id: Int,
val type: GoalType, val type: TargetType,
val name: String, val name: String,
val description: String? = null, val description: String? = null,
val amount: BigDecimal, val amount: BigDecimal,
val currentAmount: BigDecimal,
val date: LocalDate, val date: LocalDate,
val components: List<Goal.GoalComponent>, val components: List<Target.TargetComponent>,
val transactions: List<Transaction>, val transactions: List<Transaction>,
val createdBy: UserDTO, val createdBy: UserDTO,
val createdAt: Instant, val createdAt: Instant,
val updatedBy: UserDTO? = null, val updatedBy: UserDTO? = null,
val updatedAt: Instant? = null, val updatedAt: Instant? = null,
) { ) {
data class CreateGoalDTO( data class CreateTargetDTO(
val type: GoalType, val type: TargetType,
val name: String, val name: String,
val description: String?, val description: String?,
val amount: BigDecimal, val amount: BigDecimal,
val date: LocalDate val date: LocalDate
) )
data class UpdateGoalDTO( data class UpdateTargetDTO(
val type: GoalType, val type: TargetType,
val name: String, val name: String,
val description: String?, val description: String?,
val amount: BigDecimal, val amount: BigDecimal,

View File

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

View File

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

View File

@@ -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.users su on s.owner_id = su.id
join finance.categories c on ro.category_id = c.id join finance.categories c on ro.category_id = c.id
join finance.users r_created_by on ro.created_by_id = r_created_by.id join finance.users r_created_by on ro.created_by_id = r_created_by.id
where ro.space_id = :spaceId and ro.is_deleted = false where ro.space_id = :spaceId
order by ro.date, ro.id order by ro.date, ro.id
""".trimIndent() """.trimIndent()
val params = mapOf("spaceId" to spaceId) val params = mapOf("spaceId" to spaceId)
@@ -109,7 +109,7 @@ class RecurrentOperationRepoImpl(
join finance.users su on s.owner_id = su.id join finance.users su on s.owner_id = su.id
join finance.categories c on ro.category_id = c.id join finance.categories c on ro.category_id = c.id
join finance.users r_created_by on ro.created_by_id = r_created_by.id join finance.users r_created_by on ro.created_by_id = r_created_by.id
where ro.space_id = :spaceId and ro.id = :id and ro.is_deleted = false; where ro.space_id = :spaceId and ro.id = :id
""".trimIndent() """.trimIndent()
val params = mapOf("spaceId" to spaceId, "id" to id) val params = mapOf("spaceId" to spaceId, "id" to id)
return jdbcTemplate.query(sql, params, operationRowMapper()).firstOrNull() 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.users su on s.owner_id = su.id
join finance.categories c on ro.category_id = c.id join finance.categories c on ro.category_id = c.id
join finance.users r_created_by on ro.created_by_id = r_created_by.id join finance.users r_created_by on ro.created_by_id = r_created_by.id
where ro.date = :date and ro.is_deleted = false where ro.date = :date
""".trimIndent() """.trimIndent()
val params = mapOf( "date" to date) val params = mapOf( "date" to date)
return jdbcTemplate.query(sql, params, operationRowMapper()) return jdbcTemplate.query(sql, params, operationRowMapper())
@@ -203,8 +203,7 @@ class RecurrentOperationRepoImpl(
override fun delete(id: Int) { override fun delete(id: Int) {
val sql = """ val sql = """
update finance.recurrent_operations delete from finance.recurrent_operations
set is_deleted = true
where id = :id where id = :id
""".trimIndent() """.trimIndent()
val params = mapOf("id" to id) val params = mapOf("id" to id)

View File

@@ -2,11 +2,9 @@ package space.luminic.finance.repos
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
import space.luminic.finance.models.Space import space.luminic.finance.models.Space
import java.time.LocalDateTime
@Repository @Repository
interface SpaceRepo { interface SpaceRepo {
fun findSpacesForScheduling(lastRun: LocalDateTime): List<Space>
fun findSpacesAvailableForUser(userId: Int): List<Space> fun findSpacesAvailableForUser(userId: Int): List<Space>
fun findSpaceById(id: Int, userId: Int): Space? fun findSpaceById(id: Int, userId: Int): Space?
fun create(space: Space, createdById: Int): Int 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.dtos.SpaceDTO
import space.luminic.finance.models.Space import space.luminic.finance.models.Space
import space.luminic.finance.models.User import space.luminic.finance.models.User
import java.time.LocalDateTime
@Repository @Repository
class SpaceRepoImpl( class SpaceRepoImpl(
@@ -39,8 +38,7 @@ class SpaceRepoImpl(
owner = User( owner = User(
rs.getInt("s_owner_id"), rs.getInt("s_owner_id"),
rs.getString("s_owner_username"), rs.getString("s_owner_username"),
rs.getString("s_owner_firstname"), rs.getString("s_owner_firstname")
tgId = rs.getLong("s_owner_tg_id"),
), ),
participant = User(rs.getInt("sp_uid"), rs.getString("sp_username"), rs.getString("sp_first_name")), participant = User(rs.getInt("sp_uid"), rs.getString("sp_username"), rs.getString("sp_first_name")),
createdAt = rs.getTimestamp("s_created_at").toInstant(), createdAt = rs.getTimestamp("s_created_at").toInstant(),
@@ -85,44 +83,6 @@ class SpaceRepoImpl(
return spaceMap.map { it.value } 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> { override fun findSpacesAvailableForUser(userId: Int): List<Space> {
val sql = """ val sql = """
@@ -133,7 +93,6 @@ class SpaceRepoImpl(
s.owner_id as s_owner_id, s.owner_id as s_owner_id,
ou.username as s_owner_username, ou.username as s_owner_username,
ou.first_name as s_owner_firstname, ou.first_name as s_owner_firstname,
ou.tg_id as s_owner_tg_id,
sp.participants_id as sp_uid, sp.participants_id as sp_uid,
u.username as sp_username, u.username as sp_username,
u.first_name as sp_first_name, u.first_name as sp_first_name,
@@ -155,7 +114,7 @@ class SpaceRepoImpl(
where (s.owner_id = :user_id where (s.owner_id = :user_id
or sp.participants_id = :user_id) or sp.participants_id = :user_id)
and s.is_deleted = false 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; uau.username, uau.first_name;
""".trimMargin() """.trimMargin()
val params = mapOf( val params = mapOf(
@@ -174,7 +133,6 @@ class SpaceRepoImpl(
s.owner_id as s_owner_id, s.owner_id as s_owner_id,
ou.username as s_owner_username, ou.username as s_owner_username,
ou.first_name as s_owner_firstname, ou.first_name as s_owner_firstname,
ou.tg_id as s_owner_tg_id,
sp.participants_id as sp_uid, sp.participants_id as sp_uid,
u.username as sp_username, u.username as sp_username,
u.first_name as sp_first_name, u.first_name as sp_first_name,
@@ -196,7 +154,7 @@ from finance.spaces s
where (s.owner_id = :user_id where (s.owner_id = :user_id
or sp.participants_id = :user_id) or sp.participants_id = :user_id)
and s.is_deleted = false and s.id = :spaceId 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; uau.username, uau.first_name;
""".trimMargin() """.trimMargin()
val params = mapOf( 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.RowMapper
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
import space.luminic.finance.models.Goal import space.luminic.finance.models.Target
import space.luminic.finance.models.User import space.luminic.finance.models.User
@Repository @Repository
class GoalRepoImpl( class TargetRepoImpl(
private val jdbcTemplate: NamedParameterJdbcTemplate private val jdbcTemplate: NamedParameterJdbcTemplate
) : GoalRepo { ) : TargetRepo {
private val goalRowMapper = RowMapper { rs, _ -> private val targetRowMapper = RowMapper { rs, _ ->
Goal( Target(
id = rs.getInt("g_id"), id = rs.getInt("g_id"),
type = Goal.GoalType.valueOf(rs.getString("g_type")), type = Target.TargetType.valueOf(rs.getString("g_type")),
name = rs.getString("g_name"), name = rs.getString("g_name"),
description = rs.getString("g_description"), description = rs.getString("g_description"),
amount = rs.getBigDecimal("g_amount"), amount = rs.getBigDecimal("g_amount"),
@@ -30,7 +30,7 @@ class GoalRepoImpl(
} }
private val componentRowMapper = RowMapper { rs, _ -> private val componentRowMapper = RowMapper { rs, _ ->
Goal.GoalComponent( Target.TargetComponent(
id = rs.getInt("gc_id"), id = rs.getInt("gc_id"),
name = rs.getString("gc_name"), name = rs.getString("gc_name"),
amount = rs.getBigDecimal("gc_amount"), amount = rs.getBigDecimal("gc_amount"),
@@ -39,7 +39,7 @@ class GoalRepoImpl(
) )
} }
override fun findAllBySpaceId(spaceId: Int): List<Goal> { override fun findAllBySpaceId(spaceId: Int): List<Target> {
val sql = """ val sql = """
select select
g.id as g_id, g.id as g_id,
@@ -51,7 +51,7 @@ class GoalRepoImpl(
created_by.username as created_by_username, created_by.username as created_by_username,
created_by.first_name as created_by_first_name, created_by.first_name as created_by_first_name,
g.created_at as g_created_at g.created_at as g_created_at
from finance.goals g from finance.targets g
join finance.users created_by on g.created_by_id = created_by.id join finance.users created_by on g.created_by_id = created_by.id
where g.space_id = :spaceId where g.space_id = :spaceId
@@ -60,10 +60,10 @@ class GoalRepoImpl(
val params = mapOf( val params = mapOf(
"space_id" to spaceId, "space_id" to spaceId,
) )
return jdbcTemplate.query(sql, params, goalRowMapper) return jdbcTemplate.query(sql, params, targetRowMapper)
} }
override fun findBySpaceIdAndId(spaceId: Int, id: Int): Goal? { override fun findBySpaceIdAndId(spaceId: Int, id: Int): Target? {
val sql = """ val sql = """
select select
g.id as g_id, g.id as g_id,
@@ -75,7 +75,7 @@ class GoalRepoImpl(
created_by.username as created_by_username, created_by.username as created_by_username,
created_by.first_name as created_by_first_name, created_by.first_name as created_by_first_name,
g.created_at as g_created_at g.created_at as g_created_at
from finance.goals g from finance.targets g
join finance.users created_by on g.created_by_id = created_by.id join finance.users created_by on g.created_by_id = created_by.id
where g.space_id = :spaceId and g.id = :id where g.space_id = :spaceId and g.id = :id
@@ -85,12 +85,12 @@ class GoalRepoImpl(
"space_id" to spaceId, "space_id" to spaceId,
"id" to id, "id" to id,
) )
return jdbcTemplate.query(sql, params, goalRowMapper).firstOrNull() return jdbcTemplate.query(sql, params, targetRowMapper).firstOrNull()
} }
override fun create(goal: Goal, createdById: Int): Int { override fun create(target: Target, createdById: Int): Int {
val sql = """ val sql = """
insert into finance.goals( insert into finance.targets(
type, type,
name, name,
description, description,
@@ -108,19 +108,19 @@ class GoalRepoImpl(
returning id returning id
""".trimIndent() """.trimIndent()
val params = mapOf( val params = mapOf(
"type" to goal.type, "type" to target.type,
"name" to goal.name, "name" to target.name,
"description" to goal.description, "description" to target.description,
"amount" to goal.amount, "amount" to target.amount,
"until_date" to goal.untilDate, "until_date" to target.untilDate,
"created_by_id" to createdById "created_by_id" to createdById
) )
return jdbcTemplate.queryForObject(sql, params, Int::class.java)!! return jdbcTemplate.queryForObject(sql, params, Int::class.java)!!
} }
override fun update(goal: Goal, updatedById: Int) { override fun update(target: Target, updatedById: Int) {
val sql = """ val sql = """
update finance.goals set update finance.targets set
type = :type, type = :type,
name = :name, name = :name,
description = :description, description = :description,
@@ -131,12 +131,12 @@ class GoalRepoImpl(
where id = :id where id = :id
""".trimIndent() """.trimIndent()
val params = mapOf( val params = mapOf(
"id" to goal.id, "id" to target.id,
"type" to goal.type.name, "type" to target.type.name,
"name" to goal.name, "name" to target.name,
"description" to goal.description, "description" to target.description,
"amount" to goal.amount, "amount" to target.amount,
"until_date" to goal.untilDate, "until_date" to target.untilDate,
"updated_by_id" to updatedById "updated_by_id" to updatedById
) )
@@ -145,7 +145,7 @@ class GoalRepoImpl(
override fun delete(spaceId: Int, id: Int) { override fun delete(spaceId: Int, id: Int) {
val sql = """ val sql = """
delete from finance.goals where id = :id delete from finance.targets where id = :id
""".trimIndent() """.trimIndent()
val params = mapOf( val params = mapOf(
@@ -156,8 +156,8 @@ class GoalRepoImpl(
override fun getComponents( override fun getComponents(
spaceId: Int, spaceId: Int,
goalId: Int targetId: Int
): List<Goal.GoalComponent> { ): List<Target.TargetComponent> {
val sql = """ val sql = """
select select
gc.id as gc_id, gc.id as gc_id,
@@ -165,11 +165,11 @@ class GoalRepoImpl(
gc.amount as gc_amount, gc.amount as gc_amount,
gc.is_done as gc_is_done, gc.is_done as gc_is_done,
gc.date as gc_date gc.date as gc_date
from finance.goals_components gc from finance.targets_components gc
where gc.goal_id = :goal_id where gc.target_id = :target_id
""".trimIndent() """.trimIndent()
val params = mapOf( val params = mapOf(
"goal_id" to goalId "target_id" to targetId
) )
return jdbcTemplate.query(sql, params, componentRowMapper) return jdbcTemplate.query(sql, params, componentRowMapper)
@@ -177,9 +177,9 @@ class GoalRepoImpl(
override fun getComponent( override fun getComponent(
spaceId: Int, spaceId: Int,
goalId: Int, targetId: Int,
id: Int id: Int
): Goal.GoalComponent? { ): Target.TargetComponent? {
val sql = """ val sql = """
select select
gc.id as gc_id, gc.id as gc_id,
@@ -187,27 +187,27 @@ class GoalRepoImpl(
gc.amount as gc_amount, gc.amount as gc_amount,
gc.is_done as gc_is_done, gc.is_done as gc_is_done,
gc.date as gc_date gc.date as gc_date
from finance.goals_components gc from finance.targets_components gc
where gc.goal_id = :goal_id and gc.id = :id where gc.target_id = :target_id and gc.id = :id
""".trimIndent() """.trimIndent()
val params = mapOf( val params = mapOf(
"goal_id" to goalId, "target_id" to targetId,
"id" to id "id" to id
) )
return jdbcTemplate.query(sql, params, componentRowMapper).firstOrNull() return jdbcTemplate.query(sql, params, componentRowMapper).firstOrNull()
} }
override fun createComponent(goalId: Int, component: Goal.GoalComponent, createdById: Int): Int { override fun createComponent(targetId: Int, component: Target.TargetComponent, createdById: Int): Int {
val sql = """ val sql = """
insert into finance.goals_components( insert into finance.targets_components(
goal_id, target_id,
name, name,
amount, amount,
is_done, is_done,
date, date,
created_by_id created_by_id
) values ( ) values (
:goal_id, :target_id,
:name, :name,
:amount, :amount,
:is_done, :is_done,
@@ -216,7 +216,7 @@ class GoalRepoImpl(
returning id returning id
""".trimIndent() """.trimIndent()
val params = mapOf( val params = mapOf(
"goal_id" to goalId, "target_id" to targetId,
"name" to component.name, "name" to component.name,
"amount" to component.amount, "amount" to component.amount,
"is_done" to component.isDone, "is_done" to component.isDone,
@@ -226,17 +226,17 @@ class GoalRepoImpl(
return jdbcTemplate.queryForObject(sql, params, Int::class.java)!! return jdbcTemplate.queryForObject(sql, params, Int::class.java)!!
} }
override fun updateComponent(goalId: Int, componentId: Int, component: Goal.GoalComponent, updatedById: Int) { override fun updateComponent(targetId: Int, componentId: Int, component: Target.TargetComponent, updatedById: Int) {
val sql = """ val sql = """
update finance.goals_components set update finance.targets_components set
name = :name, name = :name,
amount = :amount, amount = :amount,
is_done = :is_done, is_done = :is_done,
updated_by_id = :updated_by_id updated_by_id = :updated_by_id
where goal_id = :goalId and id = :componentId where target_id = :targetId and id = :componentId
""".trimIndent() """.trimIndent()
val params = mapOf( val params = mapOf(
"goalId" to goalId, "targetId" to targetId,
"componentId" to componentId, "componentId" to componentId,
"name" to component.name, "name" to component.name,
"amount" to component.amount, "amount" to component.amount,
@@ -247,35 +247,35 @@ class GoalRepoImpl(
jdbcTemplate.update(sql, params) jdbcTemplate.update(sql, params)
} }
override fun deleteComponent(goalId: Int, componentId: Int) { override fun deleteComponent(targetId: Int, componentId: Int) {
val sql = """ val sql = """
delete from finance.goals_components where goal_id = :goalId and id = :componentId delete from finance.targets_components where target_id = :targetId and id = :componentId
""".trimIndent() """.trimIndent()
val params = mapOf( val params = mapOf(
"goalId" to goalId, "targetId" to targetId,
"componentId" to componentId "componentId" to componentId
) )
jdbcTemplate.update(sql, params) jdbcTemplate.update(sql, params)
} }
override fun assignTransaction(goalId: Int, transactionId: Int) { override fun assignTransaction(targetId: Int, transactionId: Int) {
val sql = """ val sql = """
insert into finance.goals_transactions(goal_id, transactions_id) insert into finance.targets_transactions(target_id, transactions_id)
values (:goal_id, :transaction_id) values (:targetId, :transaction_id)
""".trimIndent() """.trimIndent()
val params = mapOf( val params = mapOf(
"goal_id" to goalId, "targetId" to targetId,
"transaction_id" to transactionId "transaction_id" to transactionId
) )
jdbcTemplate.update(sql, params) jdbcTemplate.update(sql, params)
} }
override fun refuseTransaction(goalId: Int, transactionId: Int) { override fun refuseTransaction(targetId: Int, transactionId: Int) {
val sql = """ val sql = """
delete from finance.goals_transactions where goal_id = :goalId and transactions_id = :transactionId delete from finance.targets_transactions where target_id = :goalId and transactions_id = :transactionId
""".trimIndent() """.trimIndent()
val params = mapOf( val params = mapOf(
"goal_id" to goalId, "target_id" to targetId,
"transaction_id" to transactionId "transaction_id" to transactionId
) )
jdbcTemplate.update(sql, params) jdbcTemplate.update(sql, params)

View File

@@ -7,6 +7,8 @@ import space.luminic.finance.models.Category
import space.luminic.finance.models.Transaction import space.luminic.finance.models.Transaction
import space.luminic.finance.models.User import space.luminic.finance.models.User
import space.luminic.finance.services.TransactionService import space.luminic.finance.services.TransactionService
import java.time.LocalDate
import java.time.LocalDateTime
@Repository @Repository
class TransactionRepoImpl( class TransactionRepoImpl(
@@ -98,18 +100,10 @@ class TransactionRepoImpl(
sql += " AND t.type = :type" sql += " AND t.type = :type"
params.put("type", it.name) params.put("type", it.name)
} }
filters.query?.let {
sql += " AND lower(t.comment) LIKE ('%${it.lowercase()}%')"
params["query"] = it.lowercase()
}
filters.kind?.let { filters.kind?.let {
sql += " AND t.kind = :kind" sql += " AND t.kind = :kind"
params.put("kind", it.name) params.put("kind", it.name)
} }
filters.categoriesIds?.let {
sql += " AND t.category_id in (:categoriesIds)"
params.put("categoriesIds", it)
}
filters.isDone?.let { filters.isDone?.let {
sql += " AND t.is_done = :isDone" sql += " AND t.is_done = :isDone"
params.put("isDone", it) params.put("isDone", it)
@@ -117,20 +111,16 @@ class TransactionRepoImpl(
filters.dateFrom?.let { filters.dateFrom?.let {
sql += " AND t.date >= :dateFrom" sql += " AND t.date >= :dateFrom"
params.put("dateFrom", it) params.put("dateFrom", it)
} ?: {
sql += " AND t.date >= :dateFrom"
params.put("dateFrom", LocalDate.now().minusMonths(1))
} }
filters.dateTo?.let { filters.dateTo?.let {
sql += " AND t.date <= :dateTo" sql += " AND t.date <= :dateTo"
params.put("dateTo", it) 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 += """ sql += """
ORDER BY t.date, t.id
OFFSET :offset ROWS OFFSET :offset ROWS
FETCH FIRST :limit ROWS ONLY""" 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 kotlinx.coroutines.launch
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service 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.dtos.RecurrentOperationDTO
import space.luminic.finance.models.Category import space.luminic.finance.models.Category
import space.luminic.finance.models.NotFoundException import space.luminic.finance.models.NotFoundException
@@ -74,7 +72,7 @@ class RecurrentOperationServiceImpl(
category = category, category = category,
comment = creatingOperation.name, comment = creatingOperation.name,
amount = creatingOperation.amount, 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 recurrentId = createdRecurrentId
) )
) )
@@ -112,7 +110,7 @@ class RecurrentOperationServiceImpl(
type = if (it.category?.type == Category.CategoryType.EXPENSE) Transaction.TransactionType.EXPENSE else Transaction.TransactionType.INCOME, type = if (it.category?.type == Category.CategoryType.EXPENSE) Transaction.TransactionType.EXPENSE else Transaction.TransactionType.INCOME,
category = updatedOperation.category, category = updatedOperation.category,
comment = operation.name, comment = operation.name,
amount = operation.amount, amount = updatedOperation.amount,
date = LocalDate.of( date = LocalDate.of(
it.date.year, it.date.year,
it.date.monthValue, it.date.monthValue,
@@ -128,11 +126,10 @@ class RecurrentOperationServiceImpl(
} }
} }
} }
@Transactional(isolation = Isolation.READ_UNCOMMITTED)
override fun delete(spaceId: Int, id: Int) { override fun delete(spaceId: Int, id: Int) {
val userId = authService.getSecurityUserId() val userId = authService.getSecurityUserId()
spaceRepo.findSpaceById(spaceId, userId)?: throw NotFoundException("Cannot find space with id $id") spaceRepo.findSpaceById(spaceId, userId)
transactionRepo.deleteByRecurrentId(spaceId, id)
recurrentOperationRepo.delete(id) recurrentOperationRepo.delete(id)
} }

View File

@@ -4,14 +4,11 @@ import org.slf4j.LoggerFactory
import org.springframework.scheduling.annotation.EnableScheduling import org.springframework.scheduling.annotation.EnableScheduling
import org.springframework.scheduling.annotation.Scheduled import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.util.concurrent.TimeUnit
@EnableScheduling @EnableScheduling
@Service @Service
class Scheduler( class Scheduler(
private val recurrentOperationService: RecurrentOperationService, private val recurrentOperationService: RecurrentOperationService
private val notificationService: NotificationService,
private val dashboardService: DashboardService
) { ) {
private val log = LoggerFactory.getLogger(Scheduler::class.java) private val log = LoggerFactory.getLogger(Scheduler::class.java)
@@ -20,16 +17,4 @@ class Scheduler(
log.info("Creating recurrent after 13 month") log.info("Creating recurrent after 13 month")
recurrentOperationService.createRecurrentTransactions() 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.dtos.SpaceDTO
import space.luminic.finance.models.Space import space.luminic.finance.models.Space
import java.time.LocalDateTime
interface SpaceService { interface SpaceService {
fun getSpacesForScheduling(lastRun: LocalDateTime? = null): List<Space>
fun checkSpace(spaceId: Int): Space fun checkSpace(spaceId: Int): Space
fun getSpaces(): List<Space> fun getSpaces(): List<Space>
fun getSpace(id: Int, userId: Int?): 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.NotFoundException
import space.luminic.finance.models.Space import space.luminic.finance.models.Space
import space.luminic.finance.repos.SpaceRepo import space.luminic.finance.repos.SpaceRepo
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneOffset
@Service @Service
class SpaceServiceImpl( class SpaceServiceImpl(
@@ -16,11 +13,6 @@ class SpaceServiceImpl(
private val spaceRepo: SpaceRepo, private val spaceRepo: SpaceRepo,
private val categoryService: CategoryService private val categoryService: CategoryService
) : SpaceService { ) : 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 { override fun checkSpace(spaceId: Int): Space {
return getSpace(spaceId, null) return getSpace(spaceId, null)
} }
@@ -74,7 +66,6 @@ class SpaceServiceImpl(
) )
return spaceRepo.update(updatedSpace, userId) return spaceRepo.update(updatedSpace, userId)
} }
@Transactional @Transactional
override fun deleteSpace(spaceId: Int) { override fun deleteSpace(spaceId: Int) {
spaceRepo.delete(spaceId) 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 { interface TransactionService {
data class TransactionsFilter( data class TransactionsFilter(
val query : String? = null,
val type: Transaction.TransactionType? = null, val type: Transaction.TransactionType? = null,
val kind: Transaction.TransactionKind? = null, val kind: Transaction.TransactionKind? = null,
val categoriesIds: Set<Int>? = null,
val dateFrom: LocalDate? = null, val dateFrom: LocalDate? = null,
val dateTo: LocalDate? = null, val dateTo: LocalDate? = null,
val isDone: Boolean? = null, val isDone: Boolean? = null,
val offset: Int = 0, val offset: Int = 0,
val limit: Int = 10, 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( fun getTransactions(
spaceId: Int, spaceId: Int,
filter: TransactionsFilter filter: TransactionsFilter,
sortBy: String,
sortDirection: String
): List<Transaction> ): List<Transaction>
fun getTransaction(spaceId: Int, transactionId: Int): Transaction fun getTransaction(spaceId: Int, transactionId: Int): Transaction

View File

@@ -1,18 +1,11 @@
package space.luminic.finance.services package space.luminic.finance.services
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import space.luminic.finance.dtos.TransactionDTO import space.luminic.finance.dtos.TransactionDTO
import space.luminic.finance.models.NotFoundException import space.luminic.finance.models.NotFoundException
import space.luminic.finance.models.Transaction import space.luminic.finance.models.Transaction
import space.luminic.finance.repos.TransactionRepo import space.luminic.finance.repos.TransactionRepo
import space.luminic.finance.services.gpt.CategorizeService import space.luminic.finance.services.gpt.CategorizeService
import java.time.LocalDate
import java.time.LocalDateTime
@Service @Service
class TransactionServiceImpl( class TransactionServiceImpl(
@@ -21,14 +14,12 @@ class TransactionServiceImpl(
private val transactionRepo: TransactionRepo, private val transactionRepo: TransactionRepo,
private val authService: AuthService, private val authService: AuthService,
private val categorizeService: CategorizeService, private val categorizeService: CategorizeService,
private val notificationService: NotificationService,
) : TransactionService { ) : TransactionService {
private val logger = LoggerFactory.getLogger(this.javaClass)
private val serviceScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
override fun getTransactions( override fun getTransactions(
spaceId: Int, spaceId: Int,
filter: TransactionService.TransactionsFilter filter: TransactionService.TransactionsFilter,
sortBy: String,
sortDirection: String
): List<Transaction> { ): List<Transaction> {
val transactions = transactionRepo.findAllBySpaceId(spaceId, filter) val transactions = transactionRepo.findAllBySpaceId(spaceId, filter)
return transactions return transactions
@@ -62,17 +53,7 @@ class TransactionServiceImpl(
date = transaction.date, date = transaction.date,
recurrentId = transaction.recurrentId, recurrentId = transaction.recurrentId,
) )
val createdTx = transactionRepo.create(transaction, userId) return 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?) { override fun batchCreate(spaceId: Int, transactions: List<TransactionDTO.CreateTransactionDTO>, createdById: Int?) {
@@ -104,22 +85,15 @@ class TransactionServiceImpl(
transactionId: Int, transactionId: Int,
transaction: TransactionDTO.UpdateTransactionDTO transaction: TransactionDTO.UpdateTransactionDTO
): Int { ): Int {
val userId = authService.getSecurityUserId()
val space = spaceService.getSpace(spaceId, null) val space = spaceService.getSpace(spaceId, null)
val existingTransaction = getTransaction(space.id!!, transactionId) val existingTransaction = getTransaction(space.id!!, transactionId)
val newCategory = transaction.categoryId?.let { categoryService.getCategory(spaceId, it) } 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( val updatedTransaction = Transaction(
id = existingTransaction.id, id = existingTransaction.id,
space = existingTransaction.space, space = existingTransaction.space,
parent = existingTransaction.parent, parent = existingTransaction.parent,
type = transaction.type, type = transaction.type,
kind = newKind, kind = transaction.kind,
category = newCategory, category = newCategory,
comment = transaction.comment, comment = transaction.comment,
amount = transaction.amount, amount = transaction.amount,
@@ -135,34 +109,14 @@ class TransactionServiceImpl(
if ((existingTransaction.category == null && updatedTransaction.category != null) || (existingTransaction.category?.id != updatedTransaction.category?.id)) { if ((existingTransaction.category == null && updatedTransaction.category != null) || (existingTransaction.category?.id != updatedTransaction.category?.id)) {
categorizeService.notifyThatCategorySelected(updatedTransaction) categorizeService.notifyThatCategorySelected(updatedTransaction)
} }
val updatedTx = transactionRepo.update(updatedTransaction)
serviceScope.launch {
runCatching {
notificationService.sendTXNotification( return transactionRepo.update(updatedTransaction)
TxActionType.UPDATE,
space,
userId,
existingTransaction,
updatedTransaction
)
}.onFailure {
logger.error("Error while send transaction update notification", it)
}
}
return updatedTx
} }
override fun deleteTransaction(spaceId: Int, transactionId: Int) { override fun deleteTransaction(spaceId: Int, transactionId: Int) {
val userId = authService.getSecurityUserId()
val space = spaceService.getSpace(spaceId, null) val space = spaceService.getSpace(spaceId, null)
val tx = getTransaction(space.id!!, transactionId) getTransaction(space.id!!, transactionId)
transactionRepo.delete(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) { override fun deleteByRecurrentId(spaceId: Int, recurrentId: Int) {

View File

@@ -60,7 +60,7 @@ class CategorizeService(
listOf( listOf(
InlineKeyboardButton.WebApp( InlineKeyboardButton.WebApp(
"Открыть в 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( listOf(
InlineKeyboardButton.WebApp( InlineKeyboardButton.WebApp(
"Открыть в 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( listOf(
InlineKeyboardButton.WebApp( InlineKeyboardButton.WebApp(
"Открыть в 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 package space.luminic.finance.services.gpt
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import okhttp3.* import okhttp3.*
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue import com.fasterxml.jackson.module.kotlin.readValue
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import org.slf4j.LoggerFactory 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.beans.factory.annotation.Value
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import space.luminic.finance.models.Category import space.luminic.finance.models.Category
import space.luminic.finance.models.DashboardData
import space.luminic.finance.models.Transaction import space.luminic.finance.models.Transaction
import java.time.LocalDate
import java.util.concurrent.TimeUnit
@Service("dsCategorizationService") @Service("dsCategorizationService")
@@ -27,8 +21,7 @@ class DeepSeekCategorizationService(
private val endpoint = "https://api.deepseek.com/v1" private val endpoint = "https://api.deepseek.com/v1"
private val mapper = jacksonObjectMapper() private val mapper = jacksonObjectMapper()
private val client = OkHttpClient()
private val client = OkHttpClient().newBuilder().callTimeout(5, TimeUnit.MINUTES).readTimeout(1, TimeUnit.MINUTES).build()
private val logger = LoggerFactory.getLogger(javaClass) private val logger = LoggerFactory.getLogger(javaClass)
override fun suggestCategory(tx: Transaction, categories: List<Category>): CategorySuggestion { override fun suggestCategory(tx: Transaction, categories: List<Category>): CategorySuggestion {
@@ -81,181 +74,7 @@ class DeepSeekCategorizationService(
// val (idStr, name, confStr) = match.destructured // val (idStr, name, confStr) = match.destructured
val idStr = text val idStr = text
return CategorySuggestion(idStr.toInt()) 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 +1,10 @@
package space.luminic.finance.services.gpt package space.luminic.finance.services.gpt
import space.luminic.finance.models.Category import space.luminic.finance.models.Category
import space.luminic.finance.models.DashboardData
import space.luminic.finance.models.Transaction import space.luminic.finance.models.Transaction
import java.time.LocalDate
data class CategorySuggestion(val categoryId: Int, val categoryName: String? = null, val confidence: Double? = null) data class CategorySuggestion(val categoryId: Int, val categoryName: String? = null, val confidence: Double? = null)
interface GptClient { interface GptClient {
fun suggestCategory(tx: Transaction, categories: List<Category>): CategorySuggestion 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 okhttp3.*
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import space.luminic.finance.models.Category import space.luminic.finance.models.Category
import space.luminic.finance.models.DashboardData
import space.luminic.finance.models.Transaction import space.luminic.finance.models.Transaction
import java.time.LocalDate
@Service("qwenCategorizationService") @Service("qwenCategorizationService")
class QwenCategorizationService( class QwenCategorizationService(
@@ -75,8 +75,4 @@ class QwenCategorizationService(
return CategorySuggestion(idStr.toInt(), name, confStr.toDouble()) 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.Qualifier
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Lazy
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import space.luminic.finance.dtos.TransactionDTO import space.luminic.finance.dtos.TransactionDTO
@@ -31,11 +30,10 @@ import java.time.LocalDate
@Service @Service
class BotService( class BotService(
@Value("\${telegram.bot.token}") private val botToken: String, @Value("\${telegram.bot.token}") private val botToken: String,
@Value("\${spring.profiles.active}") private val profile: String,
private val userService: UserService, private val userService: UserService,
@Qualifier("spaceServiceTelegram") private val spaceService: SpaceService, @Qualifier("spaceServiceTelegram") private val spaceService: SpaceService,
private val botRepo: BotRepo, 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 @Bean
fun bot(): Bot { fun bot(): Bot {
val bot = com.github.kotlintelegrambot.bot { val bot = com.github.kotlintelegrambot.bot {
logLevel = if (profile == "prod") LogLevel.None else LogLevel.All() logLevel = LogLevel.None
token = botToken token = botToken
dispatch { dispatch {
message(Filter.Text) { message(Filter.Text) {
@@ -239,9 +237,9 @@ class BotService(
) )
} catch (e: NotFoundException) { } catch (e: NotFoundException) {
bot.sendMessage(ChatId.fromId(message.chat.id), text = "Кажется, мы еще не знакомы.") bot.sendMessage(ChatId.Companion.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.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 { interface SpaceService {
fun getSpaces(userId: Int): List<Space> 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 return spaces
} }
override fun getSpace(spaceId: Int, userId: Int): Space { override fun getSpace(spaceId: Int, userId: Int): Space? {
val space = val space =
spaceRepo.findSpaceById(spaceId, userId) ?: throw NotFoundException("Space with id $spaceId not found") spaceRepo.findSpaceById(spaceId, userId) ?: throw NotFoundException("Space with id $spaceId not found")
return space return space

View File

@@ -1,29 +1,18 @@
package space.luminic.finance.services.telegram 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.beans.factory.annotation.Qualifier
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import space.luminic.finance.dtos.TransactionDTO import space.luminic.finance.dtos.TransactionDTO
import space.luminic.finance.models.Transaction import space.luminic.finance.models.Transaction
import space.luminic.finance.repos.TransactionRepo import space.luminic.finance.repos.TransactionRepo
import space.luminic.finance.services.CategoryServiceImpl import space.luminic.finance.services.CategoryServiceImpl
import space.luminic.finance.services.NotificationService
import space.luminic.finance.services.TxActionType
@Service("transactionsServiceTelegram") @Service("transactionsServiceTelegram")
class TransactionsServiceImpl( class TransactionsServiceImpl(
private val transactionRepo: TransactionRepo, private val transactionRepo: TransactionRepo,
@Qualifier("spaceServiceTelegram") private val spaceService: SpaceService, @Qualifier("spaceServiceTelegram") private val spaceService: SpaceService,
private val categoryService: CategoryServiceImpl, private val categoryService: CategoryServiceImpl
private val notificationService: NotificationService
): TransactionService { ): TransactionService {
private val logger = LoggerFactory.getLogger(this.javaClass)
private val serviceScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
override fun createTransaction( override fun createTransaction(
spaceId: Int, spaceId: Int,
@@ -46,17 +35,9 @@ class TransactionsServiceImpl(
tgChatId = chatId, tgChatId = chatId,
tgMessageId = messageId, 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) return transactionRepo.create(transaction, userId)
} }
} }

View File

@@ -1,5 +1,6 @@
spring.application.name=budger-app 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 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.web=DEBUG
logging.level.org.springframework.data = DEBUG logging.level.org.springframework.data = DEBUG
logging.level.org.springframework.data.jpa = DEBUG logging.level.org.springframework.data.jpa = DEBUG
@@ -13,8 +14,6 @@ logging.level.org.springframework.jdbc.core.StatementCreatorUtils=INFO
logging.level.org.springframework.jdbc=INFO logging.level.org.springframework.jdbc=INFO
logging.level.org.springframework.jdbc.datasource=INFO logging.level.org.springframework.jdbc.datasource=INFO
logging.level.org.springframework.jdbc.support=INFO logging.level.org.springframework.jdbc.support=INFO
logging.level.okhttp3=INFO
logging.level.space.luminic=DEBUG
management.endpoints.web.exposure.include=* management.endpoints.web.exposure.include=*
management.endpoint.health.show-details=always 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.username=luminicspace
spring.datasource.password=LS1q2w3e4r! 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, id integer generated by default as identity not null,
space_id integer not null, space_id INTEGER,
period_start date not null, type SMALLINT,
period_end date not null, name VARCHAR(255),
analyze_text text not null, description VARCHAR(255),
first_analyze_at timestamp without time zone not null default now(), amount DECIMAL,
last_analyze_at timestamp without time zone not null default now(), until_date date,
CONSTRAINT pk_period_analyze PRIMARY KEY (id) 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;