35 Commits

Author SHA1 Message Date
xds
c84f6a3988 + notifications 2025-11-20 14:54:00 +03:00
xds
195bdd83f0 filters for transactions;
update transactions when recurrent updated
2025-11-18 00:34:02 +03:00
42cbf30bd8 Update Dockerfile 2025-11-17 15:11:46 +03:00
5803fc208b Update Dockerfile 2025-11-17 15:10:40 +03:00
a79dbffe3f recurrents 2025-11-17 15:10:25 +03:00
9d7c385654 Merge pull request 'recurrents' (#2) from recurrents into main
Reviewed-on: #2
2025-11-17 15:03:32 +03:00
xds
12afd1f90e recurrents 2025-11-17 15:02:47 +03:00
xds
d0cae182b7 build 2025-10-31 19:28:59 +03:00
xds
0f02b53bc0 build 2025-10-31 18:46:16 +03:00
xds
b08ab909c8 build 2025-10-31 18:45:28 +03:00
xds
a65f46aff3 build 2025-10-31 18:33:11 +03:00
xds
036ad00795 build 2025-10-31 18:29:31 +03:00
xds
1c3605623e build 2025-10-31 18:24:12 +03:00
xds
aaa12fcb86 build 2025-10-31 17:53:39 +03:00
xds
cef82c483f build 2025-10-31 17:46:35 +03:00
xds
e83e3a2b65 build 2025-10-31 17:40:57 +03:00
xds
c68e6afb8a build 2025-10-31 17:38:48 +03:00
xds
10b7c730ad build 2025-10-31 17:33:49 +03:00
xds
a0024def2e tg login 2025-10-31 17:13:10 +03:00
xds
d2458633db tg login 2025-10-31 17:11:40 +03:00
xds
5b9d2366db init 2025-10-31 16:26:24 +03:00
xds
a30eb52f3f init 2025-10-31 16:16:36 +03:00
xds
175c86d787 init 2025-10-31 16:14:56 +03:00
xds
3541528e77 init 2025-10-31 16:12:01 +03:00
xds
62fe88abdf init 2025-10-31 16:10:59 +03:00
xds
61bfdf273a init 2025-10-31 16:10:30 +03:00
xds
4a17b4f528 init 2025-10-31 16:09:31 +03:00
xds
50607a8c42 init 2025-10-31 15:53:26 +03:00
xds
3bb8c33094 init 2025-10-31 15:50:56 +03:00
xds
1bc5932793 init 2025-10-31 15:48:55 +03:00
xds
8d1b0f2a3c init 2025-10-31 15:46:49 +03:00
xds
224fe18639 init 2025-10-31 15:40:45 +03:00
xds
d32062a485 init 2025-10-31 15:40:10 +03:00
0b54384258 Merge pull request 'init' (#1) from sql into main
Reviewed-on: #1
2025-10-31 15:38:34 +03:00
xds
7972ea0fdf init 2025-10-31 15:31:55 +03:00
148 changed files with 5405 additions and 2047 deletions

14
Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM eclipse-temurin:17-jre AS runtime
WORKDIR /app
USER root
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
RUN groupadd --system --gid 1001 app && useradd --system --gid app --uid 1001 --shell /bin/bash --create-home app
RUN mkdir -p /app/static && chown -R app:app /app
USER app
ENV JAVA_TOOL_OPTIONS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0"
EXPOSE 8080
HEALTHCHECK --interval=20s --timeout=3s --retries=3 CMD curl -fsS http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["java","-jar","/app/luminic-space-v2.jar"]

View File

@@ -1,6 +1,7 @@
plugins {
kotlin("jvm") version "1.9.25"
kotlin("plugin.spring") version "1.9.25"
kotlin("plugin.jpa") version "1.9.25"
id("org.springframework.boot") version "3.4.0"
id("io.spring.dependency-management") version "1.1.6"
kotlin("plugin.serialization") version "2.1.0"
@@ -28,34 +29,45 @@ configurations {
repositories {
mavenCentral()
maven { url = uri("https://jitpack.io") }
}
dependencies {
// Spring
// implementation("org.springframework.boot:spring-boot-starter-data-mongodb-reactive")
implementation("org.springframework.boot:spring-boot-starter-data-r2dbc")
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("org.springframework.boot:spring-boot-starter-cache")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springdoc:springdoc-openapi-starter-webflux-ui:2.8.13")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13")
implementation("org.springframework.boot:spring-boot-starter-web") // MVC
implementation("org.springframework.boot:spring-boot-starter-jdbc")
implementation("org.postgresql:postgresql:42.7.8")
implementation("io.r2dbc:r2dbc-postgresql")
// Аудит Spring Data JPA (@CreatedBy/@CreatedDate)
implementation("org.springframework.data:spring-data-commons")
// Миграции
implementation("org.flywaydb:flyway-core")
implementation("org.flywaydb:flyway-core:11.14.1")
implementation("org.flywaydb:flyway-database-postgresql:11.14.1")
// jackson для jsonb (если маппишь объекты в json)
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("commons-logging:commons-logging:1.3.4")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.17.0")
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("io.micrometer:micrometer-registry-prometheus")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:1.7.3")
implementation("org.jetbrains.kotlin.plugin.jpa:org.jetbrains.kotlin.plugin.jpa.gradle.plugin:1.9.25")
implementation("io.jsonwebtoken:jjwt-api:0.11.5")
implementation("io.jsonwebtoken:jjwt-impl:0.11.5")
@@ -64,10 +76,11 @@ dependencies {
implementation("io.micrometer:micrometer-registry-prometheus")
implementation("org.telegram:telegrambots:6.9.7.1")
implementation("org.telegram:telegrambots-spring-boot-starter:6.9.7.1")
// implementation("org.telegram:telegrambots:6.9.7.1")
// implementation("org.telegram:telegrambots-spring-boot-starter:6.9.7.1")
implementation("com.opencsv:opencsv:5.10")
implementation("io.github.kotlin-telegram-bot.kotlin-telegram-bot:telegram:6.3.0")
compileOnly("org.projectlombok:lombok")
annotationProcessor("org.projectlombok:lombok")

10
deploy.sh Executable file
View File

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

21
docker-compose.yml Normal file
View File

@@ -0,0 +1,21 @@
networks:
postgres-net:
external: true
services:
app:
image: back-app
volumes:
- ./luminic-space-v2.jar:/app/luminic-space-v2.jar
build:
context: .
dockerfile: Dockerfile
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/luminic-space-db
SPRING_DATASOURCE_USERNAME: luminicspace
SPRING_DATASOURCE_PASSWORD: LS1q2w3e4r!
ports:
- "8089:8089"
restart: unless-stopped
networks:
- postgres-net

1
gradle.properties Normal file
View File

@@ -0,0 +1 @@
kotlin.code.style=official

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,6 @@
#Wed Oct 08 22:52:02 GET 2025
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

234
gradlew vendored Executable file
View File

@@ -0,0 +1,234 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
APP_NAME="Gradle"
APP_BASE_NAME=${0##*/}
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

89
gradlew.bat vendored Normal file
View File

@@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

4
settings.gradle.kts Normal file
View File

@@ -0,0 +1,4 @@
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0"
}
rootProject.name = "luminic-space"

View File

@@ -4,25 +4,17 @@ import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
import org.springframework.boot.runApplication
import org.springframework.cache.annotation.EnableCaching
import org.springframework.data.mongodb.config.EnableMongoAuditing
import org.springframework.data.mongodb.config.EnableReactiveMongoAuditing
import org.springframework.data.mongodb.repository.config.EnableMongoRepositories
import org.springframework.scheduling.annotation.EnableAsync
import org.springframework.scheduling.annotation.EnableScheduling
import java.util.TimeZone
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
@SpringBootApplication(scanBasePackages = ["space.luminic.finance"])
@EnableReactiveMongoAuditing(auditorAwareRef = "coroutineAuditorAware")
@EnableCaching
@EnableAsync
@EnableScheduling
//@EnableConfigurationProperties([TelegramBotProperties::class,)
@EnableWebSecurity
@ConfigurationPropertiesScan(basePackages = ["space.luminic.finance"])
@EnableMongoRepositories(basePackages = ["space.luminic.finance.repos"])
class Main
fun main(args: Array<String>) {
TimeZone.setDefault(TimeZone.getTimeZone("Europe/Moscow"))
runApplication<Main>(*args)
}

View File

@@ -1,75 +0,0 @@
package space.luminic.finance.api
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType
import io.swagger.v3.oas.annotations.security.SecurityScheme
import jakarta.ws.rs.GET
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import space.luminic.finance.dtos.AccountDTO
import space.luminic.finance.dtos.TransactionDTO
import space.luminic.finance.mappers.AccountMapper.toDto
import space.luminic.finance.mappers.TransactionMapper.toDto
import space.luminic.finance.models.Account
import space.luminic.finance.services.AccountService
@RestController
@RequestMapping("/spaces/{spaceId}/accounts")
@SecurityScheme(
name = "bearerAuth",
type = SecuritySchemeType.HTTP,
bearerFormat = "JWT",
scheme = "bearer"
)
class AccountController(
private val accountService: AccountService
) {
@GetMapping
suspend fun getAccounts(@PathVariable spaceId: String): List<AccountDTO> {
return accountService.getAccounts(spaceId).map { it.toDto() }
}
@GetMapping("/{accountId}")
suspend fun getAccount(@PathVariable spaceId: String, @PathVariable accountId: String): AccountDTO {
return accountService.getAccount(accountId, spaceId).toDto()
}
@GetMapping("/{accountId}/transactions")
suspend fun getAccountTransactions(
@PathVariable spaceId: String,
@PathVariable accountId: String
): List<TransactionDTO> {
return accountService.getAccountTransactions(spaceId, accountId).map { it.toDto() }
}
@PostMapping
suspend fun createAccount(
@PathVariable spaceId: String,
@RequestBody accountDTO: AccountDTO.CreateAccountDTO
): AccountDTO {
return accountService.createAccount(spaceId, accountDTO).toDto()
}
@PutMapping("/{accountId}")
suspend fun updateAccount(
@PathVariable spaceId: String,
@PathVariable accountId: String,
@RequestBody accountDTO: AccountDTO.UpdateAccountDTO
): AccountDTO {
return accountService.updateAccount(spaceId, accountDTO).toDto()
}
@DeleteMapping("/{accountId}")
suspend fun deleteAccount(@PathVariable spaceId: String, @PathVariable accountId: String) {
accountService.deleteAccount(accountId, spaceId)
}
}

View File

@@ -1,28 +1,124 @@
package space.luminic.finance.api
import kotlinx.coroutines.reactive.awaitSingle
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonBuilder
import org.slf4j.LoggerFactory
import org.springframework.security.core.context.ReactiveSecurityContextHolder
import org.springframework.beans.factory.annotation.Value
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.web.bind.annotation.*
import space.luminic.finance.dtos.UserDTO.*
import space.luminic.finance.dtos.UserDTO
import space.luminic.finance.dtos.UserDTO.AuthUserDTO
import space.luminic.finance.dtos.UserDTO.RegisterUserDTO
import space.luminic.finance.mappers.UserMapper.toDto
import space.luminic.finance.mappers.UserMapper.toTelegramMap
import space.luminic.finance.services.AuthService
import space.luminic.finance.services.UserService
import kotlin.jvm.javaClass
import kotlin.to
import java.net.URLDecoder
import java.security.MessageDigest
import java.time.Instant
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
@RestController
@RequestMapping("/auth")
class AuthController(
private val userService: UserService,
private val authService: AuthService
private val authService: AuthService,
@Value("\${telegram.bot.token}") private val botToken: String
) {
private val logger = LoggerFactory.getLogger(javaClass)
fun verifyTelegramAuth(
loginData: Map<String, String>? = null, // from login widget
webAppInitData: String? = null
): Boolean {
// --- LOGIN WIDGET CHECK ---
if (loginData != null) {
val hash = loginData["hash"]
if (hash != null) {
val dataCheckString = loginData
.filterKeys { it != "hash" }
.toSortedMap()
.map { "${it.key}=${it.value}" }
.joinToString("\n")
val secretKey = MessageDigest.getInstance("SHA-256")
.digest(botToken.toByteArray())
val hmac = Mac.getInstance("HmacSHA256").apply {
init(SecretKeySpec(secretKey, "HmacSHA256"))
}.doFinal(dataCheckString.toByteArray())
.joinToString("") { "%02x".format(it) }
val authDate = loginData["auth_date"]?.toLongOrNull() ?: return false
if (Instant.now().epochSecond - authDate > 3600) return false
if (hmac == hash) return true
}
}
// --- WEBAPP CHECK ---
// --- WEBAPP CHECK ---
if (webAppInitData != null) {
// Разбираем query string корректно (учитывая '=' внутри значения)
val pairs: Map<String, String> = webAppInitData.split("&")
.mapNotNull { part ->
val idx = part.indexOf('=')
if (idx <= 0) return@mapNotNull null
val k = part.substring(0, idx)
val v = part.substring(idx + 1)
k to URLDecoder.decode(v, Charsets.UTF_8.name())
}.toMap()
val receivedHash = pairs["hash"] ?: return false
// Строка для подписи: все поля КРОМЕ hash, отсортированные по ключу, в формате key=value с \n
val dataCheckString = pairs
.filterKeys { it != "hash" }
.toSortedMap()
.entries
.joinToString("\n") { (k, v) -> "$k=$v" }
// ВАЖНО: secret_key = HMAC_SHA256(message=botToken, key="WebAppData")
val secretKeyBytes = Mac.getInstance("HmacSHA256").apply {
init(SecretKeySpec("WebAppData".toByteArray(Charsets.UTF_8), "HmacSHA256"))
}.doFinal(botToken.toByteArray(Charsets.UTF_8))
// hash = HMAC_SHA256(message=data_check_string, key=secret_key)
val calcHashHex = Mac.getInstance("HmacSHA256").apply {
init(SecretKeySpec(secretKeyBytes, "HmacSHA256"))
}.doFinal(dataCheckString.toByteArray(Charsets.UTF_8))
.joinToString("") { "%02x".format(it) }
// опциональная проверка свежести
val authDate = pairs["auth_date"]?.toLongOrNull() ?: return false
val 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 =
MessageDigest.getInstance("SHA-256").digest(input.toByteArray())
private fun hmacSha256(secret: ByteArray, message: String): String {
val key = SecretKeySpec(secret, "HmacSHA256")
val mac = Mac.getInstance("HmacSHA256")
mac.init(key)
val hashBytes = mac.doFinal(message.toByteArray())
return hashBytes.joinToString("") { "%02x".format(it) }
}
@GetMapping("/test")
fun test(): String {
val authentication = SecurityContextHolder.getContext().authentication
@@ -31,26 +127,60 @@ class AuthController(
}
@PostMapping("/login")
suspend fun login(@RequestBody request: AuthUserDTO): Map<String, String> {
val token = authService.login(request.username, request.password)
fun login(@RequestBody request: AuthUserDTO): Map<String, String> {
val token = authService.login(request.username.lowercase(), request.password)
return mapOf("token" to token)
}
@PostMapping("/register")
suspend fun register(@RequestBody request: RegisterUserDTO): UserDTO {
fun register(@RequestBody request: RegisterUserDTO): UserDTO {
return authService.register(request.username, request.password, request.firstName).toDto()
}
@PostMapping("/tgLogin")
suspend fun tgLogin(@RequestHeader("X-Tg-Id") tgId: String): Map<String, String> {
val token = authService.tgLogin(tgId)
return mapOf("token" to token)
private val json = Json { ignoreUnknownKeys = true }
@PostMapping("/tg-login")
fun tgLogin(@RequestBody tgUser: UserDTO.TelegramAuthDTO): Map<String, String> {
// println(tgUser.hash)
// println(botToken)
if (tgUser.initData == null) {
if (verifyTelegramAuth(
loginData = tgUser.toTelegramMap(),
)
) {
return mapOf("token" to authService.tgAuth(tgUser))
} else throw IllegalArgumentException("Invalid Telegram login")
} else {
if (verifyTelegramAuth(webAppInitData = tgUser.initData)) {
val params = tgUser.initData.split("&").associate {
val (k, v) = it.split("=", limit = 2)
k to URLDecoder.decode(v, "UTF-8")
}
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")
suspend fun getMe(): UserDTO {
val securityContext = ReactiveSecurityContextHolder.getContext().awaitSingle()
return userService.getByUsername(securityContext.authentication.name).toDto()
fun getMe(): UserDTO {
logger.info("Get Me")
authService.getSecurityUser()
return authService.getSecurityUser().toDto()
}
}

View File

@@ -1,78 +0,0 @@
package space.luminic.finance.api
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType
import io.swagger.v3.oas.annotations.security.SecurityScheme
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import space.luminic.finance.dtos.BudgetDTO
import space.luminic.finance.dtos.TransactionDTO
import space.luminic.finance.mappers.BudgetMapper.toDto
import space.luminic.finance.mappers.BudgetMapper.toShortDto
import space.luminic.finance.mappers.TransactionMapper.toDto
import space.luminic.finance.models.Budget
import space.luminic.finance.services.BudgetService
@RestController
@RequestMapping("/spaces/{spaceId}/budgets")
@SecurityScheme(
name = "bearerAuth",
type = SecuritySchemeType.HTTP,
bearerFormat = "JWT",
scheme = "bearer"
)
class BudgetController(
private val budgetService: BudgetService
) {
@GetMapping
suspend fun getBudgets(
@PathVariable spaceId: String,
@RequestParam(value = "sort", defaultValue = "dateFrom") sortBy: String,
@RequestParam("direction", defaultValue = "DESC") sortDirection: String
): List<BudgetDTO.BudgetShortInfoDTO> {
return budgetService.getBudgets(spaceId, sortBy, sortDirection).map { it.toShortDto() }
}
@GetMapping("/{budgetId}")
suspend fun getBudgetById(@PathVariable spaceId: String, @PathVariable budgetId: String): BudgetDTO {
return budgetService.getBudget(spaceId, budgetId).toDto()
}
@GetMapping("/{budgetId}/transactions")
suspend fun getBudgetTransactions(
@PathVariable spaceId: String,
@PathVariable budgetId: String
): BudgetDTO.BudgetTransactionsDTO {
return budgetService.getBudgetTransactions(spaceId, budgetId).toDto()
}
@PostMapping
suspend fun createBudget(
@PathVariable spaceId: String,
@RequestBody createBudgetDTO: BudgetDTO.CreateBudgetDTO
): BudgetDTO {
return budgetService.createBudget(spaceId, Budget.BudgetType.SPECIAL, createBudgetDTO).toDto()
}
@PutMapping("/{budgetId}")
suspend fun updateBudget(
@PathVariable spaceId: String,
@PathVariable budgetId: String,
@RequestBody updateBudgetDTO: BudgetDTO.UpdateBudgetDTO
): BudgetDTO {
return budgetService.updateBudget(spaceId, updateBudgetDTO).toDto()
}
@DeleteMapping("/{budgetId}")
suspend fun deleteBudget(@PathVariable spaceId: String, @PathVariable budgetId: String) {
budgetService.deleteBudget(spaceId, budgetId)
}
}

View File

@@ -2,16 +2,10 @@ package space.luminic.finance.api
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType
import io.swagger.v3.oas.annotations.security.SecurityScheme
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.bind.annotation.*
import space.luminic.finance.dtos.CategoryDTO
import space.luminic.finance.mappers.CategoryMapper.toDto
import space.luminic.finance.models.Category
import space.luminic.finance.services.CategoryService
@RestController
@@ -23,43 +17,47 @@ import space.luminic.finance.services.CategoryService
scheme = "bearer"
)
class CategoryController(
private val categoryService: CategoryService,
service: CategoryService
private val categoryService: CategoryService
) {
@GetMapping
suspend fun getCategories(@PathVariable spaceId: String): List<CategoryDTO> {
fun getCategories(@PathVariable spaceId: Int): List<CategoryDTO> {
return categoryService.getCategories(spaceId).map { it.toDto() }
}
@GetMapping("/{categoryId}")
suspend fun getCategory(@PathVariable spaceId: String, @PathVariable categoryId: String): CategoryDTO {
fun getCategory(@PathVariable spaceId: Int, @PathVariable categoryId: Int): CategoryDTO {
return categoryService.getCategory(spaceId, categoryId).toDto()
}
@PostMapping
suspend fun createCategory(
@PathVariable spaceId: String,
fun createCategory(
@PathVariable spaceId: Int,
@RequestBody categoryDTO: CategoryDTO.CreateCategoryDTO
): CategoryDTO {
return categoryService.createCategory(spaceId, categoryDTO).toDto()
}
@PostMapping("/_many")
fun createManyCategory(@PathVariable spaceId: Int, @RequestBody categoryDTOs: List<Category.CategoryEtalon>): List<CategoryDTO> {
return categoryService.createEtalonCategoriesForSpace(spaceId).map { it.toDto() }
}
@PutMapping("/{categoryId}")
suspend fun updateCategory(
@PathVariable spaceId: String,
@PathVariable categoryId: String,
fun updateCategory(
@PathVariable spaceId: Int,
@PathVariable categoryId: Int,
@RequestBody categoryDTO: CategoryDTO.UpdateCategoryDTO
): CategoryDTO {
return categoryService.updateCategory(spaceId, categoryDTO).toDto()
return categoryService.updateCategory(spaceId, categoryId, categoryDTO).toDto()
}
@DeleteMapping("/{categoryId}")
suspend fun deleteCategory(@PathVariable spaceId: String, @PathVariable categoryId: String) {
fun deleteCategory(@PathVariable spaceId: Int, @PathVariable categoryId: Int) {
categoryService.deleteCategory(spaceId, categoryId)
}
}

View File

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

View File

@@ -0,0 +1,40 @@
package space.luminic.finance.api
import org.springframework.web.bind.annotation.*
import space.luminic.finance.dtos.RecurrentOperationDTO
import space.luminic.finance.mappers.RecurrentOperationMapper.toDTO
import space.luminic.finance.services.RecurrentOperationService
@RestController
@RequestMapping(value = ["/spaces/{spaceId}/recurrents"])
class RecurrentOperationController(private val recurrentOperationService: RecurrentOperationService) {
@GetMapping
fun findAll(@PathVariable spaceId: Int): List<RecurrentOperationDTO> {
return recurrentOperationService.findBySpaceId(spaceId).map { it.toDTO() }
}
@GetMapping("/{operationId}")
fun findById(@PathVariable spaceId: Int, @PathVariable operationId: Int): RecurrentOperationDTO {
return recurrentOperationService.findBySpaceIdAndId(spaceId, operationId).toDTO()
}
@PostMapping
fun createOperation(@PathVariable spaceId: Int, @RequestBody createOperation: RecurrentOperationDTO.CreateRecurrentOperationDTO): Map<String, Int> {
return mapOf("id" to recurrentOperationService.create(spaceId, createOperation))
}
@PutMapping("/{operationId}")
fun updateOperation(@PathVariable spaceId: Int, @PathVariable operationId: Int, @RequestBody operation: RecurrentOperationDTO.UpdateRecurrentOperationDTO): Map<String, Int> {
recurrentOperationService.update(spaceId, operationId, operation)
return mapOf("id" to operationId)
}
@DeleteMapping("/{operationId}")
fun deleteOperation(@PathVariable spaceId: Int, @PathVariable operationId: Int) {
recurrentOperationService.delete(spaceId, operationId)
}
}

View File

@@ -2,14 +2,7 @@ package space.luminic.finance.api
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType
import io.swagger.v3.oas.annotations.security.SecurityScheme
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.bind.annotation.*
import space.luminic.finance.dtos.CurrencyDTO
import space.luminic.finance.mappers.CurrencyMapper.toDto
import space.luminic.finance.services.CurrencyService
@@ -27,27 +20,27 @@ class ReferenceController(
) {
@GetMapping("/currencies")
suspend fun getCurrencies(): List<CurrencyDTO> {
fun getCurrencies(): List<CurrencyDTO> {
return currencyService.getCurrencies().map { it.toDto() }
}
@GetMapping("/currencies/{currencyCode}")
suspend fun getCurrency(@PathVariable currencyCode: String): CurrencyDTO {
fun getCurrency(@PathVariable currencyCode: String): CurrencyDTO {
return currencyService.getCurrency(currencyCode).toDto()
}
@PostMapping("/currencies")
suspend fun createCurrency(@RequestBody currencyDTO: CurrencyDTO): CurrencyDTO {
fun createCurrency(@RequestBody currencyDTO: CurrencyDTO): CurrencyDTO {
return currencyService.createCurrency(currencyDTO).toDto()
}
@PutMapping("/currencies/{currencyCode}")
suspend fun updateCurrency(@PathVariable currencyCode: String, @RequestBody currencyDTO: CurrencyDTO): CurrencyDTO {
fun updateCurrency(@PathVariable currencyCode: String, @RequestBody currencyDTO: CurrencyDTO): CurrencyDTO {
return currencyService.updateCurrency(currencyDTO).toDto()
}
@DeleteMapping("/currencies/{currencyCode}")
suspend fun deleteCurrency(@PathVariable currencyCode: String) {
fun deleteCurrency(@PathVariable currencyCode: String) {
currencyService.deleteCurrency(currencyCode)
}
}

View File

@@ -2,17 +2,9 @@ package space.luminic.finance.api
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType
import io.swagger.v3.oas.annotations.security.SecurityScheme
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.bind.annotation.*
import space.luminic.finance.dtos.SpaceDTO
import space.luminic.finance.mappers.SpaceMapper.toDto
import space.luminic.finance.models.Space
import space.luminic.finance.services.SpaceService
@RestController
@@ -29,27 +21,27 @@ class SpaceController(
@GetMapping
suspend fun getSpaces(): List<SpaceDTO> {
fun getSpaces(): List<SpaceDTO> {
return spaceService.getSpaces().map { it.toDto() }
}
@GetMapping("/{spaceId}")
suspend fun getSpace(@PathVariable spaceId: String): SpaceDTO {
return spaceService.getSpace(spaceId).toDto()
fun getSpace(@PathVariable spaceId: Int): SpaceDTO {
return spaceService.getSpace(spaceId, null).toDto()
}
@PostMapping
suspend fun createSpace(@RequestBody space: SpaceDTO.CreateSpaceDTO): SpaceDTO {
return spaceService.createSpace(space).toDto()
fun createSpace(@RequestBody space: SpaceDTO.CreateSpaceDTO): Map<String, Int> {
return mapOf("id" to spaceService.createSpace(space) )
}
@PutMapping("/{spaceId}")
suspend fun updateSpace(@PathVariable spaceId: String, @RequestBody space: SpaceDTO.UpdateSpaceDTO): SpaceDTO {
return spaceService.updateSpace(spaceId, space).toDto()
fun updateSpace(@PathVariable spaceId: Int, @RequestBody space: SpaceDTO.UpdateSpaceDTO): Map<String, Int> {
return mapOf("id" to spaceService.updateSpace(spaceId, space) )
}
@DeleteMapping("/{spaceId}")
suspend fun deleteSpace(@PathVariable spaceId: String) {
fun deleteSpace(@PathVariable spaceId: Int) {
spaceService.deleteSpace(spaceId)
}
}

View File

@@ -2,14 +2,7 @@ package space.luminic.finance.api
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType
import io.swagger.v3.oas.annotations.security.SecurityScheme
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.bind.annotation.*
import space.luminic.finance.dtos.TransactionDTO
import space.luminic.finance.mappers.TransactionMapper.toDto
import space.luminic.finance.services.TransactionService
@@ -25,34 +18,32 @@ import space.luminic.finance.services.TransactionService
)
class TransactionController (
private val transactionService: TransactionService,
service: TransactionService,
transactionService1: TransactionService,
){
@GetMapping
suspend fun getTransactions(@PathVariable spaceId: String) : List<TransactionDTO>{
return transactionService.getTransactions(spaceId, TransactionService.TransactionsFilter(),"date", "DESC").map { it.toDto() }
@PostMapping("/_search")
fun getTransactions(@PathVariable spaceId: Int, @RequestBody filter: TransactionService.TransactionsFilter) : List<TransactionDTO>{
return transactionService.getTransactions(spaceId, filter,"date", "DESC").map { it.toDto() }
}
@GetMapping("/{transactionId}")
suspend fun getTransaction(@PathVariable spaceId: String, @PathVariable transactionId: String): TransactionDTO {
fun getTransaction(@PathVariable spaceId: Int, @PathVariable transactionId: Int): TransactionDTO {
return transactionService.getTransaction(spaceId, transactionId).toDto()
}
@PostMapping
suspend fun createTransaction(@PathVariable spaceId: String, @RequestBody transactionDTO: TransactionDTO.CreateTransactionDTO): TransactionDTO {
return transactionService.createTransaction(spaceId, transactionDTO).toDto()
fun createTransaction(@PathVariable spaceId: Int, @RequestBody transactionDTO: TransactionDTO.CreateTransactionDTO): Map<String, Int> {
return mapOf("id" to transactionService.createTransaction(spaceId, transactionDTO))
}
@PutMapping("/{transactionId}")
suspend fun updateTransaction(@PathVariable spaceId: String, @PathVariable transactionId: String, @RequestBody transactionDTO: TransactionDTO.UpdateTransactionDTO): TransactionDTO {
return transactionService.updateTransaction(spaceId, transactionDTO).toDto()
fun updateTransaction(@PathVariable spaceId: Int, @PathVariable transactionId: Int, @RequestBody transactionDTO: TransactionDTO.UpdateTransactionDTO): Map<String, Int> {
return mapOf("id" to transactionService.updateTransaction(spaceId, transactionId, transactionDTO))
}
@DeleteMapping("/{transactionId}")
suspend fun deleteTransaction(@PathVariable spaceId: String, @PathVariable transactionId: String) {
fun deleteTransaction(@PathVariable spaceId: Int, @PathVariable transactionId: Int) {
transactionService.deleteTransaction(spaceId, transactionId)
}

View File

@@ -1,109 +1,112 @@
package space.luminic.finance.api.exceptionHandlers
import jakarta.servlet.http.HttpServletRequest
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.http.server.reactive.ServerHttpRequest
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
import org.springframework.web.server.ServerWebExchange
import reactor.core.publisher.Mono
import space.luminic.finance.configs.AuthException
import space.luminic.finance.models.NotFoundException
@RestControllerAdvice
class GlobalExceptionHandler {
data class ErrorResponse(
val timestamp: Long = System.currentTimeMillis(),
val status: Int,
val error: String,
val message: String?,
val path: String,
)
// Изменённый параметр request
fun constructErrorBody(
e: Exception,
message: String,
status: HttpStatus,
request: ServerHttpRequest
): Map<String, Any?> {
val errorResponse = mapOf(
"timestamp" to System.currentTimeMillis(),
"status" to status.value(),
"error" to message,
"message" to e.message,
"path" to request.path.value()
request: HttpServletRequest // <--- Изменённый тип
): ErrorResponse {
return ErrorResponse(
status = status.value(),
error = message,
message = e.message,
path = request.requestURI // <--- Получаем путь через HttpServletRequest
)
return errorResponse
}
@ExceptionHandler(AuthException::class)
// Изменённый параметр exchange
fun handleAuthenticationException(
ex: AuthException,
exchange: ServerWebExchange
): Mono<ResponseEntity<Map<String, Any?>>?> {
request: HttpServletRequest // <--- Изменённый тип
): ResponseEntity<ErrorResponse>? {
ex.printStackTrace()
return Mono.just(
ResponseEntity(
return ResponseEntity(
constructErrorBody(
ex,
ex.message.toString(),
HttpStatus.UNAUTHORIZED,
exchange.request
), HttpStatus.UNAUTHORIZED
)
request // <--- Передаём HttpServletRequest
),
HttpStatus.UNAUTHORIZED
)
}
@ExceptionHandler(NotFoundException::class)
// Изменённый параметр exchange
fun handleNotFoundException(
e: NotFoundException,
exchange: ServerWebExchange
): Mono<ResponseEntity<Map<String, Any?>>?> {
request: HttpServletRequest // <--- Изменённый тип
): ResponseEntity<ErrorResponse>? {
e.printStackTrace()
return Mono.just(
ResponseEntity(
return ResponseEntity(
constructErrorBody(
e,
e.message.toString(),
HttpStatus.NOT_FOUND,
exchange.request
), HttpStatus.NOT_FOUND
)
request // <--- Передаём HttpServletRequest
),
HttpStatus.NOT_FOUND
)
}
@ExceptionHandler(IllegalArgumentException::class)
// Изменённый параметр exchange
fun handleIllegalArgumentException(
e: IllegalArgumentException,
exchange: ServerWebExchange
): Mono<ResponseEntity<Map<String, Any?>>?> {
request: HttpServletRequest // <--- Изменённый тип
): ResponseEntity<ErrorResponse>? {
e.printStackTrace()
return Mono.just(
ResponseEntity(
return ResponseEntity(
constructErrorBody(
e,
e.message.toString(),
HttpStatus.BAD_REQUEST,
exchange.request
), HttpStatus.BAD_REQUEST
)
request // <--- Передаём HttpServletRequest
),
HttpStatus.BAD_REQUEST
)
}
@ExceptionHandler(Exception::class)
// Изменённый параметр exchange
fun handleGenericException(
e: Exception,
exchange: ServerWebExchange
): Mono<out ResponseEntity<out Map<String, Any?>>?> {
request: HttpServletRequest // <--- Изменённый тип
): ResponseEntity<ErrorResponse>? {
e.printStackTrace()
return Mono.just(
ResponseEntity(
return ResponseEntity(
constructErrorBody(
e,
e.message.toString(),
HttpStatus.INTERNAL_SERVER_ERROR,
exchange.request
), HttpStatus.INTERNAL_SERVER_ERROR
)
request // <--- Передаём HttpServletRequest
),
HttpStatus.INTERNAL_SERVER_ERROR
)
}
}

View File

@@ -1,49 +1,37 @@
package space.luminic.finance.api.exceptionHandlers
import org.springframework.boot.autoconfigure.web.WebProperties
import org.springframework.boot.autoconfigure.web.reactive.error.AbstractErrorWebExceptionHandler
import org.springframework.boot.web.error.ErrorAttributeOptions
import org.springframework.boot.web.reactive.error.ErrorAttributes
import org.springframework.context.ApplicationContext
import org.springframework.core.annotation.Order
import org.springframework.http.MediaType
import org.springframework.http.codec.ServerCodecConfigurer
import org.springframework.stereotype.Component
import org.springframework.web.reactive.function.BodyInserters
import org.springframework.web.reactive.function.server.*
import reactor.core.publisher.Mono
@Component
@Order(-2)
class GlobalErrorWebExceptionHandler(
errorAttributes: ErrorAttributes,
applicationContext: ApplicationContext,
serverCodecConfigurer: ServerCodecConfigurer
) : AbstractErrorWebExceptionHandler(
errorAttributes,
WebProperties.Resources(),
applicationContext
) {
init {
super.setMessageWriters(serverCodecConfigurer.writers)
super.setMessageReaders(serverCodecConfigurer.readers)
}
override fun getRoutingFunction(errorAttributes: ErrorAttributes): RouterFunction<ServerResponse> {
return RouterFunctions.route(RequestPredicates.all(), this::renderErrorResponse)
}
private fun renderErrorResponse(request: ServerRequest): Mono<ServerResponse> {
val errorAttributesMap = getErrorAttributes(
request,
ErrorAttributeOptions.of(
ErrorAttributeOptions.Include.MESSAGE
)
)
return ServerResponse.status(401)
.contentType(MediaType.APPLICATION_JSON)
.body(BodyInserters.fromValue(errorAttributesMap))
}
}
//
//@Component
//@Order(-2)
//class GlobalErrorWebExceptionHandler(
// errorAttributes: ErrorAttributes,
// applicationContext: ApplicationContext,
// serverCodecConfigurer: ServerCodecConfigurer
//) : AbstractErrorWebExceptionHandler(
// errorAttributes,
// WebProperties.Resources(),
// applicationContext
//) {
//
// init {
// super.setMessageWriters(serverCodecConfigurer.writers)
// super.setMessageReaders(serverCodecConfigurer.readers)
// }
//
// override fun getRoutingFunction(errorAttributes: ErrorAttributes): RouterFunction<ServerResponse> {
// return RouterFunctions.route(RequestPredicates.all(), this::renderErrorResponse)
// }
//
// private fun renderErrorResponse(request: ServerRequest): Mono<ServerResponse> {
// val errorAttributesMap = getErrorAttributes(
// request,
// ErrorAttributeOptions.of(
// ErrorAttributeOptions.Include.MESSAGE
// )
// )
// return ServerResponse.status(401)
// .contentType(MediaType.APPLICATION_JSON)
// .body(BodyInserters.fromValue(errorAttributesMap))
// }
//
//
//}

View File

@@ -1,56 +1,80 @@
package space.luminic.finance.configs
import kotlinx.coroutines.reactor.mono
import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.http.HttpHeaders
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.context.ReactiveSecurityContextHolder
import org.springframework.security.core.context.SecurityContextImpl
import org.springframework.security.web.server.context.SecurityContextServerWebExchangeWebFilter
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.web.util.matcher.AntPathRequestMatcher
import org.springframework.stereotype.Component
import org.springframework.web.server.ServerWebExchange
import org.springframework.web.server.WebFilterChain
import reactor.core.publisher.Mono
import org.springframework.web.filter.OncePerRequestFilter
import space.luminic.finance.services.AuthService
@Component
class BearerTokenFilter(private val authService: AuthService) : SecurityContextServerWebExchangeWebFilter() {
// private val logger = LoggerFactory.getLogger(BearerTokenFilter::class.java)
class BearerTokenFilter(
private val authService: AuthService
) : OncePerRequestFilter() {
override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono<Void> {
val token = exchange.request.headers.getFirst(HttpHeaders.AUTHORIZATION)?.removePrefix("Bearer ")
if (exchange.request.path.value() in listOf(
"/api/auth/login",
"/api/auth/register",
"/api/auth/tgLogin"
) || exchange.request.path.value().startsWith("/api/actuator") || exchange.request.path.value()
.startsWith("/api/static/")
|| exchange.request.path.value()
.startsWith("/api/wishlistexternal/")
|| exchange.request.path.value().startsWith("/api/swagger-ui") || exchange.request.path.value().startsWith("/api/v3/api-docs")
) {
return chain.filter(exchange)
}
return if (token != null) {
mono {
val userDetails = authService.isTokenValid(token) // suspend вызов
val authorities = userDetails.roles.map { SimpleGrantedAuthority(it) }
val securityContext = SecurityContextImpl(
UsernamePasswordAuthenticationToken(userDetails.username, null, authorities)
// Публичные пути — как в твоём WebFlux-фильтре
private val publicMatchers = listOf(
AntPathRequestMatcher("/auth/login", "POST"),
AntPathRequestMatcher("/auth/register", "POST"),
AntPathRequestMatcher("/auth/tg-login", "POST"),
AntPathRequestMatcher("/actuator/**"),
AntPathRequestMatcher("/static/**"),
AntPathRequestMatcher("/wishlistexternal/**"),
AntPathRequestMatcher("/swagger-ui/**"),
AntPathRequestMatcher("/v3/api-docs/**"),
)
securityContext
}.flatMap { securityContext ->
chain.filter(exchange)
.contextWrite(ReactiveSecurityContextHolder.withSecurityContext(Mono.just(securityContext)))
override fun shouldNotFilter(request: HttpServletRequest): Boolean {
return publicMatchers.any { it.matches(request) }
}
} else {
Mono.error(AuthException("Authorization token is missing"))
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
chain: FilterChain
) {
val raw = request.getHeader(HttpHeaders.AUTHORIZATION)
val token = raw?.takeIf { it.startsWith("Bearer ", ignoreCase = true) }?.substring(7)
if (token.isNullOrBlank()) {
unauthorized(response, "Authorization token is missing")
return
}
try {
// AuthService.isTokenValid у тебя suspend — в Servlet-фильтре вызываем через runBlocking
val userDetails = authService.isTokenValid(token)
val authorities = userDetails.roles.map { SimpleGrantedAuthority(it) }
val auth = UsernamePasswordAuthenticationToken(userDetails.id, null, authorities)
SecurityContextHolder.getContext().authentication = auth
chain.doFilter(request, response)
} catch (ex: Exception) {
ex.printStackTrace()
unauthorized(response, ex.message ?: "Invalid token")
} finally {
// важно не «протекать» аутентификацией за пределы запроса
SecurityContextHolder.clearContext()
}
}
private fun unauthorized(response: HttpServletResponse, message: String) {
response.status = HttpServletResponse.SC_UNAUTHORIZED
response.contentType = "application/json"
response.writer.use { out ->
out.write("""{"error":"unauthorized","message":${message.quoteJson()}}""")
}
}
private fun String.quoteJson(): String =
"\"" + this.replace("\\", "\\\\").replace("\"", "\\\"") + "\""
}
open class AuthException(msg: String) : RuntimeException(msg)

View File

@@ -1,9 +1,5 @@
package space.luminic.finance.configs
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
//class CommonConfig {
// @Bean
@@ -12,8 +8,8 @@ import org.springframework.context.annotation.Configuration
// }
//}
@ConfigurationProperties(prefix = "nlp")
data class NLPConfig(
val address: String,
)
//
//@ConfigurationProperties(prefix = "nlp")
//data class NLPConfig(
// val address: String,
//)

View File

@@ -0,0 +1,31 @@
package space.luminic.finance.configs
import org.springframework.context.annotation.Configuration
import org.springframework.data.domain.AuditorAware
import org.springframework.stereotype.Component
import space.luminic.finance.models.User
import space.luminic.finance.services.AuthService
import java.util.*
@Configuration
class JpaConfig // Класс для включения аудита
@Component // Помечаем как Spring-компонент
class AuditorAwareImpl(
private val authService: AuthService,
) : AuditorAware<User> { // Убедитесь, что тип возвращаемого ID совпадает (Int)
override fun getCurrentAuditor(): Optional<User> {
return try {
val currentUser = authService.getSecurityUser()
// Проверяем, что пользователь не null перед доступом к id
Optional.of(currentUser) // currentUser.id должен быть Int
} catch (ex: Exception) {
// Логирование (по желанию)
// logger.debug("Authentication not found or invalid, auditor is null", ex)
// Если возникла ошибка при получении пользователя (например, не аутентифицирован), возвращаем Optional.empty()
Optional.empty()
}
}
}

View File

@@ -1,60 +1,62 @@
package space.luminic.finance.configs
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.HttpMethod
import org.springframework.security.config.web.server.SecurityWebFiltersOrder
import org.springframework.security.config.web.server.ServerHttpSecurity
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.web.server.SecurityWebFilterChain
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
import org.springframework.web.cors.CorsConfiguration
import org.springframework.web.cors.CorsConfigurationSource
import org.springframework.web.cors.UrlBasedCorsConfigurationSource
@Configuration
@EnableWebSecurity
class SecurityConfig(
private val bearerTokenFilter: BearerTokenFilter, // см. примечание ниже
) {
@Bean
fun securityWebFilterChain(
http: ServerHttpSecurity,
bearerTokenFilter: BearerTokenFilter
): SecurityWebFilterChain {
return http
.csrf { it.disable() }
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http
.csrf {
it.disable()
}
.cors { it.configurationSource(corsConfigurationSource()) }
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.logout { it.disable() }
.authorizeExchange {
it.pathMatchers(HttpMethod.POST, "/auth/login", "/auth/register", "/auth/tgLogin").permitAll()
it.pathMatchers("/actuator/**", "/static/**").permitAll()
it.pathMatchers("/wishlistexternal/**").permitAll()
it.pathMatchers("/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**").permitAll()
it.anyExchange().authenticated()
}
.addFilterAt(
bearerTokenFilter,
SecurityWebFiltersOrder.AUTHENTICATION
) // BearerTokenFilter только для authenticated
.build()
.authorizeHttpRequests {
it.requestMatchers(HttpMethod.POST, "/auth/login", "/auth/register", "/auth/tg-login").permitAll()
it.requestMatchers("/actuator/**", "/static/**").permitAll()
it.requestMatchers("/wishlistexternal/**").permitAll()
it.requestMatchers("/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**").permitAll()
it.anyRequest().authenticated()
}
// ваш JWT-фильтр до стандартной аутентификации
.addFilterBefore(bearerTokenFilter, UsernamePasswordAuthenticationFilter::class.java)
@Bean
fun passwordEncoder(): PasswordEncoder {
return BCryptPasswordEncoder()
return http.build()
}
@Bean
fun corsConfigurationSource(): org.springframework.web.cors.reactive.CorsConfigurationSource {
val corsConfig = org.springframework.web.cors.CorsConfiguration()
corsConfig.allowedOrigins =
listOf("https://luminic.space", "http://localhost:5173") // Ваши разрешённые источники
corsConfig.allowedMethods = listOf("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
corsConfig.allowedHeaders = listOf("*")
corsConfig.allowCredentials = true
fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder()
val source = org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource()
source.registerCorsConfiguration("/**", corsConfig)
@Bean
fun corsConfigurationSource(): CorsConfigurationSource {
val cors = CorsConfiguration().apply {
allowedOrigins = listOf("https://app.luminic.space", "http://localhost:5173")
allowedMethods = listOf("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
allowedHeaders = listOf("*")
allowCredentials = true
}
val source = UrlBasedCorsConfigurationSource()
source.registerCorsConfiguration("/**", cors)
return source
}
}

View File

@@ -1,37 +0,0 @@
package space.luminic.finance.dtos
import space.luminic.finance.models.Account.AccountType
import space.luminic.finance.models.Currency
import java.math.BigDecimal
import java.time.Instant
data class AccountDTO(
val id: String,
val name: String,
val type: AccountType = AccountType.COLLECTING,
val currencyCode: String,
val currency: CurrencyDTO? = null,
var balance: BigDecimal = BigDecimal.ZERO,
val goal: GoalDTO? = null,
val createdBy: String? = null,
val createdAt: Instant? = null,
val updatedBy: String? = null,
val updatedAt: Instant? = null,
){
data class CreateAccountDTO(
val name: String,
val type: AccountType,
val currencyCode: String,
val amount: BigDecimal,
val goalId: String? = null,
)
data class UpdateAccountDTO(
val id: String,
val name: String,
val type: AccountType,
val currencyCode: String,
val amount: BigDecimal,
val goalId: String? = null,
)
}

View File

@@ -1,69 +0,0 @@
package space.luminic.finance.dtos
import space.luminic.finance.models.Budget
import space.luminic.finance.models.Category
import space.luminic.finance.models.Transaction
import space.luminic.finance.models.Transaction.TransactionKind
import space.luminic.finance.models.Transaction.TransactionType
import space.luminic.finance.models.User
import java.math.BigDecimal
import java.time.Instant
import java.time.LocalDate
data class BudgetDTO(
val id: String?,
val type: Budget.BudgetType,
var name: String,
var description: String? = null,
var dateFrom: LocalDate,
var dateTo: LocalDate,
val isActive: Boolean,
val createdBy: UserDTO?,
val createdAt: Instant,
var updatedBy: UserDTO?,
var updatedAt: Instant,
) {
data class BudgetShortInfoDTO(
val id: String,
val type: Budget.BudgetType,
val name: String,
val description: String?,
val dateFrom: LocalDate,
val dateTo: LocalDate,
val createdBy: String
)
data class BudgetCategoryDto(
val category: CategoryDTO,
val limit: BigDecimal,
val totalPlannedAmount: BigDecimal,
val totalSpendingAmount: BigDecimal
)
data class CreateBudgetDTO(
val name: String,
val description: String? = null,
val dateFrom: LocalDate,
val dateTo: LocalDate
)
data class UpdateBudgetDTO(
val id: String,
val name: String? = null,
val description: String? = null,
val dateFrom: LocalDate? = null,
val dateTo: LocalDate? = null,
)
data class BudgetTransactionsDTO(
val categories: List<BudgetCategoryDto>,
val transactions: List<TransactionDTO>,
val plannedIncomeTransactions: List<TransactionDTO>,
val plannedExpenseTransactions: List<TransactionDTO>,
val instantIncomeTransactions: List<TransactionDTO>,
val instantExpenseTransactions: List<TransactionDTO>
)
}

View File

@@ -4,26 +4,29 @@ import space.luminic.finance.models.Category.CategoryType
import java.time.Instant
data class CategoryDTO(
val id: String,
val id: Int,
val type: CategoryType,
val name: String,
val description: String,
val icon: String,
val createdBy: UserDTO? = null,
val createdAt: Instant,
val createdAt: Instant? = null,
val updatedBy: UserDTO? = null,
val updatedAt: Instant,
val updatedAt: Instant? = null,
) {
data class CreateCategoryDTO(
val name: String,
val type: CategoryType,
val name: String,
val description: String,
val icon: String,
)
data class UpdateCategoryDTO(
val id: String,
val name: String,
val type: CategoryType,
val name: String,
val description: String,
val icon: String,
)
}

View File

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

View File

@@ -0,0 +1,29 @@
package space.luminic.finance.dtos
import java.math.BigDecimal
import java.time.Instant
data class RecurrentOperationDTO (
val id: Int,
val category: CategoryDTO,
val name: String,
val amount: BigDecimal,
val date: Int,
val createdBy: UserDTO? = null,
val createdAt: Instant
) {
data class CreateRecurrentOperationDTO (
val categoryId: Int,
val name: String,
val amount: BigDecimal,
val date: Int,
)
data class UpdateRecurrentOperationDTO (
val categoryId: Int,
val name: String,
val amount: BigDecimal,
val date: Int,
)
}

View File

@@ -4,15 +4,29 @@ import space.luminic.finance.models.User
import java.time.Instant
data class SpaceDTO(
val id: String? = null,
val id: Int? = null,
val name: String,
val owner: UserDTO,
val participants: List<UserDTO> = emptyList(),
val participants: Set<UserDTO> = emptySet(),
val createdBy: UserDTO? = null,
val createdAt: Instant,
var updatedBy: UserDTO? = null,
var updatedAt: Instant,
) {
data class SpaceShortInfoDTO(
val id: Int,
val name: String,
val isOwner: Boolean,
val owner: User,
val participant: User,
val createdAt: Instant,
val updatedAt: Instant? = null,
val createdBy: User,
val updatedBy: User? = null,
)
data class CreateSpaceDTO(
val name: String,
val createBasicCategories: Boolean = true,

View File

@@ -0,0 +1,6 @@
package space.luminic.finance.dtos
data class SubscriptionDTO (
val endpoint: String,
val keys: Map<String, String>
)

View File

@@ -1,52 +1,44 @@
package space.luminic.finance.dtos
import space.luminic.finance.models.Account
import space.luminic.finance.models.Category
import space.luminic.finance.models.Transaction.TransactionKind
import space.luminic.finance.models.Transaction.TransactionType
import space.luminic.finance.models.User
import java.math.BigDecimal
import java.time.Instant
import java.time.LocalDate
data class TransactionDTO(
val id: String? = null,
var parentId: String? = null,
val id: Int? = null,
var parentId: Int? = null,
val type: TransactionType = TransactionType.EXPENSE,
val kind: TransactionKind = TransactionKind.INSTANT,
val categoryId: String,
val category: CategoryDTO? = null,
val comment: String,
val amount: BigDecimal,
val fees: BigDecimal = BigDecimal.ZERO,
val fromAccount: AccountDTO? = null,
val toAccount: AccountDTO? = null,
val date: Instant,
val createdBy: String? = null,
val updatedBy: String? = null,
val date: LocalDate,
val isDone: Boolean,
val createdBy: UserDTO,
val updatedBy: UserDTO? = null,
) {
data class CreateTransactionDTO(
val type: TransactionType = TransactionType.EXPENSE,
val kind: TransactionKind = TransactionKind.INSTANT,
val categoryId: String,
val categoryId: Int? = null,
val comment: String,
val amount: BigDecimal,
val fees: BigDecimal = BigDecimal.ZERO,
val fromAccountId: String,
val toAccountId: String? = null,
val date: Instant
val date: LocalDate,
val recurrentId: Int? = null
)
data class UpdateTransactionDTO(
val id: String,
val type: TransactionType = TransactionType.EXPENSE,
val kind: TransactionKind = TransactionKind.INSTANT,
val category: String,
val categoryId: Int? = null,
val comment: String,
val amount: BigDecimal,
val fees: BigDecimal = BigDecimal.ZERO,
val fromAccountId: String,
val toAccountId: String? = null,
val date: Instant
val isDone: Boolean,
val date: LocalDate
)
}

View File

@@ -1,13 +1,14 @@
package space.luminic.finance.dtos
import space.luminic.finance.models.User
import kotlinx.serialization.Serializable
data class UserDTO(
var id: String,
var id: Int,
val username: String,
var firstName: String,
var tgId: String? = null,
var tgId: Long? = null,
var tgUserName: String? = null,
var photoUrl: String? = null,
var roles: List<String>
) {
@@ -24,6 +25,29 @@ data class UserDTO (
)
data class TelegramAuthDTO(
val id: Long?,
val first_name: String?,
val last_name: String?,
val username: String?,
val photo_url: String?,
val auth_date: Long?,
val hash: String,
val initData: String?,
)
@Serializable
class TelegramUserData(
val id: Long,
val first_name: String,
val last_name: String? = null,
val username: String? = null,
val photo_url: String? = null,
)
}

View File

@@ -1,26 +0,0 @@
package space.luminic.finance.mappers
import space.luminic.finance.dtos.AccountDTO
import space.luminic.finance.mappers.CurrencyMapper.toDto
import space.luminic.finance.mappers.GoalMapper.toDto
import space.luminic.finance.models.Account
object AccountMapper {
fun Account.toDto(): AccountDTO {
return AccountDTO(
id = this.id ?: throw IllegalStateException("Account ID must not be null"),
name = this.name,
type = this.type,
currencyCode = this.currencyCode,
currency = this.currency?.toDto(),
balance = this.amount,
goal = this.goal?.toDto(),
createdBy = this.createdBy?.username,
createdAt = this.createdAt,
updatedBy = this.updatedBy?.username,
updatedAt = this.updatedAt,
)
}
}

View File

@@ -1,88 +0,0 @@
package space.luminic.finance.mappers
import space.luminic.finance.dtos.BudgetDTO
import space.luminic.finance.dtos.TransactionDTO
import space.luminic.finance.mappers.CategoryMapper.toDto
import space.luminic.finance.mappers.TransactionMapper.toDto
import space.luminic.finance.mappers.UserMapper.toDto
import space.luminic.finance.models.Budget
import space.luminic.finance.models.Transaction
import space.luminic.finance.models.Transaction.TransactionKind
import space.luminic.finance.models.Transaction.TransactionType
import java.time.LocalDate
object BudgetMapper {
fun Budget.toDto(): BudgetDTO {
val isActive = this.dateTo.isBefore(LocalDate.now())
return BudgetDTO(
id = this.id,
type = this.type,
name = this.name,
description = this.description,
dateFrom = this.dateFrom,
dateTo = this.dateTo,
isActive = isActive,
createdBy = this.createdBy?.toDto(),
createdAt = this.createdAt?: throw IllegalArgumentException("created at is null"),
updatedBy = this.updatedBy?.toDto(),
updatedAt = this.updatedAt?: throw IllegalArgumentException("updated at is null"),
)
}
fun Budget.toShortDto(): BudgetDTO.BudgetShortInfoDTO = BudgetDTO.BudgetShortInfoDTO(
id = this.id!!,
type = this.type,
name = this.name,
description = this.description,
dateFrom = this.dateFrom,
dateTo = this.dateTo,
createdBy = this.createdBy?.username ?: throw IllegalArgumentException("created by is null"),
)
fun List<Transaction>.toDto(): BudgetDTO.BudgetTransactionsDTO {
val planningSpending = this.filter {
it.type == TransactionType.EXPENSE && it.kind == TransactionKind.PLANNING
}
val planningIncomes = this.filter {
it.type == TransactionType.INCOME && it.kind == TransactionKind.PLANNING
}
val instantSpendingTransactions = this.filter {
it.type == TransactionType.EXPENSE && it.kind == TransactionKind.INSTANT && it.parentId == null }
val instantIncomeTransactions = this.filter {
it.type == TransactionType.INCOME && it.kind == TransactionKind.INSTANT && it.parentId == null
}
val totalPlannedIncome = planningIncomes.sumOf { it.amount }
val totalPlannedSpending = planningSpending.sumOf { it.amount }
val categoriesWithPlannedAmounts = this.categories.map { cat ->
val totalPlannedAmount = planningSpending
.filter { it.id == cat.categoryId }
.sumOf { it.amount }
val totalInstantAmount = instantSpendingTransactions
.filter { it.id == cat.categoryId }
.sumOf { it.amount }
BudgetDTO.BudgetCategoryDto(cat.category?.toDto() ?: throw java.lang.IllegalArgumentException("category is not provided"), cat.limit, totalPlannedAmount, totalInstantAmount)
}
return BudgetDTO.BudgetTransactionsDTO(
categories = categoriesWithPlannedAmounts,
transactions = this.map{it.toDto()},
plannedIncomeTransactions = this.filter { it.kind == TransactionKind.PLANNING && it.type == TransactionType.INCOME }.map{it.toDto()},
plannedExpenseTransactions = this.filter { it.kind == TransactionKind.PLANNING && it.type == TransactionType.EXPENSE }.map{it.toDto()},
instantIncomeTransactions = this.filter { it.kind == TransactionKind.INSTANT && it.type == TransactionType.INCOME }.map{it.toDto()},
instantExpenseTransactions = this.filter { it.kind == TransactionKind.INSTANT && it.type == TransactionType.EXPENSE }.map{it.toDto()},
)
}
}

View File

@@ -11,11 +11,12 @@ object CategoryMapper {
id = this.id ?: throw IllegalArgumentException("category id is not set"),
type = this.type,
name = this.name,
description = this.description,
icon = this.icon,
createdBy = this.createdBy?.toDto(),
createdAt = this.createdAt ?: throw IllegalArgumentException("created at is not set"),
createdAt = this.createdAt,
updatedBy = this.updatedBy?.toDto(),
updatedAt = this.updatedAt ?: throw IllegalArgumentException("updated at is not set"),
updatedAt = this.updatedAt
)

View File

@@ -10,12 +10,13 @@ object GoalMapper {
id = this.id ?: throw IllegalArgumentException("Goal id is not provided"),
type = this.type,
name = this.name,
amount = this.goalAmount,
date = this.goalDate,
amount = this.amount,
date = this.untilDate,
components = this.components,
transactions = this.transactions,
createdBy = (this.createdBy ?: throw IllegalArgumentException("created by not provided")).toDto(),
createdAt = this.createdAt ?: throw IllegalArgumentException("created at not provided"),
updatedBy = this.updatedBy?.toDto() ?: throw IllegalArgumentException("updated by not provided"),
updatedAt = this.updatedAt ?: throw IllegalArgumentException("updatedAt not provided"),
updatedBy = this.updatedBy?.toDto() ,
updatedAt = this.updatedAt
)
}

View File

@@ -0,0 +1,21 @@
package space.luminic.finance.mappers
import space.luminic.finance.dtos.RecurrentOperationDTO
import space.luminic.finance.mappers.CategoryMapper.toDto
import space.luminic.finance.mappers.UserMapper.toDto
import space.luminic.finance.models.RecurrentOperation
object RecurrentOperationMapper {
fun RecurrentOperation.toDTO(): RecurrentOperationDTO {
return RecurrentOperationDTO(
id = this.id ?: throw IllegalArgumentException("id is null"),
category = this.category.toDto(),
name = this.name,
amount = this.amount,
date = this.date,
createdBy = this.createdBy?.toDto(),
createdAt = this.createdAt ?: throw NullPointerException("created at"),
)
}
}

View File

@@ -8,8 +8,8 @@ object SpaceMapper {
fun Space.toDto() = SpaceDTO(
id = this.id,
name = this.name,
owner = this.owner?.toDto() ?: throw IllegalArgumentException("Owner is not provided"),
participants = this.participants?.map { it.toDto() } ?: emptyList(),
owner = this.owner.toDto(),
participants = this.participants.map { it.toDto() }.toSet(),
createdBy = this.createdBy?.toDto(),
createdAt = this.createdAt ?: throw IllegalArgumentException("createdAt is not provided"),
updatedBy = this.updatedBy?.toDto(),

View File

@@ -1,9 +1,6 @@
package space.luminic.finance.mappers
import space.luminic.finance.dtos.TransactionDTO
import space.luminic.finance.dtos.TransactionDTO.CreateTransactionDTO
import space.luminic.finance.dtos.TransactionDTO.UpdateTransactionDTO
import space.luminic.finance.mappers.AccountMapper.toDto
import space.luminic.finance.mappers.CategoryMapper.toDto
import space.luminic.finance.mappers.UserMapper.toDto
import space.luminic.finance.models.Transaction
@@ -11,25 +8,16 @@ import space.luminic.finance.models.Transaction
object TransactionMapper {
fun Transaction.toDto() = TransactionDTO(
id = this.id,
parentId = this.parentId,
parentId = this.parent?.id,
type = this.type,
kind = this.kind,
categoryId = this.categoryId,
category = this.category?.toDto(),
comment = this.comment,
amount = this.amount,
fees = this.fees,
fromAccount = this.fromAccount?.toDto(),
toAccount = this.toAccount?.toDto(),
date = this.date,
createdBy = this.createdBy?.username,
updatedBy = this.updatedBy?.username,
isDone = this.isDone,
createdBy = this.createdBy?.toDto() ?: throw IllegalStateException("created by null"),
updatedBy = this.updatedBy?.toDto()
)
}

View File

@@ -1,6 +1,7 @@
package space.luminic.finance.mappers
import space.luminic.finance.dtos.UserDTO
import space.luminic.finance.dtos.UserDTO.TelegramAuthDTO
import space.luminic.finance.models.User
object UserMapper {
@@ -11,6 +12,18 @@ object UserMapper {
firstName = this.firstName,
tgId = this.tgId,
tgUserName = this.tgUserName,
photoUrl = this.photoUrl,
roles = this.roles
)
fun TelegramAuthDTO.toTelegramMap(): Map<String, String> =
mapOf(
"id" to id.toString(),
"first_name" to (first_name ?: ""),
"last_name" to (last_name ?: ""),
"username" to (username ?: ""),
"photo_url" to (photo_url ?: ""),
"auth_date" to auth_date.toString(),
"hash" to hash
)
}

View File

@@ -1,51 +0,0 @@
package space.luminic.finance.models
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.Id
import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.annotation.ReadOnlyProperty
import org.springframework.data.relational.core.mapping.Column
import org.springframework.data.relational.core.mapping.Table
import java.math.BigDecimal
import java.time.Instant
@Table(name = "accounts")
data class Account (
@Id val id: Int? = null,
@Column("space_id")
val spaceId: Int,
val name: String,
val type: AccountType = AccountType.COLLECTING,
@Column("currency_code")
val currencyCode: String,
var amount: BigDecimal,
@Column("goal_id")
var goalId: Int? = null,
@Column("is_deleted")
var isDeleted: Boolean = false,
@Column("created_by")
val createdById: String? = null,
@CreatedDate
@Column("created_at")
val createdAt: Instant? = null,
@Column("updated_by")
val updatedById: String? = null,
@LastModifiedDate
@Column("updated_at")
val updatedAt: Instant? = null,
) {
@ReadOnlyProperty var goal: Goal? = null
@ReadOnlyProperty var currency: Currency? = null
@ReadOnlyProperty var transactions: List<Transaction>? = null
@ReadOnlyProperty var createdBy: User? = null
@ReadOnlyProperty var updatedBy: User? = null
enum class AccountType(displayName: String) {
SALARY("Зарплатный"),
COLLECTING("Накопительный"),
LOANS("Долговой"),
}
}

View File

@@ -1,62 +0,0 @@
package space.luminic.finance.models
import org.springframework.data.annotation.CreatedBy
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.Id
import org.springframework.data.annotation.LastModifiedBy
import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.annotation.ReadOnlyProperty
import org.springframework.data.relational.core.mapping.Column
import org.springframework.data.relational.core.mapping.Table
import java.math.BigDecimal
import java.time.Instant
import java.time.LocalDate
@Table( "budgets")
data class Budget(
@Id var id: Int? = null,
@Column("space_id")
val spaceId: Int,
val type: BudgetType = BudgetType.SPECIAL,
var name: String,
var description: String? = null,
@Column("date_from")
var dateFrom: LocalDate,
@Column("date_to")
var dateTo: LocalDate,
val transactions: List<Transaction> = listOf(),
val categories: List<BudgetCategory> = listOf(),
@Column("is_deleted")
var isDeleted: Boolean = false,
@CreatedBy
@Column("created_by")
val createdById: Int? = null,
@CreatedDate
@Column("created_at")
var createdAt: Instant? = null,
@LastModifiedBy
@Column("updated_by")
var updatedById: Int? = null,
@LastModifiedDate
@Column("updated_at")
var updatedAt: Instant? = null,
) {
@ReadOnlyProperty var createdBy: User? = null
@ReadOnlyProperty var updatedBy: User? = null
data class BudgetCategory(
val categoryId: String,
val limit: BigDecimal
) {
@ReadOnlyProperty var category: Category? = null
}
enum class BudgetType(val displayName: String) {
MONTHLY("Месячный"),
SPECIAL("Специальный")
}
}

View File

@@ -2,35 +2,24 @@ package space.luminic.finance.models
import org.springframework.data.annotation.CreatedBy
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.Id
import org.springframework.data.annotation.LastModifiedBy
import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.annotation.ReadOnlyProperty
import org.springframework.data.mongodb.core.mapping.Document
import org.springframework.data.annotation.Transient
import java.time.Instant
@Document(collection = "categories")
data class Category(
@Id val id: String? = null,
val spaceId: String,
var id: Int? = null,
val space: Space? = null,
val type: CategoryType,
val name: String,
val description: String,
val icon: String,
var isDeleted: Boolean = false,
@CreatedBy
val createdById: String? = null,
@CreatedDate
val createdAt: Instant? = null,
@LastModifiedBy
val updatedById: String? = null,
@LastModifiedDate
val updatedAt: Instant? = null,
@CreatedBy var createdBy: User? = null,
@CreatedDate var createdAt: Instant? = null,
@LastModifiedBy var updatedBy: User? = null,
@LastModifiedDate var updatedAt: Instant? = null,
) {
@ReadOnlyProperty var createdBy: User? = null
@ReadOnlyProperty var updatedBy: User? = null
enum class CategoryType(val displayName: String) {
INCOME("Поступления"),
@@ -38,9 +27,8 @@ data class Category(
}
@Document(collection = "categories_etalon")
data class CategoryEtalon(
@Id val id: String? = null,
var id: Int? = null,
val type: CategoryType,
val name: String,
val icon: String

View File

@@ -1,12 +1,9 @@
package space.luminic.finance.models
import org.springframework.data.annotation.Id
import org.springframework.data.mongodb.core.mapping.Document
import java.math.BigDecimal
@Document(collection = "currencies_ref")
data class Currency(
@Id val code: String,
val code: String,
val name: String,
val symbol: String
)

View File

@@ -1,18 +1,12 @@
package space.luminic.finance.models
import org.springframework.data.annotation.Id
import org.springframework.data.annotation.ReadOnlyProperty
import org.springframework.data.mongodb.core.mapping.Document
import java.math.BigDecimal
import java.time.LocalDate
@Document(collection = "currency_rates")
data class CurrencyRate(
@Id val id: String? = null,
val currencyCode: String,
var id: Int? = null,
val currency: Currency,
val rate: BigDecimal,
val date: LocalDate
)
{
@ReadOnlyProperty var currency: Currency? = null
}

View File

@@ -2,35 +2,43 @@ package space.luminic.finance.models
import org.springframework.data.annotation.CreatedBy
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.Id
import org.springframework.data.annotation.LastModifiedBy
import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.mongodb.core.mapping.Document
import org.springframework.data.annotation.Transient
import space.luminic.finance.dtos.UserDTO
import java.math.BigDecimal
import java.text.Bidi
import java.time.Instant
import java.time.LocalDate
@Document(collection = "goals")
data class Goal(
@Id val id: String? = null,
val spaceId: String,
var id: Int? = null,
val space: Space? = null,
val type: GoalType,
val name: String,
val description: String? = null,
val goalAmount: BigDecimal,
val goalDate: LocalDate,
@CreatedBy val createdById: String,
@Transient val createdBy: User? = null,
@CreatedDate val createdAt: Instant? = null,
@LastModifiedBy val updatedById: String,
@Transient val updatedBy: User? = null,
@LastModifiedDate val updatedAt: Instant? = null,
val amount: BigDecimal,
val components: List<GoalComponent> = emptyList(),
val transactions: List<Transaction> = emptyList(),
val untilDate: LocalDate,
@CreatedBy var createdBy: User? = null,
@CreatedDate var createdAt: Instant? = null,
@LastModifiedBy var updatedBy: User? = null,
@LastModifiedDate var updatedAt: Instant? = null,
) {
var currentAmount: BigDecimal = {
this.transactions.sumOf { it.amount }
} as BigDecimal
data class GoalComponent(
val id: Int? = null,
val name: String,
val amount: BigDecimal,
val isDone: Boolean = false,
val date: LocalDate = LocalDate.now(),
)
enum class GoalType(val displayName: String, val icon: String) {
AUTO("Авто", "🏎️"),
VACATION("Отпуск", "🏖️"),

View File

@@ -0,0 +1,17 @@
package space.luminic.finance.models
import java.math.BigDecimal
import java.time.Instant
data class RecurrentOperation(
val id: Int? = null,
val space: Space,
val category: Category,
val name: String,
val amount: BigDecimal,
val date: Int,
val createdBy: User? = null,
val createdAt: Instant? = null,
val updatedBy: User? = null,
val updatedAt: Instant? = null,
)

View File

@@ -2,35 +2,21 @@ package space.luminic.finance.models
import org.springframework.data.annotation.CreatedBy
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.Id
import org.springframework.data.annotation.LastModifiedBy
import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.annotation.ReadOnlyProperty
import org.springframework.data.mongodb.core.mapping.Document
import org.springframework.data.annotation.Transient
import java.time.Instant
@Document(collection = "spaces")
data class Space (
@Id val id: String? = null,
var id: Int? = null,
val name: String,
val ownerId: String,
val participantsIds: List<String> = emptyList(),
val owner: User,
var participants: Set<User>,
var isDeleted: Boolean = false,
@CreatedBy val createdById: String? = null,
@CreatedDate val createdAt: Instant? = null,
@LastModifiedBy val updatedById: String? = null,
@CreatedBy var createdBy: User? = null,
@CreatedDate var createdAt: Instant? = null,
@LastModifiedBy var updatedBy: User? = null,
@LastModifiedDate var updatedAt: Instant ? = null,
) {
@ReadOnlyProperty var owner: User? = null
@ReadOnlyProperty var participants: List<User>? = null
@ReadOnlyProperty var createdBy: User? = null
@ReadOnlyProperty var updatedBy: User? = null
override fun equals(other: Any?) = this === other || (other is Space && id != null && id == other.id)
override fun hashCode() = id?.hashCode() ?: 0
}

View File

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

View File

@@ -1,23 +1,19 @@
package space.luminic.finance.models
import org.springframework.data.annotation.Id
import org.springframework.data.mongodb.core.mapping.DBRef
import org.springframework.data.mongodb.core.mapping.Document
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.LastModifiedDate
import java.time.Instant
@Document(collection = "subscriptions")
data class Subscription(
@Id val id: String? = null,
@DBRef val user: User? = null,
var id: Int? = null,
val user: User,
val endpoint: String,
val auth: String,
val p256dh: String,
var isActive: Boolean,
val createdAt: Instant = Instant.now(),
@CreatedDate val createdAt: Instant? = null,
@LastModifiedDate val updatedAt: Instant? = null,
)
data class SubscriptionDTO (
val endpoint: String,
val keys: Map<String, String>
)

View File

@@ -1,17 +1,13 @@
package space.luminic.finance.models
import org.springframework.data.annotation.Id
import org.springframework.data.mongodb.core.mapping.Document
import java.time.LocalDateTime
import java.time.Instant
@Document(collection = "tokens")
data class Token(
@Id
val id: String? = null,
var id: Int? = null,
val token: String,
val username: String,
val issuedAt: LocalDateTime,
val expiresAt: LocalDateTime,
val user: User,
val issuedAt: Instant = Instant.now(),
val expiresAt: Instant,
val status: TokenStatus = TokenStatus.ACTIVE
) {

View File

@@ -2,49 +2,34 @@ package space.luminic.finance.models
import org.springframework.data.annotation.CreatedBy
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.Id
import org.springframework.data.annotation.LastModifiedBy
import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.annotation.ReadOnlyProperty
import org.springframework.data.mongodb.core.mapping.Document
import org.springframework.data.annotation.Transient
import java.math.BigDecimal
import java.time.Instant
import java.time.LocalDate
@Document(collection = "transactions")
data class Transaction(
@Id val id: String? = null,
val spaceId: String,
var parentId: String? = null,
var id: Int? = null,
val space: Space? = null,
var parent: Transaction? = null,
val type: TransactionType = TransactionType.EXPENSE,
val kind: TransactionKind = TransactionKind.INSTANT,
val categoryId: String,
val comment: String,
val category: Category? = null,
var comment: String,
val amount: BigDecimal,
val fees: BigDecimal = BigDecimal.ZERO,
val fromAccountId: String,
val toAccountId: String? = null,
val date: Instant = Instant.now(),
val date: LocalDate = LocalDate.now(),
var isDeleted: Boolean = false,
@CreatedBy
val createdById: String? = null,
@CreatedDate
val createdAt: Instant? = null,
@LastModifiedBy
val updatedById: String? = null,
@LastModifiedDate
val updatedAt: Instant? = null,
val isDone: Boolean = false,
@CreatedBy var createdBy: User? = null,
@CreatedDate var createdAt: Instant? = null,
@LastModifiedBy var updatedBy: User? = null,
@LastModifiedDate var updatedAt: Instant? = null,
val tgChatId: Long? = null,
val tgMessageId: Long? = null,
val recurrentId: Int? = null,
) {
@ReadOnlyProperty var category: Category? = null
@ReadOnlyProperty var toAccount: Account? = null
@ReadOnlyProperty var fromAccount: Account? = null
@ReadOnlyProperty var createdBy: User? = null
@ReadOnlyProperty var updatedBy: User? = null
enum class TransactionType(val displayName: String) {
INCOME("Поступления"),

View File

@@ -1,25 +1,25 @@
package space.luminic.finance.models
import org.springframework.data.annotation.Id
import org.springframework.data.mongodb.core.mapping.Document
import org.springframework.data.annotation.Transient
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.LastModifiedDate
import java.time.Instant
import java.time.LocalDate
import java.time.LocalDateTime
@Document("users")
data class User(
@Id
var id: String? = null,
var id: Int? = null,
val username: String,
var firstName: String,
var tgId: String? = null,
var tgId: Long? = null,
var tgUserName: String? = null,
var password: String,
val photoUrl: String? = null,
var password: String? = null,
var isActive: Boolean = true,
var regDate: LocalDate = LocalDate.now(),
val createdAt: LocalDateTime = LocalDateTime.now(),
var roles: MutableList<String> = mutableListOf(),
@CreatedDate val createdAt: Instant? = null,
@LastModifiedDate var updatedAt: Instant? = null,
var roles: List<String> = listOf(),
)

View File

@@ -1,7 +0,0 @@
package space.luminic.finance.repos
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import space.luminic.finance.models.Account
interface AccountRepo : ReactiveMongoRepository<Account, String> {
}

View File

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

View File

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

View File

@@ -1,14 +0,0 @@
package space.luminic.finance.repos
import org.bson.types.ObjectId
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import org.springframework.stereotype.Repository
import reactor.core.publisher.Flux
import space.luminic.finance.models.Budget
@Repository
interface BudgetRepo: ReactiveMongoRepository<Budget, String> {
suspend fun findBudgetsBySpaceIdAndIsDeletedFalse(spaceId: String): Flux<Budget>
suspend fun findBudgetsBySpaceIdAndId(spaceId: String, budgetId: String): Flux<Budget>
}

View File

@@ -1,9 +1,16 @@
package space.luminic.finance.repos
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import space.luminic.finance.models.Category
interface CategoryRepo: ReactiveMongoRepository<Category, String> {
interface CategoryRepo {
fun findBySpaceId(spaceId: Int): List<Category>
fun findBySpaceIdAndId(spaceId: Int, id: Int): Category?
fun create(category: Category, createdById: Int): Category
fun update(category: Category, updatedById: Int): Category
fun delete(categoryId: Int)
}
interface CategoryEtalonRepo: ReactiveMongoRepository<Category.CategoryEtalon, String>
interface CategoryEtalonRepo {
fun findAll(): List<Category>
}

View File

@@ -0,0 +1,107 @@
package space.luminic.finance.repos
import org.springframework.dao.DuplicateKeyException
import org.springframework.jdbc.core.RowMapper
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
import org.springframework.stereotype.Repository
import space.luminic.finance.models.Category
@Repository
class CategoryRepoImpl(
private val jdbcTemplate: NamedParameterJdbcTemplate
) : CategoryRepo {
private fun categoryRowMapper() = RowMapper<Category> { rs, _ ->
Category(
id = rs.getInt("id"),
type = Category.CategoryType.valueOf(rs.getString("type")),
name = rs.getString("name"),
description = rs.getString("description"),
icon = rs.getString("icon"),
isDeleted = rs.getBoolean("is_deleted"),
createdAt = rs.getTimestamp("created_at").toInstant(),
updatedAt = if (rs.getTimestamp("updated_at") != null) rs.getTimestamp("updated_at").toInstant() else null
)
}
override fun findBySpaceId(spaceId: Int): List<Category> {
val query = "select * from finance.categories where space_id = :space_id order by name"
val params = mapOf("space_id" to spaceId)
return jdbcTemplate.query(query, params, categoryRowMapper())
}
override fun findBySpaceIdAndId(spaceId: Int, id: Int): Category? {
val query = "select * from finance.categories where space_id = :space_id and id = :id"
val params = mapOf("id" to id, "space_id" to spaceId)
return jdbcTemplate.query(query, params, categoryRowMapper()).firstOrNull()
}
override fun create(category: Category, createdById: Int): Category {
if (category.id == null) {
val query =
"insert into finance.categories(space_id, type, name, description, icon, is_deleted, created_by_id) values (:space_id, :type, :name, :description, :icon, :isDeleted, :createdById) returning id"
val params = mapOf(
"space_id" to category.space!!.id,
"type" to category.type.name,
"name" to category.name,
"description" to category.description,
"icon" to category.icon,
"isDeleted" to category.isDeleted,
"createdById" to createdById
)
try {
category.id = jdbcTemplate.queryForObject(query, params, Int::class.java)
return category
} catch (ex: DuplicateKeyException) {
throw IllegalArgumentException("You cannot create a category with similar name in one space", ex)
}
} else throw IllegalArgumentException("Category id must by null")
}
override fun update(category: Category, updatedById: Int): Category {
if (category.id != null) {
val query =
"update finance.categories set type=:type, name=:name, description=:description, icon=:icon, is_deleted=:isDeleted , updated_by_id = :updatedById, updated_at = now() where id = :id"
val params = mapOf(
"id" to category.id!!,
"type" to category.type.name,
"name" to category.name,
"description" to category.description,
"icon" to category.icon,
"isDeleted" to category.isDeleted,
"updatedById" to updatedById
)
jdbcTemplate.update(query, params)
return category
} else throw IllegalArgumentException("Category cannot be null")
}
override fun delete(categoryId: Int) {
val query = "update finance.categories set is_deleted=:isDeleted where id = :id"
val params = mapOf(
"id" to categoryId,
"isDeleted" to true
)
jdbcTemplate.update(query, params)
}
}
@Repository
class CategoryEtalonRepoImpl(
private val jdbcTemplate: NamedParameterJdbcTemplate
) : CategoryEtalonRepo {
private val rowMapper = RowMapper { rs, _ ->
Category(
type = Category.CategoryType.valueOf(rs.getString("type")),
name = rs.getString("name"),
description = rs.getString("description"),
icon = rs.getString("icon"),
)
}
override fun findAll(): List<Category> {
val query = "select * from finance.categories_etalon"
return jdbcTemplate.query(query, rowMapper)
}
}

View File

@@ -1,8 +1,21 @@
package space.luminic.finance.repos
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import org.springframework.stereotype.Repository
import space.luminic.finance.models.Currency
import space.luminic.finance.models.CurrencyRate
interface CurrencyRepo: ReactiveMongoRepository<Currency, String>
interface CurrencyRateRepo: ReactiveMongoRepository<CurrencyRate, String>
@Repository
interface CurrencyRepo {
fun findAll(): List<Currency>
fun findByCode(code: String): Currency?
fun save(currency: Currency): Currency
fun update(currency: Currency)
fun delete(currencyCode: String)
}
//
//interface CurrencyRateRepo {
// fun findByCode(code: String): List<CurrencyRate>
// fun save(currencyRate: CurrencyRate)
// fun update(currencyRate: CurrencyRate)
// fun delete(id: Int)
//}

View File

@@ -0,0 +1,50 @@
package space.luminic.finance.repos
import org.springframework.jdbc.core.RowMapper
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
import org.springframework.stereotype.Repository
import space.luminic.finance.models.Currency
@Repository
class CurrencyRepoImpl(
private val jdbcTemplate: NamedParameterJdbcTemplate
): CurrencyRepo {
private fun currencyRowMapper() = RowMapper<Currency> { rs, _ ->
Currency(
code = rs.getString("code"),
name = rs.getString("name"),
symbol = rs.getString("symbol"),
)
}
override fun findAll(): List<Currency> {
val sql = "SELECT * FROM finance.currencies_ref"
return jdbcTemplate.query(sql, currencyRowMapper())
}
override fun findByCode(code: String): Currency? {
val sql = "SELECT * FROM finance.currencies_ref WHERE code = :code"
val params = mapOf("code" to code)
return jdbcTemplate.queryForObject(sql, params, Currency::class.java)
}
override fun save(currency: Currency): Currency {
val sql = "insert into finance.currencies_ref (code, name, symbol) values (:code, :name, :symbol)"
val params = mapOf("code" to currency.code, "name" to currency.name, "symbol" to currency.symbol)
jdbcTemplate.update(sql, params)
return currency
}
override fun update(currency: Currency) {
val sql = "update finance.currencies_ref set name=:name, symbol=:symbol where code = :code"
val params = mapOf("name" to currency.code, "symbol" to currency.name)
jdbcTemplate.update(sql, params)
}
override fun delete(currencyCode: String) {
val sql = "delete from finance.currencies_ref where code = :code"
val params = mapOf("code" to currencyCode)
jdbcTemplate.update(sql, params)
}
}

View File

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

View File

@@ -0,0 +1,283 @@
package space.luminic.finance.repos
import org.springframework.jdbc.core.RowMapper
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
import org.springframework.stereotype.Repository
import space.luminic.finance.models.Goal
import space.luminic.finance.models.User
@Repository
class GoalRepoImpl(
private val jdbcTemplate: NamedParameterJdbcTemplate
) : GoalRepo {
private val goalRowMapper = RowMapper { rs, _ ->
Goal(
id = rs.getInt("g_id"),
type = Goal.GoalType.valueOf(rs.getString("g_type")),
name = rs.getString("g_name"),
description = rs.getString("g_description"),
amount = rs.getBigDecimal("g_amount"),
components = listOf(),
transactions = listOf(),
untilDate = rs.getDate("g_until_date").toLocalDate(),
createdBy = User(
id = rs.getInt("created_by_id"),
username = rs.getString("created_by_username"),
firstName = rs.getString("created_by_first_name"),
),
createdAt = rs.getTimestamp("g_created_at").toInstant()
)
}
private val componentRowMapper = RowMapper { rs, _ ->
Goal.GoalComponent(
id = rs.getInt("gc_id"),
name = rs.getString("gc_name"),
amount = rs.getBigDecimal("gc_amount"),
isDone = rs.getBoolean("gc_is_done"),
date = rs.getDate("gc_date").toLocalDate()
)
}
override fun findAllBySpaceId(spaceId: Int): List<Goal> {
val sql = """
select
g.id as g_id,
g.type as g_type,
g.name as g_name,
g.description as g_description,
g.amount as g_amount,
created_by.id as created_by_id,
created_by.username as created_by_username,
created_by.first_name as created_by_first_name,
g.created_at as g_created_at
from finance.goals g
join finance.users created_by on g.created_by_id = created_by.id
where g.space_id = :spaceId
""".trimIndent()
val params = mapOf(
"space_id" to spaceId,
)
return jdbcTemplate.query(sql, params, goalRowMapper)
}
override fun findBySpaceIdAndId(spaceId: Int, id: Int): Goal? {
val sql = """
select
g.id as g_id,
g.type as g_type,
g.name as g_name,
g.description as g_description,
g.amount as g_amount,
created_by.id as created_by_id,
created_by.username as created_by_username,
created_by.first_name as created_by_first_name,
g.created_at as g_created_at
from finance.goals g
join finance.users created_by on g.created_by_id = created_by.id
where g.space_id = :spaceId and g.id = :id
""".trimIndent()
val params = mapOf(
"space_id" to spaceId,
"id" to id,
)
return jdbcTemplate.query(sql, params, goalRowMapper).firstOrNull()
}
override fun create(goal: Goal, createdById: Int): Int {
val sql = """
insert into finance.goals(
type,
name,
description,
amount,
until_date,
created_by_id
) values (
:type,
:name,
:description,
:amount,
:until_date,
:created_by_id
)
returning id
""".trimIndent()
val params = mapOf(
"type" to goal.type,
"name" to goal.name,
"description" to goal.description,
"amount" to goal.amount,
"until_date" to goal.untilDate,
"created_by_id" to createdById
)
return jdbcTemplate.queryForObject(sql, params, Int::class.java)!!
}
override fun update(goal: Goal, updatedById: Int) {
val sql = """
update finance.goals set
type = :type,
name = :name,
description = :description,
amount = :amount,
until_date = :until_date,
updated_by_id = :updated_by_id,
updated_at = now()
where id = :id
""".trimIndent()
val params = mapOf(
"id" to goal.id,
"type" to goal.type.name,
"name" to goal.name,
"description" to goal.description,
"amount" to goal.amount,
"until_date" to goal.untilDate,
"updated_by_id" to updatedById
)
jdbcTemplate.update(sql, params)
}
override fun delete(spaceId: Int, id: Int) {
val sql = """
delete from finance.goals where id = :id
""".trimIndent()
val params = mapOf(
"id" to id
)
jdbcTemplate.update(sql, params)
}
override fun getComponents(
spaceId: Int,
goalId: Int
): List<Goal.GoalComponent> {
val sql = """
select
gc.id as gc_id,
gc.name as gc_name,
gc.amount as gc_amount,
gc.is_done as gc_is_done,
gc.date as gc_date
from finance.goals_components gc
where gc.goal_id = :goal_id
""".trimIndent()
val params = mapOf(
"goal_id" to goalId
)
return jdbcTemplate.query(sql, params, componentRowMapper)
}
override fun getComponent(
spaceId: Int,
goalId: Int,
id: Int
): Goal.GoalComponent? {
val sql = """
select
gc.id as gc_id,
gc.name as gc_name,
gc.amount as gc_amount,
gc.is_done as gc_is_done,
gc.date as gc_date
from finance.goals_components gc
where gc.goal_id = :goal_id and gc.id = :id
""".trimIndent()
val params = mapOf(
"goal_id" to goalId,
"id" to id
)
return jdbcTemplate.query(sql, params, componentRowMapper).firstOrNull()
}
override fun createComponent(goalId: Int, component: Goal.GoalComponent, createdById: Int): Int {
val sql = """
insert into finance.goals_components(
goal_id,
name,
amount,
is_done,
date,
created_by_id
) values (
:goal_id,
:name,
:amount,
:is_done,
:date,
:created_by_id)
returning id
""".trimIndent()
val params = mapOf(
"goal_id" to goalId,
"name" to component.name,
"amount" to component.amount,
"is_done" to component.isDone,
"date" to component.date,
"created_by_id" to createdById
)
return jdbcTemplate.queryForObject(sql, params, Int::class.java)!!
}
override fun updateComponent(goalId: Int, componentId: Int, component: Goal.GoalComponent, updatedById: Int) {
val sql = """
update finance.goals_components set
name = :name,
amount = :amount,
is_done = :is_done,
updated_by_id = :updated_by_id
where goal_id = :goalId and id = :componentId
""".trimIndent()
val params = mapOf(
"goalId" to goalId,
"componentId" to componentId,
"name" to component.name,
"amount" to component.amount,
"is_done" to component.isDone,
"date" to component.date,
"updated_by_id" to updatedById
)
jdbcTemplate.update(sql, params)
}
override fun deleteComponent(goalId: Int, componentId: Int) {
val sql = """
delete from finance.goals_components where goal_id = :goalId and id = :componentId
""".trimIndent()
val params = mapOf(
"goalId" to goalId,
"componentId" to componentId
)
jdbcTemplate.update(sql, params)
}
override fun assignTransaction(goalId: Int, transactionId: Int) {
val sql = """
insert into finance.goals_transactions(goal_id, transactions_id)
values (:goal_id, :transaction_id)
""".trimIndent()
val params = mapOf(
"goal_id" to goalId,
"transaction_id" to transactionId
)
jdbcTemplate.update(sql, params)
}
override fun refuseTransaction(goalId: Int, transactionId: Int) {
val sql = """
delete from finance.goals_transactions where goal_id = :goalId and transactions_id = :transactionId
""".trimIndent()
val params = mapOf(
"goal_id" to goalId,
"transaction_id" to transactionId
)
jdbcTemplate.update(sql, params)
}
}

View File

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

View File

@@ -0,0 +1,221 @@
package space.luminic.finance.repos
import org.springframework.jdbc.core.RowMapper
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
import org.springframework.stereotype.Repository
import space.luminic.finance.models.Category
import space.luminic.finance.models.RecurrentOperation
import space.luminic.finance.models.Space
import space.luminic.finance.models.User
@Repository
class RecurrentOperationRepoImpl(
private val jdbcTemplate: NamedParameterJdbcTemplate
) : RecurrentOperationRepo {
private fun operationRowMapper() = RowMapper<RecurrentOperation> { rs, _ ->
RecurrentOperation(
id = rs.getInt("r_id"),
space = Space(
id = rs.getInt("r_space_id"),
name = rs.getString("s_name"),
owner = User(
rs.getInt("s_owner_id"),
username = rs.getString("su_username"),
firstName = rs.getString("su_first_name"),
),
participants = setOf()
),
category = Category(
id = rs.getInt("r_category_id"),
type = Category.CategoryType.valueOf(rs.getString("c_type")),
name = rs.getString("c_name"),
description = rs.getString("c_description"),
icon = rs.getString("c_icon"),
),
name = rs.getString("r_name"),
amount = rs.getBigDecimal("r_amount"),
date = rs.getInt("r_date"),
createdBy = User(
id = rs.getInt("r_created_by_id"),
username = rs.getString("r_created_by_username"),
firstName = rs.getString("r_created_by_first_name"),
),
createdAt = rs.getTimestamp("r_created_at").toInstant()
)
}
override fun findAllBySpaceId(spaceId: 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.space_id = :spaceId
order by ro.date, ro.id
""".trimIndent()
val params = mapOf("spaceId" to spaceId)
return jdbcTemplate.query(sql, params, operationRowMapper())
}
override fun findBySpaceIdAndId(
spaceId: Int,
id: Int
): 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.space_id = :spaceId and ro.id = :id
""".trimIndent()
val params = mapOf("spaceId" to spaceId, "id" to id)
return jdbcTemplate.query(sql, params, operationRowMapper()).firstOrNull()
}
override fun findByDate(
date: Int
): List<RecurrentOperation> {
val sql = """
select
ro.id as r_id,
ro.space_id AS r_space_id,
s.name AS s_name,
s.owner_id as s_owner_id,
su.username as su_username,
su.first_name AS su_first_name,
ro.category_id as r_category_id,
c.type AS c_type,
c.name AS c_name,
c.description AS c_description,
c.icon AS c_icon,
ro.name AS r_name,
ro.amount AS r_amount,
ro.date AS r_date,
ro.created_by_id as r_created_by_id,
r_created_by.username as r_created_by_username,
r_created_by.first_name as r_created_by_first_name,
ro.created_at as r_created_at
from finance.recurrent_operations ro
join finance.spaces s on ro.space_id = s.id
join finance.users su on s.owner_id = su.id
join finance.categories c on ro.category_id = c.id
join finance.users r_created_by on ro.created_by_id = r_created_by.id
where ro.date = :date
""".trimIndent()
val params = mapOf( "date" to date)
return jdbcTemplate.query(sql, params, operationRowMapper())
}
override fun create(operation: RecurrentOperation, createdById: Int): Int {
val sql = """
insert into finance.recurrent_operations (
space_id,
category_id,
name,
amount,
date,
created_by_id)
values (
:spaceId,
:categoryId,
:name,
:amount,
:date,
:created_by
)
returning id
""".trimIndent()
val params = mapOf(
"spaceId" to operation.space.id,
"categoryId" to operation.category.id,
"name" to operation.name,
"amount" to operation.amount,
"date" to operation.date,
"created_by" to createdById
)
return jdbcTemplate.queryForObject(sql, params, Int::class.java)!!
}
override fun update(operation: RecurrentOperation, updatedById: Int) {
val sql = """
update finance.recurrent_operations set
category_id = :categoryId,
name = :name,
amount = :amount,
date = :date,
updated_by_id = :updatedBy,
updated_at = now()
where id = :id
""".trimIndent()
val params = mapOf(
"categoryId" to operation.category.id,
"name" to operation.name,
"amount" to operation.amount,
"date" to operation.date,
"updatedBy" to updatedById,
"id" to operation.id
)
jdbcTemplate.update(sql, params)
}
override fun delete(id: Int) {
val sql = """
delete from finance.recurrent_operations
where id = :id
""".trimIndent()
val params = mapOf("id" to id)
jdbcTemplate.update(sql, params)
}
override fun findRecurrentsToCreate(spaceId: Int): List<RecurrentOperation> {
val sql = """
select * from finance.transactions where space_id = :spaceId and t.date >
""".trimIndent()
TODO("Not ready")
}
}

View File

@@ -1,8 +1,13 @@
package space.luminic.finance.repos
import org.springframework.data.mongodb.core.ReactiveMongoTemplate
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import org.springframework.stereotype.Repository
import space.luminic.finance.models.Space
interface SpaceRepo: ReactiveMongoRepository<Space, String> {
@Repository
interface SpaceRepo {
fun findSpacesAvailableForUser(userId: Int): List<Space>
fun findSpaceById(id: Int, userId: Int): Space?
fun create(space: Space, createdById: Int): Int
fun update(space: Space, updatedById: Int): Int
fun delete(id: Int)
}

View File

@@ -0,0 +1,224 @@
package space.luminic.finance.repos
import org.springframework.jdbc.core.RowMapper
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
import org.springframework.stereotype.Repository
import space.luminic.finance.dtos.SpaceDTO
import space.luminic.finance.models.Space
import space.luminic.finance.models.User
@Repository
class SpaceRepoImpl(
private val userRepo: UserRepo,
private val jdbcTemplate: NamedParameterJdbcTemplate
) : SpaceRepo {
private fun spaceRowMapper() = RowMapper { rs, _ ->
Space(
id = rs.getInt("id"),
name = rs.getString("name"),
owner = User(
id = rs.getInt("owner_id"),
username = rs.getString("username"),
firstName = rs.getString("first_name"),
password = rs.getString("password"),
),
participants = userRepo.findParticipantsBySpace(rs.getInt("id")).toSet(),
isDeleted = rs.getBoolean("is_deleted"),
createdAt = rs.getTimestamp("created_at").toInstant(),
updatedAt = rs.getTimestamp("updated_at").toInstant()
)
}
private fun shortRowMapper() = RowMapper { rs, _ ->
SpaceDTO.SpaceShortInfoDTO(
id = rs.getInt("s_id"),
name = rs.getString("s_name"),
isOwner = rs.getBoolean("s_is_owner"),
owner = User(
rs.getInt("s_owner_id"),
rs.getString("s_owner_username"),
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")),
createdAt = rs.getTimestamp("s_created_at").toInstant(),
createdBy = User(
rs.getInt("s_created_by"),
rs.getString("s_created_by_username"),
rs.getString("s_created_by_firstname")
),
updatedAt = if (rs.getTimestamp("s_updated_at") != null) rs.getTimestamp("s_updated_at")
.toInstant() else null,
updatedBy = if (rs.getInt("s_updated_by") != 0) User(
rs.getInt("s_updated_by"),
rs.getString("s_updated_by_username"),
rs.getString("s_updated_by_firstname")
) else null
)
}
private fun collectParticipants(spaces: List<SpaceDTO.SpaceShortInfoDTO>): List<Space> {
val spaceMap = mutableMapOf<Int, Space>()
spaces.forEach { row ->
val entity = row.participant
val existing = spaceMap[row.id]
if (existing == null) {
spaceMap[row.id] = Space(
id = row.id,
name = row.name,
owner = row.owner,
participants = setOf(entity),
createdBy = row.createdBy,
createdAt = row.createdAt,
updatedAt = row.updatedAt,
updatedBy = row.updatedBy
)
} else {
spaceMap[row.id] = existing.copy(
participants = existing.participants + entity
)
}
}
return spaceMap.map { it.value }
}
override fun findSpacesAvailableForUser(userId: Int): List<Space> {
val sql = """
select s.id as s_id,
s.name as s_name,
s.created_at as s_created_at,
s.owner_id = :user_id as s_is_owner,
s.owner_id as s_owner_id,
ou.username as s_owner_username,
ou.first_name as s_owner_firstname,
ou.tg_id as s_owner_tg_id,
sp.participants_id as sp_uid,
u.username as sp_username,
u.first_name as sp_first_name,
s.created_at as s_created_at,
s.created_by_id as s_created_by,
cau.username as s_created_by_username,
cau.first_name as s_created_by_firstname,
s.updated_at as s_updated_at,
s.updated_by_id as s_updated_by,
uau.username as s_updated_by_username,
uau.first_name as s_updated_by_firstname
from finance.spaces s
join finance.users ou on s.owner_id = ou.id
join finance.spaces_participants sp on sp.space_id = s.id
join finance.users u on sp.participants_id = u.id
left join finance.users cau on s.created_by_id = cau.id
left join finance.users uau on s.updated_by_id = uau.id
where (s.owner_id = :user_id
or sp.participants_id = :user_id)
and s.is_deleted = false
group by s.id, ou.username, ou.first_name, ou.tg_id, sp.participants_id, u.username, u.first_name, cau.username, cau.first_name,
uau.username, uau.first_name;
""".trimMargin()
val params = mapOf(
"user_id" to userId,
)
val spaces = jdbcTemplate.query(sql, params, shortRowMapper())
return collectParticipants(spaces)
}
override fun findSpaceById(id: Int, userId: Int): Space? {
val sql = """
select s.id as s_id,
s.name as s_name,
s.created_at as s_created_at,
s.owner_id = :user_id as s_is_owner,
s.owner_id as s_owner_id,
ou.username as s_owner_username,
ou.first_name as s_owner_firstname,
ou.tg_id as s_owner_tg_id,
sp.participants_id as sp_uid,
u.username as sp_username,
u.first_name as sp_first_name,
s.created_at as s_created_at,
s.created_by_id as s_created_by,
cau.username as s_created_by_username,
cau.first_name as s_created_by_firstname,
s.updated_at as s_updated_at,
s.updated_by_id as s_updated_by,
uau.username as s_updated_byusername,
uau.first_name as s_updated_byfirstname
from finance.spaces s
join finance.users ou on s.owner_id = ou.id
join finance.spaces_participants sp on sp.space_id = s.id
join finance.users u on sp.participants_id = u.id
left join finance.users cau on s.created_by_id = cau.id
left join finance.users uau on s.updated_by_id = uau.id
where (s.owner_id = :user_id
or sp.participants_id = :user_id)
and s.is_deleted = false and s.id = :spaceId
group by s.id, ou.username, ou.first_name, ou.tg_id, sp.participants_id, u.username, u.first_name, cau.username, cau.first_name,
uau.username, uau.first_name;
""".trimMargin()
val params = mapOf(
"spaceId" to id,
"user_id" to userId,
)
val spaces = jdbcTemplate.query(sql, params, shortRowMapper())
return collectParticipants(spaces).firstOrNull()
}
override fun create(space: Space, createdById: Int): Int {
val sql = """
insert into finance.spaces (name, owner_id, is_deleted, created_by_id) values (:name, :owner_id, :is_deleted, :created_by_id)
RETURNING id
""".trimIndent()
val params = mapOf(
"name" to space.name,
"owner_id" to space.owner.id,
"is_deleted" to false,
"created_by_id" to createdById
)
val createdSpaceId = jdbcTemplate.queryForObject(sql, params, Int::class.java)
// 2) батч-вставка участников (если есть)
if (space.participants.isNotEmpty()) {
val batchParams = space.participants.map { p ->
mapOf(
"spaceId" to createdSpaceId,
"participantId" to requireNotNull(p.id) { "participant.id is null" }
)
}.toTypedArray()
jdbcTemplate.batchUpdate(
"""
INSERT INTO finance.spaces_participants (space_id, participants_id)
VALUES (:spaceId, :participantId)
""".trimIndent(),
batchParams
)
}
return createdSpaceId!!
}
override fun update(space: Space, updatedById: Int): Int {
val sql = """
update finance.spaces set name = :name, updated_by_id = :updated_by_id, updated_at = now() where id = :spaceId
""".trimIndent()
val params = mapOf(
"name" to space.name,
"spaceId" to space.id,
"updated_by_id" to updatedById
)
jdbcTemplate.update(sql, params)
return space.id!!
}
override fun delete(id: Int) {
val sql = """
update finance.spaces set is_deleted = true where id = :id
""".trimIndent()
jdbcTemplate.update(sql, mapOf("id" to id))
}
}

View File

@@ -1,21 +1,7 @@
package space.luminic.finance.repos
import org.bson.types.ObjectId
import org.springframework.data.mongodb.repository.Query
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import org.springframework.stereotype.Repository
import reactor.core.publisher.Flux
import space.luminic.finance.models.Subscription
@Repository
interface SubscriptionRepo : ReactiveMongoRepository<Subscription, String> {
@Query("{ \$and: [ " +
"{ 'user': { '\$ref': 'users', '\$id': ?0 } }, " +
"{ 'isActive': true } " +
"]}")
fun findByUserIdAndIsActive(userId: ObjectId): Flux<Subscription>
}
interface SubscriptionRepo

View File

@@ -1,15 +1,16 @@
package space.luminic.finance.repos
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import org.springframework.stereotype.Repository
import reactor.core.publisher.Mono
import space.luminic.finance.models.Token
import java.time.LocalDateTime
import java.time.Instant
@Repository
interface TokenRepo: ReactiveMongoRepository<Token, String> {
interface TokenRepo {
fun findByToken(token: String): Token?
fun deleteByExpiresAtBefore(dateTime: Instant)
fun findByToken(token: String): Mono<Token>
fun create(token: Token): Token
fun update(token: Token): Token
fun delete(id: Int)
fun deleteByExpiresAtBefore(dateTime: LocalDateTime)
}

View File

@@ -0,0 +1,84 @@
package space.luminic.finance.repos
import org.springframework.jdbc.core.RowMapper
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
import org.springframework.stereotype.Repository
import space.luminic.finance.models.Token
import java.sql.Timestamp
import java.time.Instant
@Repository
class TokenRepoImpl(
private val userRepo: UserRepo,
private val jdbcTemplate: NamedParameterJdbcTemplate
) : TokenRepo {
private fun tokenRowMapper() = RowMapper { rs, _ ->
Token(
id = rs.getInt("id"),
token = rs.getString("token"),
user = userRepo.findById(rs.getInt("user_id")) ?: throw IllegalArgumentException("User not found"),
issuedAt = rs.getTimestamp("issued_at").toInstant(),
expiresAt = rs.getTimestamp("expires_at").toInstant(),
status = Token.TokenStatus.valueOf(rs.getString("status")),
)
}
override fun findByToken(token: String): Token? {
val sql = "SELECT * FROM finance.tokens WHERE token = :token"
val params = mapOf(
"token" to token,
)
return jdbcTemplate.query(sql, params, tokenRowMapper()).firstOrNull()
}
override fun deleteByExpiresAtBefore(dateTime: Instant) {
val sql = """
update finance.tokens set status = :status where expires_at <= :dateTime
""".trimIndent()
val params = mapOf(
"status" to Token.TokenStatus.ACTIVE,
"dateTime" to dateTime
)
jdbcTemplate.update(sql, params)
}
override fun create(token: Token): Token {
val sql = """
insert into finance.tokens(token, user_id, expires_at, status) values (:token, :userId, :expiresAt, :status)
""".trimIndent()
val params = mapOf(
"token" to token.token,
"userId" to token.user.id,
"expiresAt" to Timestamp.from(token.expiresAt),
"status" to Token.TokenStatus.ACTIVE.name,
)
val createdTokenId = jdbcTemplate.update(sql, params)
token.id = createdTokenId
return token
}
override fun update(token: Token): Token {
val sql = """
update finance.tokens set status = :status where token = :token
""".trimIndent()
val params = mapOf(
"token" to token.token,
"status" to token.status.name
)
jdbcTemplate.update(sql, params)
return token
}
override fun delete(id: Int) {
val sql = """
update finance.tokens set status = :status where id = :id
""".trimIndent()
val params = mapOf(
"id" to id,
"status" to Token.TokenStatus.REVOKED
)
jdbcTemplate.update(sql, params)
}
}

View File

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

View File

@@ -0,0 +1,379 @@
package space.luminic.finance.repos
import org.springframework.jdbc.core.RowMapper
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
import org.springframework.stereotype.Repository
import space.luminic.finance.models.Category
import space.luminic.finance.models.Transaction
import space.luminic.finance.models.User
import space.luminic.finance.services.TransactionService
@Repository
class TransactionRepoImpl(
private val jdbcTemplate: NamedParameterJdbcTemplate,
) : TransactionRepo {
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(
id = rs.getInt("t_id"),
parent = parent,
type = Transaction.TransactionType.valueOf(rs.getString("t_type")),
kind = Transaction.TransactionKind.valueOf(rs.getString("t_kind")),
category = category,
comment = rs.getString("t_comment"),
amount = rs.getBigDecimal("t_amount"),
fees = rs.getBigDecimal("t_fees"),
date = rs.getDate("t_date").toLocalDate(),
isDeleted = rs.getBoolean("t_is_deleted"),
isDone = rs.getBoolean("t_is_done"),
createdBy = User(
id = rs.getInt("u_id"),
username = rs.getString("u_username"),
firstName = rs.getString("u_first_name")
),
createdAt = rs.getTimestamp("t_created_at").toInstant(),
updatedAt = rs.getTimestamp("t_updated_at").toInstant(),
tgChatId = rs.getLong("tg_chat_id"),
tgMessageId = rs.getLong("tg_message_id"),
recurrentId = rs.getInt("t_recurrent_id"),
)
}
override fun findAllBySpaceId(spaceId: Int, filters: TransactionService.TransactionsFilter): List<Transaction> {
var sql = """
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,
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.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
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.is_deleted = false
""".trimIndent()
val params = mutableMapOf<String, Any?>(
"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())
}
override fun findBySpaceIdAndId(spaceId: Int, id: Int): 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.id = :id and t.is_deleted = false""".trimMargin()
val params = mapOf(
"spaceId" to spaceId,
"id" to id,
)
return jdbcTemplate.query(sql, params, transactionRowMapper()).firstOrNull()
}
override fun findBySpaceIdAndRecurrentId(
spaceId: Int,
recurrentId: Int
): List<Transaction> {
val sql = """SELECT
t.id AS t_id,
t.parent_id AS t_parent_id,
t.space_id AS t_space_id,
t.type AS t_type,
t.kind AS t_kind,
t.comment AS t_comment,
t.amount AS t_amount,
t.fees AS t_fees,
t.date AS t_date,
t.is_deleted AS t_is_deleted,
t.is_done AS t_is_done,
t.created_at AS t_created_at,
t.updated_at AS t_updated_at,
t.tg_chat_id AS tg_chat_id,
t.tg_message_id AS tg_message_id,
c.id AS c_id,
c.type AS c_type,
c.name AS c_name,
c.description AS c_description,
c.icon AS c_icon,
c.is_deleted AS c_is_deleted,
c.created_at AS c_created_at,
c.updated_at AS c_updated_at,
u.id AS u_id,
u.username AS u_username,
u.first_name AS u_first_name,
t.recurrent_id AS t_recurrent_id
FROM finance.transactions t
LEFT JOIN finance.categories c ON t.category_id = c.id
JOIN finance.users u ON u.id = t.created_by_id
WHERE t.space_id = :spaceId and t.recurrent_id = :recurrentId and t.is_deleted = false""".trimMargin()
val params = mapOf(
"spaceId" to spaceId,
"recurrentId" to recurrentId,
)
return jdbcTemplate.query(sql, params, transactionRowMapper())
}
override fun create(transaction: Transaction, userId: Int): Int {
val sql = """
INSERT INTO finance.transactions (space_id, parent_id, type, kind, category_id, comment, amount, fees, date, is_deleted, is_done, created_by_id, tg_chat_id, tg_message_id, recurrent_id) VALUES (
:spaceId,
:parentId,
:type,
:kind,
:categoryId,
:comment,
:amount,
:fees,
:date,
:is_deleted,
:is_done,
:createdById,
:tgChatId,
:tgMessageId,
:recurrentId)
returning id
""".trimIndent()
val params = 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,
)
val createdTxId = jdbcTemplate.queryForObject(sql, params, Int::class.java)
transaction.id = createdTxId
return createdTxId!!
}
override fun createBatch(transactions: List<Transaction>, userId: Int) {
val sql = """
INSERT INTO finance.transactions (
space_id, parent_id, type, kind, category_id, comment, amount, fees, date,
is_deleted, is_done, created_by_id, tg_chat_id, tg_message_id, recurrent_id
) VALUES (
:spaceId, :parentId, :type, :kind, :categoryId, :comment, :amount, :fees, :date,
:is_deleted, :is_done, :createdById, :tgChatId, :tgMessageId, :recurrentId
)
""".trimIndent()
val batchValues = transactions.map { transaction ->
mapOf(
"spaceId" to transaction.space!!.id,
"parentId" to transaction.parent?.id,
"type" to transaction.type.name,
"kind" to transaction.kind.name,
"categoryId" to transaction.category?.id,
"comment" to transaction.comment,
"amount" to transaction.amount,
"fees" to transaction.fees,
"date" to transaction.date,
"is_deleted" to transaction.isDeleted,
"is_done" to transaction.isDone,
"createdById" to userId,
"tgChatId" to transaction.tgChatId,
"tgMessageId" to transaction.tgMessageId,
"recurrentId" to transaction.recurrentId
)
}.toTypedArray()
jdbcTemplate.batchUpdate(sql, batchValues)
}
override fun update(transaction: Transaction): Int {
val sql = """
UPDATE finance.transactions
set type = :type,
kind = :kind,
category_id = :categoryId,
comment = :comment,
amount = :amount,
fees = :fees,
is_done = :is_done,
date = :date
where id = :id
""".trimIndent()
val params = 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,
)
jdbcTemplate.update(sql, params)
return transaction.id!!
}
override fun updateBatch(transactions: List<Transaction>, userId: Int) {
val sql = """
UPDATE finance.transactions
set type = :type,
kind = :kind,
category_id = :categoryId,
comment = :comment,
amount = :amount,
fees = :fees,
is_done = :is_done,
date = :date,
updated_by_id = :updatedById,
updated_at = now()
where id = :id
""".trimIndent()
val batchValues = transactions.map { transaction ->
mapOf(
"id" to transaction.id,
"type" to transaction.type.name,
"kind" to transaction.kind.name,
"categoryId" to transaction.category?.id,
"comment" to transaction.comment,
"amount" to transaction.amount,
"fees" to transaction.fees,
"date" to transaction.date,
"is_done" to transaction.isDone,
"updatedById" to userId,
)
}.toTypedArray()
jdbcTemplate.batchUpdate(sql, batchValues)
}
override fun delete(transactionId: Int) {
val sql = """
update finance.transactions set is_deleted = true where id = :id
""".trimIndent()
val params = mapOf(
"id" to transactionId,
)
jdbcTemplate.update(sql, params)
}
override fun deleteByRecurrentId(spaceId: Int, recurrentId: Int) {
val sql = """
update finance.transactions set is_deleted = true where recurrent_id = :recurrentId
""".trimIndent()
val params = mapOf(
"recurrentId" to recurrentId,
)
jdbcTemplate.update(sql, params)
}
override fun setCategory(txId: Int, categoryId: Int) {
val sql = """
UPDATE finance.transactions
SET category_id = :categoryId
where id = :txId
""".trimIndent()
val params = mapOf(
"categoryId" to categoryId,
"txId" to txId,
)
jdbcTemplate.update(sql, params)
}
}

View File

@@ -1,19 +1,16 @@
package space.luminic.finance.repos
import org.springframework.data.mongodb.repository.Query
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import org.springframework.stereotype.Repository
import reactor.core.publisher.Mono
import space.luminic.finance.models.User
@Repository
interface UserRepo : ReactiveMongoRepository<User, String> {
@Query(value = "{ 'username': ?0 }", fields = "{ 'password': 0 }")
fun findByUsernameWOPassword(username: String): Mono<User>
fun findByUsername(username: String): Mono<User>
fun findByTgId(id: String): Mono<User>
interface UserRepo {
fun findAll(): List<User>
fun findById(id: Int): User?
fun findByUsername(username: String): User?
fun findParticipantsBySpace(spaceId: Int): Set<User>
fun findByTgId(tgId: Long): User?
fun create(user: User): User
fun update(user: User): User
fun deleteById(id: Long)
}

View File

@@ -0,0 +1,85 @@
package space.luminic.finance.repos
import org.springframework.jdbc.core.RowMapper
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
import org.springframework.stereotype.Repository
import space.luminic.finance.models.User
@Repository
class UserRepoImpl(
private val jdbcTemplate: NamedParameterJdbcTemplate
) : UserRepo {
private fun userRowMapper() = RowMapper { rs, _ ->
User(
id = rs.getInt("id"),
username = rs.getString("username"),
firstName = rs.getString("first_name"),
tgId = rs.getLong("tg_id"),
tgUserName = rs.getString("tg_user_name"),
photoUrl = rs.getString("photo_url"),
password = rs.getString("password"),
isActive = rs.getBoolean("is_active"),
regDate = rs.getDate("reg_date").toLocalDate(),
createdAt = rs.getTimestamp("created_at").toInstant(),
updatedAt = rs.getTimestamp("updated_at").toInstant(),
roles = (rs.getArray("roles")?.array as? Array<String>)?.toList() ?: emptyList()
)
}
override fun findAll(): List<User> {
val sql = "select * from finance.users order by created_at desc"
return jdbcTemplate.query(sql, userRowMapper())
}
override fun findById(id: Int): User? {
val sql = "select * from finance.users where id = :userId"
return jdbcTemplate.queryForObject(sql, mapOf("userId" to id), userRowMapper())
}
override fun findByUsername(username: String): User? {
val sql = "select * from finance.users where username = :username"
return jdbcTemplate.query(sql, mapOf("username" to username), userRowMapper()).firstOrNull()
}
override fun findParticipantsBySpace(spaceId: Int): Set<User> {
val sql =
"select * from finance.users u join finance.spaces_participants sp on sp.participants_id = u.id where sp.space_id = :spaceId"
return jdbcTemplate.query(sql, mapOf("spaceId" to spaceId), userRowMapper()).toSet()
}
override fun findByTgId(tgId: Long): User? {
val sql = """
select * from finance.users u where tg_id = :tgId
""".trimIndent()
val params = mapOf("tgId" to tgId)
return jdbcTemplate.query(sql, params, userRowMapper()).firstOrNull()
}
override fun create(user: User): User {
val sql =
"insert into finance.users(username, first_name, tg_id, tg_user_name, photo_url, password, is_active, reg_date) values (:username, :firstname, :tg_id, :tg_user_name, :photo_url, :password, :isActive, :regDate) returning ID"
val params = mapOf(
"username" to user.username,
"firstname" to user.firstName,
"tg_id" to user.tgId,
"tg_user_name" to user.tgUserName,
"photo_url" to user.photoUrl,
"password" to user.password,
"isActive" to user.isActive,
"regDate" to user.regDate,
)
val savedId = jdbcTemplate.queryForObject(sql, params, Int::class.java)
user.id = savedId
return user
}
override fun update(user: User): User {
TODO("Not yet implemented")
}
override fun deleteById(id: Long) {
val sql = "update finance.users set is_active = false where id = :id"
jdbcTemplate.update(sql, mapOf("id" to id))
}
}

View File

@@ -1,14 +0,0 @@
package space.luminic.finance.services
import space.luminic.finance.dtos.AccountDTO
import space.luminic.finance.models.Account
import space.luminic.finance.models.Transaction
interface AccountService {
suspend fun getAccounts(spaceId: String): List<Account>
suspend fun getAccount(spaceId: String, accountId: String): Account
suspend fun getAccountTransactions(spaceId: String, accountId: String): List<Transaction>
suspend fun createAccount(spaceId: String, account: AccountDTO.CreateAccountDTO): Account
suspend fun updateAccount(spaceId: String, account: AccountDTO.UpdateAccountDTO): Account
suspend fun deleteAccount(spaceId: String, accountId: String)
}

View File

@@ -1,119 +0,0 @@
package space.luminic.finance.services
import kotlinx.coroutines.reactive.awaitSingle
import org.bson.Document
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.aggregation.LookupOperation
import org.springframework.data.mongodb.core.query.Criteria
import org.springframework.stereotype.Service
import space.luminic.finance.dtos.AccountDTO
import space.luminic.finance.models.Account
import space.luminic.finance.models.Transaction
import space.luminic.finance.repos.AccountRepo
@Service
class AccountServiceImpl(
private val accountRepo: AccountRepo,
private val mongoTemplate: ReactiveMongoTemplate,
private val spaceService: SpaceService,
private val transactionService: TransactionService
): AccountService {
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 getAccounts(spaceId: String): List<Account> {
val basicAggregation = basicAggregation(spaceId)
val aggregation = newAggregation(*basicAggregation.toTypedArray())
return mongoTemplate.aggregate(aggregation, "accounts", Account::class.java)
.collectList()
.awaitSingle()
}
override suspend fun getAccount(
spaceId: String,
accountId: String
): Account {
val basicAggregation = basicAggregation(spaceId)
val matchStage = match (Criteria.where("_id").`is`(ObjectId(accountId)))
val aggregation = newAggregation(matchStage, *basicAggregation.toTypedArray())
return mongoTemplate.aggregate(aggregation, "accounts", Account::class.java)
.awaitSingle()
}
override suspend fun getAccountTransactions(
spaceId: String,
accountId: String
): List<Transaction> {
val space = spaceService.checkSpace(spaceId)
val filter = TransactionService.TransactionsFilter(
accountId = accountId,
)
return transactionService.getTransactions(spaceId, filter, "date", "ASC")
}
override suspend fun createAccount(
spaceId: String,
account: AccountDTO.CreateAccountDTO
): Account {
val createdAccount = Account(
type = account.type,
spaceId = spaceId,
name = account.name,
currencyCode = account.currencyCode,
amount = account.amount,
goalId = account.goalId,
)
return accountRepo.save(createdAccount).awaitSingle()
}
override suspend fun updateAccount(
spaceId: String,
account: AccountDTO.UpdateAccountDTO
): Account {
val existingAccount = getAccount(spaceId, account.id)
val newAccount = existingAccount.copy(
name = account.name,
type = account.type,
currencyCode = account.currencyCode,
amount = account.amount,
goalId = account.goalId,
)
return accountRepo.save(newAccount).awaitSingle()
}
override suspend fun deleteAccount(spaceId: String, accountId: String) {
val existingAccount = getAccount(spaceId, accountId)
existingAccount.isDeleted = true
accountRepo.save(existingAccount).awaitSingle()
}
}

View File

@@ -1,26 +1,24 @@
package space.luminic.finance.services
import kotlinx.coroutines.reactive.awaitFirstOrNull
import kotlinx.coroutines.reactor.awaitSingle
import kotlinx.coroutines.reactor.awaitSingleOrNull
import org.springframework.cache.annotation.Cacheable
import org.springframework.security.core.context.ReactiveSecurityContextHolder
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.core.userdetails.UsernameNotFoundException
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.stereotype.Service
import space.luminic.finance.configs.AuthException
import space.luminic.finance.dtos.UserDTO
import space.luminic.finance.models.NotFoundException
import space.luminic.finance.models.Token
import space.luminic.finance.models.User
import space.luminic.finance.repos.UserRepo
import space.luminic.finance.utils.JWTUtil
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.Instant
import java.util.*
@Service
class AuthService(
private val userRepository: UserRepo,
private val userRepo: UserRepo,
private val tokenService: TokenService,
private val jwtUtil: JWTUtil,
private val userService: UserService,
@@ -28,18 +26,28 @@ class AuthService(
) {
private val passwordEncoder = BCryptPasswordEncoder()
suspend fun getSecurityUser(): User {
val securityContextHolder = ReactiveSecurityContextHolder.getContext().awaitSingleOrNull()
fun getSecurityUser(): User {
val securityContextHolder = SecurityContextHolder.getContext()
?: throw AuthException("Authentication failed")
val authentication = securityContextHolder.authentication
val username = authentication.name
// Получаем пользователя по имени
return userService.getByUsername(username)
return userService.getById(username.toInt())
}
suspend fun login(username: String, password: String): String {
val user = userRepository.findByUsername(username).awaitFirstOrNull()
fun getSecurityUserId(): Int {
val securityContextHolder = SecurityContextHolder.getContext()
?: throw AuthException("Authentication failed")
val authentication = securityContextHolder.authentication
val username = authentication.name
// Получаем пользователя по имени
return username.toInt()
}
fun login(username: String, password: String): String {
val user = userRepo.findByUsername(username)
?: throw UsernameNotFoundException("Пользователь не найден")
return if (passwordEncoder.matches(password, user.password)) {
val token = jwtUtil.generateToken(user.username)
@@ -47,10 +55,7 @@ class AuthService(
tokenService.saveToken(
token = token,
username = username,
expiresAt = LocalDateTime.ofInstant(
expireAt.toInstant(),
ZoneId.systemDefault()
)
expiresAt = expireAt.toInstant()
)
token
} else {
@@ -58,26 +63,40 @@ class AuthService(
}
}
suspend fun tgLogin(tgId: String): String {
val user =
userRepository.findByTgId(tgId).awaitSingleOrNull() ?: throw UsernameNotFoundException("Пользователь не найден")
fun tgAuth(tgUser: UserDTO.TelegramAuthDTO): String {
val user: User = try {
tgLogin(tgUser.id!!)
} catch (e: NotFoundException) {
registerTg(tgUser)
}
val token = jwtUtil.generateToken(user.username)
val expireAt = Date(System.currentTimeMillis() + 1000 * 60 * 60 * 24 * 10)
tokenService.saveToken(
token = token,
username = user.username,
expiresAt = LocalDateTime.ofInstant(
expireAt.toInstant(),
ZoneId.systemDefault()
)
expiresAt = expireAt.toInstant()
)
return token
}
suspend fun register(username: String, password: String, firstName: String): User {
val user = userRepository.findByUsername(username).awaitSingleOrNull()
fun registerTg(tgUser: UserDTO.TelegramAuthDTO): User {
val user = User(
username = tgUser.username ?: UUID.randomUUID().toString().split('-')[0],
firstName = tgUser.first_name ?: UUID.randomUUID().toString().split('-')[0],
tgId = tgUser.id,
tgUserName = tgUser.username,
photoUrl = tgUser.photo_url,
roles = mutableListOf("USER")
)
return userRepo.create(user)
}
fun tgLogin(tgId: Long): User {
return userRepo.findByTgId(tgId) ?: throw NotFoundException("User with provided TG id $tgId not found")
}
fun register(username: String, password: String, firstName: String): User {
val user = userRepo.findByUsername(username)
if (user == null) {
var newUser = User(
username = username,
@@ -85,18 +104,18 @@ class AuthService(
firstName = firstName,
roles = mutableListOf("USER")
)
newUser = userRepository.save(newUser).awaitSingle()
newUser = userRepo.create(newUser)
return newUser
} else throw IllegalArgumentException("Пользователь уже зарегистрирован")
}
@Cacheable(cacheNames = ["tokens"], key = "#token")
suspend fun isTokenValid(token: String): User {
val tokenDetails = tokenService.getToken(token).awaitFirstOrNull() ?: throw AuthException("Токен не валиден")
fun isTokenValid(token: String): User {
val tokenDetails = tokenService.getToken(token)
when {
tokenDetails.status == Token.TokenStatus.ACTIVE && tokenDetails.expiresAt.isAfter(LocalDateTime.now()) -> {
return userService.getByUsername(tokenDetails.username)
tokenDetails.status == Token.TokenStatus.ACTIVE && tokenDetails.expiresAt.isAfter(Instant.now()) -> {
return tokenDetails.user
}
else -> {

View File

@@ -1,18 +0,0 @@
package space.luminic.finance.services
import space.luminic.finance.dtos.BudgetDTO.*
import space.luminic.finance.models.Budget
import space.luminic.finance.models.Transaction
interface BudgetService {
suspend fun getBudgets(spaceId: String, sortBy: String, sortDirection: String): List<Budget>
suspend fun getBudget(spaceId: String, budgetId: String): Budget
suspend fun getBudgetTransactions(spaceId: String, budgetId: String): List<Transaction>
suspend fun createBudget(spaceId: String, type: Budget.BudgetType, budgetDto: CreateBudgetDTO): Budget
suspend fun updateBudget(spaceId: String, budgetDto: UpdateBudgetDTO): Budget
suspend fun deleteBudget(spaceId: String, budgetId: String)
}

View File

@@ -1,188 +0,0 @@
package space.luminic.finance.services
import kotlinx.coroutines.reactive.awaitFirstOrNull
import kotlinx.coroutines.reactive.awaitSingle
import org.bson.Document
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.*
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.AggregationOperation
import org.springframework.data.mongodb.core.aggregation.ConvertOperators
import org.springframework.data.mongodb.core.aggregation.LookupOperation
import org.springframework.data.mongodb.core.aggregation.SetOperation.set
import org.springframework.data.mongodb.core.aggregation.UnsetOperation.unset
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.BudgetDTO
import space.luminic.finance.models.Budget
import space.luminic.finance.models.NotFoundException
import space.luminic.finance.models.Transaction
import space.luminic.finance.repos.BudgetRepo
import java.math.BigDecimal
@Service
class BudgetServiceImpl(
private val budgetRepo: BudgetRepo,
private val authService: AuthService,
private val categoryService: CategoryService,
private val mongoTemplate: ReactiveMongoTemplate,
private val spaceService: SpaceService,
private val transactionService: TransactionService,
) : BudgetService {
private fun basicAggregation(spaceId: String): List<AggregationOperation> {
val unwindCategories = unwind("categories", true)
val setCategoryIdOI = set("categories.categoryIdOI")
.toValue(ConvertOperators.valueOf("categories.categoryId").convertToObjectId())
val lookupCategory = lookup(
"categories", // from
"categories.categoryIdOI", // localField
"_id", // foreignField
"joinedCategory" // as
)
val unwindJoinedCategory = unwind("joinedCategory", true)
val setEmbeddedCategory = set("categories.category").toValue("\$joinedCategory")
val unsetTemps = unset("joinedCategory", "categories.categoryIdOI")
val groupBack: AggregationOperation = AggregationOperation {
Document(
"\$group", Document()
.append("_id", "\$_id")
.append("doc", Document("\$first", "\$\$ROOT"))
.append("categories", Document("\$push", "\$categories"))
)
}
val setDocCategories: AggregationOperation = AggregationOperation {
Document("\$set", Document("doc.categories", "\$categories"))
}
val replaceRootDoc = replaceRoot("doc")
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(matchStage,
unwindCategories,
setCategoryIdOI,
lookupCategory,
unwindJoinedCategory,
setEmbeddedCategory,
unsetTemps,
groupBack,
setDocCategories,
replaceRootDoc,
addFieldsAsOJ,
lookupCreatedBy,
unwindCreatedBy,
lookupUpdatedBy,
unwindUpdatedBy)
}
override suspend fun getBudgets(spaceId: String, sortBy: String, sortDirection: String): List<Budget> {
require(spaceId.isNotBlank()) { "Space ID must not be blank" }
val allowedSortFields = setOf("dateFrom", "dateTo", "amount", "categoryName", "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 sort = sort(Sort.by(direction, sortBy))
val basicAggregation = basicAggregation(spaceId)
val aggregation =
newAggregation(
*basicAggregation.toTypedArray(),
sort
)
return mongoTemplate.aggregate(aggregation, "budgets", Budget::class.java)
.collectList()
.awaitSingle()
}
override suspend fun getBudget(spaceId: String, budgetId: String): Budget {
val basicAggregation = basicAggregation(spaceId)
val matchStage = match(Criteria.where("_id").`is`(ObjectId(budgetId)))
val aggregation = newAggregation(matchStage, *basicAggregation.toTypedArray(), )
return mongoTemplate.aggregate(aggregation, "budgets", Budget::class.java).awaitFirstOrNull()
?: throw NotFoundException("Budget not found")
}
override suspend fun getBudgetTransactions(
spaceId: String,
budgetId: String
): List<Transaction> {
spaceService.checkSpace(spaceId)
val budget = getBudget(spaceId, budgetId)
val filter = TransactionService.TransactionsFilter(
dateFrom = budget.dateFrom,
dateTo = budget.dateTo
)
return transactionService.getTransactions(spaceId, filter, "date", "ASC")
}
override suspend fun createBudget(
spaceId: String,
type: Budget.BudgetType,
budgetDto: BudgetDTO.CreateBudgetDTO
): Budget {
val user = authService.getSecurityUser()
val categories = categoryService.getCategories(spaceId)
val budget = Budget(
spaceId = spaceId,
type = type,
name = budgetDto.name,
description = budgetDto.description,
categories = categories.map { Budget.BudgetCategory(it.id!!, BigDecimal.ZERO) },
dateFrom = budgetDto.dateFrom,
dateTo = budgetDto.dateTo
)
return budgetRepo.save(budget).awaitSingle()
}
override suspend fun updateBudget(
spaceId: String,
budgetDto: BudgetDTO.UpdateBudgetDTO
): Budget {
val budget = getBudget(spaceId, budgetDto.id)
budgetDto.name?.let { name -> budget.name = name }
budgetDto.description?.let { description -> budget.description = description }
budgetDto.dateFrom?.let { dateFrom -> budget.dateFrom = dateFrom }
budgetDto.dateTo?.let { dateTo -> budget.dateTo = dateTo }
return budgetRepo.save(budget).awaitSingle()
}
override suspend fun deleteBudget(spaceId: String, budgetId: String) {
val budget = getBudget(spaceId, budgetId)
budget.isDeleted = true
budgetRepo.save(budget).awaitSingle()
}
}

View File

@@ -1,15 +1,13 @@
package space.luminic.finance.services
import space.luminic.finance.dtos.BudgetDTO
import space.luminic.finance.dtos.CategoryDTO
import space.luminic.finance.models.Category
import space.luminic.finance.models.Space
interface CategoryService {
suspend fun getCategories(spaceId: String): List<Category>
suspend fun getCategory(spaceId: String, id: String): Category
suspend fun createCategory(spaceId: String, category: CategoryDTO.CreateCategoryDTO): Category
suspend fun updateCategory(spaceId: String,category: CategoryDTO.UpdateCategoryDTO): Category
suspend fun deleteCategory(spaceId: String, id: String)
suspend fun createCategoriesForSpace(spaceId: String): List<Category>
fun getCategories(spaceId: Int): List<Category>
fun getCategory(spaceId: Int, id: Int): Category
fun createCategory(spaceId: Int, category: CategoryDTO.CreateCategoryDTO): Category
fun createEtalonCategoriesForSpace(spaceId: Int): List<Category>
fun updateCategory(spaceId: Int,categoryId:Int, category: CategoryDTO.UpdateCategoryDTO): Category
fun deleteCategory(spaceId: Int, id: Int)
}

View File

@@ -1,108 +1,105 @@
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.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Propagation
import org.springframework.transaction.annotation.Transactional
import space.luminic.finance.dtos.CategoryDTO
import space.luminic.finance.models.Category
import space.luminic.finance.models.Space
import space.luminic.finance.models.NotFoundException
import space.luminic.finance.repos.CategoryEtalonRepo
import space.luminic.finance.repos.CategoryRepo
import space.luminic.finance.repos.SpaceRepo
@Service
class CategoryServiceImpl(
private val categoryRepo: CategoryRepo,
private val categoryEtalonRepo: CategoryEtalonRepo,
private val reactiveMongoTemplate: ReactiveMongoTemplate,
private val authService: AuthService,
private val spaceRepo: SpaceRepo,
private val categoriesRepo: CategoryRepo,
private val categoriesEtalonRepo: CategoryEtalonRepo,
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 fun getCategories(spaceId: Int): List<Category> {
val userId = authService.getSecurityUserId()
val space = spaceRepo.findSpaceById(spaceId, userId)
return categoriesRepo.findBySpaceId(spaceId)
}
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 fun getCategory(spaceId: Int, id: Int): Category {
val userId = authService.getSecurityUserId()
val space = spaceRepo.findSpaceById(spaceId, userId)
return categoriesRepo.findBySpaceIdAndId(spaceId, id)
?: throw NotFoundException("Category with id $id not found")
}
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,
@Transactional
override fun createCategory(
spaceId: Int,
category: CategoryDTO.CreateCategoryDTO
): Category {
val createdCategory = Category(
spaceId = spaceId,
type = category.type,
val userId = authService.getSecurityUserId()
val space = spaceRepo.findSpaceById(spaceId, userId)
val newCategory = Category(
space = space,
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,
description = category.description,
icon = category.icon,
)
return categoryRepo.save(updatedCategory).awaitSingle()
return categoriesRepo.create(newCategory, userId)
}
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 {
@Transactional(propagation = Propagation.NESTED)
override fun createEtalonCategoriesForSpace(
spaceId: Int
): List<Category> {
val userId = authService.getSecurityUserId()
val space = spaceRepo.findSpaceById(spaceId, userId)
val categories = categoriesEtalonRepo.findAll()
val newCategories = mutableListOf<Category>()
categories.forEach { category ->
newCategories.add(
categoriesRepo.create(
Category(
spaceId = spaceId,
type = it.type,
name = it.name,
icon = it.icon
space = space,
name = category.name,
description = category.description,
type = category.type,
icon = category.icon,
), userId
)
)
}
return categoryRepo.saveAll(toCreate).collectList().awaitSingle()
return newCategories
}
@Transactional
override fun updateCategory(
spaceId: Int,
categoryId: Int,
category: CategoryDTO.UpdateCategoryDTO
): Category {
val userId = authService.getSecurityUserId()
val space = spaceRepo.findSpaceById(spaceId, userId)
val existingCategory = getCategory(spaceId, categoryId)
val newCategory = Category(
id = existingCategory.id,
space = space,
name = category.name,
description = category.description,
type = category.type,
icon = category.icon,
isDeleted = existingCategory.isDeleted,
createdBy = existingCategory.createdBy,
createdAt = existingCategory.createdAt,
)
return categoriesRepo.update(newCategory, userId)
}
@Transactional
override fun deleteCategory(spaceId: Int, id: Int) {
val userId = authService.getSecurityUserId()
val space = spaceRepo.findSpaceById(spaceId, userId)
categoriesRepo.delete(id)
}

View File

@@ -1,16 +0,0 @@
package space.luminic.finance.services
import kotlinx.coroutines.reactor.mono
import org.springframework.data.domain.ReactiveAuditorAware
import org.springframework.stereotype.Component
import reactor.core.publisher.Mono
@Component
class CoroutineAuditorAware(
private val authService: AuthService
) : ReactiveAuditorAware<String> {
override fun getCurrentAuditor(): Mono<String> =
mono {
authService.getSecurityUser().id!!
}
}

View File

@@ -6,11 +6,11 @@ import space.luminic.finance.models.CurrencyRate
interface CurrencyService {
suspend fun getCurrencies(): List<Currency>
suspend fun getCurrency(currencyCode: String): Currency
suspend fun createCurrency(currency: CurrencyDTO): Currency
suspend fun updateCurrency(currency: CurrencyDTO): Currency
suspend fun deleteCurrency(currencyCode: String)
suspend fun createCurrencyRate(currencyCode: String): CurrencyRate
fun getCurrencies(): List<Currency>
fun getCurrency(currencyCode: String): Currency
fun createCurrency(currency: CurrencyDTO): Currency
fun updateCurrency(currency: CurrencyDTO): Currency
fun deleteCurrency(currencyCode: String)
fun createCurrencyRate(currencyCode: String): CurrencyRate
}

View File

@@ -1,11 +1,10 @@
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.models.NotFoundException
import space.luminic.finance.repos.CurrencyRepo
import java.math.BigDecimal
import java.time.LocalDate
@@ -13,39 +12,39 @@ import java.time.LocalDate
@Service
class CurrencyServiceImpl(
private val currencyRepo: CurrencyRepo,
private val currencyRateRepo: CurrencyRateRepo
) : CurrencyService {
override suspend fun getCurrencies(): List<Currency> {
return currencyRepo.findAll().collectList().awaitSingle()
override fun getCurrencies(): List<Currency> {
return currencyRepo.findAll()
}
override suspend fun getCurrency(currencyCode: String): Currency {
return currencyRepo.findById(currencyCode).awaitSingle()
override fun getCurrency(currencyCode: String): Currency {
return currencyRepo.findByCode(currencyCode)
?: throw NotFoundException("Currency code $currencyCode not found")
}
override suspend fun createCurrency(currency: CurrencyDTO): Currency {
val createdCurrency = Currency(currency.code, currency.name, currency.symbol)
return currencyRepo.save(createdCurrency).awaitSingle()
override fun createCurrency(currency: CurrencyDTO): Currency {
val currency = Currency(currency.code, currency.name, currency.code)
return currencyRepo.save(currency)
}
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(),
override fun updateCurrency(currency: CurrencyDTO): Currency {
getCurrency(currency.code)
val updatedCurrency =
Currency(
code = currency.code,
name = currency.name,
symbol = currency.symbol
)
).awaitSingle()
return currencyRepo.save(updatedCurrency)
}
override fun deleteCurrency(currencyCode: String) {
currencyRepo.delete(currencyCode)
}
override fun createCurrencyRate(currencyCode: String): CurrencyRate {
print("createCurrencyRate")
val currency = getCurrency(currencyCode)
return CurrencyRate(currency = currency, rate = BigDecimal.ZERO, date = LocalDate.now())
}
}

View File

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

View File

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

View File

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

View File

@@ -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")
}
}

View File

@@ -0,0 +1,15 @@
package space.luminic.finance.services
import space.luminic.finance.dtos.RecurrentOperationDTO
import space.luminic.finance.models.RecurrentOperation
interface RecurrentOperationService {
fun findBySpaceId(spaceId: Int): List<RecurrentOperation>
fun findBySpaceIdAndId(spaceId: Int, id: Int): RecurrentOperation
fun create(spaceId: Int, operation: RecurrentOperationDTO.CreateRecurrentOperationDTO): Int
fun update(spaceId: Int, operationId: Int, operation: RecurrentOperationDTO.UpdateRecurrentOperationDTO)
fun delete(spaceId: Int, id: Int)
fun createRecurrentTransactions()
}

View File

@@ -0,0 +1,155 @@
package space.luminic.finance.services
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import space.luminic.finance.dtos.RecurrentOperationDTO
import space.luminic.finance.models.Category
import space.luminic.finance.models.NotFoundException
import space.luminic.finance.models.RecurrentOperation
import space.luminic.finance.models.Transaction
import space.luminic.finance.repos.RecurrentOperationRepo
import space.luminic.finance.repos.SpaceRepo
import space.luminic.finance.repos.TransactionRepo
import java.time.LocalDate
import kotlin.math.min
@Service
class RecurrentOperationServiceImpl(
private val authService: AuthService,
private val spaceRepo: SpaceRepo,
private val recurrentOperationRepo: RecurrentOperationRepo,
private val categoryService: CategoryService,
private val transactionRepo: TransactionRepo
) : RecurrentOperationService {
private val logger = LoggerFactory.getLogger(this.javaClass)
private val serviceScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
override fun findBySpaceId(spaceId: Int): List<RecurrentOperation> {
val userId = authService.getSecurityUserId()
spaceRepo.findSpaceById(spaceId, userId)
return recurrentOperationRepo.findAllBySpaceId(spaceId)
}
override fun findBySpaceIdAndId(
spaceId: Int,
id: Int
): RecurrentOperation {
val userId = authService.getSecurityUserId()
spaceRepo.findSpaceById(spaceId, userId)
return recurrentOperationRepo.findBySpaceIdAndId(spaceId, id)
?: throw NotFoundException("Cannot find recurrent operation with id ${id}")
}
override fun create(spaceId: Int, operation: RecurrentOperationDTO.CreateRecurrentOperationDTO): Int {
val userId = authService.getSecurityUserId()
val space =
spaceRepo.findSpaceById(spaceId, userId) ?: throw NotFoundException("Cannot find space with id ${spaceId}")
val category = categoryService.getCategory(spaceId, operation.categoryId)
val creatingOperation = RecurrentOperation(
space = space,
category = category,
name = operation.name,
amount = operation.amount,
date = operation.date
)
val createdRecurrentId = recurrentOperationRepo.create(creatingOperation, userId)
val transactionsToCreate = mutableListOf<Transaction>()
serviceScope.launch {
runCatching {
val now = LocalDate.now()
val date = now.withDayOfMonth(min(operation.date, now.lengthOfMonth()))
for (i in 1..12) {
transactionsToCreate.add(
Transaction(
space = space,
type = if (category.type == Category.CategoryType.EXPENSE) Transaction.TransactionType.EXPENSE else Transaction.TransactionType.INCOME,
kind = Transaction.TransactionKind.PLANNING,
category = category,
comment = creatingOperation.name,
amount = creatingOperation.amount,
date = 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) {
val userId = authService.getSecurityUserId()
spaceRepo.findSpaceById(spaceId, userId)
val newCategory = categoryService.getCategory(spaceId, operation.categoryId)
val existingOperation = recurrentOperationRepo.findBySpaceIdAndId(spaceId, operationId)
?: throw NotFoundException("Cannot find operation with id $operationId")
val updatedOperation = existingOperation.copy(
category = newCategory,
name = operation.name,
amount = operation.amount,
date = operation.date
)
recurrentOperationRepo.update(updatedOperation, userId)
serviceScope.launch {
val transactionsToUpdate = mutableListOf<Transaction>()
runCatching {
val txs = transactionRepo.findBySpaceIdAndRecurrentId(spaceId, operationId)
txs.forEach {
transactionsToUpdate.add(
it.copy(
type = if (it.category?.type == Category.CategoryType.EXPENSE) Transaction.TransactionType.EXPENSE else Transaction.TransactionType.INCOME,
category = updatedOperation.category,
comment = operation.name,
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) {
val userId = authService.getSecurityUserId()
spaceRepo.findSpaceById(spaceId, userId)
recurrentOperationRepo.delete(id)
}
override fun createRecurrentTransactions() {
val today = LocalDate.now()
val recurrents = recurrentOperationRepo.findByDate(today.dayOfMonth)
recurrents.forEach {
transactionRepo.create(
Transaction(
space = it.space,
type = if (it.category.type == Category.CategoryType.EXPENSE) Transaction.TransactionType.EXPENSE else Transaction.TransactionType.INCOME,
kind = Transaction.TransactionKind.PLANNING,
category = it.category,
comment = it.name,
amount = it.amount,
date = today.plusMonths(13),
recurrentId = it.id
), it.createdBy?.id!!
)
}
}
}

View File

@@ -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()
}
}

View File

@@ -1,16 +1,15 @@
package space.luminic.finance.services
import space.luminic.finance.dtos.SpaceDTO
import space.luminic.finance.models.Budget
import space.luminic.finance.models.Space
interface SpaceService {
suspend fun checkSpace(spaceId: String): Space
suspend fun getSpaces(): List<Space>
suspend fun getSpace(id: String): Space
suspend fun createSpace(space: SpaceDTO.CreateSpaceDTO): Space
suspend fun updateSpace(spaceId: String, space: SpaceDTO.UpdateSpaceDTO): Space
suspend fun deleteSpace(spaceId: String)
fun checkSpace(spaceId: Int): Space
fun getSpaces(): List<Space>
fun getSpace(id: Int, userId: Int?): Space
fun createSpace(space: SpaceDTO.CreateSpaceDTO): Int
fun updateSpace(spaceId: Int, space: SpaceDTO.UpdateSpaceDTO): Int
fun deleteSpace(spaceId: Int)
}

View File

@@ -1,149 +1,73 @@
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 kotlinx.coroutines.reactive.awaitSingleOrNull
import org.bson.types.ObjectId
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.ArrayOperators
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 org.springframework.transaction.annotation.Transactional
import space.luminic.finance.dtos.SpaceDTO
import space.luminic.finance.models.Budget
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 SpaceServiceImpl(
private val authService: AuthService,
private val spaceRepo: SpaceRepo,
private val mongoTemplate: ReactiveMongoTemplate,
private val categoryService: CategoryService
) : 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)
override fun checkSpace(spaceId: Int): Space {
return getSpace(spaceId, null)
}
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)
// @Cacheable(cacheNames = ["spaces"])
override fun getSpaces(): List<Space> {
val user = authService.getSecurityUserId()
val spaces = spaceRepo.findSpacesAvailableForUser(user)
return spaces
}
override suspend fun checkSpace(spaceId: String): Space {
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")
return space
}
@Transactional
override fun createSpace(space: SpaceDTO.CreateSpaceDTO): Int {
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(
val creatingSpace = Space(
name = space.name,
ownerId = owner.id!!,
participantsIds = listOf(owner.id!!),
owner = user,
participants = setOf(user)
)
createdSpace.owner = owner
createdSpace.participants?.toMutableList()?.add(owner)
val savedSpace = spaceRepo.save(createdSpace).awaitSingle()
val userId = authService.getSecurityUserId()
val savedSpace = spaceRepo.create(creatingSpace, userId)
if (space.createBasicCategories) {
categoryService.createEtalonCategoriesForSpace(savedSpace)
}
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(
@Transactional
override fun updateSpace(
spaceId: Int,
space: SpaceDTO.UpdateSpaceDTO
): Int {
val userId = authService.getSecurityUserId()
val existingSpace = getSpace(spaceId, null)
val updatedSpace = Space(
id = existingSpace.id,
name = space.name,
owner = existingSpace.owner,
participants = existingSpace.participants,
isDeleted = existingSpace.isDeleted,
createdBy = existingSpace.createdBy,
createdAt = existingSpace.createdAt,
)
updatedSpace.owner = existingSpace.owner
updatedSpace.participants = existingSpace.participants
return spaceRepo.save(updatedSpace).awaitFirst()
return spaceRepo.update(updatedSpace, userId)
}
override suspend fun deleteSpace(spaceId: String) {
val space = spaceRepo.findById(spaceId).awaitFirstOrNull() ?: throw NotFoundException("Space not found")
space.isDeleted = true
spaceRepo.save(space).awaitFirst()
@Transactional
override fun deleteSpace(spaceId: Int) {
spaceRepo.delete(spaceId)
}
}

View File

@@ -1,113 +1,111 @@
package space.luminic.finance.services
import com.interaso.webpush.VapidKeys
import com.interaso.webpush.WebPushService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.reactive.awaitSingle
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.bson.types.ObjectId
import org.slf4j.LoggerFactory
import org.springframework.dao.DuplicateKeyException
import org.springframework.stereotype.Service
import space.luminic.finance.models.PushMessage
import space.luminic.finance.models.Subscription
import space.luminic.finance.models.SubscriptionDTO
import space.luminic.finance.models.User
import space.luminic.finance.repos.SubscriptionRepo
import space.luminic.finance.services.VapidConstants.VAPID_PRIVATE_KEY
import space.luminic.finance.services.VapidConstants.VAPID_PUBLIC_KEY
import space.luminic.finance.services.VapidConstants.VAPID_SUBJECT
import kotlin.collections.forEach
import kotlin.jvm.javaClass
import kotlin.text.orEmpty
object VapidConstants {
const val VAPID_PUBLIC_KEY =
"BKmMyBUhpkcmzYWcYsjH_spqcy0zf_8eVtZo60f7949TgLztCmv3YD0E_vtV2dTfECQ4sdLdPK3ICDcyOkCqr84"
const val VAPID_PRIVATE_KEY = "YeJH_0LhnVYN6RdxMidgR6WMYlpGXTJS3HjT9V3NSGI"
const val VAPID_SUBJECT = "mailto:voroninvyu@gmail.com"
}
@Service
class SubscriptionService(private val subscriptionRepo: SubscriptionRepo) {
private val logger = LoggerFactory.getLogger(javaClass)
private val pushService =
WebPushService(
subject = VAPID_SUBJECT,
vapidKeys = VapidKeys.fromUncompressedBytes(VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY)
)
suspend fun sendToSpaceOwner(ownerId: String, message: PushMessage) = coroutineScope {
val ownerTokens = subscriptionRepo.findByUserIdAndIsActive(ObjectId(ownerId)).collectList().awaitSingle()
ownerTokens.forEach { token ->
launch(Dispatchers.IO) { // Теперь мы точно в корутин скоупе
try {
sendNotification(token.endpoint, token.p256dh, token.auth, message)
} catch (e: Exception) {
logger.error("Ошибка при отправке уведомления: ${e.message}", e)
}
}
}
}
suspend fun sendNotification(endpoint: String, p256dh: String, auth: String, payload: PushMessage) {
try {
pushService.send(
payload = Json.encodeToString(payload),
endpoint = endpoint,
p256dh = p256dh,
auth = auth
)
logger.info("Уведомление успешно отправлено на endpoint: $endpoint")
} catch (e: Exception) {
logger.error("Ошибка при отправке уведомления на endpoint $endpoint: ${e.message}")
throw e
}
}
suspend fun sendToAll(payload: PushMessage) {
subscriptionRepo.findAll().collectList().awaitSingle().forEach { sub ->
try {
sendNotification(sub.endpoint, sub.p256dh, sub.auth, payload)
} catch (e: Exception) {
sub.isActive = false
subscriptionRepo.save(sub).awaitSingle()
}
}
}
suspend fun subscribe(subscriptionDTO: SubscriptionDTO, user: User): String {
val subscription = Subscription(
id = null,
user = user,
endpoint = subscriptionDTO.endpoint,
auth = subscriptionDTO.keys["auth"].orEmpty(),
p256dh = subscriptionDTO.keys["p256dh"].orEmpty(),
isActive = true
)
return try {
val savedSubscription = subscriptionRepo.save(subscription).awaitSingle()
"Subscription created with ID: ${savedSubscription.id}"
} catch (e: DuplicateKeyException) {
logger.info("Subscription already exists. Skipping.")
"Subscription already exists. Skipping."
} catch (e: Exception) {
logger.error("Error while saving subscription: ${e.message}")
throw kotlin.RuntimeException("Error while saving subscription")
}
}
}
//package space.luminic.finance.services
//
//
//import com.interaso.webpush.VapidKeys
//import com.interaso.webpush.WebPushService
//import kotlinx.coroutines.Dispatchers
//import kotlinx.coroutines.coroutineScope
//import kotlinx.coroutines.launch
//import kotlinx.coroutines.reactive.awaitSingle
//import kotlinx.serialization.encodeToString
//import kotlinx.serialization.json.Json
//import org.slf4j.LoggerFactory
//import org.springframework.dao.DuplicateKeyException
//import org.springframework.stereotype.Service
//import space.luminic.finance.models.PushMessage
//import space.luminic.finance.models.Subscription
//import space.luminic.finance.models.User
//import space.luminic.finance.repos.SubscriptionRepo
//import space.luminic.finance.services.VapidConstants.VAPID_PRIVATE_KEY
//import space.luminic.finance.services.VapidConstants.VAPID_PUBLIC_KEY
//import space.luminic.finance.services.VapidConstants.VAPID_SUBJECT
//import kotlin.collections.forEach
//import kotlin.jvm.javaClass
//import kotlin.text.orEmpty
//
//object VapidConstants {
// const val VAPID_PUBLIC_KEY =
// "BKmMyBUhpkcmzYWcYsjH_spqcy0zf_8eVtZo60f7949TgLztCmv3YD0E_vtV2dTfECQ4sdLdPK3ICDcyOkCqr84"
// const val VAPID_PRIVATE_KEY = "YeJH_0LhnVYN6RdxMidgR6WMYlpGXTJS3HjT9V3NSGI"
// const val VAPID_SUBJECT = "mailto:voroninvyu@gmail.com"
//}
//
//@Service
//class SubscriptionService(private val subscriptionRepo: SubscriptionRepo) {
//
// private val logger = LoggerFactory.getLogger(javaClass)
// private val pushService =
// WebPushService(
// subject = VAPID_SUBJECT,
// vapidKeys = VapidKeys.fromUncompressedBytes(VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY)
// )
//
//
// suspend fun sendToSpaceOwner(ownerId: String, message: PushMessage) = coroutineScope {
// val ownerTokens = subscriptionRepo.findByUserIdAndIsActive(ObjectId(ownerId)).collectList().awaitSingle()
//
// ownerTokens.forEach { token ->
// launch(Dispatchers.IO) { // Теперь мы точно в корутин скоупе
// try {
// sendNotification(token.endpoint, token.p256dh, token.auth, message)
// } catch (e: Exception) {
// logger.error("Ошибка при отправке уведомления: ${e.message}", e)
// }
// }
// }
// }
//
//
// suspend fun sendNotification(endpoint: String, p256dh: String, auth: String, payload: PushMessage) {
// try {
// pushService.send(
// payload = Json.encodeToString(payload),
// endpoint = endpoint,
// p256dh = p256dh,
// auth = auth
// )
// logger.info("Уведомление успешно отправлено на endpoint: $endpoint")
//
// } catch (e: Exception) {
// logger.error("Ошибка при отправке уведомления на endpoint $endpoint: ${e.message}")
// throw e
// }
// }
//
//
// suspend fun sendToAll(payload: PushMessage) {
//
// subscriptionRepo.findAll().collectList().awaitSingle().forEach { sub ->
//
// try {
// sendNotification(sub.endpoint, sub.p256dh, sub.auth, payload)
// } catch (e: Exception) {
// sub.isActive = false
// subscriptionRepo.save(sub).awaitSingle()
// }
// }
// }
//
//
// suspend fun subscribe(subscriptionDTO: SubscriptionDTO, user: User): String {
// val subscription = Subscription(
// id = null,
// user = user,
// endpoint = subscriptionDTO.endpoint,
// auth = subscriptionDTO.keys["auth"].orEmpty(),
// p256dh = subscriptionDTO.keys["p256dh"].orEmpty(),
// isActive = true
// )
//
// return try {
// val savedSubscription = subscriptionRepo.save(subscription).awaitSingle()
// "Subscription created with ID: ${savedSubscription.id}"
// } catch (e: DuplicateKeyException) {
// logger.info("Subscription already exists. Skipping.")
// "Subscription already exists. Skipping."
// } catch (e: Exception) {
// logger.error("Error while saving subscription: ${e.message}")
// throw kotlin.RuntimeException("Error while saving subscription")
// }
// }
//}

View File

@@ -1,43 +1,51 @@
package space.luminic.finance.services
import kotlinx.coroutines.reactor.awaitSingle
import org.springframework.cache.annotation.CacheEvict
import org.springframework.stereotype.Service
import reactor.core.publisher.Mono
import space.luminic.finance.configs.AuthException
import space.luminic.finance.models.Token
import space.luminic.finance.models.Token.TokenStatus
import space.luminic.finance.repos.TokenRepo
import java.time.LocalDateTime
import java.time.Instant
@Service
class TokenService(private val tokenRepository: TokenRepo) {
class TokenService(
private val userService: UserService,
private val tokenRepo: TokenRepo) {
@CacheEvict("tokens", allEntries = true)
suspend fun saveToken(token: String, username: String, expiresAt: LocalDateTime): Token {
fun saveToken(token: String, username: String, expiresAt: Instant): Token {
val user = userService.getByUsername(username)
val newToken = Token(
token = token,
username = username,
issuedAt = LocalDateTime.now(),
user = user,
issuedAt = Instant.now(),
expiresAt = expiresAt
)
return tokenRepository.save(newToken).awaitSingle()
return tokenRepo.create(newToken)
}
fun getToken(token: String): Mono<Token> {
return tokenRepository.findByToken(token)
fun getToken(token: String): Token {
return tokenRepo.findByToken(token) ?: throw AuthException("Токен не валиден")
}
fun revokeToken(token: String) {
val tokenDetail =
tokenRepository.findByToken(token).block()!!
val updatedToken = tokenDetail.copy(status = TokenStatus.REVOKED)
tokenRepository.save(updatedToken).block()
val tokenDetail = getToken(token)
val updatedToken = Token(
id = tokenDetail.id,
token = tokenDetail.token,
user = tokenDetail.user,
status = TokenStatus.REVOKED,
issuedAt = tokenDetail.issuedAt,
expiresAt = tokenDetail.expiresAt
)
tokenRepo.update(updatedToken)
}
@CacheEvict("tokens", allEntries = true)
fun deleteExpiredTokens() {
tokenRepository.deleteByExpiresAtBefore(LocalDateTime.now())
tokenRepo.deleteByExpiresAtBefore(Instant.now())
}
}

View File

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

View File

@@ -1,185 +1,167 @@
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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import space.luminic.finance.dtos.TransactionDTO
import space.luminic.finance.models.NotFoundException
import space.luminic.finance.models.Transaction
import space.luminic.finance.repos.TransactionRepo
import space.luminic.finance.services.gpt.CategorizeService
import java.time.format.DateTimeFormatter
@Service
class TransactionServiceImpl(
private val mongoTemplate: ReactiveMongoTemplate,
private val transactionRepo: TransactionRepo,
private val spaceService: SpaceService,
private val categoryService: CategoryService,
private val transactionRepo: TransactionRepo,
private val authService: AuthService,
private val categorizeService: CategorizeService,
private val notificationService: NotificationService,
) : TransactionService {
private val logger = LoggerFactory.getLogger(this.javaClass)
private val serviceScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
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,
override fun getTransactions(
spaceId: Int,
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()
val transactions = transactionRepo.findAllBySpaceId(spaceId, filter)
return transactions
}
override suspend fun getTransaction(
spaceId: String,
transactionId: String
override fun getTransaction(
spaceId: Int,
transactionId: Int
): 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")
spaceService.getSpace(spaceId, null)
return transactionRepo.findBySpaceIdAndId(spaceId, transactionId)
?: throw NotFoundException("Transaction with id $transactionId not found")
}
override suspend fun createTransaction(
spaceId: String,
override fun createTransaction(
spaceId: Int,
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")
}
): Int {
val userId = authService.getSecurityUserId()
val space = spaceService.getSpace(spaceId, null)
val category = transaction.categoryId?.let { categoryService.getCategory(spaceId, it) }
val transaction = Transaction(
spaceId = spaceId,
space = space,
type = transaction.type,
kind = transaction.kind,
categoryId = transaction.categoryId,
category = category,
comment = transaction.comment,
amount = transaction.amount,
fees = transaction.fees,
date = transaction.date,
fromAccountId = transaction.fromAccountId,
toAccountId = transaction.toAccountId,
recurrentId = transaction.recurrentId,
)
return transactionRepo.save(transaction).awaitSingle()
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 suspend fun updateTransaction(
spaceId: String,
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(
spaceId: Int,
transactionId: Int,
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,
): Int {
val userId = authService.getSecurityUserId()
val space = spaceService.getSpace(spaceId, null)
val existingTransaction = getTransaction(space.id!!, transactionId)
val newCategory = transaction.categoryId?.let { categoryService.getCategory(spaceId, it) }
val updatedTransaction = Transaction(
id = existingTransaction.id,
space = existingTransaction.space,
parent = existingTransaction.parent,
type = transaction.type,
kind = transaction.kind,
categoryId = transaction.category,
category = newCategory,
comment = transaction.comment,
amount = transaction.amount,
fees = transaction.fees,
date = transaction.date,
fromAccountId = transaction.fromAccountId,
toAccountId = transaction.toAccountId,
isDeleted = existingTransaction.isDeleted,
isDone = transaction.isDone,
createdBy = existingTransaction.createdBy,
createdAt = existingTransaction.createdAt,
tgChatId = existingTransaction.tgChatId,
tgMessageId = existingTransaction.tgMessageId,
)
return transactionRepo.save(transaction).awaitSingle()
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 suspend fun deleteTransaction(spaceId: String, transactionId: String) {
val transaction = getTransaction(spaceId, transactionId)
transaction.isDeleted = true
transactionRepo.save(transaction).awaitSingle()
override fun deleteTransaction(spaceId: Int, transactionId: Int) {
val userId = authService.getSecurityUserId()
val space = spaceService.getSpace(spaceId, null)
val tx = getTransaction(space.id!!, transactionId)
transactionRepo.delete(transactionId)
serviceScope.launch {
runCatching {
notificationService.sendTXNotification(TxActionType.DELETE, space, userId, tx)
}.onFailure { logger.error("Error while transaction delete notification", it) }
}
}
override fun deleteByRecurrentId(spaceId: Int, recurrentId: Int) {
TODO("Not yet implemented")
}
}

Some files were not shown because too many files have changed in this diff Show More