Compare commits
18 Commits
90f1a3ce08
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c84f6a3988 | |||
| 195bdd83f0 | |||
| 42cbf30bd8 | |||
| 5803fc208b | |||
| a79dbffe3f | |||
| 9d7c385654 | |||
| 12afd1f90e | |||
| d0cae182b7 | |||
| 0f02b53bc0 | |||
| b08ab909c8 | |||
| a65f46aff3 | |||
| 036ad00795 | |||
| 1c3605623e | |||
| aaa12fcb86 | |||
| cef82c483f | |||
| e83e3a2b65 | |||
| c68e6afb8a | |||
| 10b7c730ad |
26
Dockerfile
26
Dockerfile
@@ -1,36 +1,14 @@
|
|||||||
# ---------- build stage ----------
|
|
||||||
FROM gradle:8.9.0-jdk17-alpine AS build
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Копируем wrapper + его папку (важно, что это две разные сущности)
|
|
||||||
COPY --chown=gradle:gradle gradlew ./gradlew
|
|
||||||
COPY --chown=gradle:gradle gradle/ ./gradle/
|
|
||||||
|
|
||||||
# Копируем скрипты сборки и исходники
|
|
||||||
COPY --chown=gradle:gradle build.gradle.kts settings.gradle.kts ./
|
|
||||||
COPY --chown=gradle:gradle src ./src
|
|
||||||
|
|
||||||
# Делаем gradlew исполняемым
|
|
||||||
RUN chmod +x gradlew
|
|
||||||
|
|
||||||
# Подкачаем зависимости (кэшируется) и соберём jar
|
|
||||||
RUN ./gradlew --no-daemon dependencies
|
|
||||||
RUN ./gradlew --no-daemon clean bootJar
|
|
||||||
|
|
||||||
# ---------- run stage ----------
|
|
||||||
FROM eclipse-temurin:17-jre AS runtime
|
FROM eclipse-temurin:17-jre AS runtime
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# (Опционально) установим curl для HEALTHCHECK
|
|
||||||
USER root
|
USER root
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
|
||||||
RUN groupadd --system --gid 1001 app && useradd --system --gid app --uid 1001 --shell /bin/bash --create-home app
|
RUN groupadd --system --gid 1001 app && useradd --system --gid app --uid 1001 --shell /bin/bash --create-home app
|
||||||
RUN mkdir -p /app/static && chown -R app:app /app
|
RUN mkdir -p /app/static && chown -R app:app /app
|
||||||
USER app
|
USER app
|
||||||
|
|
||||||
COPY --from=build /app/build/libs/*.jar /app/app.jar
|
|
||||||
|
|
||||||
ENV JAVA_TOOL_OPTIONS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0"
|
ENV JAVA_TOOL_OPTIONS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0"
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
HEALTHCHECK --interval=20s --timeout=3s --retries=3 CMD curl -fsS http://localhost:8080/actuator/health || exit 1
|
HEALTHCHECK --interval=20s --timeout=3s --retries=3 CMD curl -fsS http://localhost:8080/actuator/health || exit 1
|
||||||
ENTRYPOINT ["java","-jar","/app/app.jar"]
|
|
||||||
|
ENTRYPOINT ["java","-jar","/app/luminic-space-v2.jar"]
|
||||||
@@ -29,13 +29,16 @@ configurations {
|
|||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
maven { url = uri("https://jitpack.io") }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
// Spring
|
// Spring
|
||||||
implementation("org.springframework.boot:spring-boot-starter-cache")
|
implementation("org.springframework.boot:spring-boot-starter-cache")
|
||||||
implementation("org.springframework.boot:spring-boot-starter-security")
|
implementation("org.springframework.boot:spring-boot-starter-security")
|
||||||
implementation ("org.springframework.boot:spring-boot-starter-actuator")
|
implementation("org.springframework.boot:spring-boot-starter-actuator")
|
||||||
implementation("org.springframework.boot:spring-boot-starter-validation")
|
implementation("org.springframework.boot:spring-boot-starter-validation")
|
||||||
implementation("org.springframework.boot:spring-boot-starter-actuator")
|
implementation("org.springframework.boot:spring-boot-starter-actuator")
|
||||||
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13")
|
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13")
|
||||||
@@ -53,6 +56,12 @@ dependencies {
|
|||||||
|
|
||||||
implementation("commons-logging:commons-logging:1.3.4")
|
implementation("commons-logging:commons-logging:1.3.4")
|
||||||
|
|
||||||
|
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||||
|
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.17.0")
|
||||||
|
|
||||||
|
implementation("org.springframework.boot:spring-boot-starter-actuator")
|
||||||
|
implementation("io.micrometer:micrometer-registry-prometheus")
|
||||||
|
|
||||||
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
|
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
|
||||||
implementation("org.jetbrains.kotlin:kotlin-reflect")
|
implementation("org.jetbrains.kotlin:kotlin-reflect")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
||||||
@@ -67,10 +76,11 @@ dependencies {
|
|||||||
|
|
||||||
implementation("io.micrometer:micrometer-registry-prometheus")
|
implementation("io.micrometer:micrometer-registry-prometheus")
|
||||||
|
|
||||||
implementation("org.telegram:telegrambots:6.9.7.1")
|
// implementation("org.telegram:telegrambots:6.9.7.1")
|
||||||
implementation("org.telegram:telegrambots-spring-boot-starter:6.9.7.1")
|
// implementation("org.telegram:telegrambots-spring-boot-starter:6.9.7.1")
|
||||||
implementation("com.opencsv:opencsv:5.10")
|
implementation("com.opencsv:opencsv:5.10")
|
||||||
|
|
||||||
|
implementation("io.github.kotlin-telegram-bot.kotlin-telegram-bot:telegram:6.3.0")
|
||||||
|
|
||||||
compileOnly("org.projectlombok:lombok")
|
compileOnly("org.projectlombok:lombok")
|
||||||
annotationProcessor("org.projectlombok:lombok")
|
annotationProcessor("org.projectlombok:lombok")
|
||||||
|
|||||||
10
deploy.sh
Executable file
10
deploy.sh
Executable file
@@ -0,0 +1,10 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
./gradlew bootJar || exit 1
|
||||||
|
|
||||||
|
scp build/libs/luminic-space-v2.jar root@213.226.71.138:/root/luminic/space/back
|
||||||
|
|
||||||
|
ssh root@213.226.71.138 "
|
||||||
|
cd /root/luminic/space/back &&
|
||||||
|
docker compose up -d --build &&
|
||||||
|
docker restart back-app-1
|
||||||
|
"
|
||||||
@@ -4,6 +4,9 @@ networks:
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
|
image: back-app
|
||||||
|
volumes:
|
||||||
|
- ./luminic-space-v2.jar:/app/luminic-space-v2.jar
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
@@ -11,7 +14,6 @@ services:
|
|||||||
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/luminic-space-db
|
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/luminic-space-db
|
||||||
SPRING_DATASOURCE_USERNAME: luminicspace
|
SPRING_DATASOURCE_USERNAME: luminicspace
|
||||||
SPRING_DATASOURCE_PASSWORD: LS1q2w3e4r!
|
SPRING_DATASOURCE_PASSWORD: LS1q2w3e4r!
|
||||||
MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE: health,info
|
|
||||||
ports:
|
ports:
|
||||||
- "8089:8089"
|
- "8089:8089"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package space.luminic.finance.api
|
package space.luminic.finance.api
|
||||||
|
|
||||||
|
|
||||||
import org.apache.commons.codec.digest.DigestUtils.sha256
|
import kotlinx.serialization.json.Json
|
||||||
import org.apache.commons.codec.digest.HmacUtils.hmacSha256
|
import kotlinx.serialization.json.JsonBuilder
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
import org.springframework.security.core.context.SecurityContextHolder
|
import org.springframework.security.core.context.SecurityContextHolder
|
||||||
@@ -13,6 +13,7 @@ import space.luminic.finance.dtos.UserDTO.RegisterUserDTO
|
|||||||
import space.luminic.finance.mappers.UserMapper.toDto
|
import space.luminic.finance.mappers.UserMapper.toDto
|
||||||
import space.luminic.finance.mappers.UserMapper.toTelegramMap
|
import space.luminic.finance.mappers.UserMapper.toTelegramMap
|
||||||
import space.luminic.finance.services.AuthService
|
import space.luminic.finance.services.AuthService
|
||||||
|
import java.net.URLDecoder
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import javax.crypto.Mac
|
import javax.crypto.Mac
|
||||||
@@ -27,28 +28,83 @@ class AuthController(
|
|||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(javaClass)
|
private val logger = LoggerFactory.getLogger(javaClass)
|
||||||
|
|
||||||
fun verifyTelegramAuth(data: Map<String, String>, botToken: String): Boolean {
|
fun verifyTelegramAuth(
|
||||||
val hash = data["hash"] ?: return false
|
loginData: Map<String, String>? = null, // from login widget
|
||||||
|
webAppInitData: String? = null
|
||||||
|
): Boolean {
|
||||||
|
|
||||||
val dataCheckString = data
|
// --- LOGIN WIDGET CHECK ---
|
||||||
.filterKeys { it != "hash" }
|
if (loginData != null) {
|
||||||
.toSortedMap()
|
val hash = loginData["hash"]
|
||||||
.map { "${it.key}=${it.value}" }
|
if (hash != null) {
|
||||||
.joinToString("\n")
|
val dataCheckString = loginData
|
||||||
|
.filterKeys { it != "hash" }
|
||||||
|
.toSortedMap()
|
||||||
|
.map { "${it.key}=${it.value}" }
|
||||||
|
.joinToString("\n")
|
||||||
|
|
||||||
val secretKey = sha256(botToken)
|
val secretKey = MessageDigest.getInstance("SHA-256")
|
||||||
val hmacHex = hmacSha256(secretKey, dataCheckString)
|
.digest(botToken.toByteArray())
|
||||||
|
|
||||||
if (hmacHex != hash) return false
|
val hmac = Mac.getInstance("HmacSHA256").apply {
|
||||||
|
init(SecretKeySpec(secretKey, "HmacSHA256"))
|
||||||
|
}.doFinal(dataCheckString.toByteArray())
|
||||||
|
.joinToString("") { "%02x".format(it) }
|
||||||
|
|
||||||
val authDate = data["auth_date"]?.toLongOrNull() ?: return false
|
val authDate = loginData["auth_date"]?.toLongOrNull() ?: return false
|
||||||
val now = Instant.now().epochSecond
|
if (Instant.now().epochSecond - authDate > 3600) return false
|
||||||
|
|
||||||
// Опционально — запрет старых ответов (например, старше 1 часа)
|
if (hmac == hash) return true
|
||||||
val maxAgeSeconds = 3600
|
}
|
||||||
if (now - authDate > maxAgeSeconds) return false
|
}
|
||||||
|
|
||||||
return true
|
// --- WEBAPP CHECK ---
|
||||||
|
// --- WEBAPP CHECK ---
|
||||||
|
if (webAppInitData != null) {
|
||||||
|
// Разбираем query string корректно (учитывая '=' внутри значения)
|
||||||
|
val pairs: Map<String, String> = webAppInitData.split("&")
|
||||||
|
.mapNotNull { part ->
|
||||||
|
val idx = part.indexOf('=')
|
||||||
|
if (idx <= 0) return@mapNotNull null
|
||||||
|
val k = part.substring(0, idx)
|
||||||
|
val v = part.substring(idx + 1)
|
||||||
|
k to URLDecoder.decode(v, Charsets.UTF_8.name())
|
||||||
|
}.toMap()
|
||||||
|
|
||||||
|
val receivedHash = pairs["hash"] ?: return false
|
||||||
|
|
||||||
|
// Строка для подписи: все поля КРОМЕ hash, отсортированные по ключу, в формате key=value с \n
|
||||||
|
val dataCheckString = pairs
|
||||||
|
.filterKeys { it != "hash" }
|
||||||
|
.toSortedMap()
|
||||||
|
.entries
|
||||||
|
.joinToString("\n") { (k, v) -> "$k=$v" }
|
||||||
|
|
||||||
|
// ВАЖНО: secret_key = HMAC_SHA256(message=botToken, key="WebAppData")
|
||||||
|
val secretKeyBytes = Mac.getInstance("HmacSHA256").apply {
|
||||||
|
init(SecretKeySpec("WebAppData".toByteArray(Charsets.UTF_8), "HmacSHA256"))
|
||||||
|
}.doFinal(botToken.toByteArray(Charsets.UTF_8))
|
||||||
|
|
||||||
|
// hash = HMAC_SHA256(message=data_check_string, key=secret_key)
|
||||||
|
val calcHashHex = Mac.getInstance("HmacSHA256").apply {
|
||||||
|
init(SecretKeySpec(secretKeyBytes, "HmacSHA256"))
|
||||||
|
}.doFinal(dataCheckString.toByteArray(Charsets.UTF_8))
|
||||||
|
.joinToString("") { "%02x".format(it) }
|
||||||
|
|
||||||
|
// опциональная проверка свежести
|
||||||
|
val authDate = pairs["auth_date"]?.toLongOrNull() ?: return false
|
||||||
|
val now = Instant.now().epochSecond
|
||||||
|
val ttl = 6 * 3600L // например, 6 часов для WebApp
|
||||||
|
val skew = 300L // допускаем до 5 минут будущего/прошлого
|
||||||
|
val diff = now - authDate
|
||||||
|
if (diff > ttl || diff < -skew) return false
|
||||||
|
|
||||||
|
if (calcHashHex == receivedHash) return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sha256(input: String): ByteArray =
|
private fun sha256(input: String): ByteArray =
|
||||||
@@ -62,6 +118,7 @@ class AuthController(
|
|||||||
return hashBytes.joinToString("") { "%02x".format(it) }
|
return hashBytes.joinToString("") { "%02x".format(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@GetMapping("/test")
|
@GetMapping("/test")
|
||||||
fun test(): String {
|
fun test(): String {
|
||||||
val authentication = SecurityContextHolder.getContext().authentication
|
val authentication = SecurityContextHolder.getContext().authentication
|
||||||
@@ -80,16 +137,45 @@ class AuthController(
|
|||||||
return authService.register(request.username, request.password, request.firstName).toDto()
|
return authService.register(request.username, request.password, request.firstName).toDto()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
|
||||||
@PostMapping("/tg-login")
|
@PostMapping("/tg-login")
|
||||||
fun tgLogin(@RequestBody tgUser: UserDTO.TelegramAuthDTO): String {
|
fun tgLogin(@RequestBody tgUser: UserDTO.TelegramAuthDTO): Map<String, String> {
|
||||||
val ok = verifyTelegramAuth(tgUser.toTelegramMap(), botToken)
|
// println(tgUser.hash)
|
||||||
if (!ok) throw IllegalArgumentException("Invalid Telegram login")
|
// println(botToken)
|
||||||
return authService.tgAuth(tgUser)
|
if (tgUser.initData == null) {
|
||||||
|
if (verifyTelegramAuth(
|
||||||
|
loginData = tgUser.toTelegramMap(),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return mapOf("token" to authService.tgAuth(tgUser))
|
||||||
|
} else throw IllegalArgumentException("Invalid Telegram login")
|
||||||
|
} else {
|
||||||
|
if (verifyTelegramAuth(webAppInitData = tgUser.initData)) {
|
||||||
|
val params = tgUser.initData.split("&").associate {
|
||||||
|
val (k, v) = it.split("=", limit = 2)
|
||||||
|
k to URLDecoder.decode(v, "UTF-8")
|
||||||
|
}
|
||||||
|
|
||||||
|
val userJson = params["user"] ?: error("No user data")
|
||||||
|
val jsonUser = json.decodeFromString<UserDTO.TelegramUserData>(userJson)
|
||||||
|
val newUser = UserDTO.TelegramAuthDTO(
|
||||||
|
jsonUser.id,
|
||||||
|
jsonUser.first_name,
|
||||||
|
jsonUser.last_name,
|
||||||
|
jsonUser.username,
|
||||||
|
jsonUser.photo_url,
|
||||||
|
null,
|
||||||
|
hash = tgUser.hash,
|
||||||
|
initData = null,
|
||||||
|
)
|
||||||
|
return mapOf("token" to authService.tgAuth(newUser))
|
||||||
|
} else throw IllegalArgumentException("Invalid Telegram login")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@GetMapping("/me")
|
@GetMapping("/me")
|
||||||
fun getMe(): UserDTO {
|
fun getMe(): UserDTO {
|
||||||
logger.info("Get Me")
|
logger.info("Get Me")
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class SpaceController(
|
|||||||
|
|
||||||
@GetMapping("/{spaceId}")
|
@GetMapping("/{spaceId}")
|
||||||
fun getSpace(@PathVariable spaceId: Int): SpaceDTO {
|
fun getSpace(@PathVariable spaceId: Int): SpaceDTO {
|
||||||
return spaceService.getSpace(spaceId).toDto()
|
return spaceService.getSpace(spaceId, null).toDto()
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ class TransactionController (
|
|||||||
){
|
){
|
||||||
|
|
||||||
|
|
||||||
@GetMapping
|
@PostMapping("/_search")
|
||||||
fun getTransactions(@PathVariable spaceId: Int) : List<TransactionDTO>{
|
fun getTransactions(@PathVariable spaceId: Int, @RequestBody filter: TransactionService.TransactionsFilter) : List<TransactionDTO>{
|
||||||
return transactionService.getTransactions(spaceId, TransactionService.TransactionsFilter(),"date", "DESC").map { it.toDto() }
|
return transactionService.getTransactions(spaceId, filter,"date", "DESC").map { it.toDto() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{transactionId}")
|
@GetMapping("/{transactionId}")
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class BearerTokenFilter(
|
|||||||
private val publicMatchers = listOf(
|
private val publicMatchers = listOf(
|
||||||
AntPathRequestMatcher("/auth/login", "POST"),
|
AntPathRequestMatcher("/auth/login", "POST"),
|
||||||
AntPathRequestMatcher("/auth/register", "POST"),
|
AntPathRequestMatcher("/auth/register", "POST"),
|
||||||
AntPathRequestMatcher("/auth/tgLogin", "POST"),
|
AntPathRequestMatcher("/auth/tg-login", "POST"),
|
||||||
AntPathRequestMatcher("/actuator/**"),
|
AntPathRequestMatcher("/actuator/**"),
|
||||||
AntPathRequestMatcher("/static/**"),
|
AntPathRequestMatcher("/static/**"),
|
||||||
AntPathRequestMatcher("/wishlistexternal/**"),
|
AntPathRequestMatcher("/wishlistexternal/**"),
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class SecurityConfig(
|
|||||||
.logout { it.disable() }
|
.logout { it.disable() }
|
||||||
|
|
||||||
.authorizeHttpRequests {
|
.authorizeHttpRequests {
|
||||||
it.requestMatchers(HttpMethod.POST, "/auth/login", "/auth/register", "/auth/tgLogin").permitAll()
|
it.requestMatchers(HttpMethod.POST, "/auth/login", "/auth/register", "/auth/tg-login").permitAll()
|
||||||
it.requestMatchers("/actuator/**", "/static/**").permitAll()
|
it.requestMatchers("/actuator/**", "/static/**").permitAll()
|
||||||
it.requestMatchers("/wishlistexternal/**").permitAll()
|
it.requestMatchers("/wishlistexternal/**").permitAll()
|
||||||
it.requestMatchers("/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**").permitAll()
|
it.requestMatchers("/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**").permitAll()
|
||||||
@@ -50,7 +50,7 @@ class SecurityConfig(
|
|||||||
@Bean
|
@Bean
|
||||||
fun corsConfigurationSource(): CorsConfigurationSource {
|
fun corsConfigurationSource(): CorsConfigurationSource {
|
||||||
val cors = CorsConfiguration().apply {
|
val cors = CorsConfiguration().apply {
|
||||||
allowedOrigins = listOf("https://luminic.space", "http://localhost:5173")
|
allowedOrigins = listOf("https://app.luminic.space", "http://localhost:5173")
|
||||||
allowedMethods = listOf("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
|
allowedMethods = listOf("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
|
||||||
allowedHeaders = listOf("*")
|
allowedHeaders = listOf("*")
|
||||||
allowCredentials = true
|
allowCredentials = true
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ data class TransactionDTO(
|
|||||||
var parentId: Int? = null,
|
var parentId: Int? = null,
|
||||||
val type: TransactionType = TransactionType.EXPENSE,
|
val type: TransactionType = TransactionType.EXPENSE,
|
||||||
val kind: TransactionKind = TransactionKind.INSTANT,
|
val kind: TransactionKind = TransactionKind.INSTANT,
|
||||||
val category: CategoryDTO,
|
val category: CategoryDTO? = null,
|
||||||
val comment: String,
|
val comment: String,
|
||||||
val amount: BigDecimal,
|
val amount: BigDecimal,
|
||||||
val fees: BigDecimal = BigDecimal.ZERO,
|
val fees: BigDecimal = BigDecimal.ZERO,
|
||||||
@@ -23,17 +23,18 @@ data class TransactionDTO(
|
|||||||
data class CreateTransactionDTO(
|
data class CreateTransactionDTO(
|
||||||
val type: TransactionType = TransactionType.EXPENSE,
|
val type: TransactionType = TransactionType.EXPENSE,
|
||||||
val kind: TransactionKind = TransactionKind.INSTANT,
|
val kind: TransactionKind = TransactionKind.INSTANT,
|
||||||
val categoryId: Int,
|
val categoryId: Int? = null,
|
||||||
val comment: String,
|
val comment: String,
|
||||||
val amount: BigDecimal,
|
val amount: BigDecimal,
|
||||||
val fees: BigDecimal = BigDecimal.ZERO,
|
val fees: BigDecimal = BigDecimal.ZERO,
|
||||||
val date: LocalDate,
|
val date: LocalDate,
|
||||||
|
val recurrentId: Int? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
data class UpdateTransactionDTO(
|
data class UpdateTransactionDTO(
|
||||||
val type: TransactionType = TransactionType.EXPENSE,
|
val type: TransactionType = TransactionType.EXPENSE,
|
||||||
val kind: TransactionKind = TransactionKind.INSTANT,
|
val kind: TransactionKind = TransactionKind.INSTANT,
|
||||||
val categoryId: Int,
|
val categoryId: Int? = null,
|
||||||
val comment: String,
|
val comment: String,
|
||||||
val amount: BigDecimal,
|
val amount: BigDecimal,
|
||||||
val fees: BigDecimal = BigDecimal.ZERO,
|
val fees: BigDecimal = BigDecimal.ZERO,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package space.luminic.finance.dtos
|
package space.luminic.finance.dtos
|
||||||
|
|
||||||
import java.util.Date
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
data class UserDTO (
|
data class UserDTO(
|
||||||
var id: Int,
|
var id: Int,
|
||||||
val username: String,
|
val username: String,
|
||||||
var firstName: String,
|
var firstName: String,
|
||||||
@@ -13,12 +13,12 @@ data class UserDTO (
|
|||||||
|
|
||||||
) {
|
) {
|
||||||
|
|
||||||
data class AuthUserDTO (
|
data class AuthUserDTO(
|
||||||
var username: String,
|
var username: String,
|
||||||
var password: String,
|
var password: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class RegisterUserDTO (
|
data class RegisterUserDTO(
|
||||||
var username: String,
|
var username: String,
|
||||||
var firstName: String,
|
var firstName: String,
|
||||||
var password: String,
|
var password: String,
|
||||||
@@ -26,15 +26,27 @@ data class UserDTO (
|
|||||||
)
|
)
|
||||||
|
|
||||||
data class TelegramAuthDTO(
|
data class TelegramAuthDTO(
|
||||||
val id: Long,
|
val id: Long?,
|
||||||
val first_name: String?,
|
val first_name: String?,
|
||||||
val last_name: String?,
|
val last_name: String?,
|
||||||
val username: String?,
|
val username: String?,
|
||||||
val photo_url: String?,
|
val photo_url: String?,
|
||||||
val auth_date: Long,
|
val auth_date: Long?,
|
||||||
val hash: String
|
val hash: String,
|
||||||
|
val initData: String?,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class TelegramUserData(
|
||||||
|
val id: Long,
|
||||||
|
val first_name: String,
|
||||||
|
val last_name: String? = null,
|
||||||
|
val username: String? = null,
|
||||||
|
val photo_url: String? = null,
|
||||||
|
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ object TransactionMapper {
|
|||||||
parentId = this.parent?.id,
|
parentId = this.parent?.id,
|
||||||
type = this.type,
|
type = this.type,
|
||||||
kind = this.kind,
|
kind = this.kind,
|
||||||
category = this.category.toDto(),
|
category = this.category?.toDto(),
|
||||||
comment = this.comment,
|
comment = this.comment,
|
||||||
amount = this.amount,
|
amount = this.amount,
|
||||||
fees = this.fees,
|
fees = this.fees,
|
||||||
|
|||||||
16
src/main/kotlin/space/luminic/finance/models/State.kt
Normal file
16
src/main/kotlin/space/luminic/finance/models/State.kt
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package space.luminic.finance.models
|
||||||
|
|
||||||
|
data class State(
|
||||||
|
val user: User,
|
||||||
|
val state: StateCode = StateCode.AWAIT_SPACE_SELECT,
|
||||||
|
val data: Map<String, String> = mapOf()
|
||||||
|
) {
|
||||||
|
enum class StateCode {
|
||||||
|
AWAIT_SPACE_SELECT,
|
||||||
|
SPACE_SELECTED,
|
||||||
|
AWAIT_TRANSACTION,
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -14,8 +14,8 @@ data class Transaction(
|
|||||||
var parent: Transaction? = null,
|
var parent: Transaction? = null,
|
||||||
val type: TransactionType = TransactionType.EXPENSE,
|
val type: TransactionType = TransactionType.EXPENSE,
|
||||||
val kind: TransactionKind = TransactionKind.INSTANT,
|
val kind: TransactionKind = TransactionKind.INSTANT,
|
||||||
val category: Category,
|
val category: Category? = null,
|
||||||
val comment: String,
|
var comment: String,
|
||||||
val amount: BigDecimal,
|
val amount: BigDecimal,
|
||||||
val fees: BigDecimal = BigDecimal.ZERO,
|
val fees: BigDecimal = BigDecimal.ZERO,
|
||||||
val date: LocalDate = LocalDate.now(),
|
val date: LocalDate = LocalDate.now(),
|
||||||
@@ -25,6 +25,9 @@ data class Transaction(
|
|||||||
@CreatedDate var createdAt: Instant? = null,
|
@CreatedDate var createdAt: Instant? = null,
|
||||||
@LastModifiedBy var updatedBy: User? = null,
|
@LastModifiedBy var updatedBy: User? = null,
|
||||||
@LastModifiedDate var updatedAt: Instant? = null,
|
@LastModifiedDate var updatedAt: Instant? = null,
|
||||||
|
val tgChatId: Long? = null,
|
||||||
|
val tgMessageId: Long? = null,
|
||||||
|
val recurrentId: Int? = null,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
9
src/main/kotlin/space/luminic/finance/repos/BotRepo.kt
Normal file
9
src/main/kotlin/space/luminic/finance/repos/BotRepo.kt
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
package space.luminic.finance.repos
|
||||||
|
|
||||||
|
import space.luminic.finance.models.State
|
||||||
|
|
||||||
|
interface BotRepo {
|
||||||
|
fun getState(tgUserId: Long): State?
|
||||||
|
fun setState(userId: Int, stateCode: State.StateCode, stateData: Map<String, String>)
|
||||||
|
fun clearState(userId: Int)
|
||||||
|
}
|
||||||
114
src/main/kotlin/space/luminic/finance/repos/BotRepoImpl.kt
Normal file
114
src/main/kotlin/space/luminic/finance/repos/BotRepoImpl.kt
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
package space.luminic.finance.repos
|
||||||
|
|
||||||
|
import org.springframework.jdbc.core.ResultSetExtractor
|
||||||
|
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
import space.luminic.finance.models.State
|
||||||
|
import space.luminic.finance.models.User
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
class BotRepoImpl(
|
||||||
|
private val jdbcTemplate: NamedParameterJdbcTemplate,
|
||||||
|
) : BotRepo {
|
||||||
|
override fun getState(tgUserId: Long): State? {
|
||||||
|
val sql = """
|
||||||
|
select
|
||||||
|
bs.user_id as bs_user_id,
|
||||||
|
u.username as u_username,
|
||||||
|
u.first_name as u_first_name,
|
||||||
|
bs.state_code as bs_state_code,
|
||||||
|
bsd.data_code as bs_data_code,
|
||||||
|
bsd.data_value as bs_data_value
|
||||||
|
from finance.bot_states bs
|
||||||
|
join finance.users u on u.id = bs.user_id
|
||||||
|
left join finance.bot_states_data bsd on bsd.user_id = bs.user_id
|
||||||
|
where u.tg_id = :user_id
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val params = mapOf("user_id" to tgUserId)
|
||||||
|
|
||||||
|
return jdbcTemplate.query(sql, params, ResultSetExtractor { rs ->
|
||||||
|
var user: User? = null
|
||||||
|
var stateCode: State.StateCode? = null
|
||||||
|
val data = mutableMapOf<String, String>()
|
||||||
|
|
||||||
|
while (rs.next()) {
|
||||||
|
if (user == null) {
|
||||||
|
user = User(
|
||||||
|
id = rs.getInt("bs_user_id"),
|
||||||
|
username = rs.getString("u_username"),
|
||||||
|
firstName = rs.getString("u_first_name")
|
||||||
|
)
|
||||||
|
stateCode = rs.getString("bs_state_code")?.let { raw ->
|
||||||
|
runCatching { State.StateCode.valueOf(raw) }
|
||||||
|
.getOrElse { State.StateCode.AWAIT_SPACE_SELECT }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val code = rs.getString("bs_data_code")
|
||||||
|
val value = rs.getString("bs_data_value")
|
||||||
|
if (code != null && value != null) {
|
||||||
|
data[code] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
user?.let {
|
||||||
|
State(
|
||||||
|
user = it,
|
||||||
|
state = stateCode ?: State.StateCode.AWAIT_SPACE_SELECT,
|
||||||
|
data = data.toMap()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setState(
|
||||||
|
userId: Int,
|
||||||
|
stateCode: State.StateCode,
|
||||||
|
stateData: Map<String, String>
|
||||||
|
) {
|
||||||
|
// 1) UPSERT state (по user_id)
|
||||||
|
val upsertStateSql = """
|
||||||
|
INSERT INTO finance.bot_states (user_id, state_code)
|
||||||
|
VALUES (:user_id, :state_code)
|
||||||
|
ON CONFLICT (user_id) DO UPDATE
|
||||||
|
SET state_code = EXCLUDED.state_code
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
jdbcTemplate.update(
|
||||||
|
upsertStateSql,
|
||||||
|
mapOf(
|
||||||
|
"user_id" to userId,
|
||||||
|
// если в БД enum — чаще всего ок передать name(); если колонка TEXT/VARCHAR — тоже ок
|
||||||
|
"state_code" to stateCode.name
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 2) Обновление data: вариант A — апсерты (рекомендуется)
|
||||||
|
if (stateData.isNotEmpty()) {
|
||||||
|
val upsertDataSql = """
|
||||||
|
INSERT INTO finance.bot_states_data (user_id, data_code, data_value)
|
||||||
|
VALUES (:user_id, :data_code, :data_value)
|
||||||
|
ON CONFLICT (user_id, data_code) DO UPDATE
|
||||||
|
SET data_value = EXCLUDED.data_value
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val batch = stateData.map { (code, value) ->
|
||||||
|
mapOf(
|
||||||
|
"user_id" to userId,
|
||||||
|
"data_code" to code,
|
||||||
|
"data_value" to value
|
||||||
|
)
|
||||||
|
}.toTypedArray()
|
||||||
|
|
||||||
|
jdbcTemplate.batchUpdate(upsertDataSql, batch)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если тебе принципиально "перезаписывать" состояние данных (вариант B):
|
||||||
|
// np.update("DELETE FROM finance.bot_states_data WHERE user_id = :user_id", mapOf("user_id" to userId))
|
||||||
|
// затем обычный INSERT batch без ON CONFLICT.
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun clearState(userId: Int) {
|
||||||
|
TODO("Not yet implemented")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ class CategoryRepoImpl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun findBySpaceId(spaceId: Int): List<Category> {
|
override fun findBySpaceId(spaceId: Int): List<Category> {
|
||||||
val query = "select * from finance.categories where space_id = :space_id order by id"
|
val query = "select * from finance.categories where space_id = :space_id order by name"
|
||||||
val params = mapOf("space_id" to spaceId)
|
val params = mapOf("space_id" to spaceId)
|
||||||
return jdbcTemplate.query(query, params, categoryRowMapper())
|
return jdbcTemplate.query(query, params, categoryRowMapper())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import space.luminic.finance.models.RecurrentOperation
|
|||||||
interface RecurrentOperationRepo {
|
interface RecurrentOperationRepo {
|
||||||
fun findAllBySpaceId(spaceId: Int): List<RecurrentOperation>
|
fun findAllBySpaceId(spaceId: Int): List<RecurrentOperation>
|
||||||
fun findBySpaceIdAndId(spaceId: Int, id: Int): RecurrentOperation?
|
fun findBySpaceIdAndId(spaceId: Int, id: Int): RecurrentOperation?
|
||||||
|
fun findByDate( date: Int): List<RecurrentOperation>
|
||||||
fun create(operation: RecurrentOperation, createdById: Int): Int
|
fun create(operation: RecurrentOperation, createdById: Int): Int
|
||||||
fun update(operation: RecurrentOperation, updatedById: Int)
|
fun update(operation: RecurrentOperation, updatedById: Int)
|
||||||
fun delete(id: Int)
|
fun delete(id: Int)
|
||||||
|
fun findRecurrentsToCreate(spaceId: Int): List<RecurrentOperation>
|
||||||
}
|
}
|
||||||
@@ -74,7 +74,7 @@ class RecurrentOperationRepoImpl(
|
|||||||
join finance.categories c on ro.category_id = c.id
|
join finance.categories c on ro.category_id = c.id
|
||||||
join finance.users r_created_by on ro.created_by_id = r_created_by.id
|
join finance.users r_created_by on ro.created_by_id = r_created_by.id
|
||||||
where ro.space_id = :spaceId
|
where ro.space_id = :spaceId
|
||||||
order by ro.date
|
order by ro.date, ro.id
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
val params = mapOf("spaceId" to spaceId)
|
val params = mapOf("spaceId" to spaceId)
|
||||||
return jdbcTemplate.query(sql, params, operationRowMapper())
|
return jdbcTemplate.query(sql, params, operationRowMapper())
|
||||||
@@ -115,6 +115,40 @@ class RecurrentOperationRepoImpl(
|
|||||||
return jdbcTemplate.query(sql, params, operationRowMapper()).firstOrNull()
|
return jdbcTemplate.query(sql, params, operationRowMapper()).firstOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun findByDate(
|
||||||
|
date: Int
|
||||||
|
): List<RecurrentOperation> {
|
||||||
|
val sql = """
|
||||||
|
select
|
||||||
|
ro.id as r_id,
|
||||||
|
ro.space_id AS r_space_id,
|
||||||
|
s.name AS s_name,
|
||||||
|
s.owner_id as s_owner_id,
|
||||||
|
su.username as su_username,
|
||||||
|
su.first_name AS su_first_name,
|
||||||
|
ro.category_id as r_category_id,
|
||||||
|
c.type AS c_type,
|
||||||
|
c.name AS c_name,
|
||||||
|
c.description AS c_description,
|
||||||
|
c.icon AS c_icon,
|
||||||
|
ro.name AS r_name,
|
||||||
|
ro.amount AS r_amount,
|
||||||
|
ro.date AS r_date,
|
||||||
|
ro.created_by_id as r_created_by_id,
|
||||||
|
r_created_by.username as r_created_by_username,
|
||||||
|
r_created_by.first_name as r_created_by_first_name,
|
||||||
|
ro.created_at as r_created_at
|
||||||
|
from finance.recurrent_operations ro
|
||||||
|
join finance.spaces s on ro.space_id = s.id
|
||||||
|
join finance.users su on s.owner_id = su.id
|
||||||
|
join finance.categories c on ro.category_id = c.id
|
||||||
|
join finance.users r_created_by on ro.created_by_id = r_created_by.id
|
||||||
|
where ro.date = :date
|
||||||
|
""".trimIndent()
|
||||||
|
val params = mapOf( "date" to date)
|
||||||
|
return jdbcTemplate.query(sql, params, operationRowMapper())
|
||||||
|
}
|
||||||
|
|
||||||
override fun create(operation: RecurrentOperation, createdById: Int): Int {
|
override fun create(operation: RecurrentOperation, createdById: Int): Int {
|
||||||
val sql = """
|
val sql = """
|
||||||
insert into finance.recurrent_operations (
|
insert into finance.recurrent_operations (
|
||||||
@@ -175,4 +209,13 @@ class RecurrentOperationRepoImpl(
|
|||||||
val params = mapOf("id" to id)
|
val params = mapOf("id" to id)
|
||||||
jdbcTemplate.update(sql, params)
|
jdbcTemplate.update(sql, params)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun findRecurrentsToCreate(spaceId: Int): List<RecurrentOperation> {
|
||||||
|
val sql = """
|
||||||
|
select * from finance.transactions where space_id = :spaceId and t.date >
|
||||||
|
""".trimIndent()
|
||||||
|
TODO("Not ready")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -38,7 +38,8 @@ 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(),
|
||||||
@@ -93,6 +94,7 @@ 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,
|
||||||
@@ -114,7 +116,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, sp.participants_id, u.username, u.first_name, cau.username, cau.first_name,
|
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;
|
uau.username, uau.first_name;
|
||||||
""".trimMargin()
|
""".trimMargin()
|
||||||
val params = mapOf(
|
val params = mapOf(
|
||||||
@@ -133,6 +135,7 @@ 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,
|
||||||
@@ -154,7 +157,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, sp.participants_id, u.username, u.first_name, cau.username, cau.first_name,
|
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;
|
uau.username, uau.first_name;
|
||||||
""".trimMargin()
|
""".trimMargin()
|
||||||
val params = mapOf(
|
val params = mapOf(
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ class TokenRepoImpl(
|
|||||||
update finance.tokens set status = :status where token = :token
|
update finance.tokens set status = :status where token = :token
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
val params = mapOf(
|
val params = mapOf(
|
||||||
"token" to token,
|
"token" to token.token,
|
||||||
"status" to token.status
|
"status" to token.status.name
|
||||||
)
|
)
|
||||||
jdbcTemplate.update(sql, params)
|
jdbcTemplate.update(sql, params)
|
||||||
return token
|
return token
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
package space.luminic.finance.repos
|
package space.luminic.finance.repos
|
||||||
|
|
||||||
import space.luminic.finance.models.Transaction
|
import space.luminic.finance.models.Transaction
|
||||||
|
import space.luminic.finance.services.TransactionService
|
||||||
|
|
||||||
interface TransactionRepo {
|
interface TransactionRepo {
|
||||||
fun findAllBySpaceId(spaceId: Int): List<Transaction>
|
fun findAllBySpaceId(spaceId: Int, filters: TransactionService.TransactionsFilter): List<Transaction>
|
||||||
fun findBySpaceIdAndId(spaceId: Int, id: Int): Transaction?
|
fun findBySpaceIdAndId(spaceId: Int, id: Int): Transaction?
|
||||||
|
fun findBySpaceIdAndRecurrentId(spaceId: Int, recurrentId: Int): List<Transaction>
|
||||||
fun create(transaction: Transaction, userId: Int): Int
|
fun create(transaction: Transaction, userId: Int): Int
|
||||||
|
fun createBatch(transactions: List<Transaction>, userId: Int)
|
||||||
fun update(transaction: Transaction): Int
|
fun update(transaction: Transaction): Int
|
||||||
|
fun updateBatch(transactions: List<Transaction>, userId: Int)
|
||||||
fun delete(transactionId: Int)
|
fun delete(transactionId: Int)
|
||||||
|
fun deleteByRecurrentId(spaceId: Int, recurrentId: Int)
|
||||||
|
|
||||||
|
fun setCategory(txId:Int, categoryId: Int)
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ import org.springframework.stereotype.Repository
|
|||||||
import space.luminic.finance.models.Category
|
import space.luminic.finance.models.Category
|
||||||
import space.luminic.finance.models.Transaction
|
import space.luminic.finance.models.Transaction
|
||||||
import space.luminic.finance.models.User
|
import space.luminic.finance.models.User
|
||||||
|
import space.luminic.finance.services.TransactionService
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
class TransactionRepoImpl(
|
class TransactionRepoImpl(
|
||||||
@@ -13,21 +14,26 @@ class TransactionRepoImpl(
|
|||||||
) : TransactionRepo {
|
) : TransactionRepo {
|
||||||
|
|
||||||
private fun transactionRowMapper() = RowMapper { rs, _ ->
|
private fun transactionRowMapper() = RowMapper { rs, _ ->
|
||||||
|
val category = if (rs.getString("c_id") == null) null else Category(
|
||||||
|
id = rs.getInt("c_id"),
|
||||||
|
type = Category.CategoryType.valueOf(rs.getString("c_type")),
|
||||||
|
name = rs.getString(("c_name")),
|
||||||
|
description = rs.getString(("c_description")),
|
||||||
|
icon = rs.getString("c_icon"),
|
||||||
|
isDeleted = rs.getBoolean(("c_is_deleted")),
|
||||||
|
createdAt = rs.getTimestamp("c_created_at").toInstant(),
|
||||||
|
updatedAt = rs.getTimestamp("c_updated_at")?.toInstant(),
|
||||||
|
)
|
||||||
|
val parent = if (rs.getInt("t_parent_id") != 0) findBySpaceIdAndId(
|
||||||
|
spaceId = rs.getInt("t_space_id"),
|
||||||
|
rs.getInt("t_parent_id")
|
||||||
|
) else null
|
||||||
Transaction(
|
Transaction(
|
||||||
id = rs.getInt("t_id"),
|
id = rs.getInt("t_id"),
|
||||||
parent = findBySpaceIdAndId(spaceId = rs.getInt("t_space_id"), rs.getInt("t_parent_id")),
|
parent = parent,
|
||||||
type = Transaction.TransactionType.valueOf(rs.getString("t_type")),
|
type = Transaction.TransactionType.valueOf(rs.getString("t_type")),
|
||||||
kind = Transaction.TransactionKind.valueOf(rs.getString("t_kind")),
|
kind = Transaction.TransactionKind.valueOf(rs.getString("t_kind")),
|
||||||
category = Category(
|
category = category,
|
||||||
id = rs.getInt("c_id"),
|
|
||||||
type = Category.CategoryType.valueOf(rs.getString("c_type")),
|
|
||||||
name = rs.getString(("c_name")),
|
|
||||||
description = rs.getString(("c_description")),
|
|
||||||
icon = rs.getString("c_icon"),
|
|
||||||
isDeleted = rs.getBoolean(("c_is_deleted")),
|
|
||||||
createdAt = rs.getTimestamp("c_created_at").toInstant(),
|
|
||||||
updatedAt = rs.getTimestamp("c_updated_at").toInstant(),
|
|
||||||
),
|
|
||||||
comment = rs.getString("t_comment"),
|
comment = rs.getString("t_comment"),
|
||||||
amount = rs.getBigDecimal("t_amount"),
|
amount = rs.getBigDecimal("t_amount"),
|
||||||
fees = rs.getBigDecimal("t_fees"),
|
fees = rs.getBigDecimal("t_fees"),
|
||||||
@@ -41,11 +47,14 @@ class TransactionRepoImpl(
|
|||||||
),
|
),
|
||||||
createdAt = rs.getTimestamp("t_created_at").toInstant(),
|
createdAt = rs.getTimestamp("t_created_at").toInstant(),
|
||||||
updatedAt = rs.getTimestamp("t_updated_at").toInstant(),
|
updatedAt = rs.getTimestamp("t_updated_at").toInstant(),
|
||||||
|
tgChatId = rs.getLong("tg_chat_id"),
|
||||||
|
tgMessageId = rs.getLong("tg_message_id"),
|
||||||
|
recurrentId = rs.getInt("t_recurrent_id"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun findAllBySpaceId(spaceId: Int): List<Transaction> {
|
override fun findAllBySpaceId(spaceId: Int, filters: TransactionService.TransactionsFilter): List<Transaction> {
|
||||||
val sql = """
|
var sql = """
|
||||||
SELECT
|
SELECT
|
||||||
t.id AS t_id,
|
t.id AS t_id,
|
||||||
t.parent_id AS t_parent_id,
|
t.parent_id AS t_parent_id,
|
||||||
@@ -70,17 +79,47 @@ class TransactionRepoImpl(
|
|||||||
c.updated_at AS c_updated_at,
|
c.updated_at AS c_updated_at,
|
||||||
u.id AS u_id,
|
u.id AS u_id,
|
||||||
u.username AS u_username,
|
u.username AS u_username,
|
||||||
u.first_name AS u_first_name
|
u.first_name AS u_first_name,
|
||||||
|
t.tg_chat_id AS tg_chat_id,
|
||||||
|
t.tg_message_id AS tg_message_id,
|
||||||
|
t.recurrent_id AS t_recurrent_id
|
||||||
FROM finance.transactions t
|
FROM finance.transactions t
|
||||||
JOIN finance.categories c ON t.category_id = c.id
|
LEFT JOIN finance.categories c ON t.category_id = c.id
|
||||||
JOIN finance.users u ON u.id = t.created_by_id
|
JOIN finance.users u ON u.id = t.created_by_id
|
||||||
WHERE t.space_id = :spaceId and t.is_deleted = false
|
WHERE t.space_id = :spaceId and t.is_deleted = false
|
||||||
ORDER BY t.date
|
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
val params = mapOf(
|
val params = mutableMapOf<String, Any?>(
|
||||||
"spaceId" to spaceId,
|
"spaceId" to spaceId,
|
||||||
|
"offset" to filters.offset,
|
||||||
|
"limit" to filters.limit,
|
||||||
)
|
)
|
||||||
|
filters.type?.let {
|
||||||
|
sql += " AND t.type = :type"
|
||||||
|
params.put("type", it.name)
|
||||||
|
}
|
||||||
|
filters.kind?.let {
|
||||||
|
sql += " AND t.kind = :kind"
|
||||||
|
params.put("kind", it.name)
|
||||||
|
}
|
||||||
|
filters.isDone?.let {
|
||||||
|
sql += " AND t.is_done = :isDone"
|
||||||
|
params.put("isDone", it)
|
||||||
|
}
|
||||||
|
filters.dateFrom?.let {
|
||||||
|
sql += " AND t.date >= :dateFrom"
|
||||||
|
params.put("dateFrom", it)
|
||||||
|
}
|
||||||
|
filters.dateTo?.let {
|
||||||
|
sql += " AND t.date <= :dateTo"
|
||||||
|
params.put("dateTo", it)
|
||||||
|
}
|
||||||
|
sql += """
|
||||||
|
ORDER BY t.date, t.id
|
||||||
|
OFFSET :offset ROWS
|
||||||
|
FETCH FIRST :limit ROWS ONLY"""
|
||||||
|
|
||||||
|
|
||||||
return jdbcTemplate.query(sql, params, transactionRowMapper())
|
return jdbcTemplate.query(sql, params, transactionRowMapper())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +138,8 @@ class TransactionRepoImpl(
|
|||||||
t.is_done AS t_is_done,
|
t.is_done AS t_is_done,
|
||||||
t.created_at AS t_created_at,
|
t.created_at AS t_created_at,
|
||||||
t.updated_at AS t_updated_at,
|
t.updated_at AS t_updated_at,
|
||||||
|
t.tg_chat_id AS tg_chat_id,
|
||||||
|
t.tg_message_id AS tg_message_id,
|
||||||
c.id AS c_id,
|
c.id AS c_id,
|
||||||
c.type AS c_type,
|
c.type AS c_type,
|
||||||
c.name AS c_name,
|
c.name AS c_name,
|
||||||
@@ -109,9 +150,10 @@ class TransactionRepoImpl(
|
|||||||
c.updated_at AS c_updated_at,
|
c.updated_at AS c_updated_at,
|
||||||
u.id AS u_id,
|
u.id AS u_id,
|
||||||
u.username AS u_username,
|
u.username AS u_username,
|
||||||
u.first_name AS u_first_name
|
u.first_name AS u_first_name,
|
||||||
|
t.recurrent_id AS t_recurrent_id
|
||||||
FROM finance.transactions t
|
FROM finance.transactions t
|
||||||
JOIN finance.categories c ON t.category_id = c.id
|
LEFT JOIN finance.categories c ON t.category_id = c.id
|
||||||
JOIN finance.users u ON u.id = t.created_by_id
|
JOIN finance.users u ON u.id = t.created_by_id
|
||||||
WHERE t.space_id = :spaceId and t.id = :id and t.is_deleted = false""".trimMargin()
|
WHERE t.space_id = :spaceId and t.id = :id and t.is_deleted = false""".trimMargin()
|
||||||
val params = mapOf(
|
val params = mapOf(
|
||||||
@@ -121,9 +163,52 @@ class TransactionRepoImpl(
|
|||||||
return jdbcTemplate.query(sql, params, transactionRowMapper()).firstOrNull()
|
return jdbcTemplate.query(sql, params, transactionRowMapper()).firstOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun findBySpaceIdAndRecurrentId(
|
||||||
|
spaceId: Int,
|
||||||
|
recurrentId: Int
|
||||||
|
): List<Transaction> {
|
||||||
|
val sql = """SELECT
|
||||||
|
t.id AS t_id,
|
||||||
|
t.parent_id AS t_parent_id,
|
||||||
|
t.space_id AS t_space_id,
|
||||||
|
t.type AS t_type,
|
||||||
|
t.kind AS t_kind,
|
||||||
|
t.comment AS t_comment,
|
||||||
|
t.amount AS t_amount,
|
||||||
|
t.fees AS t_fees,
|
||||||
|
t.date AS t_date,
|
||||||
|
t.is_deleted AS t_is_deleted,
|
||||||
|
t.is_done AS t_is_done,
|
||||||
|
t.created_at AS t_created_at,
|
||||||
|
t.updated_at AS t_updated_at,
|
||||||
|
t.tg_chat_id AS tg_chat_id,
|
||||||
|
t.tg_message_id AS tg_message_id,
|
||||||
|
c.id AS c_id,
|
||||||
|
c.type AS c_type,
|
||||||
|
c.name AS c_name,
|
||||||
|
c.description AS c_description,
|
||||||
|
c.icon AS c_icon,
|
||||||
|
c.is_deleted AS c_is_deleted,
|
||||||
|
c.created_at AS c_created_at,
|
||||||
|
c.updated_at AS c_updated_at,
|
||||||
|
u.id AS u_id,
|
||||||
|
u.username AS u_username,
|
||||||
|
u.first_name AS u_first_name,
|
||||||
|
t.recurrent_id AS t_recurrent_id
|
||||||
|
FROM finance.transactions t
|
||||||
|
LEFT JOIN finance.categories c ON t.category_id = c.id
|
||||||
|
JOIN finance.users u ON u.id = t.created_by_id
|
||||||
|
WHERE t.space_id = :spaceId and t.recurrent_id = :recurrentId and t.is_deleted = false""".trimMargin()
|
||||||
|
val params = mapOf(
|
||||||
|
"spaceId" to spaceId,
|
||||||
|
"recurrentId" to recurrentId,
|
||||||
|
)
|
||||||
|
return jdbcTemplate.query(sql, params, transactionRowMapper())
|
||||||
|
}
|
||||||
|
|
||||||
override fun create(transaction: Transaction, userId: Int): Int {
|
override fun create(transaction: Transaction, userId: Int): Int {
|
||||||
val sql = """
|
val sql = """
|
||||||
INSERT INTO finance.transactions (space_id, parent_id, type, kind, category_id, comment, amount, fees, date, is_deleted, is_done, created_by_id) VALUES (
|
INSERT INTO finance.transactions (space_id, parent_id, type, kind, category_id, comment, amount, fees, date, is_deleted, is_done, created_by_id, tg_chat_id, tg_message_id, recurrent_id) VALUES (
|
||||||
:spaceId,
|
:spaceId,
|
||||||
:parentId,
|
:parentId,
|
||||||
:type,
|
:type,
|
||||||
@@ -135,7 +220,10 @@ class TransactionRepoImpl(
|
|||||||
:date,
|
:date,
|
||||||
:is_deleted,
|
:is_deleted,
|
||||||
:is_done,
|
:is_done,
|
||||||
:createdById)
|
:createdById,
|
||||||
|
:tgChatId,
|
||||||
|
:tgMessageId,
|
||||||
|
:recurrentId)
|
||||||
returning id
|
returning id
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
val params = mapOf(
|
val params = mapOf(
|
||||||
@@ -143,29 +231,58 @@ class TransactionRepoImpl(
|
|||||||
"parentId" to transaction.parent?.id,
|
"parentId" to transaction.parent?.id,
|
||||||
"type" to transaction.type.name,
|
"type" to transaction.type.name,
|
||||||
"kind" to transaction.kind.name,
|
"kind" to transaction.kind.name,
|
||||||
"categoryId" to transaction.category.id,
|
"categoryId" to transaction.category?.id,
|
||||||
"comment" to transaction.comment,
|
"comment" to transaction.comment,
|
||||||
"amount" to transaction.amount,
|
"amount" to transaction.amount,
|
||||||
"fees" to transaction.fees,
|
"fees" to transaction.fees,
|
||||||
"date" to transaction.date,
|
"date" to transaction.date,
|
||||||
"is_deleted" to transaction.isDeleted,
|
"is_deleted" to transaction.isDeleted,
|
||||||
"is_done" to transaction.isDone,
|
"is_done" to transaction.isDone,
|
||||||
"createdById" to userId
|
"createdById" to userId,
|
||||||
|
"tgChatId" to transaction.tgChatId,
|
||||||
|
"tgMessageId" to transaction.tgMessageId,
|
||||||
|
"recurrentId" to transaction.recurrentId,
|
||||||
)
|
)
|
||||||
val createdTxId = jdbcTemplate.queryForObject(sql, params, Int::class.java)
|
val createdTxId = jdbcTemplate.queryForObject(sql, params, Int::class.java)
|
||||||
transaction.id = createdTxId
|
transaction.id = createdTxId
|
||||||
return createdTxId!!
|
return createdTxId!!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun createBatch(transactions: List<Transaction>, userId: Int) {
|
||||||
|
val sql = """
|
||||||
|
INSERT INTO finance.transactions (
|
||||||
|
space_id, parent_id, type, kind, category_id, comment, amount, fees, date,
|
||||||
|
is_deleted, is_done, created_by_id, tg_chat_id, tg_message_id, recurrent_id
|
||||||
|
) VALUES (
|
||||||
|
:spaceId, :parentId, :type, :kind, :categoryId, :comment, :amount, :fees, :date,
|
||||||
|
:is_deleted, :is_done, :createdById, :tgChatId, :tgMessageId, :recurrentId
|
||||||
|
)
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val batchValues = transactions.map { transaction ->
|
||||||
|
mapOf(
|
||||||
|
"spaceId" to transaction.space!!.id,
|
||||||
|
"parentId" to transaction.parent?.id,
|
||||||
|
"type" to transaction.type.name,
|
||||||
|
"kind" to transaction.kind.name,
|
||||||
|
"categoryId" to transaction.category?.id,
|
||||||
|
"comment" to transaction.comment,
|
||||||
|
"amount" to transaction.amount,
|
||||||
|
"fees" to transaction.fees,
|
||||||
|
"date" to transaction.date,
|
||||||
|
"is_deleted" to transaction.isDeleted,
|
||||||
|
"is_done" to transaction.isDone,
|
||||||
|
"createdById" to userId,
|
||||||
|
"tgChatId" to transaction.tgChatId,
|
||||||
|
"tgMessageId" to transaction.tgMessageId,
|
||||||
|
"recurrentId" to transaction.recurrentId
|
||||||
|
)
|
||||||
|
}.toTypedArray()
|
||||||
|
|
||||||
|
jdbcTemplate.batchUpdate(sql, batchValues)
|
||||||
|
}
|
||||||
|
|
||||||
override fun update(transaction: Transaction): Int {
|
override fun update(transaction: Transaction): Int {
|
||||||
// val type: TransactionType = TransactionType.EXPENSE,
|
|
||||||
// val kind: TransactionKind = TransactionKind.INSTANT,
|
|
||||||
// val category: Int,
|
|
||||||
// val comment: String,
|
|
||||||
// val amount: BigDecimal,
|
|
||||||
// val fees: BigDecimal = BigDecimal.ZERO,
|
|
||||||
// val isDone: Boolean,
|
|
||||||
// val date: Instant
|
|
||||||
|
|
||||||
val sql = """
|
val sql = """
|
||||||
UPDATE finance.transactions
|
UPDATE finance.transactions
|
||||||
@@ -183,7 +300,7 @@ class TransactionRepoImpl(
|
|||||||
"id" to transaction.id,
|
"id" to transaction.id,
|
||||||
"type" to transaction.type.name,
|
"type" to transaction.type.name,
|
||||||
"kind" to transaction.kind.name,
|
"kind" to transaction.kind.name,
|
||||||
"categoryId" to transaction.category.id,
|
"categoryId" to transaction.category?.id,
|
||||||
"comment" to transaction.comment,
|
"comment" to transaction.comment,
|
||||||
"amount" to transaction.amount,
|
"amount" to transaction.amount,
|
||||||
"fees" to transaction.fees,
|
"fees" to transaction.fees,
|
||||||
@@ -194,6 +311,39 @@ class TransactionRepoImpl(
|
|||||||
return transaction.id!!
|
return transaction.id!!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun updateBatch(transactions: List<Transaction>, userId: Int) {
|
||||||
|
val sql = """
|
||||||
|
UPDATE finance.transactions
|
||||||
|
set type = :type,
|
||||||
|
kind = :kind,
|
||||||
|
category_id = :categoryId,
|
||||||
|
comment = :comment,
|
||||||
|
amount = :amount,
|
||||||
|
fees = :fees,
|
||||||
|
is_done = :is_done,
|
||||||
|
date = :date,
|
||||||
|
updated_by_id = :updatedById,
|
||||||
|
updated_at = now()
|
||||||
|
where id = :id
|
||||||
|
""".trimIndent()
|
||||||
|
val batchValues = transactions.map { transaction ->
|
||||||
|
mapOf(
|
||||||
|
"id" to transaction.id,
|
||||||
|
"type" to transaction.type.name,
|
||||||
|
"kind" to transaction.kind.name,
|
||||||
|
"categoryId" to transaction.category?.id,
|
||||||
|
"comment" to transaction.comment,
|
||||||
|
"amount" to transaction.amount,
|
||||||
|
"fees" to transaction.fees,
|
||||||
|
"date" to transaction.date,
|
||||||
|
"is_done" to transaction.isDone,
|
||||||
|
"updatedById" to userId,
|
||||||
|
)
|
||||||
|
}.toTypedArray()
|
||||||
|
jdbcTemplate.batchUpdate(sql, batchValues)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
override fun delete(transactionId: Int) {
|
override fun delete(transactionId: Int) {
|
||||||
val sql = """
|
val sql = """
|
||||||
update finance.transactions set is_deleted = true where id = :id
|
update finance.transactions set is_deleted = true where id = :id
|
||||||
@@ -203,4 +353,27 @@ class TransactionRepoImpl(
|
|||||||
)
|
)
|
||||||
jdbcTemplate.update(sql, params)
|
jdbcTemplate.update(sql, params)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun deleteByRecurrentId(spaceId: Int, recurrentId: Int) {
|
||||||
|
val sql = """
|
||||||
|
update finance.transactions set is_deleted = true where recurrent_id = :recurrentId
|
||||||
|
""".trimIndent()
|
||||||
|
val params = mapOf(
|
||||||
|
"recurrentId" to recurrentId,
|
||||||
|
)
|
||||||
|
jdbcTemplate.update(sql, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setCategory(txId: Int, categoryId: Int) {
|
||||||
|
val sql = """
|
||||||
|
UPDATE finance.transactions
|
||||||
|
SET category_id = :categoryId
|
||||||
|
where id = :txId
|
||||||
|
""".trimIndent()
|
||||||
|
val params = mapOf(
|
||||||
|
"categoryId" to categoryId,
|
||||||
|
"txId" to txId,
|
||||||
|
)
|
||||||
|
jdbcTemplate.update(sql, params)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -53,12 +53,12 @@ class UserRepoImpl(
|
|||||||
select * from finance.users u where tg_id = :tgId
|
select * from finance.users u where tg_id = :tgId
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
val params = mapOf("tgId" to tgId)
|
val params = mapOf("tgId" to tgId)
|
||||||
return jdbcTemplate.queryForObject(sql, params, userRowMapper())
|
return jdbcTemplate.query(sql, params, userRowMapper()).firstOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun create(user: User): User {
|
override fun create(user: User): User {
|
||||||
val sql =
|
val sql =
|
||||||
"insert into finance.users(username, first_name, tg_id, tg_user_name, photo_url, password, is_active, reg_date) values (:username, :firstname, :tg_id, :tg_user_name, :photo_url :password, :isActive, :regDate) returning ID"
|
"insert into finance.users(username, first_name, tg_id, tg_user_name, photo_url, password, is_active, reg_date) values (:username, :firstname, :tg_id, :tg_user_name, :photo_url, :password, :isActive, :regDate) returning ID"
|
||||||
val params = mapOf(
|
val params = mapOf(
|
||||||
"username" to user.username,
|
"username" to user.username,
|
||||||
"firstname" to user.firstName,
|
"firstname" to user.firstName,
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ class AuthService(
|
|||||||
|
|
||||||
fun tgAuth(tgUser: UserDTO.TelegramAuthDTO): String {
|
fun tgAuth(tgUser: UserDTO.TelegramAuthDTO): String {
|
||||||
val user: User = try {
|
val user: User = try {
|
||||||
tgLogin(tgUser.id)
|
tgLogin(tgUser.id!!)
|
||||||
} catch (e: NotFoundException) {
|
} catch (e: NotFoundException) {
|
||||||
registerTg(tgUser)
|
registerTg(tgUser)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
//package space.luminic.finance.services
|
|
||||||
//
|
|
||||||
//import kotlinx.coroutines.reactive.awaitSingle
|
|
||||||
//import org.bson.types.ObjectId
|
|
||||||
//import org.springframework.data.mongodb.core.ReactiveMongoTemplate
|
|
||||||
//import org.springframework.data.mongodb.core.aggregation.Aggregation.addFields
|
|
||||||
//import org.springframework.data.mongodb.core.aggregation.Aggregation.lookup
|
|
||||||
//import org.springframework.data.mongodb.core.aggregation.Aggregation.match
|
|
||||||
//import org.springframework.data.mongodb.core.aggregation.Aggregation.newAggregation
|
|
||||||
//import org.springframework.data.mongodb.core.aggregation.Aggregation.unwind
|
|
||||||
//import org.springframework.data.mongodb.core.aggregation.AggregationOperation
|
|
||||||
//import org.springframework.data.mongodb.core.aggregation.ConvertOperators
|
|
||||||
//import org.springframework.data.mongodb.core.query.Criteria
|
|
||||||
//import org.springframework.stereotype.Service
|
|
||||||
//import space.luminic.finance.dtos.CategoryDTO
|
|
||||||
//import space.luminic.finance.models.Category
|
|
||||||
//import space.luminic.finance.repos.CategoryEtalonRepo
|
|
||||||
//import space.luminic.finance.repos.CategoryRepo
|
|
||||||
//
|
|
||||||
//@Service
|
|
||||||
//class CategoryServiceMongoImpl(
|
|
||||||
// private val categoryRepo: CategoryRepo,
|
|
||||||
// private val categoryEtalonRepo: CategoryEtalonRepo,
|
|
||||||
// private val reactiveMongoTemplate: ReactiveMongoTemplate,
|
|
||||||
// private val authService: AuthService,
|
|
||||||
//) : CategoryService {
|
|
||||||
//
|
|
||||||
// private fun basicAggregation(spaceId: String): List<AggregationOperation> {
|
|
||||||
// val addFieldsAsOJ = addFields()
|
|
||||||
// .addField("createdByOI")
|
|
||||||
// .withValue(ConvertOperators.valueOf("createdById").convertToObjectId())
|
|
||||||
// .addField("updatedByOI")
|
|
||||||
// .withValue(ConvertOperators.valueOf("updatedById").convertToObjectId())
|
|
||||||
// .build()
|
|
||||||
// val lookupCreatedBy = lookup("users", "createdByOI", "_id", "createdBy")
|
|
||||||
// val unwindCreatedBy = unwind("createdBy")
|
|
||||||
//
|
|
||||||
// val lookupUpdatedBy = lookup("users", "updatedByOI", "_id", "updatedBy")
|
|
||||||
// val unwindUpdatedBy = unwind("updatedBy")
|
|
||||||
// val matchCriteria = mutableListOf<Criteria>()
|
|
||||||
// matchCriteria.add(Criteria.where("spaceId").`is`(spaceId))
|
|
||||||
// matchCriteria.add(Criteria.where("isDeleted").`is`(false))
|
|
||||||
// val matchStage = match(Criteria().andOperator(*matchCriteria.toTypedArray()))
|
|
||||||
//
|
|
||||||
// return listOf(addFieldsAsOJ, lookupCreatedBy, unwindCreatedBy, lookupUpdatedBy, unwindUpdatedBy, matchStage)
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// override suspend fun getCategories(spaceId: String): List<Category> {
|
|
||||||
// val basicAggregation = basicAggregation(spaceId)
|
|
||||||
// val aggregation = newAggregation(*basicAggregation.toTypedArray())
|
|
||||||
// return reactiveMongoTemplate.aggregate(aggregation, "categories", Category::class.java).collectList().awaitSingle()
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// override suspend fun getCategory(spaceId: String, id: String): Category {
|
|
||||||
// val basicAggregation = basicAggregation(spaceId)
|
|
||||||
// val match = match(Criteria.where("_id").`is`(ObjectId(id)))
|
|
||||||
// val aggregation = newAggregation(*basicAggregation.toTypedArray(), match)
|
|
||||||
// return reactiveMongoTemplate.aggregate(aggregation, "categories", Category::class.java).awaitSingle()
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
//
|
|
||||||
// override suspend fun createCategory(
|
|
||||||
// spaceId: String,
|
|
||||||
// category: CategoryDTO.CreateCategoryDTO
|
|
||||||
// ): Category {
|
|
||||||
// val createdCategory = Category(
|
|
||||||
// spaceId = spaceId,
|
|
||||||
// type = category.type,
|
|
||||||
// name = category.name,
|
|
||||||
// icon = category.icon
|
|
||||||
// )
|
|
||||||
// return categoryRepo.save(createdCategory).awaitSingle()
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// override suspend fun updateCategory(
|
|
||||||
// spaceId: String,
|
|
||||||
// category: CategoryDTO.UpdateCategoryDTO
|
|
||||||
// ): Category {
|
|
||||||
// val existingCategory = getCategory(spaceId, category.id)
|
|
||||||
// val updatedCategory = existingCategory.copy(
|
|
||||||
// type = category.type,
|
|
||||||
// name = category.name,
|
|
||||||
// icon = category.icon,
|
|
||||||
// )
|
|
||||||
// return categoryRepo.save(updatedCategory).awaitSingle()
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// override suspend fun deleteCategory(spaceId: String, id: String) {
|
|
||||||
// val existingCategory = getCategory(spaceId, id)
|
|
||||||
// existingCategory.isDeleted = true
|
|
||||||
// categoryRepo.save(existingCategory).awaitSingle()
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// override suspend fun createCategoriesForSpace(spaceId: String): List<Category> {
|
|
||||||
// val etalonCategories = categoryEtalonRepo.findAll().collectList().awaitSingle()
|
|
||||||
// val toCreate = etalonCategories.map {
|
|
||||||
// Category(
|
|
||||||
// spaceId = spaceId,
|
|
||||||
// type = it.type,
|
|
||||||
// name = it.name,
|
|
||||||
// icon = it.icon
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
// return categoryRepo.saveAll(toCreate).collectList().awaitSingle()
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
//
|
|
||||||
//}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
//package space.luminic.finance.services
|
|
||||||
//
|
|
||||||
//import kotlinx.coroutines.reactive.awaitSingle
|
|
||||||
//import org.springframework.stereotype.Service
|
|
||||||
//import space.luminic.finance.dtos.CurrencyDTO
|
|
||||||
//import space.luminic.finance.models.Currency
|
|
||||||
//import space.luminic.finance.models.CurrencyRate
|
|
||||||
//import space.luminic.finance.repos.CurrencyRateRepo
|
|
||||||
//import space.luminic.finance.repos.CurrencyRepo
|
|
||||||
//import java.math.BigDecimal
|
|
||||||
//import java.time.LocalDate
|
|
||||||
//
|
|
||||||
//@Service
|
|
||||||
//class CurrencyServiceMongoImpl(
|
|
||||||
// private val currencyRepo: CurrencyRepo,
|
|
||||||
// private val currencyRateRepo: CurrencyRateRepo
|
|
||||||
//) : CurrencyService {
|
|
||||||
//
|
|
||||||
// override suspend fun getCurrencies(): List<Currency> {
|
|
||||||
// return currencyRepo.findAll().collectList().awaitSingle()
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// override suspend fun getCurrency(currencyCode: String): Currency {
|
|
||||||
// return currencyRepo.findById(currencyCode).awaitSingle()
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// override suspend fun createCurrency(currency: CurrencyDTO): Currency {
|
|
||||||
// val createdCurrency = Currency(currency.code, currency.name, currency.symbol)
|
|
||||||
// return currencyRepo.save(createdCurrency).awaitSingle()
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// override suspend fun updateCurrency(currency: CurrencyDTO): Currency {
|
|
||||||
// val existingCurrency = currencyRepo.findById(currency.code).awaitSingle()
|
|
||||||
// val newCurrency = existingCurrency.copy(name = currency.name, symbol = currency.symbol)
|
|
||||||
// return currencyRepo.save(newCurrency).awaitSingle()
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// override suspend fun deleteCurrency(currencyCode: String) {
|
|
||||||
// currencyRepo.deleteById(currencyCode).awaitSingle()
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// override suspend fun createCurrencyRate(currencyCode: String): CurrencyRate {
|
|
||||||
// return currencyRateRepo.save(
|
|
||||||
// CurrencyRate(
|
|
||||||
// currencyCode = currencyCode,
|
|
||||||
// rate = BigDecimal(12.0),
|
|
||||||
// date = LocalDate.now(),
|
|
||||||
// )
|
|
||||||
// ).awaitSingle()
|
|
||||||
// }
|
|
||||||
//}
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,4 +10,6 @@ interface RecurrentOperationService {
|
|||||||
fun create(spaceId: Int, operation: RecurrentOperationDTO.CreateRecurrentOperationDTO): Int
|
fun create(spaceId: Int, operation: RecurrentOperationDTO.CreateRecurrentOperationDTO): Int
|
||||||
fun update(spaceId: Int, operationId: Int, operation: RecurrentOperationDTO.UpdateRecurrentOperationDTO)
|
fun update(spaceId: Int, operationId: Int, operation: RecurrentOperationDTO.UpdateRecurrentOperationDTO)
|
||||||
fun delete(spaceId: Int, id: Int)
|
fun delete(spaceId: Int, id: Int)
|
||||||
|
|
||||||
|
fun createRecurrentTransactions()
|
||||||
}
|
}
|
||||||
@@ -1,19 +1,33 @@
|
|||||||
package space.luminic.finance.services
|
package space.luminic.finance.services
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import space.luminic.finance.dtos.RecurrentOperationDTO
|
import space.luminic.finance.dtos.RecurrentOperationDTO
|
||||||
|
import space.luminic.finance.models.Category
|
||||||
import space.luminic.finance.models.NotFoundException
|
import space.luminic.finance.models.NotFoundException
|
||||||
import space.luminic.finance.models.RecurrentOperation
|
import space.luminic.finance.models.RecurrentOperation
|
||||||
|
import space.luminic.finance.models.Transaction
|
||||||
import space.luminic.finance.repos.RecurrentOperationRepo
|
import space.luminic.finance.repos.RecurrentOperationRepo
|
||||||
import space.luminic.finance.repos.SpaceRepo
|
import space.luminic.finance.repos.SpaceRepo
|
||||||
|
import space.luminic.finance.repos.TransactionRepo
|
||||||
|
import java.time.LocalDate
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class RecurrentOperationServiceImpl(
|
class RecurrentOperationServiceImpl(
|
||||||
private val authService: AuthService,
|
private val authService: AuthService,
|
||||||
private val spaceRepo: SpaceRepo,
|
private val spaceRepo: SpaceRepo,
|
||||||
private val recurrentOperationRepo: RecurrentOperationRepo,
|
private val recurrentOperationRepo: RecurrentOperationRepo,
|
||||||
private val categoryService: CategoryService
|
private val categoryService: CategoryService,
|
||||||
): RecurrentOperationService {
|
private val transactionRepo: TransactionRepo
|
||||||
|
) : RecurrentOperationService {
|
||||||
|
private val logger = LoggerFactory.getLogger(this.javaClass)
|
||||||
|
private val serviceScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||||
|
|
||||||
override fun findBySpaceId(spaceId: Int): List<RecurrentOperation> {
|
override fun findBySpaceId(spaceId: Int): List<RecurrentOperation> {
|
||||||
val userId = authService.getSecurityUserId()
|
val userId = authService.getSecurityUserId()
|
||||||
spaceRepo.findSpaceById(spaceId, userId)
|
spaceRepo.findSpaceById(spaceId, userId)
|
||||||
@@ -26,12 +40,14 @@ class RecurrentOperationServiceImpl(
|
|||||||
): RecurrentOperation {
|
): RecurrentOperation {
|
||||||
val userId = authService.getSecurityUserId()
|
val userId = authService.getSecurityUserId()
|
||||||
spaceRepo.findSpaceById(spaceId, userId)
|
spaceRepo.findSpaceById(spaceId, userId)
|
||||||
return recurrentOperationRepo.findBySpaceIdAndId(spaceId, id) ?: throw NotFoundException("Cannot find recurrent operation with id ${id}")
|
return recurrentOperationRepo.findBySpaceIdAndId(spaceId, id)
|
||||||
|
?: throw NotFoundException("Cannot find recurrent operation with id ${id}")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun create(spaceId: Int, operation: RecurrentOperationDTO.CreateRecurrentOperationDTO): Int {
|
override fun create(spaceId: Int, operation: RecurrentOperationDTO.CreateRecurrentOperationDTO): Int {
|
||||||
val userId = authService.getSecurityUserId()
|
val userId = authService.getSecurityUserId()
|
||||||
val space = spaceRepo.findSpaceById(spaceId, userId) ?: throw NotFoundException("Cannot find space with id ${spaceId}")
|
val space =
|
||||||
|
spaceRepo.findSpaceById(spaceId, userId) ?: throw NotFoundException("Cannot find space with id ${spaceId}")
|
||||||
val category = categoryService.getCategory(spaceId, operation.categoryId)
|
val category = categoryService.getCategory(spaceId, operation.categoryId)
|
||||||
val creatingOperation = RecurrentOperation(
|
val creatingOperation = RecurrentOperation(
|
||||||
space = space,
|
space = space,
|
||||||
@@ -40,14 +56,43 @@ class RecurrentOperationServiceImpl(
|
|||||||
amount = operation.amount,
|
amount = operation.amount,
|
||||||
date = operation.date
|
date = operation.date
|
||||||
)
|
)
|
||||||
return recurrentOperationRepo.create(creatingOperation, userId)
|
|
||||||
|
val createdRecurrentId = recurrentOperationRepo.create(creatingOperation, userId)
|
||||||
|
val transactionsToCreate = mutableListOf<Transaction>()
|
||||||
|
serviceScope.launch {
|
||||||
|
runCatching {
|
||||||
|
val now = LocalDate.now()
|
||||||
|
val date = now.withDayOfMonth(min(operation.date, now.lengthOfMonth()))
|
||||||
|
for (i in 1..12) {
|
||||||
|
transactionsToCreate.add(
|
||||||
|
Transaction(
|
||||||
|
space = space,
|
||||||
|
type = if (category.type == Category.CategoryType.EXPENSE) Transaction.TransactionType.EXPENSE else Transaction.TransactionType.INCOME,
|
||||||
|
kind = Transaction.TransactionKind.PLANNING,
|
||||||
|
category = category,
|
||||||
|
comment = creatingOperation.name,
|
||||||
|
amount = creatingOperation.amount,
|
||||||
|
date = date.plusMonths(i.toLong()),
|
||||||
|
recurrentId = createdRecurrentId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
transactionRepo.createBatch(transactionsToCreate, userId)
|
||||||
|
// transactionService.batchCreate(spaceId, transactionsToCreate, userId)
|
||||||
|
}.onFailure {
|
||||||
|
logger.error("Error creating recurring operation", it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return createdRecurrentId
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun update(spaceId: Int, operationId: Int, operation: RecurrentOperationDTO.UpdateRecurrentOperationDTO) {
|
override fun update(spaceId: Int, operationId: Int, operation: RecurrentOperationDTO.UpdateRecurrentOperationDTO) {
|
||||||
val userId = authService.getSecurityUserId()
|
val userId = authService.getSecurityUserId()
|
||||||
spaceRepo.findSpaceById(spaceId, userId)
|
spaceRepo.findSpaceById(spaceId, userId)
|
||||||
val newCategory = categoryService.getCategory(spaceId, operation.categoryId)
|
val newCategory = categoryService.getCategory(spaceId, operation.categoryId)
|
||||||
val existingOperation = recurrentOperationRepo.findBySpaceIdAndId(spaceId,operationId ) ?: throw NotFoundException("Cannot find operation with id $operationId")
|
val existingOperation = recurrentOperationRepo.findBySpaceIdAndId(spaceId, operationId)
|
||||||
|
?: throw NotFoundException("Cannot find operation with id $operationId")
|
||||||
val updatedOperation = existingOperation.copy(
|
val updatedOperation = existingOperation.copy(
|
||||||
category = newCategory,
|
category = newCategory,
|
||||||
name = operation.name,
|
name = operation.name,
|
||||||
@@ -55,7 +100,30 @@ class RecurrentOperationServiceImpl(
|
|||||||
date = operation.date
|
date = operation.date
|
||||||
)
|
)
|
||||||
recurrentOperationRepo.update(updatedOperation, userId)
|
recurrentOperationRepo.update(updatedOperation, userId)
|
||||||
|
serviceScope.launch {
|
||||||
|
val transactionsToUpdate = mutableListOf<Transaction>()
|
||||||
|
runCatching {
|
||||||
|
val txs = transactionRepo.findBySpaceIdAndRecurrentId(spaceId, operationId)
|
||||||
|
txs.forEach {
|
||||||
|
transactionsToUpdate.add(
|
||||||
|
it.copy(
|
||||||
|
type = if (it.category?.type == Category.CategoryType.EXPENSE) Transaction.TransactionType.EXPENSE else Transaction.TransactionType.INCOME,
|
||||||
|
category = updatedOperation.category,
|
||||||
|
comment = operation.name,
|
||||||
|
date = LocalDate.of(
|
||||||
|
it.date.year,
|
||||||
|
it.date.monthValue,
|
||||||
|
min(it.date.lengthOfMonth(), updatedOperation.date)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
transactionRepo.updateBatch(transactionsToUpdate, userId)
|
||||||
|
}.onFailure {
|
||||||
|
logger.error("Error creating recurring operation", it)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun delete(spaceId: Int, id: Int) {
|
override fun delete(spaceId: Int, id: Int) {
|
||||||
@@ -63,4 +131,25 @@ class RecurrentOperationServiceImpl(
|
|||||||
spaceRepo.findSpaceById(spaceId, userId)
|
spaceRepo.findSpaceById(spaceId, userId)
|
||||||
recurrentOperationRepo.delete(id)
|
recurrentOperationRepo.delete(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun createRecurrentTransactions() {
|
||||||
|
val today = LocalDate.now()
|
||||||
|
val recurrents = recurrentOperationRepo.findByDate(today.dayOfMonth)
|
||||||
|
recurrents.forEach {
|
||||||
|
transactionRepo.create(
|
||||||
|
Transaction(
|
||||||
|
space = it.space,
|
||||||
|
type = if (it.category.type == Category.CategoryType.EXPENSE) Transaction.TransactionType.EXPENSE else Transaction.TransactionType.INCOME,
|
||||||
|
kind = Transaction.TransactionKind.PLANNING,
|
||||||
|
category = it.category,
|
||||||
|
comment = it.name,
|
||||||
|
amount = it.amount,
|
||||||
|
date = today.plusMonths(13),
|
||||||
|
recurrentId = it.id
|
||||||
|
), it.createdBy?.id!!
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
27
src/main/kotlin/space/luminic/finance/services/Scheduler.kt
Normal file
27
src/main/kotlin/space/luminic/finance/services/Scheduler.kt
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package space.luminic.finance.services
|
||||||
|
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
|
||||||
|
@EnableScheduling
|
||||||
|
@Service
|
||||||
|
class Scheduler(
|
||||||
|
private val recurrentOperationService: RecurrentOperationService,
|
||||||
|
private val notificationService: NotificationService
|
||||||
|
) {
|
||||||
|
private val log = LoggerFactory.getLogger(Scheduler::class.java)
|
||||||
|
|
||||||
|
@Scheduled(cron = "0 0 3 * * *")
|
||||||
|
fun createRecurrentAfter13Month() {
|
||||||
|
log.info("Creating recurrent after 13 month")
|
||||||
|
recurrentOperationService.createRecurrentTransactions()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(cron = "0 30 19 * * *")
|
||||||
|
fun sendDailyReminders() {
|
||||||
|
log.info("Sending daily reminders")
|
||||||
|
notificationService.sendDailyReminder()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ interface SpaceService {
|
|||||||
|
|
||||||
fun checkSpace(spaceId: Int): Space
|
fun checkSpace(spaceId: Int): Space
|
||||||
fun getSpaces(): List<Space>
|
fun getSpaces(): List<Space>
|
||||||
fun getSpace(id: Int): Space
|
fun getSpace(id: Int, userId: Int?): Space
|
||||||
fun createSpace(space: SpaceDTO.CreateSpaceDTO): Int
|
fun createSpace(space: SpaceDTO.CreateSpaceDTO): Int
|
||||||
fun updateSpace(spaceId: Int, space: SpaceDTO.UpdateSpaceDTO): Int
|
fun updateSpace(spaceId: Int, space: SpaceDTO.UpdateSpaceDTO): Int
|
||||||
fun deleteSpace(spaceId: Int)
|
fun deleteSpace(spaceId: Int)
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
package space.luminic.finance.services
|
package space.luminic.finance.services
|
||||||
|
|
||||||
import org.springframework.cache.annotation.CacheEvict
|
|
||||||
import org.springframework.cache.annotation.Cacheable
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate
|
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
import space.luminic.finance.dtos.SpaceDTO
|
import space.luminic.finance.dtos.SpaceDTO
|
||||||
@@ -17,7 +14,7 @@ class SpaceServiceImpl(
|
|||||||
private val categoryService: CategoryService
|
private val categoryService: CategoryService
|
||||||
) : SpaceService {
|
) : SpaceService {
|
||||||
override fun checkSpace(spaceId: Int): Space {
|
override fun checkSpace(spaceId: Int): Space {
|
||||||
return getSpace(spaceId)
|
return getSpace(spaceId, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
// @Cacheable(cacheNames = ["spaces"])
|
// @Cacheable(cacheNames = ["spaces"])
|
||||||
@@ -27,8 +24,9 @@ class SpaceServiceImpl(
|
|||||||
return spaces
|
return spaces
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getSpace(id: Int): Space {
|
|
||||||
val user = authService.getSecurityUserId()
|
override fun getSpace(id: Int, userId: Int?): Space {
|
||||||
|
val user = userId ?: authService.getSecurityUserId()
|
||||||
val space = spaceRepo.findSpaceById(id, user) ?: throw NotFoundException("Space with id $id not found")
|
val space = spaceRepo.findSpaceById(id, user) ?: throw NotFoundException("Space with id $id not found")
|
||||||
return space
|
return space
|
||||||
|
|
||||||
@@ -56,7 +54,7 @@ class SpaceServiceImpl(
|
|||||||
space: SpaceDTO.UpdateSpaceDTO
|
space: SpaceDTO.UpdateSpaceDTO
|
||||||
): Int {
|
): Int {
|
||||||
val userId = authService.getSecurityUserId()
|
val userId = authService.getSecurityUserId()
|
||||||
val existingSpace = getSpace(spaceId)
|
val existingSpace = getSpace(spaceId, null)
|
||||||
val updatedSpace = Space(
|
val updatedSpace = Space(
|
||||||
id = existingSpace.id,
|
id = existingSpace.id,
|
||||||
name = space.name,
|
name = space.name,
|
||||||
|
|||||||
@@ -1,145 +0,0 @@
|
|||||||
//package space.luminic.finance.services
|
|
||||||
//
|
|
||||||
//import com.mongodb.client.model.Aggregates.sort
|
|
||||||
//import kotlinx.coroutines.reactive.awaitFirst
|
|
||||||
//import kotlinx.coroutines.reactive.awaitFirstOrNull
|
|
||||||
//import kotlinx.coroutines.reactive.awaitSingle
|
|
||||||
//import org.springframework.data.domain.Sort
|
|
||||||
//import org.springframework.data.mongodb.core.ReactiveMongoTemplate
|
|
||||||
//import org.springframework.data.mongodb.core.aggregation.Aggregation.*
|
|
||||||
//import org.springframework.data.mongodb.core.aggregation.AggregationOperation
|
|
||||||
//
|
|
||||||
//import org.springframework.data.mongodb.core.aggregation.ConvertOperators
|
|
||||||
//import org.springframework.data.mongodb.core.aggregation.VariableOperators
|
|
||||||
//import org.springframework.data.mongodb.core.query.Criteria
|
|
||||||
//import org.springframework.stereotype.Service
|
|
||||||
//import space.luminic.finance.dtos.SpaceDTO
|
|
||||||
//import space.luminic.finance.models.NotFoundException
|
|
||||||
//import space.luminic.finance.models.Space
|
|
||||||
//import space.luminic.finance.models.User
|
|
||||||
//import space.luminic.finance.repos.SpaceRepo
|
|
||||||
//
|
|
||||||
//@Service
|
|
||||||
//class SpaceServiceMongoImpl(
|
|
||||||
// private val authService: AuthService,
|
|
||||||
// private val spaceRepo: SpaceRepo,
|
|
||||||
// private val mongoTemplate: ReactiveMongoTemplate,
|
|
||||||
//) : SpaceService {
|
|
||||||
//
|
|
||||||
// private fun basicAggregation(user: User): List<AggregationOperation> {
|
|
||||||
// val addFieldsAsOJ = addFields()
|
|
||||||
// .addField("createdByOI")
|
|
||||||
// .withValue(ConvertOperators.valueOf("createdById").convertToObjectId())
|
|
||||||
// .addField("updatedByOI")
|
|
||||||
// .withValue(ConvertOperators.valueOf("updatedById").convertToObjectId())
|
|
||||||
// .build()
|
|
||||||
// val lookupCreatedBy = lookup("users", "createdByOI", "_id", "createdBy")
|
|
||||||
// val unwindCreatedBy = unwind("createdBy")
|
|
||||||
//
|
|
||||||
// val lookupUpdatedBy = lookup("users", "updatedByOI", "_id", "updatedBy")
|
|
||||||
// val unwindUpdatedBy = unwind("updatedBy")
|
|
||||||
//
|
|
||||||
//
|
|
||||||
//
|
|
||||||
// val matchCriteria = mutableListOf<Criteria>()
|
|
||||||
// matchCriteria.add(
|
|
||||||
// Criteria().orOperator(
|
|
||||||
// Criteria.where("ownerId").`is`(user.id),
|
|
||||||
// Criteria.where("participantsIds").`is`(user.id)
|
|
||||||
// )
|
|
||||||
// )
|
|
||||||
// matchCriteria.add(Criteria.where("isDeleted").`is`(false))
|
|
||||||
// val matchStage = match(Criteria().andOperator(*matchCriteria.toTypedArray()))
|
|
||||||
//
|
|
||||||
// return listOf(addFieldsAsOJ, lookupCreatedBy, unwindCreatedBy, lookupUpdatedBy, unwindUpdatedBy, matchStage)
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// private fun ownerAndParticipantsLookups(): List<AggregationOperation>{
|
|
||||||
// val addOwnerAsOJ = addFields()
|
|
||||||
// .addField("ownerIdAsObjectId")
|
|
||||||
// .withValue(ConvertOperators.valueOf("ownerId").convertToObjectId())
|
|
||||||
// .addField("participantsIdsAsObjectId")
|
|
||||||
// .withValue(
|
|
||||||
// VariableOperators.Map.itemsOf("participantsIds")
|
|
||||||
// .`as`("id")
|
|
||||||
// .andApply(
|
|
||||||
// ConvertOperators.valueOf("$\$id").convertToObjectId()
|
|
||||||
// )
|
|
||||||
// )
|
|
||||||
// .build()
|
|
||||||
// val lookupOwner = lookup("users", "ownerIdAsObjectId", "_id", "owner")
|
|
||||||
// val unwindOwner = unwind("owner")
|
|
||||||
// val lookupUsers = lookup("users", "participantsIdsAsObjectId", "_id", "participants")
|
|
||||||
// return listOf(addOwnerAsOJ, lookupOwner, unwindOwner, lookupUsers)
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// override suspend fun checkSpace(spaceId: String): Space {
|
|
||||||
// val user = authService.getSecurityUser()
|
|
||||||
// val space = getSpace(spaceId)
|
|
||||||
//
|
|
||||||
// // Проверяем доступ пользователя к пространству
|
|
||||||
// return if (space.participants!!.none { it.id.toString() == user.id }) {
|
|
||||||
// throw IllegalArgumentException("User does not have access to this Space")
|
|
||||||
// } else space
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// override suspend fun getSpaces(): List<Space> {
|
|
||||||
// val user = authService.getSecurityUser()
|
|
||||||
// val basicAggregation = basicAggregation(user)
|
|
||||||
// val ownerAndParticipantsLookup = ownerAndParticipantsLookups()
|
|
||||||
//
|
|
||||||
// val sort = sort(Sort.by(Sort.Direction.DESC, "createdAt"))
|
|
||||||
// val aggregation = newAggregation(
|
|
||||||
// *basicAggregation.toTypedArray(),
|
|
||||||
// *ownerAndParticipantsLookup.toTypedArray(),
|
|
||||||
// sort,
|
|
||||||
// )
|
|
||||||
// return mongoTemplate.aggregate(aggregation, "spaces", Space::class.java).collectList().awaitSingle()
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// override suspend fun getSpace(id: String): Space {
|
|
||||||
// val user = authService.getSecurityUser()
|
|
||||||
// val basicAggregation = basicAggregation(user)
|
|
||||||
// val ownerAndParticipantsLookup = ownerAndParticipantsLookups()
|
|
||||||
//
|
|
||||||
// val aggregation = newAggregation(
|
|
||||||
// *basicAggregation.toTypedArray(),
|
|
||||||
// *ownerAndParticipantsLookup.toTypedArray(),
|
|
||||||
// )
|
|
||||||
// return mongoTemplate.aggregate(aggregation, "spaces", Space::class.java).awaitFirstOrNull()
|
|
||||||
// ?: throw NotFoundException("Space not found")
|
|
||||||
//
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// override suspend fun createSpace(space: SpaceDTO.CreateSpaceDTO): Space {
|
|
||||||
// val owner = authService.getSecurityUser()
|
|
||||||
// val createdSpace = Space(
|
|
||||||
// name = space.name,
|
|
||||||
// ownerId = owner.id!!,
|
|
||||||
//
|
|
||||||
// participantsIds = listOf(owner.id!!),
|
|
||||||
//
|
|
||||||
//
|
|
||||||
// )
|
|
||||||
// createdSpace.owner = owner
|
|
||||||
// createdSpace.participants?.toMutableList()?.add(owner)
|
|
||||||
// val savedSpace = spaceRepo.save(createdSpace).awaitSingle()
|
|
||||||
// return savedSpace
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// override suspend fun updateSpace(spaceId: String, space: SpaceDTO.UpdateSpaceDTO): Space {
|
|
||||||
// val existingSpace = spaceRepo.findById(spaceId).awaitFirstOrNull() ?: throw NotFoundException("Space not found")
|
|
||||||
// val updatedSpace = existingSpace.copy(
|
|
||||||
// name = space.name,
|
|
||||||
// )
|
|
||||||
// updatedSpace.owner = existingSpace.owner
|
|
||||||
// updatedSpace.participants = existingSpace.participants
|
|
||||||
// return spaceRepo.save(updatedSpace).awaitFirst()
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// override suspend fun deleteSpace(spaceId: String) {
|
|
||||||
// val space = spaceRepo.findById(spaceId).awaitFirstOrNull() ?: throw NotFoundException("Space not found")
|
|
||||||
// space.isDeleted = true
|
|
||||||
// spaceRepo.save(space).awaitFirst()
|
|
||||||
// }
|
|
||||||
//}
|
|
||||||
@@ -7,13 +7,26 @@ import java.time.LocalDate
|
|||||||
interface TransactionService {
|
interface TransactionService {
|
||||||
|
|
||||||
data class TransactionsFilter(
|
data class TransactionsFilter(
|
||||||
|
val type: Transaction.TransactionType? = null,
|
||||||
|
val kind: Transaction.TransactionKind? = null,
|
||||||
val dateFrom: LocalDate? = null,
|
val dateFrom: LocalDate? = null,
|
||||||
val dateTo: LocalDate? = null,
|
val dateTo: LocalDate? = null,
|
||||||
|
val isDone: Boolean? = null,
|
||||||
|
val offset: Int = 0,
|
||||||
|
val limit: Int = 10,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun getTransactions(spaceId: Int, filter: TransactionsFilter, sortBy: String, sortDirection: String): List<Transaction>
|
fun getTransactions(
|
||||||
fun getTransaction(spaceId: Int, transactionId: Int): Transaction
|
spaceId: Int,
|
||||||
fun createTransaction(spaceId: Int, transaction: TransactionDTO.CreateTransactionDTO): Int
|
filter: TransactionsFilter,
|
||||||
fun updateTransaction(spaceId: Int, transactionId: Int, transaction: TransactionDTO.UpdateTransactionDTO): Int
|
sortBy: String,
|
||||||
|
sortDirection: String
|
||||||
|
): List<Transaction>
|
||||||
|
|
||||||
|
fun getTransaction(spaceId: Int, transactionId: Int): Transaction
|
||||||
|
fun createTransaction(spaceId: Int, transaction: TransactionDTO.CreateTransactionDTO): Int
|
||||||
|
fun batchCreate(spaceId: Int, transactions: List<TransactionDTO.CreateTransactionDTO>, createdById: Int?)
|
||||||
|
fun updateTransaction(spaceId: Int, transactionId: Int, transaction: TransactionDTO.UpdateTransactionDTO): Int
|
||||||
fun deleteTransaction(spaceId: Int, transactionId: Int)
|
fun deleteTransaction(spaceId: Int, transactionId: Int)
|
||||||
|
fun deleteByRecurrentId(spaceId: Int, recurrentId: Int)
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,17 @@
|
|||||||
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 java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class TransactionServiceImpl(
|
class TransactionServiceImpl(
|
||||||
@@ -12,21 +19,27 @@ class TransactionServiceImpl(
|
|||||||
private val categoryService: CategoryService,
|
private val categoryService: CategoryService,
|
||||||
private val transactionRepo: TransactionRepo,
|
private val transactionRepo: TransactionRepo,
|
||||||
private val authService: AuthService,
|
private val authService: AuthService,
|
||||||
|
private val categorizeService: CategorizeService,
|
||||||
|
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,
|
sortBy: String,
|
||||||
sortDirection: String
|
sortDirection: String
|
||||||
): List<Transaction> {
|
): List<Transaction> {
|
||||||
return transactionRepo.findAllBySpaceId(spaceId)
|
val transactions = transactionRepo.findAllBySpaceId(spaceId, filter)
|
||||||
|
return transactions
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getTransaction(
|
override fun getTransaction(
|
||||||
spaceId: Int,
|
spaceId: Int,
|
||||||
transactionId: Int
|
transactionId: Int
|
||||||
): Transaction {
|
): Transaction {
|
||||||
spaceService.getSpace(spaceId)
|
spaceService.getSpace(spaceId, null)
|
||||||
return transactionRepo.findBySpaceIdAndId(spaceId, transactionId)
|
return transactionRepo.findBySpaceIdAndId(spaceId, transactionId)
|
||||||
?: throw NotFoundException("Transaction with id $transactionId not found")
|
?: throw NotFoundException("Transaction with id $transactionId not found")
|
||||||
}
|
}
|
||||||
@@ -36,8 +49,9 @@ class TransactionServiceImpl(
|
|||||||
transaction: TransactionDTO.CreateTransactionDTO
|
transaction: TransactionDTO.CreateTransactionDTO
|
||||||
): Int {
|
): Int {
|
||||||
val userId = authService.getSecurityUserId()
|
val userId = authService.getSecurityUserId()
|
||||||
val space = spaceService.getSpace(spaceId)
|
val space = spaceService.getSpace(spaceId, null)
|
||||||
val category = categoryService.getCategory(spaceId, transaction.categoryId)
|
|
||||||
|
val category = transaction.categoryId?.let { categoryService.getCategory(spaceId, it) }
|
||||||
val transaction = Transaction(
|
val transaction = Transaction(
|
||||||
space = space,
|
space = space,
|
||||||
type = transaction.type,
|
type = transaction.type,
|
||||||
@@ -47,8 +61,43 @@ class TransactionServiceImpl(
|
|||||||
amount = transaction.amount,
|
amount = transaction.amount,
|
||||||
fees = transaction.fees,
|
fees = transaction.fees,
|
||||||
date = transaction.date,
|
date = transaction.date,
|
||||||
|
recurrentId = transaction.recurrentId,
|
||||||
)
|
)
|
||||||
return transactionRepo.create(transaction, userId)
|
val createdTx = transactionRepo.create(transaction, userId)
|
||||||
|
serviceScope.launch {
|
||||||
|
runCatching {
|
||||||
|
if (space.owner.id != userId) {
|
||||||
|
notificationService.sendTXNotification(TxActionType.CREATE, space, userId, transaction)
|
||||||
|
}
|
||||||
|
}.onFailure {
|
||||||
|
logger.error("Error while creating transaction", it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return createdTx
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun batchCreate(spaceId: Int, transactions: List<TransactionDTO.CreateTransactionDTO>, createdById: Int?) {
|
||||||
|
val userId = createdById ?: authService.getSecurityUserId()
|
||||||
|
val space = spaceService.getSpace(spaceId, userId)
|
||||||
|
val transactionsToCreate = mutableListOf<Transaction>()
|
||||||
|
transactions.forEach { transaction ->
|
||||||
|
val category = transaction.categoryId?.let { categoryService.getCategory(spaceId, it) }
|
||||||
|
transactionsToCreate.add(
|
||||||
|
Transaction(
|
||||||
|
space = space,
|
||||||
|
type = transaction.type,
|
||||||
|
kind = transaction.kind,
|
||||||
|
category = category,
|
||||||
|
comment = transaction.comment,
|
||||||
|
amount = transaction.amount,
|
||||||
|
fees = transaction.fees,
|
||||||
|
date = transaction.date,
|
||||||
|
recurrentId = transaction.recurrentId,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
transactionRepo.createBatch(transactionsToCreate, userId)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateTransaction(
|
override fun updateTransaction(
|
||||||
@@ -56,17 +105,10 @@ class TransactionServiceImpl(
|
|||||||
transactionId: Int,
|
transactionId: Int,
|
||||||
transaction: TransactionDTO.UpdateTransactionDTO
|
transaction: TransactionDTO.UpdateTransactionDTO
|
||||||
): Int {
|
): Int {
|
||||||
val space = spaceService.getSpace(spaceId)
|
val userId = authService.getSecurityUserId()
|
||||||
|
val space = spaceService.getSpace(spaceId, null)
|
||||||
val existingTransaction = getTransaction(space.id!!, transactionId)
|
val existingTransaction = getTransaction(space.id!!, transactionId)
|
||||||
val newCategory = categoryService.getCategory(spaceId, transaction.categoryId)
|
val newCategory = transaction.categoryId?.let { categoryService.getCategory(spaceId, it) }
|
||||||
// val id: Int,
|
|
||||||
// val type: TransactionType = TransactionType.EXPENSE,
|
|
||||||
// val kind: TransactionKind = TransactionKind.INSTANT,
|
|
||||||
// val category: Int,
|
|
||||||
// val comment: String,
|
|
||||||
// val amount: BigDecimal,
|
|
||||||
// val fees: BigDecimal = BigDecimal.ZERO,
|
|
||||||
// val date: Instant
|
|
||||||
val updatedTransaction = Transaction(
|
val updatedTransaction = Transaction(
|
||||||
id = existingTransaction.id,
|
id = existingTransaction.id,
|
||||||
space = existingTransaction.space,
|
space = existingTransaction.space,
|
||||||
@@ -81,16 +123,45 @@ class TransactionServiceImpl(
|
|||||||
isDeleted = existingTransaction.isDeleted,
|
isDeleted = existingTransaction.isDeleted,
|
||||||
isDone = transaction.isDone,
|
isDone = transaction.isDone,
|
||||||
createdBy = existingTransaction.createdBy,
|
createdBy = existingTransaction.createdBy,
|
||||||
createdAt = existingTransaction.createdAt
|
createdAt = existingTransaction.createdAt,
|
||||||
|
tgChatId = existingTransaction.tgChatId,
|
||||||
|
tgMessageId = existingTransaction.tgMessageId,
|
||||||
)
|
)
|
||||||
return transactionRepo.update(updatedTransaction)
|
if ((existingTransaction.category == null && updatedTransaction.category != null) || (existingTransaction.category?.id != updatedTransaction.category?.id)) {
|
||||||
|
categorizeService.notifyThatCategorySelected(updatedTransaction)
|
||||||
|
}
|
||||||
|
val updatedTx = transactionRepo.update(updatedTransaction)
|
||||||
|
serviceScope.launch {
|
||||||
|
runCatching {
|
||||||
|
|
||||||
|
notificationService.sendTXNotification(
|
||||||
|
TxActionType.UPDATE,
|
||||||
|
space,
|
||||||
|
userId,
|
||||||
|
existingTransaction,
|
||||||
|
updatedTransaction
|
||||||
|
)
|
||||||
|
}.onFailure {
|
||||||
|
logger.error("Error while send transaction update notification", it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return updatedTx
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun deleteTransaction(spaceId: Int, transactionId: Int) {
|
override fun deleteTransaction(spaceId: Int, transactionId: Int) {
|
||||||
val space = spaceService.getSpace(spaceId)
|
val userId = authService.getSecurityUserId()
|
||||||
getTransaction(space.id!!, transactionId)
|
val space = spaceService.getSpace(spaceId, null)
|
||||||
|
val tx = 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) {
|
||||||
|
TODO("Not yet implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
//package space.luminic.finance.services
|
|
||||||
//
|
|
||||||
//import kotlinx.coroutines.reactive.awaitFirstOrNull
|
|
||||||
//import kotlinx.coroutines.reactive.awaitSingle
|
|
||||||
//import org.bson.types.ObjectId
|
|
||||||
//import org.springframework.data.domain.Sort
|
|
||||||
//import org.springframework.data.domain.Sort.Direction
|
|
||||||
//import org.springframework.data.mongodb.core.ReactiveMongoTemplate
|
|
||||||
//import org.springframework.data.mongodb.core.aggregation.Aggregation.addFields
|
|
||||||
//import org.springframework.data.mongodb.core.aggregation.Aggregation.lookup
|
|
||||||
//import org.springframework.data.mongodb.core.aggregation.Aggregation.match
|
|
||||||
//import org.springframework.data.mongodb.core.aggregation.Aggregation.newAggregation
|
|
||||||
//import org.springframework.data.mongodb.core.aggregation.Aggregation.sort
|
|
||||||
//import org.springframework.data.mongodb.core.aggregation.Aggregation.unwind
|
|
||||||
//import org.springframework.data.mongodb.core.aggregation.AggregationOperation
|
|
||||||
//import org.springframework.data.mongodb.core.aggregation.ConvertOperators
|
|
||||||
//import org.springframework.data.mongodb.core.query.Criteria
|
|
||||||
//import org.springframework.stereotype.Service
|
|
||||||
//import space.luminic.finance.dtos.TransactionDTO
|
|
||||||
//import space.luminic.finance.models.NotFoundException
|
|
||||||
//import space.luminic.finance.models.Transaction
|
|
||||||
//import space.luminic.finance.repos.TransactionRepo
|
|
||||||
//
|
|
||||||
//@Service
|
|
||||||
//class TransactionServiceMongoImpl(
|
|
||||||
// private val mongoTemplate: ReactiveMongoTemplate,
|
|
||||||
// private val transactionRepo: TransactionRepo,
|
|
||||||
// private val categoryService: CategoryService,
|
|
||||||
//) : TransactionService {
|
|
||||||
//
|
|
||||||
//
|
|
||||||
// private fun basicAggregation(spaceId: String): List<AggregationOperation> {
|
|
||||||
// val addFieldsOI = addFields()
|
|
||||||
// .addField("createdByOI")
|
|
||||||
// .withValue(ConvertOperators.valueOf("createdById").convertToObjectId())
|
|
||||||
// .addField("updatedByOI")
|
|
||||||
// .withValue(ConvertOperators.valueOf("updatedById").convertToObjectId())
|
|
||||||
// .addField("fromAccountIdOI")
|
|
||||||
// .withValue(ConvertOperators.valueOf("fromAccountId").convertToObjectId())
|
|
||||||
// .addField("toAccountIdOI")
|
|
||||||
// .withValue(ConvertOperators.valueOf("toAccountId").convertToObjectId())
|
|
||||||
// .addField("categoryIdOI")
|
|
||||||
// .withValue(ConvertOperators.valueOf("categoryId").convertToObjectId())
|
|
||||||
// .build()
|
|
||||||
//
|
|
||||||
// val lookupFromAccount = lookup("accounts", "fromAccountIdOI", "_id", "fromAccount")
|
|
||||||
// val unwindFromAccount = unwind("fromAccount")
|
|
||||||
// val lookupToAccount = lookup("accounts", "toAccountIdOI", "_id", "toAccount")
|
|
||||||
// val unwindToAccount = unwind("toAccount", true)
|
|
||||||
//
|
|
||||||
// val lookupCategory = lookup("categories", "categoryIdOI", "_id", "category")
|
|
||||||
// val unwindCategory = unwind("category")
|
|
||||||
//
|
|
||||||
// val lookupCreatedBy = lookup("users", "createdByOI", "_id", "createdBy")
|
|
||||||
// val unwindCreatedBy = unwind("createdBy")
|
|
||||||
//
|
|
||||||
// val lookupUpdatedBy = lookup("users", "updatedByOI", "_id", "updatedBy")
|
|
||||||
// val unwindUpdatedBy = unwind("updatedBy")
|
|
||||||
// val matchCriteria = mutableListOf<Criteria>()
|
|
||||||
// matchCriteria.add(Criteria.where("spaceId").`is`(spaceId))
|
|
||||||
// val matchStage = match(Criteria().andOperator(*matchCriteria.toTypedArray()))
|
|
||||||
//
|
|
||||||
// return listOf(
|
|
||||||
// matchStage,
|
|
||||||
// addFieldsOI,
|
|
||||||
// lookupFromAccount,
|
|
||||||
// unwindFromAccount,
|
|
||||||
// lookupToAccount,
|
|
||||||
// unwindToAccount,
|
|
||||||
// lookupCategory,
|
|
||||||
// unwindCategory,
|
|
||||||
// lookupCreatedBy,
|
|
||||||
// unwindCreatedBy,
|
|
||||||
// lookupUpdatedBy,
|
|
||||||
// unwindUpdatedBy
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// override suspend fun getTransactions(
|
|
||||||
// spaceId: String,
|
|
||||||
// filter: TransactionService.TransactionsFilter,
|
|
||||||
// sortBy: String,
|
|
||||||
// sortDirection: String
|
|
||||||
// ): List<Transaction> {
|
|
||||||
// val allowedSortFields = setOf("date", "amount", "category.name", "createdAt")
|
|
||||||
// require(sortBy in allowedSortFields) { "Invalid sort field: $sortBy" }
|
|
||||||
//
|
|
||||||
// val direction = when (sortDirection.uppercase()) {
|
|
||||||
// "ASC" -> Direction.ASC
|
|
||||||
// "DESC" -> Direction.DESC
|
|
||||||
// else -> throw IllegalArgumentException("Sort direction must be 'ASC' or 'DESC'")
|
|
||||||
// }
|
|
||||||
// val basicAggregation = basicAggregation(spaceId)
|
|
||||||
//
|
|
||||||
// val sort = sort(Sort.by(direction, sortBy))
|
|
||||||
// val matchCriteria = mutableListOf<Criteria>()
|
|
||||||
// filter.dateFrom?.let { matchCriteria.add(Criteria.where("date").gte(it)) }
|
|
||||||
// filter.dateTo?.let { matchCriteria.add(Criteria.where("date").lte(it)) }
|
|
||||||
// val matchStage = match(Criteria().andOperator(*matchCriteria.toTypedArray()))
|
|
||||||
// val aggregation =
|
|
||||||
// newAggregation(
|
|
||||||
// matchStage,
|
|
||||||
// *basicAggregation.toTypedArray(),
|
|
||||||
// sort
|
|
||||||
// )
|
|
||||||
//
|
|
||||||
// return mongoTemplate.aggregate(aggregation, "transactions", Transaction::class.java)
|
|
||||||
// .collectList()
|
|
||||||
// .awaitSingle()
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// override suspend fun getTransaction(
|
|
||||||
// spaceId: String,
|
|
||||||
// transactionId: String
|
|
||||||
// ): Transaction {
|
|
||||||
// val matchCriteria = mutableListOf<Criteria>()
|
|
||||||
// matchCriteria.add(Criteria.where("spaceId").`is`(spaceId))
|
|
||||||
// matchCriteria.add(Criteria.where("_id").`is`(ObjectId(transactionId)))
|
|
||||||
// val matchStage = match(Criteria().andOperator(*matchCriteria.toTypedArray()))
|
|
||||||
//
|
|
||||||
// val aggregation =
|
|
||||||
// newAggregation(
|
|
||||||
// matchStage,
|
|
||||||
// )
|
|
||||||
// return mongoTemplate.aggregate(aggregation, "transactions", Transaction::class.java)
|
|
||||||
// .awaitFirstOrNull() ?: throw NotFoundException("Transaction with ID $transactionId not found")
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// override suspend fun createTransaction(
|
|
||||||
// spaceId: String,
|
|
||||||
// transaction: TransactionDTO.CreateTransactionDTO
|
|
||||||
// ): Transaction {
|
|
||||||
// if (transaction.type == Transaction.TransactionType.TRANSFER && transaction.toAccountId == null) {
|
|
||||||
// throw IllegalArgumentException("Cannot create a transaction with type TRANSFER without a toAccountId")
|
|
||||||
// }
|
|
||||||
// val category = categoryService.getCategory(spaceId, transaction.categoryId)
|
|
||||||
// if (transaction.type != Transaction.TransactionType.TRANSFER && transaction.type.name != category.type.name) {
|
|
||||||
// throw IllegalArgumentException("Transaction type should match with category type")
|
|
||||||
// }
|
|
||||||
// val transaction = Transaction(
|
|
||||||
// spaceId = spaceId,
|
|
||||||
// type = transaction.type,
|
|
||||||
// kind = transaction.kind,
|
|
||||||
// categoryId = transaction.categoryId,
|
|
||||||
// comment = transaction.comment,
|
|
||||||
// amount = transaction.amount,
|
|
||||||
// fees = transaction.fees,
|
|
||||||
// date = transaction.date,
|
|
||||||
// fromAccountId = transaction.fromAccountId,
|
|
||||||
// toAccountId = transaction.toAccountId,
|
|
||||||
// )
|
|
||||||
// return transactionRepo.save(transaction).awaitSingle()
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// override suspend fun updateTransaction(
|
|
||||||
// spaceId: String,
|
|
||||||
// transaction: TransactionDTO.UpdateTransactionDTO
|
|
||||||
// ): Transaction {
|
|
||||||
// if (transaction.type == Transaction.TransactionType.TRANSFER && transaction.toAccountId == null) {
|
|
||||||
// throw IllegalArgumentException("Cannot edit a transaction with type TRANSFER without a toAccountId")
|
|
||||||
// }
|
|
||||||
// val exitingTx = getTransaction(spaceId, transaction.id)
|
|
||||||
// val transaction = exitingTx.copy(
|
|
||||||
// spaceId = exitingTx.spaceId,
|
|
||||||
// type = transaction.type,
|
|
||||||
// kind = transaction.kind,
|
|
||||||
// categoryId = transaction.category,
|
|
||||||
// comment = transaction.comment,
|
|
||||||
// amount = transaction.amount,
|
|
||||||
// fees = transaction.fees,
|
|
||||||
// date = transaction.date,
|
|
||||||
// fromAccountId = transaction.fromAccountId,
|
|
||||||
// toAccountId = transaction.toAccountId,
|
|
||||||
// )
|
|
||||||
// return transactionRepo.save(transaction).awaitSingle()
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// override suspend fun deleteTransaction(spaceId: String, transactionId: String) {
|
|
||||||
// val transaction = getTransaction(spaceId, transactionId)
|
|
||||||
// transaction.isDeleted = true
|
|
||||||
// transactionRepo.save(transaction).awaitSingle()
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
//
|
|
||||||
//}
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package space.luminic.finance.services.gpt
|
||||||
|
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class CategorizeBotScheduler(
|
||||||
|
private val seeder: CategoryJobSeeder,
|
||||||
|
private val picker: CategoryJobRepo,
|
||||||
|
private val service: CategorizeService
|
||||||
|
) {
|
||||||
|
|
||||||
|
|
||||||
|
@Scheduled(cron = "* * * * * *")
|
||||||
|
fun work() {
|
||||||
|
val jobs = picker.pickBatch(limit = 50)
|
||||||
|
if (jobs.isEmpty()) return
|
||||||
|
service.processBatch(jobs)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(cron = "* * * * * *")
|
||||||
|
fun createJob(){
|
||||||
|
seeder.seedMissing()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package space.luminic.finance.services.gpt
|
||||||
|
|
||||||
|
import com.github.kotlintelegrambot.Bot
|
||||||
|
import com.github.kotlintelegrambot.entities.ChatId
|
||||||
|
import com.github.kotlintelegrambot.entities.InlineKeyboardMarkup
|
||||||
|
import com.github.kotlintelegrambot.entities.Message
|
||||||
|
import com.github.kotlintelegrambot.entities.ParseMode
|
||||||
|
import com.github.kotlintelegrambot.entities.keyboard.InlineKeyboardButton
|
||||||
|
import com.github.kotlintelegrambot.entities.keyboard.WebAppInfo
|
||||||
|
import com.github.kotlintelegrambot.entities.reaction.ReactionType
|
||||||
|
import com.github.kotlintelegrambot.types.TelegramBotResult
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import space.luminic.finance.models.Transaction
|
||||||
|
import space.luminic.finance.repos.CategoryRepo
|
||||||
|
import space.luminic.finance.repos.TransactionRepo
|
||||||
|
|
||||||
|
enum class JobStatus { NEW, PROCESSING, DONE, FAILED }
|
||||||
|
|
||||||
|
data class CategoryResult(val categoryId: Int)
|
||||||
|
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class CategorizeService(
|
||||||
|
private val transactionRepo: TransactionRepo,
|
||||||
|
@Qualifier("dsCategorizationService") private val gpt: GptClient,
|
||||||
|
@Value("\${app.categorize.parallel:4}") private val parallel: Int,
|
||||||
|
private val categoriesRepo: CategoryRepo,
|
||||||
|
private val categoryJobRepo: CategoryJobRepo,
|
||||||
|
private val bot: Bot
|
||||||
|
) {
|
||||||
|
private val exec = java.util.concurrent.Executors.newFixedThreadPool(parallel)
|
||||||
|
|
||||||
|
fun processBatch(jobs: List<CategoryJob>) {
|
||||||
|
jobs.forEach { job ->
|
||||||
|
exec.submit {
|
||||||
|
runCatching {
|
||||||
|
val tx = transactionRepo.findBySpaceIdAndId(job.spaceId, job.txId)
|
||||||
|
?: throw IllegalArgumentException("Transaction ${job.txId} not found")
|
||||||
|
val res = gpt.suggestCategory(
|
||||||
|
tx,
|
||||||
|
categoriesRepo.findBySpaceId(job.spaceId)
|
||||||
|
) // тут твой вызов GPT
|
||||||
|
var message: TelegramBotResult<Message>? = null
|
||||||
|
|
||||||
|
if (res.categoryId == 0) {
|
||||||
|
if (tx.tgChatId != null && tx.tgMessageId != null) {
|
||||||
|
bot.setMessageReaction(
|
||||||
|
ChatId.fromId(tx.tgChatId),
|
||||||
|
tx.tgMessageId,
|
||||||
|
listOf(ReactionType.Emoji("💔")),
|
||||||
|
isBig = false
|
||||||
|
)
|
||||||
|
message = bot.sendMessage(
|
||||||
|
ChatId.fromId(tx.tgChatId),
|
||||||
|
replyToMessageId = tx.tgMessageId,
|
||||||
|
text = "К сожалению, мы не смогли распознать категорию.\n\nПопробуйте выставить ее самостоятельно.",
|
||||||
|
replyMarkup = InlineKeyboardMarkup.create(
|
||||||
|
listOf(
|
||||||
|
InlineKeyboardButton.WebApp(
|
||||||
|
"Открыть в WebApp",
|
||||||
|
WebAppInfo("https://app.luminic.space/transactions/${tx.id}/edit")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
transactionRepo.setCategory(job.txId, res.categoryId)
|
||||||
|
if (tx.tgChatId != null && tx.tgMessageId != null) {
|
||||||
|
bot.setMessageReaction(
|
||||||
|
ChatId.fromId(tx.tgChatId),
|
||||||
|
tx.tgMessageId,
|
||||||
|
listOf(ReactionType.Emoji("👌")),
|
||||||
|
isBig = false
|
||||||
|
)
|
||||||
|
val category = categoriesRepo.findBySpaceIdAndId(job.spaceId, res.categoryId)
|
||||||
|
if (category != null) {
|
||||||
|
message = bot.sendMessage(
|
||||||
|
ChatId.fromId(tx.tgChatId),
|
||||||
|
replyToMessageId = tx.tgMessageId,
|
||||||
|
text = "Определили категорию: <b>${category.name}</b>.\n\nЕсли это не так, исправьте это в WebApp.",
|
||||||
|
replyMarkup = InlineKeyboardMarkup.create(
|
||||||
|
listOf(
|
||||||
|
InlineKeyboardButton.WebApp(
|
||||||
|
"Открыть в WebApp",
|
||||||
|
WebAppInfo("https://app.luminic.space/transactions/${tx.id}/edit")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
parseMode = ParseMode.HTML
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (message != null) {
|
||||||
|
categoryJobRepo.successJob(
|
||||||
|
job.id,
|
||||||
|
message.get().chat.id,
|
||||||
|
message.get().messageId
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
categoryJobRepo.successJob(job.id)
|
||||||
|
}
|
||||||
|
}.onFailure { e ->
|
||||||
|
print(e.localizedMessage)
|
||||||
|
categoryJobRepo.failJob(job.id, e.localizedMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun notifyThatCategorySelected(tx: Transaction) {
|
||||||
|
val job = categoryJobRepo.getJobByTxId(tx.id!!)
|
||||||
|
|
||||||
|
job?.let {
|
||||||
|
if (tx.tgChatId != null && tx.tgMessageId != null) {
|
||||||
|
bot.setMessageReaction(
|
||||||
|
ChatId.fromId(tx.tgChatId),
|
||||||
|
tx.tgMessageId,
|
||||||
|
listOf(ReactionType.Emoji("👌"))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (it.chatId != null && it.messageId != null) {
|
||||||
|
bot.editMessageText(
|
||||||
|
ChatId.fromId(it.chatId),
|
||||||
|
messageId = it.messageId,
|
||||||
|
text = "Выбрана: <b>${tx.category!!.name}</b>.\n\nЕсли это не так, измените это в WebApp.",
|
||||||
|
replyMarkup = InlineKeyboardMarkup.create(
|
||||||
|
listOf(
|
||||||
|
InlineKeyboardButton.WebApp(
|
||||||
|
"Открыть в WebApp",
|
||||||
|
WebAppInfo("https://app.luminic.space/transactions/${tx.id}/edit")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
parseMode = ParseMode.HTML
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package space.luminic.finance.services.gpt
|
||||||
|
|
||||||
|
import com.github.kotlintelegrambot.Bot
|
||||||
|
import com.github.kotlintelegrambot.entities.ChatId
|
||||||
|
import com.github.kotlintelegrambot.entities.reaction.ReactionType
|
||||||
|
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
|
||||||
|
data class CategoryJob(
|
||||||
|
val id: Long,
|
||||||
|
val spaceId: Int,
|
||||||
|
val txId: Int,
|
||||||
|
val attempts: Int,
|
||||||
|
val chatId: Long? = null,
|
||||||
|
val messageId: Long? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
class CategoryJobRepo(
|
||||||
|
private val np: NamedParameterJdbcTemplate,
|
||||||
|
private val bot: Bot
|
||||||
|
) {
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun pickBatch(limit: Int = 50): List<CategoryJob> {
|
||||||
|
val selectSql = """
|
||||||
|
SELECT cj.id as cj_id, cj.tx_id as cj_tx_id, cj.attempts as cj_attempts, s.id as s_id
|
||||||
|
FROM finance.category_jobs cj
|
||||||
|
JOIN finance.transactions t on cj.tx_id = t.id
|
||||||
|
JOIN finance.spaces s on t.space_id = s.id
|
||||||
|
WHERE status IN ('NEW', 'FAILED')
|
||||||
|
ORDER BY cj.created_at
|
||||||
|
FOR UPDATE SKIP LOCKED
|
||||||
|
LIMIT :limit
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val jobs = np.query(selectSql, mapOf("limit" to limit)) { rs, _ ->
|
||||||
|
CategoryJob(
|
||||||
|
id = rs.getLong("cj_id"),
|
||||||
|
spaceId = rs.getInt("s_id"),
|
||||||
|
txId = rs.getInt("cj_tx_id"),
|
||||||
|
attempts = rs.getInt("cj_attempts")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jobs.isNotEmpty()) {
|
||||||
|
val updateSql = """
|
||||||
|
UPDATE finance.category_jobs
|
||||||
|
SET status = 'PROCESSING',
|
||||||
|
attempts = attempts + 1,
|
||||||
|
started_at = NOW()
|
||||||
|
WHERE id IN (:ids)
|
||||||
|
""".trimIndent()
|
||||||
|
np.update(updateSql, mapOf("ids" to jobs.map { it.id }))
|
||||||
|
}
|
||||||
|
return jobs
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun successJob(id: Long, chatId: Long? = null, messageId: Long? = null) {
|
||||||
|
val sql =
|
||||||
|
"""UPDATE finance.category_jobs SET status = 'DONE', finished_at = now(), tg_chat_id = :chatId, tg_message_id = :messageId WHERE id = :id """.trimIndent()
|
||||||
|
np.update(sql, mapOf("id" to id, "chatId" to chatId, "messageId" to messageId))
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun failJob(id: Long, errorMessage: String?) {
|
||||||
|
val sql =
|
||||||
|
"""UPDATE finance.category_jobs SET status = 'FAILED', last_error = :message WHERE id = :id """.trimIndent()
|
||||||
|
np.update(sql, mapOf("id" to id, "errorMessage" to errorMessage))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun getJobByTxId(txId: Int): CategoryJob? {
|
||||||
|
val selectSql = """
|
||||||
|
SELECT cj.id as cj_id, cj.tx_id as cj_tx_id, cj.attempts as cj_attempts, s.id as s_id, cj.tg_chat_id as cj_chat_id, cj.tg_message_id as cj_message_id
|
||||||
|
FROM finance.category_jobs cj
|
||||||
|
JOIN finance.transactions t on cj.tx_id = t.id
|
||||||
|
JOIN finance.spaces s on t.space_id = s.id
|
||||||
|
WHERE cj.tx_id = :txId
|
||||||
|
""".trimIndent()
|
||||||
|
val jobs = np.query(selectSql, mapOf("txId" to txId), { rs, _ ->
|
||||||
|
CategoryJob(
|
||||||
|
id = rs.getLong("cj_id"),
|
||||||
|
spaceId = rs.getInt("s_id"),
|
||||||
|
txId = rs.getInt("cj_tx_id"),
|
||||||
|
attempts = rs.getInt("cj_attempts"),
|
||||||
|
chatId = rs.getLong("cj_chat_id"),
|
||||||
|
messageId = rs.getLong("cj_message_id")
|
||||||
|
)
|
||||||
|
})
|
||||||
|
return jobs.firstOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package space.luminic.finance.services.gpt
|
||||||
|
|
||||||
|
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
class CategoryJobSeeder(private val np: NamedParameterJdbcTemplate) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создаёт задачи для всех транзакций без категории.
|
||||||
|
* Ограничь лимит, чтобы не захлестнуть очередь.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
fun seedMissing(limit: Int = 1000) : Int {
|
||||||
|
val sql = """
|
||||||
|
INSERT INTO finance.category_jobs (tx_id)
|
||||||
|
SELECT t.id
|
||||||
|
FROM finance.transactions t
|
||||||
|
WHERE t.category_id IS NULL
|
||||||
|
AND t.is_deleted = false
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM finance.category_jobs j WHERE j.tx_id = t.id
|
||||||
|
)
|
||||||
|
ORDER BY t.date DESC
|
||||||
|
LIMIT :limit
|
||||||
|
ON CONFLICT (tx_id) DO NOTHING
|
||||||
|
""".trimIndent()
|
||||||
|
return np.update(sql, mapOf("limit" to limit))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package space.luminic.finance.services.gpt
|
||||||
|
|
||||||
|
|
||||||
|
import okhttp3.*
|
||||||
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
|
import com.fasterxml.jackson.module.kotlin.readValue
|
||||||
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import space.luminic.finance.models.Category
|
||||||
|
import space.luminic.finance.models.Transaction
|
||||||
|
|
||||||
|
|
||||||
|
@Service("dsCategorizationService")
|
||||||
|
class DeepSeekCategorizationService(
|
||||||
|
@Value("\${ds.api_key}") private val apiKey: String,
|
||||||
|
) : GptClient {
|
||||||
|
|
||||||
|
private val endpoint = "https://api.deepseek.com/v1"
|
||||||
|
private val mapper = jacksonObjectMapper()
|
||||||
|
private val client = OkHttpClient()
|
||||||
|
private val logger = LoggerFactory.getLogger(javaClass)
|
||||||
|
|
||||||
|
override fun suggestCategory(tx: Transaction, categories: List<Category>): CategorySuggestion {
|
||||||
|
val catList = categories.joinToString("\n") { "- ${it.id}: ${it.name}" }
|
||||||
|
val txInfo = """
|
||||||
|
{ \"amount\": ${tx.amount}, \"comment\": \"${tx.comment}\", \"date\":${tx.date}\" }
|
||||||
|
""".trimIndent()
|
||||||
|
val prompt = """
|
||||||
|
Пользователь имеет следующие категории:
|
||||||
|
$catList
|
||||||
|
|
||||||
|
Задача:
|
||||||
|
1. Определите наиболее подходящую категорию из списка выше для транзакции пользователя.
|
||||||
|
2. Верните ответ в формате: ID категории", например 3.
|
||||||
|
3. Если ни одна категория из списка не подходит, верните: 0.
|
||||||
|
|
||||||
|
Ответ должен быть кратким, одной строкой, без дополнительных пояснений.
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val body = mapOf(
|
||||||
|
"model" to "deepseek-chat",
|
||||||
|
"messages" to listOf(
|
||||||
|
mapOf("role" to "assistant", "content" to prompt),
|
||||||
|
mapOf("role" to "user", "content" to txInfo)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val jsonBody = mapper.writeValueAsString(body)
|
||||||
|
val requestBody = jsonBody.toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull())
|
||||||
|
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("${endpoint}/chat/completions")
|
||||||
|
.addHeader("Authorization", "Bearer $apiKey")
|
||||||
|
.post(requestBody)
|
||||||
|
.build()
|
||||||
|
println(request)
|
||||||
|
logger.info(request.toString())
|
||||||
|
client.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) error("Qwen error: ${response.code} ${response.body?.string()}")
|
||||||
|
|
||||||
|
val bodyStr = response.body?.string().orEmpty()
|
||||||
|
|
||||||
|
// Берём content из choices[0].message.content
|
||||||
|
val root = mapper.readTree(bodyStr)
|
||||||
|
val text = root["choices"]?.get(0)?.get("message")?.get("content")?.asText()
|
||||||
|
?: error("No choices[0].message.content in response")
|
||||||
|
|
||||||
|
// Парсим "ID – Название (вероятность)"
|
||||||
|
// val regex = Regex("""^\s*(\d+)\s*[–-]\s*(.+?)\s*\((0(?:\.\d+)?|1(?:\.0)?)\)\s*$""")
|
||||||
|
// val match = regex.find(text.trim()) ?: error("Bad format: '$text'")
|
||||||
|
|
||||||
|
// val (idStr, name, confStr) = match.destructured
|
||||||
|
val idStr = text
|
||||||
|
return CategorySuggestion(idStr.toInt(), )
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package space.luminic.finance.services.gpt
|
||||||
|
|
||||||
|
import space.luminic.finance.models.Category
|
||||||
|
import space.luminic.finance.models.Transaction
|
||||||
|
|
||||||
|
data class CategorySuggestion(val categoryId: Int, val categoryName: String? = null, val confidence: Double? = null)
|
||||||
|
|
||||||
|
interface GptClient {
|
||||||
|
fun suggestCategory(tx: Transaction, categories: List<Category>): CategorySuggestion
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package space.luminic.finance.services.gpt
|
||||||
|
|
||||||
|
|
||||||
|
import okhttp3.*
|
||||||
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
|
import com.fasterxml.jackson.module.kotlin.readValue
|
||||||
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import space.luminic.finance.models.Category
|
||||||
|
import space.luminic.finance.models.Transaction
|
||||||
|
|
||||||
|
@Service("qwenCategorizationService")
|
||||||
|
class QwenCategorizationService(
|
||||||
|
@Value("\${qwen.api_key}") private val apiKey: String,
|
||||||
|
) : GptClient {
|
||||||
|
|
||||||
|
private val endpoint = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
|
||||||
|
private val mapper = jacksonObjectMapper()
|
||||||
|
private val client = OkHttpClient()
|
||||||
|
private val logger = LoggerFactory.getLogger(javaClass)
|
||||||
|
|
||||||
|
override fun suggestCategory(tx: Transaction, categories: List<Category>): CategorySuggestion {
|
||||||
|
val catList = categories.joinToString("\n") { "- ${it.id}: ${it.name}" }
|
||||||
|
val txInfo = """
|
||||||
|
{ \"amount\": ${tx.amount}, \"comment\": \"${tx.comment}\", \"date\":${tx.date}\" }
|
||||||
|
""".trimIndent()
|
||||||
|
val prompt = """
|
||||||
|
Пользователь имеет следующие категории:
|
||||||
|
$catList
|
||||||
|
|
||||||
|
Задача:
|
||||||
|
1. Определите наиболее подходящую категорию из списка выше для транзакции пользователя.
|
||||||
|
2. Верните ответ в формате: "ID категории – имя категории (вероятность)", например "3 – Продукты (0.87)".
|
||||||
|
3. Если ни одна категория из списка не подходит, верните: "0 – Другое (вероятность)".
|
||||||
|
|
||||||
|
Ответ должен быть кратким, одной строкой, без дополнительных пояснений.
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val body = mapOf(
|
||||||
|
"model" to "qwen-plus",
|
||||||
|
"messages" to listOf(
|
||||||
|
mapOf("role" to "assistant", "content" to prompt),
|
||||||
|
mapOf("role" to "user", "content" to txInfo)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val jsonBody = mapper.writeValueAsString(body)
|
||||||
|
val requestBody = jsonBody.toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull())
|
||||||
|
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("${endpoint}/chat/completions")
|
||||||
|
.addHeader("Authorization", "Bearer $apiKey")
|
||||||
|
.post(requestBody)
|
||||||
|
.build()
|
||||||
|
println(request)
|
||||||
|
logger.info(request.toString())
|
||||||
|
client.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) error("Qwen error: ${response.code} ${response.body?.string()}")
|
||||||
|
|
||||||
|
val bodyStr = response.body?.string().orEmpty()
|
||||||
|
|
||||||
|
// Берём content из choices[0].message.content
|
||||||
|
val root = mapper.readTree(bodyStr)
|
||||||
|
val text = root["choices"]?.get(0)?.get("message")?.get("content")?.asText()
|
||||||
|
?: error("No choices[0].message.content in response")
|
||||||
|
|
||||||
|
// Парсим "ID – Название (вероятность)"
|
||||||
|
val regex = Regex("""^\s*(\d+)\s*[–-]\s*(.+?)\s*\((0(?:\.\d+)?|1(?:\.0)?)\)\s*$""")
|
||||||
|
val match = regex.find(text.trim()) ?: error("Bad format: '$text'")
|
||||||
|
|
||||||
|
val (idStr, name, confStr) = match.destructured
|
||||||
|
return CategorySuggestion(idStr.toInt(), name, confStr.toDouble())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
package space.luminic.finance.services.telegram
|
||||||
|
|
||||||
|
import com.github.kotlintelegrambot.Bot
|
||||||
|
import com.github.kotlintelegrambot.dispatch
|
||||||
|
import com.github.kotlintelegrambot.dispatcher.callbackQuery
|
||||||
|
import com.github.kotlintelegrambot.dispatcher.command
|
||||||
|
import com.github.kotlintelegrambot.dispatcher.message
|
||||||
|
import com.github.kotlintelegrambot.entities.ChatId
|
||||||
|
import com.github.kotlintelegrambot.entities.InlineKeyboardMarkup
|
||||||
|
import com.github.kotlintelegrambot.entities.ParseMode
|
||||||
|
import com.github.kotlintelegrambot.entities.keyboard.InlineKeyboardButton
|
||||||
|
import com.github.kotlintelegrambot.entities.keyboard.WebAppInfo
|
||||||
|
import com.github.kotlintelegrambot.entities.reaction.ReactionType
|
||||||
|
import com.github.kotlintelegrambot.extensions.filters.Filter
|
||||||
|
import com.github.kotlintelegrambot.logging.LogLevel
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.context.annotation.Bean
|
||||||
|
import org.springframework.context.annotation.Lazy
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import space.luminic.finance.dtos.TransactionDTO
|
||||||
|
import space.luminic.finance.models.NotFoundException
|
||||||
|
import space.luminic.finance.models.State
|
||||||
|
import space.luminic.finance.models.Transaction
|
||||||
|
import space.luminic.finance.models.User
|
||||||
|
import space.luminic.finance.repos.BotRepo
|
||||||
|
import space.luminic.finance.services.UserService
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class BotService(
|
||||||
|
@Value("\${telegram.bot.token}") private val botToken: String,
|
||||||
|
@Value("\${spring.profiles.active}") private val profile: String,
|
||||||
|
private val userService: UserService,
|
||||||
|
@Qualifier("spaceServiceTelegram") private val spaceService: SpaceService,
|
||||||
|
private val botRepo: BotRepo,
|
||||||
|
@Lazy @Qualifier("transactionsServiceTelegram") private val transactionService: TransactionService
|
||||||
|
) {
|
||||||
|
|
||||||
|
|
||||||
|
private fun buildSpaceSelector(userId: Int): InlineKeyboardMarkup {
|
||||||
|
val spaces = spaceService.getSpaces(userId)
|
||||||
|
|
||||||
|
val keyboard = mutableListOf<List<InlineKeyboardButton>>()
|
||||||
|
val row = mutableListOf<InlineKeyboardButton>()
|
||||||
|
if (spaces.isNotEmpty()) {
|
||||||
|
for ((index, space) in spaces.withIndex()) {
|
||||||
|
val button =
|
||||||
|
InlineKeyboardButton.CallbackData(text = space.name, callbackData = "select_space_${space.id}")
|
||||||
|
|
||||||
|
row.add(button)
|
||||||
|
|
||||||
|
// Если 2 кнопки в строке — отправляем строку и очищаем
|
||||||
|
if (row.size == 2) {
|
||||||
|
keyboard.add(ArrayList(row))
|
||||||
|
row.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если осталась 1 кнопка — добавляем последнюю строку
|
||||||
|
if (row.isNotEmpty()) {
|
||||||
|
keyboard.add(ArrayList(row))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
row.add(InlineKeyboardButton.CallbackData("Создать пространство!", callbackData = "create_space"))
|
||||||
|
keyboard.add(ArrayList(row))
|
||||||
|
}
|
||||||
|
|
||||||
|
return InlineKeyboardMarkup.Companion.create(keyboard)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun selectSpace(tgUserId: Long, selectedSpaceId: Int) {
|
||||||
|
val user = userService.getUserByTelegramId(tgUserId)
|
||||||
|
botRepo.setState(
|
||||||
|
user.id!!, State.StateCode.SPACE_SELECTED, mapOf(
|
||||||
|
"selected_space" to selectedSpaceId.toString(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildRegister() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildMenu(tgUserId: Long): InlineKeyboardMarkup {
|
||||||
|
val user = userService.getUserByTelegramId(tgUserId)
|
||||||
|
val userId = requireNotNull(user.id) { "User must have id" }
|
||||||
|
|
||||||
|
val state = botRepo.getState(tgUserId)
|
||||||
|
|
||||||
|
val spaceId = state?.data?.get("selected_space")?.toIntOrNull()
|
||||||
|
val space = spaceId?.let { id -> spaceService.getSpace(spaceId, userId) }
|
||||||
|
|
||||||
|
val keyboard = mutableListOf<List<InlineKeyboardButton>>()
|
||||||
|
|
||||||
|
// Кнопка с названием выбранного space (или плейсхолдером)
|
||||||
|
keyboard.add(
|
||||||
|
listOf(
|
||||||
|
InlineKeyboardButton.CallbackData(
|
||||||
|
text = space?.name ?: "Select space",
|
||||||
|
"select_space"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Если нужен второй баттон — сделай другой смысл/текст; иначе этот блок можно убрать
|
||||||
|
keyboard.add(
|
||||||
|
listOf(
|
||||||
|
InlineKeyboardButton.WebApp(
|
||||||
|
text = "Открыть WebApp",
|
||||||
|
webApp = WebAppInfo(url = "https://app.luminic.space")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return InlineKeyboardMarkup.create(keyboard)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun bot(): Bot {
|
||||||
|
val bot = com.github.kotlintelegrambot.bot {
|
||||||
|
logLevel = if (profile == "proc") LogLevel.None else LogLevel.All()
|
||||||
|
token = botToken
|
||||||
|
dispatch {
|
||||||
|
message(Filter.Text) {
|
||||||
|
val fromId = message.from?.id ?: throw IllegalArgumentException("user is empty")
|
||||||
|
val user = userService.getUserByTelegramId(fromId)
|
||||||
|
val state = botRepo.getState(message.from?.id ?: throw IllegalArgumentException("user is empty"))
|
||||||
|
when (state?.state) {
|
||||||
|
State.StateCode.SPACE_SELECTED -> {
|
||||||
|
try {
|
||||||
|
val parts = message.text!!.trim().split(" ", limit = 2)
|
||||||
|
if (parts.isEmpty()) {
|
||||||
|
bot.sendMessage(
|
||||||
|
chatId = ChatId.fromId(message.chat.id),
|
||||||
|
text = "Введите сумму и комментарий, например: `250 обед`",
|
||||||
|
parseMode = ParseMode.MARKDOWN
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
val amount = parts[0].toIntOrNull()
|
||||||
|
?: throw IllegalArgumentException("Сумма транзакции не число!")
|
||||||
|
if (amount <= 0) {
|
||||||
|
throw IllegalArgumentException("Сумма не может быть меньше 1.")
|
||||||
|
}
|
||||||
|
val comment = parts.getOrNull(1)?.trim().orEmpty()
|
||||||
|
if (comment.isEmpty()) throw IllegalArgumentException("Комментарий не может быть пустым.")
|
||||||
|
|
||||||
|
|
||||||
|
// bot.sendMessage(
|
||||||
|
// chatId = ChatId.fromId(message.chat.id),
|
||||||
|
// text = "Принято: сумма = $amount, комментарий = \"$comment\""
|
||||||
|
// )
|
||||||
|
|
||||||
|
try {
|
||||||
|
transactionService.createTransaction(
|
||||||
|
state.data["selected_space"]?.toInt()
|
||||||
|
?: throw IllegalArgumentException("selected space is empty"),
|
||||||
|
user.id!!,
|
||||||
|
TransactionDTO.CreateTransactionDTO(
|
||||||
|
Transaction.TransactionType.EXPENSE,
|
||||||
|
Transaction.TransactionKind.INSTANT,
|
||||||
|
comment = comment,
|
||||||
|
amount = amount.toBigDecimal(),
|
||||||
|
date = LocalDate.now(),
|
||||||
|
),
|
||||||
|
message.chat.id,
|
||||||
|
message.messageId
|
||||||
|
)
|
||||||
|
bot.setMessageReaction(
|
||||||
|
chatId = ChatId.fromId(message.chat.id),
|
||||||
|
messageId = message.messageId,
|
||||||
|
reaction = listOf(ReactionType.Emoji("🤝")),
|
||||||
|
isBig = false
|
||||||
|
)
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
bot.sendMessage(
|
||||||
|
ChatId.Companion.fromId(message.chat.id),
|
||||||
|
text = "Кажется у вас не выбран Space",
|
||||||
|
replyMarkup = buildSpaceSelector(user.id!!)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
bot.sendMessage(
|
||||||
|
chatId = ChatId.Companion.fromId(message.chat.id),
|
||||||
|
text = "Ошибка: ${e.message}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
callbackQuery {
|
||||||
|
if (callbackQuery.data.startsWith("select_space_")) {
|
||||||
|
val spaceId = callbackQuery.data.substringAfter("select_space_").toInt()
|
||||||
|
println(spaceId)
|
||||||
|
try {
|
||||||
|
selectSpace(callbackQuery.from.id, spaceId)
|
||||||
|
bot.editMessageText(
|
||||||
|
chatId = ChatId.Companion.fromId(callbackQuery.message!!.chat.id),
|
||||||
|
messageId = callbackQuery.message!!.messageId,
|
||||||
|
text = "Успешно!\n\nМы готовы принимать Ваши транзакции.\n\nПросто пишите их в формате:\n\n <i>сумма комментарий</i>\n\n <b>Первой обязательно должна быть сумма!</b>",
|
||||||
|
parseMode = ParseMode.HTML,
|
||||||
|
replyMarkup = buildMenu(callbackQuery.from.id)
|
||||||
|
)
|
||||||
|
} catch (e: NotFoundException) {
|
||||||
|
e.printStackTrace()
|
||||||
|
bot.sendMessage(
|
||||||
|
ChatId.Companion.fromId(callbackQuery.message!!.chat.id),
|
||||||
|
text = "Мы кажется не знакомы"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (callbackQuery.data.equals("select_space", ignoreCase = true)) {
|
||||||
|
bot.editMessageText(
|
||||||
|
ChatId.Companion.fromId(callbackQuery.message!!.chat.id),
|
||||||
|
callbackQuery.message!!.messageId,
|
||||||
|
text = "Выберите новое пространство",
|
||||||
|
replyMarkup = buildSpaceSelector(
|
||||||
|
userService.getUserByTelegramId(callbackQuery.from.id).id!!
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
command("start") {
|
||||||
|
val user: User
|
||||||
|
try {
|
||||||
|
user = userService.getUserByTelegramId(
|
||||||
|
message.from?.id ?: throw IllegalArgumentException("User not found")
|
||||||
|
)
|
||||||
|
bot.sendMessage(
|
||||||
|
ChatId.Companion.fromId(message.chat.id),
|
||||||
|
text = "Привет!\n\nРады тебя снова видеть!\n\nНачнем с выбора пространства:",
|
||||||
|
replyMarkup = buildSpaceSelector(user.id!!)
|
||||||
|
)
|
||||||
|
|
||||||
|
} catch (e: NotFoundException) {
|
||||||
|
bot.sendMessage(ChatId.Companion.fromId(message.chat.id), text = "Кажется, мы еще не знакомы.")
|
||||||
|
bot.sendMessage(ChatId.Companion.fromId(message.chat.id), text = "Давайте зарегистрируемся? ")
|
||||||
|
bot.sendMessage(ChatId.Companion.fromId(message.chat.id), text = "")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
bot.startPolling()
|
||||||
|
return bot
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package space.luminic.finance.services.telegram
|
||||||
|
|
||||||
|
import space.luminic.finance.models.Space
|
||||||
|
|
||||||
|
interface SpaceService {
|
||||||
|
fun getSpaces(userId: Int): List<Space>
|
||||||
|
fun getSpace(spaceId: Int, userId: Int): Space
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package space.luminic.finance.services.telegram
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import space.luminic.finance.models.NotFoundException
|
||||||
|
import space.luminic.finance.models.Space
|
||||||
|
import space.luminic.finance.repos.SpaceRepo
|
||||||
|
|
||||||
|
@Service("spaceServiceTelegram")
|
||||||
|
class SpaceServiceImpl(
|
||||||
|
private val spaceRepo: SpaceRepo
|
||||||
|
) : SpaceService {
|
||||||
|
override fun getSpaces(userId: Int): List<Space> {
|
||||||
|
val spaces = spaceRepo.findSpacesAvailableForUser(userId)
|
||||||
|
return spaces
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getSpace(spaceId: Int, userId: Int): Space {
|
||||||
|
val space =
|
||||||
|
spaceRepo.findSpaceById(spaceId, userId) ?: throw NotFoundException("Space with id $spaceId not found")
|
||||||
|
return space
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package space.luminic.finance.services.telegram
|
||||||
|
|
||||||
|
import space.luminic.finance.dtos.TransactionDTO
|
||||||
|
|
||||||
|
interface TransactionService {
|
||||||
|
|
||||||
|
fun createTransaction(spaceId: Int, userId: Int, transaction: TransactionDTO.CreateTransactionDTO, chatId: Long, messageId: Long ): Int
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package space.luminic.finance.services.telegram
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import space.luminic.finance.dtos.TransactionDTO
|
||||||
|
import space.luminic.finance.models.Transaction
|
||||||
|
import space.luminic.finance.repos.TransactionRepo
|
||||||
|
import space.luminic.finance.services.CategoryServiceImpl
|
||||||
|
import space.luminic.finance.services.NotificationService
|
||||||
|
import space.luminic.finance.services.TxActionType
|
||||||
|
|
||||||
|
@Service("transactionsServiceTelegram")
|
||||||
|
class TransactionsServiceImpl(
|
||||||
|
private val transactionRepo: TransactionRepo,
|
||||||
|
@Qualifier("spaceServiceTelegram") private val spaceService: SpaceService,
|
||||||
|
private val categoryService: CategoryServiceImpl,
|
||||||
|
private val notificationService: NotificationService
|
||||||
|
) : TransactionService {
|
||||||
|
private val logger = LoggerFactory.getLogger(this.javaClass)
|
||||||
|
private val serviceScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||||
|
|
||||||
|
|
||||||
|
override fun createTransaction(
|
||||||
|
spaceId: Int,
|
||||||
|
userId: Int,
|
||||||
|
transaction: TransactionDTO.CreateTransactionDTO,
|
||||||
|
chatId: Long,
|
||||||
|
messageId: Long
|
||||||
|
): Int {
|
||||||
|
val space = spaceService.getSpace(spaceId, userId)
|
||||||
|
val category = transaction.categoryId?.let { categoryService.getCategory(spaceId, it) }
|
||||||
|
val transaction = Transaction(
|
||||||
|
space = space,
|
||||||
|
type = transaction.type,
|
||||||
|
kind = transaction.kind,
|
||||||
|
category = category,
|
||||||
|
comment = transaction.comment,
|
||||||
|
amount = transaction.amount,
|
||||||
|
fees = transaction.fees,
|
||||||
|
date = transaction.date,
|
||||||
|
tgChatId = chatId,
|
||||||
|
tgMessageId = messageId,
|
||||||
|
)
|
||||||
|
serviceScope.launch {
|
||||||
|
runCatching {
|
||||||
|
if (space.owner.id != userId) {
|
||||||
|
notificationService.sendTXNotification(TxActionType.CREATE, space, userId, transaction)
|
||||||
|
}
|
||||||
|
}.onFailure {
|
||||||
|
logger.error("Error while transaction notification", it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return transactionRepo.create(transaction, userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -9,16 +9,16 @@ logging.level.org.springframework.security = DEBUG
|
|||||||
#logging.level.org.springframework.data.mongodb.code = DEBUG
|
#logging.level.org.springframework.data.mongodb.code = DEBUG
|
||||||
logging.level.org.springframework.web.reactive=DEBUG
|
logging.level.org.springframework.web.reactive=DEBUG
|
||||||
logging.level.org.mongodb.driver.protocol.command = DEBUG
|
logging.level.org.mongodb.driver.protocol.command = DEBUG
|
||||||
logging.level.org.springframework.jdbc.core=DEBUG
|
logging.level.org.springframework.jdbc.core=INFO
|
||||||
logging.level.org.springframework.jdbc.core.StatementCreatorUtils=TRACE
|
logging.level.org.springframework.jdbc.core.StatementCreatorUtils=INFO
|
||||||
logging.level.org.springframework.jdbc=DEBUG
|
logging.level.org.springframework.jdbc=INFO
|
||||||
logging.level.org.springframework.jdbc.datasource=DEBUG
|
logging.level.org.springframework.jdbc.datasource=INFO
|
||||||
logging.level.org.springframework.jdbc.support=DEBUG
|
logging.level.org.springframework.jdbc.support=INFO
|
||||||
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE
|
|
||||||
|
|
||||||
management.endpoints.web.exposure.include=*
|
management.endpoints.web.exposure.include=*
|
||||||
management.endpoint.health.show-details=always
|
management.endpoint.health.show-details=always
|
||||||
telegram.bot.token=6972242509:AAGyXuL3T-BNE4XMoo_qvtaYxw_SuiS_dDs
|
# vector test
|
||||||
|
telegram.bot.token=8127199836:AAEPepyKDAf8PvFpw-fpxBXUuPdx_LS20fI
|
||||||
nlp.address=http://127.0.0.1:8000
|
nlp.address=http://127.0.0.1:8000
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
spring.application.name=budger-app
|
|
||||||
spring.data.mongodb.uri=mongodb://budger-app:BA1q2w3e4r!@luminic.space:27017/budger-app-v2?authSource=admin&minPoolSize=10&maxPoolSize=100
|
|
||||||
|
|
||||||
logging.level.org.springframework.web=INFO
|
logging.level.org.springframework.web=INFO
|
||||||
logging.level.org.springframework.data = INFO
|
logging.level.org.springframework.data = INFO
|
||||||
@@ -9,15 +7,12 @@ logging.level.org.springframework.data.mongodb.code = INFO
|
|||||||
logging.level.org.springframework.web.reactive=INFO
|
logging.level.org.springframework.web.reactive=INFO
|
||||||
logging.level.org.mongodb.driver.protocol.command = INFO
|
logging.level.org.mongodb.driver.protocol.command = INFO
|
||||||
|
|
||||||
#management.endpoints.web.exposure.include=*
|
|
||||||
#management.endpoint.metrics.access=read_only
|
|
||||||
|
|
||||||
|
|
||||||
telegram.bot.token = 7999296388:AAGXPE5r0yt3ZFehBoUh8FGm5FBbs9pYIks
|
telegram.bot.token = 7999296388:AAGXPE5r0yt3ZFehBoUh8FGm5FBbs9pYIks
|
||||||
nlp.address=https://nlp.luminic.space
|
nlp.address=https://nlp.luminic.space
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
spring.datasource.url=jdbc:postgresql://postgresql:5432/luminic-space-db
|
#spring.datasource.url=jdbc:postgresql://postgresql:5432/luminic-space-db
|
||||||
|
spring.datasource.url=jdbc:postgresql://213.226.71.138:5432/luminic-space-db
|
||||||
spring.datasource.username=luminicspace
|
spring.datasource.username=luminicspace
|
||||||
spring.datasource.password=LS1q2w3e4r!
|
spring.datasource.password=LS1q2w3e4r!
|
||||||
@@ -17,13 +17,16 @@ spring.servlet.multipart.max-request-size=10MB
|
|||||||
storage.location: static
|
storage.location: static
|
||||||
|
|
||||||
spring.jackson.default-property-inclusion=non_null
|
spring.jackson.default-property-inclusion=non_null
|
||||||
# Expose prometheus, health, and info endpoints
|
management.endpoints.web.exposure.include=health,info,prometheus
|
||||||
#management.endpoints.web.exposure.include=prometheus,health,info
|
|
||||||
management.endpoints.web.exposure.include=*
|
|
||||||
|
|
||||||
# Enable Prometheus metrics export
|
#management.endpoint.prometheus.access=unrestricted
|
||||||
|
management.endpoint.prometheus.enabled=true
|
||||||
management.prometheus.metrics.export.enabled=true
|
management.prometheus.metrics.export.enabled=true
|
||||||
|
|
||||||
|
management.metrics.tags.application=luminic-app
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
telegram.bot.username = expenses_diary_bot
|
telegram.bot.username = expenses_diary_bot
|
||||||
spring.flyway.enabled=true
|
spring.flyway.enabled=true
|
||||||
spring.flyway.locations=classpath:db/migration
|
spring.flyway.locations=classpath:db/migration
|
||||||
@@ -31,3 +34,5 @@ spring.flyway.baseline-on-migrate= false
|
|||||||
spring.flyway.schemas=finance
|
spring.flyway.schemas=finance
|
||||||
spring.jpa.properties.hibernate.default_schema=finance
|
spring.jpa.properties.hibernate.default_schema=finance
|
||||||
spring.jpa.properties.hibernate.default_batch_fetch_size=50
|
spring.jpa.properties.hibernate.default_batch_fetch_size=50
|
||||||
|
qwen.api_key=sk-991942d15b424cc89513498bb2946045
|
||||||
|
ds.api_key=sk-b5949728e79747f08af0a1d65bc6a7a2
|
||||||
2
src/main/resources/db/migration/V23__.sql
Normal file
2
src/main/resources/db/migration/V23__.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE finance.users
|
||||||
|
ADD CONSTRAINT uq_users_username UNIQUE (username);
|
||||||
21
src/main/resources/db/migration/V24__.sql
Normal file
21
src/main/resources/db/migration/V24__.sql
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
create table if not exists finance.bot_states
|
||||||
|
(
|
||||||
|
user_id integer primary key,
|
||||||
|
state_code varchar not null
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists finance.bot_states_data
|
||||||
|
(
|
||||||
|
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||||
|
user_id integer not null,
|
||||||
|
data_code varchar(255) not null,
|
||||||
|
data_value varchar(255) not null,
|
||||||
|
CONSTRAINT pk_bot_states_data PRIMARY KEY (id)
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE finance.bot_states
|
||||||
|
ADD CONSTRAINT FK_STATE_ON_USER FOREIGN KEY (user_id) REFERENCES finance.users (id);
|
||||||
|
|
||||||
|
ALTER TABLE finance.bot_states
|
||||||
|
ADD CONSTRAINT FK_STATE_DATA_ON_USER FOREIGN KEY (user_id) REFERENCES finance.users (id);
|
||||||
|
|
||||||
3
src/main/resources/db/migration/V25__.sql
Normal file
3
src/main/resources/db/migration/V25__.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
-- уникальное ограничение под ON CONFLICT (user_id, data_code)
|
||||||
|
ALTER TABLE finance.bot_states_data
|
||||||
|
ADD CONSTRAINT ux_bot_states_data_user_code UNIQUE (user_id, data_code);
|
||||||
3
src/main/resources/db/migration/V26__.sql
Normal file
3
src/main/resources/db/migration/V26__.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
-- уникальное ограничение под ON CONFLICT (user_id, data_code)
|
||||||
|
ALTER TABLE finance.transactions
|
||||||
|
ALTER COLUMN category_id DROP NOT NULL;
|
||||||
19
src/main/resources/db/migration/V27__.sql
Normal file
19
src/main/resources/db/migration/V27__.sql
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
-- Очередь классификации категорий
|
||||||
|
CREATE TABLE IF NOT EXISTS finance.category_jobs (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
tx_id INT NOT NULL UNIQUE, -- одна задача на транзакцию
|
||||||
|
status TEXT NOT NULL DEFAULT 'NEW', -- NEW | PROCESSING | DONE | FAILED
|
||||||
|
attempts INT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
started_at TIMESTAMPTZ,
|
||||||
|
finished_at TIMESTAMPTZ,
|
||||||
|
last_error TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Быстрые выборки по статусу
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_category_jobs_status ON finance.category_jobs(status);
|
||||||
|
|
||||||
|
-- (опционально) защита от «зависших» задач
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_category_jobs_processing_time
|
||||||
|
ON finance.category_jobs(started_at)
|
||||||
|
WHERE status = 'PROCESSING';
|
||||||
3
src/main/resources/db/migration/V28__.sql
Normal file
3
src/main/resources/db/migration/V28__.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
alter table finance.transactions
|
||||||
|
add column tg_chat_id bigint null,
|
||||||
|
add column tg_message_id bigint null;
|
||||||
3
src/main/resources/db/migration/V29__.sql
Normal file
3
src/main/resources/db/migration/V29__.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
alter table finance.category_jobs
|
||||||
|
add column tg_chat_id bigint null,
|
||||||
|
add column tg_message_id bigint null;
|
||||||
5
src/main/resources/db/migration/V30__.sql
Normal file
5
src/main/resources/db/migration/V30__.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE finance.recurrent_operations
|
||||||
|
ADD CONSTRAINT recurrent_operations_pk PRIMARY KEY (id);
|
||||||
|
alter table finance.transactions
|
||||||
|
add column recurrent_id integer null,
|
||||||
|
ADD CONSTRAINT FK_RECURRENTS FOREIGN KEY (recurrent_id) REFERENCES finance.recurrent_operations (id);
|
||||||
Reference in New Issue
Block a user