This commit is contained in:
xds
2025-10-16 15:06:20 +03:00
commit 040da34ff7
78 changed files with 3934 additions and 0 deletions

View File

@@ -0,0 +1,28 @@
package space.luminic.finance
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
@SpringBootApplication(scanBasePackages = ["space.luminic.finance"])
@EnableReactiveMongoAuditing(auditorAwareRef = "coroutineAuditorAware")
@EnableCaching
@EnableAsync
@EnableScheduling
//@EnableConfigurationProperties([TelegramBotProperties::class,)
@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

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

@@ -0,0 +1,56 @@
package space.luminic.finance.api
import kotlinx.coroutines.reactive.awaitSingle
import org.slf4j.LoggerFactory
import org.springframework.security.core.context.ReactiveSecurityContextHolder
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.mappers.UserMapper.toDto
import space.luminic.finance.services.AuthService
import space.luminic.finance.services.UserService
import kotlin.jvm.javaClass
import kotlin.to
@RestController
@RequestMapping("/auth")
class AuthController(
private val userService: UserService,
private val authService: AuthService
) {
private val logger = LoggerFactory.getLogger(javaClass)
@GetMapping("/test")
fun test(): String {
val authentication = SecurityContextHolder.getContext().authentication
logger.info("SecurityContext in controller: $authentication")
return "Hello, ${authentication.name}"
}
@PostMapping("/login")
suspend fun login(@RequestBody request: AuthUserDTO): Map<String, String> {
val token = authService.login(request.username, request.password)
return mapOf("token" to token)
}
@PostMapping("/register")
suspend 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)
}
@GetMapping("/me")
suspend fun getMe(): UserDTO {
val securityContext = ReactiveSecurityContextHolder.getContext().awaitSingle()
return userService.getByUsername(securityContext.authentication.name).toDto()
}
}

View File

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

@@ -0,0 +1,65 @@
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 space.luminic.finance.dtos.CategoryDTO
import space.luminic.finance.mappers.CategoryMapper.toDto
import space.luminic.finance.services.CategoryService
@RestController
@RequestMapping("/spaces/{spaceId}/categories")
@SecurityScheme(
name = "bearerAuth",
type = SecuritySchemeType.HTTP,
bearerFormat = "JWT",
scheme = "bearer"
)
class CategoryController(
private val categoryService: CategoryService,
service: CategoryService
) {
@GetMapping
suspend fun getCategories(@PathVariable spaceId: String): List<CategoryDTO> {
return categoryService.getCategories(spaceId).map { it.toDto() }
}
@GetMapping("/{categoryId}")
suspend fun getCategory(@PathVariable spaceId: String, @PathVariable categoryId: String): CategoryDTO {
return categoryService.getCategory(spaceId, categoryId).toDto()
}
@PostMapping
suspend fun createCategory(
@PathVariable spaceId: String,
@RequestBody categoryDTO: CategoryDTO.CreateCategoryDTO
): CategoryDTO {
return categoryService.createCategory(spaceId, categoryDTO).toDto()
}
@PutMapping("/{categoryId}")
suspend fun updateCategory(
@PathVariable spaceId: String,
@PathVariable categoryId: String,
@RequestBody categoryDTO: CategoryDTO.UpdateCategoryDTO
): CategoryDTO {
return categoryService.updateCategory(spaceId, categoryDTO).toDto()
}
@DeleteMapping("/{categoryId}")
suspend fun deleteCategory(@PathVariable spaceId: String, @PathVariable categoryId: String) {
categoryService.deleteCategory(spaceId, categoryId)
}
}

View File

@@ -0,0 +1,53 @@
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 space.luminic.finance.dtos.CurrencyDTO
import space.luminic.finance.mappers.CurrencyMapper.toDto
import space.luminic.finance.services.CurrencyService
@RestController
@RequestMapping("/references")
@SecurityScheme(
name = "bearerAuth",
type = SecuritySchemeType.HTTP,
bearerFormat = "JWT",
scheme = "bearer"
)
class ReferenceController(
private val currencyService: CurrencyService
) {
@GetMapping("/currencies")
suspend fun getCurrencies(): List<CurrencyDTO> {
return currencyService.getCurrencies().map { it.toDto() }
}
@GetMapping("/currencies/{currencyCode}")
suspend fun getCurrency(@PathVariable currencyCode: String): CurrencyDTO {
return currencyService.getCurrency(currencyCode).toDto()
}
@PostMapping("/currencies")
suspend fun createCurrency(@RequestBody currencyDTO: CurrencyDTO): CurrencyDTO {
return currencyService.createCurrency(currencyDTO).toDto()
}
@PutMapping("/currencies/{currencyCode}")
suspend fun updateCurrency(@PathVariable currencyCode: String, @RequestBody currencyDTO: CurrencyDTO): CurrencyDTO {
return currencyService.updateCurrency(currencyDTO).toDto()
}
@DeleteMapping("/currencies/{currencyCode}")
suspend fun deleteCurrency(@PathVariable currencyCode: String) {
currencyService.deleteCurrency(currencyCode)
}
}

View File

@@ -0,0 +1,55 @@
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 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
@RequestMapping("/spaces")
@SecurityScheme(
name = "bearerAuth",
type = SecuritySchemeType.HTTP,
bearerFormat = "JWT",
scheme = "bearer"
)
class SpaceController(
private val spaceService: SpaceService,
) {
@GetMapping
suspend fun getSpaces(): List<SpaceDTO> {
return spaceService.getSpaces().map { it.toDto() }
}
@GetMapping("/{spaceId}")
suspend fun getSpace(@PathVariable spaceId: String): SpaceDTO {
return spaceService.getSpace(spaceId).toDto()
}
@PostMapping
suspend fun createSpace(@RequestBody space: SpaceDTO.CreateSpaceDTO): SpaceDTO {
return spaceService.createSpace(space).toDto()
}
@PutMapping("/{spaceId}")
suspend fun updateSpace(@PathVariable spaceId: String, @RequestBody space: SpaceDTO.UpdateSpaceDTO): SpaceDTO {
return spaceService.updateSpace(spaceId, space).toDto()
}
@DeleteMapping("/{spaceId}")
suspend fun deleteSpace(@PathVariable spaceId: String) {
spaceService.deleteSpace(spaceId)
}
}

View File

@@ -0,0 +1,60 @@
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 space.luminic.finance.dtos.TransactionDTO
import space.luminic.finance.mappers.TransactionMapper.toDto
import space.luminic.finance.services.TransactionService
@RestController
@RequestMapping("/spaces/{spaceId}/transactions")
@SecurityScheme(
name = "bearerAuth",
type = SecuritySchemeType.HTTP,
bearerFormat = "JWT",
scheme = "bearer"
)
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() }
}
@GetMapping("/{transactionId}")
suspend fun getTransaction(@PathVariable spaceId: String, @PathVariable transactionId: String): 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()
}
@PutMapping("/{transactionId}")
suspend fun updateTransaction(@PathVariable spaceId: String, @PathVariable transactionId: String, @RequestBody transactionDTO: TransactionDTO.UpdateTransactionDTO): TransactionDTO {
return transactionService.updateTransaction(spaceId, transactionDTO).toDto()
}
@DeleteMapping("/{transactionId}")
suspend fun deleteTransaction(@PathVariable spaceId: String, @PathVariable transactionId: String) {
transactionService.deleteTransaction(spaceId, transactionId)
}
}

View File

@@ -0,0 +1,109 @@
package space.luminic.finance.api.exceptionHandlers
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 {
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()
)
return errorResponse
}
@ExceptionHandler(AuthException::class)
fun handleAuthenticationException(
ex: AuthException,
exchange: ServerWebExchange
): Mono<ResponseEntity<Map<String, Any?>>?> {
ex.printStackTrace()
return Mono.just(
ResponseEntity(
constructErrorBody(
ex,
ex.message.toString(),
HttpStatus.UNAUTHORIZED,
exchange.request
), HttpStatus.UNAUTHORIZED
)
)
}
@ExceptionHandler(NotFoundException::class)
fun handleNotFoundException(
e: NotFoundException,
exchange: ServerWebExchange
): Mono<ResponseEntity<Map<String, Any?>>?> {
e.printStackTrace()
return Mono.just(
ResponseEntity(
constructErrorBody(
e,
e.message.toString(),
HttpStatus.NOT_FOUND,
exchange.request
), HttpStatus.NOT_FOUND
)
)
}
@ExceptionHandler(IllegalArgumentException::class)
fun handleIllegalArgumentException(
e: IllegalArgumentException,
exchange: ServerWebExchange
): Mono<ResponseEntity<Map<String, Any?>>?> {
e.printStackTrace()
return Mono.just(
ResponseEntity(
constructErrorBody(
e,
e.message.toString(),
HttpStatus.BAD_REQUEST,
exchange.request
), HttpStatus.BAD_REQUEST
)
)
}
@ExceptionHandler(Exception::class)
fun handleGenericException(
e: Exception,
exchange: ServerWebExchange
): Mono<out ResponseEntity<out Map<String, Any?>>?> {
e.printStackTrace()
return Mono.just(
ResponseEntity(
constructErrorBody(
e,
e.message.toString(),
HttpStatus.INTERNAL_SERVER_ERROR,
exchange.request
), HttpStatus.INTERNAL_SERVER_ERROR
)
)
}
}

View File

@@ -0,0 +1,49 @@
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))
}
}

View File

@@ -0,0 +1,56 @@
package space.luminic.finance.configs
import kotlinx.coroutines.reactor.mono
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.stereotype.Component
import org.springframework.web.server.ServerWebExchange
import org.springframework.web.server.WebFilterChain
import reactor.core.publisher.Mono
import space.luminic.finance.services.AuthService
@Component
class BearerTokenFilter(private val authService: AuthService) : SecurityContextServerWebExchangeWebFilter() {
// private val logger = LoggerFactory.getLogger(BearerTokenFilter::class.java)
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)
)
securityContext
}.flatMap { securityContext ->
chain.filter(exchange)
.contextWrite(ReactiveSecurityContextHolder.withSecurityContext(Mono.just(securityContext)))
}
} else {
Mono.error(AuthException("Authorization token is missing"))
}
}
}
open class AuthException(msg: String) : RuntimeException(msg)

View File

@@ -0,0 +1,19 @@
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
// fun httpTraceRepository(): HttpTraceRepository {
// return InMemoryHttpTraceRepository()
// }
//}
@ConfigurationProperties(prefix = "nlp")
data class NLPConfig(
val address: String,
)

View File

@@ -0,0 +1,60 @@
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.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.web.server.SecurityWebFilterChain
@Configuration
class SecurityConfig(
) {
@Bean
fun securityWebFilterChain(
http: ServerHttpSecurity,
bearerTokenFilter: BearerTokenFilter
): SecurityWebFilterChain {
return http
.csrf { it.disable() }
.cors { it.configurationSource(corsConfigurationSource()) }
.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()
}
@Bean
fun passwordEncoder(): PasswordEncoder {
return BCryptPasswordEncoder()
}
@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
val source = org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource()
source.registerCorsConfiguration("/**", corsConfig)
return source
}
}

View File

@@ -0,0 +1,18 @@
package space.luminic.finance.configs
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Configuration
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
@Configuration
class StorageConfig(@Value("\${storage.location}") location: String) {
val rootLocation: Path = Paths.get(location)
init {
Files.createDirectories(rootLocation) // Создаем папку, если её нет
}
}

View File

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

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

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

View File

@@ -0,0 +1,8 @@
package space.luminic.finance.dtos
data class CurrencyDTO(
val code: String,
val name: String,
val symbol: String
){
}

View File

@@ -0,0 +1,20 @@
package space.luminic.finance.dtos
import space.luminic.finance.models.Goal.GoalType
import java.math.BigDecimal
import java.time.Instant
import java.time.LocalDate
data class GoalDTO(
val id: String,
val type: GoalType,
val name: String,
val description: String? = null,
val amount: BigDecimal,
val date: LocalDate,
val createdBy: UserDTO,
val createdAt: Instant,
val updatedBy: UserDTO,
val updatedAt: Instant,
) {
}

View File

@@ -0,0 +1,24 @@
package space.luminic.finance.dtos
import space.luminic.finance.models.User
import java.time.Instant
data class SpaceDTO(
val id: String? = null,
val name: String,
val owner: UserDTO,
val participants: List<UserDTO> = emptyList(),
val createdBy: UserDTO? = null,
val createdAt: Instant,
var updatedBy: UserDTO? = null,
var updatedAt: Instant,
) {
data class CreateSpaceDTO(
val name: String,
val createBasicCategories: Boolean = true,
)
data class UpdateSpaceDTO(
val name: String
)
}

View File

@@ -0,0 +1,52 @@
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
data class TransactionDTO(
val id: String? = null,
var parentId: String? = 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,
) {
data class CreateTransactionDTO(
val type: TransactionType = TransactionType.EXPENSE,
val kind: TransactionKind = TransactionKind.INSTANT,
val categoryId: String,
val comment: String,
val amount: BigDecimal,
val fees: BigDecimal = BigDecimal.ZERO,
val fromAccountId: String,
val toAccountId: String? = null,
val date: Instant
)
data class UpdateTransactionDTO(
val id: String,
val type: TransactionType = TransactionType.EXPENSE,
val kind: TransactionKind = TransactionKind.INSTANT,
val category: String,
val comment: String,
val amount: BigDecimal,
val fees: BigDecimal = BigDecimal.ZERO,
val fromAccountId: String,
val toAccountId: String? = null,
val date: Instant
)
}

View File

@@ -0,0 +1,30 @@
package space.luminic.finance.dtos
import space.luminic.finance.models.User
data class UserDTO (
var id: String,
val username: String,
var firstName: String,
var tgId: String? = null,
var tgUserName: String? = null,
var roles: List<String>
) {
data class AuthUserDTO (
var username: String,
var password: String,
)
data class RegisterUserDTO (
var username: String,
var firstName: String,
var password: String,
)
}

View File

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

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

@@ -0,0 +1,22 @@
package space.luminic.finance.mappers
import space.luminic.finance.dtos.CategoryDTO
import space.luminic.finance.mappers.UserMapper.toDto
import space.luminic.finance.models.Category
object CategoryMapper {
fun Category.toDto() = CategoryDTO(
id = this.id ?: throw IllegalArgumentException("category id is not set"),
type = this.type,
name = this.name,
icon = this.icon,
createdBy = this.createdBy?.toDto(),
createdAt = this.createdAt ?: throw IllegalArgumentException("created at is not set"),
updatedBy = this.updatedBy?.toDto(),
updatedAt = this.updatedAt ?: throw IllegalArgumentException("updated at is not set"),
)
}

View File

@@ -0,0 +1,11 @@
package space.luminic.finance.mappers
import space.luminic.finance.dtos.CurrencyDTO
import space.luminic.finance.models.Currency
object CurrencyMapper {
fun Currency.toDto() = CurrencyDTO(
code, name, symbol
)
}

View File

@@ -0,0 +1,21 @@
package space.luminic.finance.mappers
import space.luminic.finance.dtos.GoalDTO
import space.luminic.finance.mappers.UserMapper.toDto
import space.luminic.finance.models.Goal
object GoalMapper {
fun Goal.toDto() = GoalDTO(
id = this.id ?: throw IllegalArgumentException("Goal id is not provided"),
type = this.type,
name = this.name,
amount = this.goalAmount,
date = this.goalDate,
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"),
)
}

View File

@@ -0,0 +1,18 @@
package space.luminic.finance.mappers
import space.luminic.finance.dtos.SpaceDTO
import space.luminic.finance.mappers.UserMapper.toDto
import space.luminic.finance.models.Space
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(),
createdBy = this.createdBy?.toDto(),
createdAt = this.createdAt ?: throw IllegalArgumentException("createdAt is not provided"),
updatedBy = this.updatedBy?.toDto(),
updatedAt = this.updatedAt ?: throw IllegalArgumentException("updatedAt is not provided"),
)
}

View File

@@ -0,0 +1,35 @@
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
object TransactionMapper {
fun Transaction.toDto() = TransactionDTO(
id = this.id,
parentId = this.parentId,
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,
)
}

View File

@@ -0,0 +1,16 @@
package space.luminic.finance.mappers
import space.luminic.finance.dtos.UserDTO
import space.luminic.finance.models.User
object UserMapper {
// UserMapping.kt
fun User.toDto(): UserDTO = UserDTO(
id = this.id!!,
username = this.username,
firstName = this.firstName,
tgId = this.tgId,
tgUserName = this.tgUserName,
roles = this.roles
)
}

View File

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

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

@@ -0,0 +1,49 @@
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,
val type: CategoryType,
val name: 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,
) {
@ReadOnlyProperty var createdBy: User? = null
@ReadOnlyProperty var updatedBy: User? = null
enum class CategoryType(val displayName: String) {
INCOME("Поступления"),
EXPENSE("Расходы")
}
@Document(collection = "categories_etalon")
data class CategoryEtalon(
@Id val id: String? = null,
val type: CategoryType,
val name: String,
val icon: String
)
}

View File

@@ -0,0 +1,13 @@
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 name: String,
val symbol: String
)

View File

@@ -0,0 +1,18 @@
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,
val rate: BigDecimal,
val date: LocalDate
)
{
@ReadOnlyProperty var currency: Currency? = null
}

View File

@@ -0,0 +1,5 @@
package space.luminic.finance.models
open class NotFoundException(message: String) : Exception(message)
open class TelegramBotException(message: String, val chatId: Long) : Exception(message)

View File

@@ -0,0 +1,40 @@
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,
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,
) {
enum class GoalType(val displayName: String, val icon: String) {
AUTO("Авто", "🏎️"),
VACATION("Отпуск", "🏖️"),
GOODS("Покупка", "🛍️"),
OTHER("Прочее", "💸")
}
}

View File

@@ -0,0 +1,20 @@
package space.luminic.finance.models
import kotlinx.serialization.Serializable
@Serializable
data class PushMessage(
// title: str
// body: str
// icon: str
// badge: str
// url: str
val title: String,
val body: String,
val icon: String? = null,
val badge: String? = null,
val url: String? = null
)

View File

@@ -0,0 +1,36 @@
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,
val name: String,
val ownerId: String,
val participantsIds: List<String> = emptyList(),
var isDeleted: Boolean = false,
@CreatedBy val createdById: String? = null,
@CreatedDate val createdAt: Instant? = null,
@LastModifiedBy val updatedById: String? = 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
}

View File

@@ -0,0 +1,23 @@
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 java.time.Instant
@Document(collection = "subscriptions")
data class Subscription(
@Id val id: String? = null,
@DBRef val user: User? = null,
val endpoint: String,
val auth: String,
val p256dh: String,
var isActive: Boolean,
val createdAt: Instant = Instant.now(),
)
data class SubscriptionDTO (
val endpoint: String,
val keys: Map<String, String>
)

View File

@@ -0,0 +1,22 @@
package space.luminic.finance.models
import org.springframework.data.annotation.Id
import org.springframework.data.mongodb.core.mapping.Document
import java.time.LocalDateTime
@Document(collection = "tokens")
data class Token(
@Id
val id: String? = null,
val token: String,
val username: String,
val issuedAt: LocalDateTime,
val expiresAt: LocalDateTime,
val status: TokenStatus = TokenStatus.ACTIVE
) {
enum class TokenStatus {
ACTIVE, REVOKED, EXPIRED
}
}

View File

@@ -0,0 +1,59 @@
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
@Document(collection = "transactions")
data class Transaction(
@Id val id: String? = null,
val spaceId: String,
var parentId: String? = null,
val type: TransactionType = TransactionType.EXPENSE,
val kind: TransactionKind = TransactionKind.INSTANT,
val categoryId: String,
val comment: String,
val amount: BigDecimal,
val fees: BigDecimal = BigDecimal.ZERO,
val fromAccountId: String,
val toAccountId: String? = null,
val date: Instant = Instant.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,
) {
@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("Поступления"),
EXPENSE("Расходы"),
TRANSFER("Перевод")
}
enum class TransactionKind(val displayName: String) {
PLANNING("Плановая"),
INSTANT("Текущая")
}
}

View File

@@ -0,0 +1,26 @@
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 java.time.LocalDate
import java.time.LocalDateTime
@Document("users")
data class User(
@Id
var id: String? = null,
val username: String,
var firstName: String,
var tgId: String? = null,
var tgUserName: String? = null,
var password: String,
var isActive: Boolean = true,
var regDate: LocalDate = LocalDate.now(),
val createdAt: LocalDateTime = LocalDateTime.now(),
var roles: MutableList<String> = mutableListOf(),
)

View File

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

@@ -0,0 +1,9 @@
package space.luminic.finance.repos
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import space.luminic.finance.models.Category
interface CategoryRepo: ReactiveMongoRepository<Category, String> {
}
interface CategoryEtalonRepo: ReactiveMongoRepository<Category.CategoryEtalon, String>

View File

@@ -0,0 +1,8 @@
package space.luminic.finance.repos
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import space.luminic.finance.models.Currency
import space.luminic.finance.models.CurrencyRate
interface CurrencyRepo: ReactiveMongoRepository<Currency, String>
interface CurrencyRateRepo: ReactiveMongoRepository<CurrencyRate, String>

View File

@@ -0,0 +1,8 @@
package space.luminic.finance.repos
import org.springframework.data.mongodb.core.ReactiveMongoTemplate
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import space.luminic.finance.models.Space
interface SpaceRepo: ReactiveMongoRepository<Space, String> {
}

View File

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

View File

@@ -0,0 +1,15 @@
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
@Repository
interface TokenRepo: ReactiveMongoRepository<Token, String> {
fun findByToken(token: String): Mono<Token>
fun deleteByExpiresAtBefore(dateTime: LocalDateTime)
}

View File

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

View File

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

View File

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

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

@@ -0,0 +1,109 @@
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.userdetails.UsernameNotFoundException
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.stereotype.Service
import space.luminic.finance.configs.AuthException
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.util.*
@Service
class AuthService(
private val userRepository: UserRepo,
private val tokenService: TokenService,
private val jwtUtil: JWTUtil,
private val userService: UserService,
) {
private val passwordEncoder = BCryptPasswordEncoder()
suspend fun getSecurityUser(): User {
val securityContextHolder = ReactiveSecurityContextHolder.getContext().awaitSingleOrNull()
?: throw AuthException("Authentication failed")
val authentication = securityContextHolder.authentication
val username = authentication.name
// Получаем пользователя по имени
return userService.getByUsername(username)
}
suspend fun login(username: String, password: String): String {
val user = userRepository.findByUsername(username).awaitFirstOrNull()
?: throw UsernameNotFoundException("Пользователь не найден")
return if (passwordEncoder.matches(password, user.password)) {
val token = jwtUtil.generateToken(user.username)
val expireAt = Date(System.currentTimeMillis() + 1000 * 60 * 60 * 24 * 10)
tokenService.saveToken(
token = token,
username = username,
expiresAt = LocalDateTime.ofInstant(
expireAt.toInstant(),
ZoneId.systemDefault()
)
)
token
} else {
throw IllegalArgumentException("Ошибка логина или пароля")
}
}
suspend fun tgLogin(tgId: String): String {
val user =
userRepository.findByTgId(tgId).awaitSingleOrNull() ?: throw UsernameNotFoundException("Пользователь не найден")
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()
)
)
return token
}
suspend fun register(username: String, password: String, firstName: String): User {
val user = userRepository.findByUsername(username).awaitSingleOrNull()
if (user == null) {
var newUser = User(
username = username,
password = passwordEncoder.encode(password), // Шифрование пароля
firstName = firstName,
roles = mutableListOf("USER")
)
newUser = userRepository.save(newUser).awaitSingle()
return newUser
} else throw IllegalArgumentException("Пользователь уже зарегистрирован")
}
@Cacheable(cacheNames = ["tokens"], key = "#token")
suspend fun isTokenValid(token: String): User {
val tokenDetails = tokenService.getToken(token).awaitFirstOrNull() ?: throw AuthException("Токен не валиден")
when {
tokenDetails.status == Token.TokenStatus.ACTIVE && tokenDetails.expiresAt.isAfter(LocalDateTime.now()) -> {
return userService.getByUsername(tokenDetails.username)
}
else -> {
tokenService.revokeToken(tokenDetails.token)
throw AuthException("Токен истек или не валиден")
}
}
}
}

View File

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

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

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

View File

@@ -0,0 +1,109 @@
package space.luminic.finance.services
import kotlinx.coroutines.reactive.awaitSingle
import org.bson.types.ObjectId
import org.springframework.data.mongodb.core.ReactiveMongoTemplate
import org.springframework.data.mongodb.core.aggregation.Aggregation.addFields
import org.springframework.data.mongodb.core.aggregation.Aggregation.lookup
import org.springframework.data.mongodb.core.aggregation.Aggregation.match
import org.springframework.data.mongodb.core.aggregation.Aggregation.newAggregation
import org.springframework.data.mongodb.core.aggregation.Aggregation.unwind
import org.springframework.data.mongodb.core.aggregation.AggregationOperation
import org.springframework.data.mongodb.core.aggregation.ConvertOperators
import org.springframework.data.mongodb.core.query.Criteria
import org.springframework.stereotype.Service
import space.luminic.finance.dtos.CategoryDTO
import space.luminic.finance.models.Category
import space.luminic.finance.models.Space
import space.luminic.finance.repos.CategoryEtalonRepo
import space.luminic.finance.repos.CategoryRepo
@Service
class CategoryServiceImpl(
private val categoryRepo: CategoryRepo,
private val categoryEtalonRepo: CategoryEtalonRepo,
private val reactiveMongoTemplate: ReactiveMongoTemplate,
private val authService: AuthService,
) : CategoryService {
private fun basicAggregation(spaceId: String): List<AggregationOperation> {
val addFieldsAsOJ = addFields()
.addField("createdByOI")
.withValue(ConvertOperators.valueOf("createdById").convertToObjectId())
.addField("updatedByOI")
.withValue(ConvertOperators.valueOf("updatedById").convertToObjectId())
.build()
val lookupCreatedBy = lookup("users", "createdByOI", "_id", "createdBy")
val unwindCreatedBy = unwind("createdBy")
val lookupUpdatedBy = lookup("users", "updatedByOI", "_id", "updatedBy")
val unwindUpdatedBy = unwind("updatedBy")
val matchCriteria = mutableListOf<Criteria>()
matchCriteria.add(Criteria.where("spaceId").`is`(spaceId))
matchCriteria.add(Criteria.where("isDeleted").`is`(false))
val matchStage = match(Criteria().andOperator(*matchCriteria.toTypedArray()))
return listOf(addFieldsAsOJ, lookupCreatedBy, unwindCreatedBy, lookupUpdatedBy, unwindUpdatedBy, matchStage)
}
override suspend fun getCategories(spaceId: String): List<Category> {
val basicAggregation = basicAggregation(spaceId)
val aggregation = newAggregation(*basicAggregation.toTypedArray())
return reactiveMongoTemplate.aggregate(aggregation, "categories", Category::class.java).collectList().awaitSingle()
}
override suspend fun getCategory(spaceId: String, id: String): Category {
val basicAggregation = basicAggregation(spaceId)
val match = match(Criteria.where("_id").`is`(ObjectId(id)))
val aggregation = newAggregation(*basicAggregation.toTypedArray(), match)
return reactiveMongoTemplate.aggregate(aggregation, "categories", Category::class.java).awaitSingle()
}
override suspend fun createCategory(
spaceId: String,
category: CategoryDTO.CreateCategoryDTO
): Category {
val createdCategory = Category(
spaceId = spaceId,
type = category.type,
name = category.name,
icon = category.icon
)
return categoryRepo.save(createdCategory).awaitSingle()
}
override suspend fun updateCategory(
spaceId: String,
category: CategoryDTO.UpdateCategoryDTO
): Category {
val existingCategory = getCategory(spaceId, category.id)
val updatedCategory = existingCategory.copy(
type = category.type,
name = category.name,
icon = category.icon,
)
return categoryRepo.save(updatedCategory).awaitSingle()
}
override suspend fun deleteCategory(spaceId: String, id: String) {
val existingCategory = getCategory(spaceId, id)
existingCategory.isDeleted = true
categoryRepo.save(existingCategory).awaitSingle()
}
override suspend fun createCategoriesForSpace(spaceId: String): List<Category> {
val etalonCategories = categoryEtalonRepo.findAll().collectList().awaitSingle()
val toCreate = etalonCategories.map {
Category(
spaceId = spaceId,
type = it.type,
name = it.name,
icon = it.icon
)
}
return categoryRepo.saveAll(toCreate).collectList().awaitSingle()
}
}

View File

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

@@ -0,0 +1,16 @@
package space.luminic.finance.services
import space.luminic.finance.dtos.CurrencyDTO
import space.luminic.finance.models.Currency
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
}

View File

@@ -0,0 +1,51 @@
package space.luminic.finance.services
import kotlinx.coroutines.reactive.awaitSingle
import org.springframework.stereotype.Service
import space.luminic.finance.dtos.CurrencyDTO
import space.luminic.finance.models.Currency
import space.luminic.finance.models.CurrencyRate
import space.luminic.finance.repos.CurrencyRateRepo
import space.luminic.finance.repos.CurrencyRepo
import java.math.BigDecimal
import java.time.LocalDate
@Service
class CurrencyServiceImpl(
private val currencyRepo: CurrencyRepo,
private val currencyRateRepo: CurrencyRateRepo
) : CurrencyService {
override suspend fun getCurrencies(): List<Currency> {
return currencyRepo.findAll().collectList().awaitSingle()
}
override suspend fun getCurrency(currencyCode: String): Currency {
return currencyRepo.findById(currencyCode).awaitSingle()
}
override suspend fun createCurrency(currency: CurrencyDTO): Currency {
val createdCurrency = Currency(currency.code, currency.name, currency.symbol)
return currencyRepo.save(createdCurrency).awaitSingle()
}
override suspend fun updateCurrency(currency: CurrencyDTO): Currency {
val existingCurrency = currencyRepo.findById(currency.code).awaitSingle()
val newCurrency = existingCurrency.copy(name = currency.name, symbol = currency.symbol)
return currencyRepo.save(newCurrency).awaitSingle()
}
override suspend fun deleteCurrency(currencyCode: String) {
currencyRepo.deleteById(currencyCode).awaitSingle()
}
override suspend fun createCurrencyRate(currencyCode: String): CurrencyRate {
return currencyRateRepo.save(
CurrencyRate(
currencyCode = currencyCode,
rate = BigDecimal(12.0),
date = LocalDate.now(),
)
).awaitSingle()
}
}

View File

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

View File

@@ -0,0 +1,149 @@
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 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,
) : SpaceService {
private fun basicAggregation(user: User): List<AggregationOperation> {
val addFieldsAsOJ = addFields()
.addField("createdByOI")
.withValue(ConvertOperators.valueOf("createdById").convertToObjectId())
.addField("updatedByOI")
.withValue(ConvertOperators.valueOf("updatedById").convertToObjectId())
.build()
val lookupCreatedBy = lookup("users", "createdByOI", "_id", "createdBy")
val unwindCreatedBy = unwind("createdBy")
val lookupUpdatedBy = lookup("users", "updatedByOI", "_id", "updatedBy")
val unwindUpdatedBy = unwind("updatedBy")
val matchCriteria = mutableListOf<Criteria>()
matchCriteria.add(
Criteria().orOperator(
Criteria.where("ownerId").`is`(user.id),
Criteria.where("participantsIds").`is`(user.id)
)
)
matchCriteria.add(Criteria.where("isDeleted").`is`(false))
val matchStage = match(Criteria().andOperator(*matchCriteria.toTypedArray()))
return listOf(addFieldsAsOJ, lookupCreatedBy, unwindCreatedBy, lookupUpdatedBy, unwindUpdatedBy, matchStage)
}
private fun ownerAndParticipantsLookups(): List<AggregationOperation>{
val addOwnerAsOJ = addFields()
.addField("ownerIdAsObjectId")
.withValue(ConvertOperators.valueOf("ownerId").convertToObjectId())
.addField("participantsIdsAsObjectId")
.withValue(
VariableOperators.Map.itemsOf("participantsIds")
.`as`("id")
.andApply(
ConvertOperators.valueOf("$\$id").convertToObjectId()
)
)
.build()
val lookupOwner = lookup("users", "ownerIdAsObjectId", "_id", "owner")
val unwindOwner = unwind("owner")
val lookupUsers = lookup("users", "participantsIdsAsObjectId", "_id", "participants")
return listOf(addOwnerAsOJ, lookupOwner, unwindOwner, lookupUsers)
}
override suspend fun checkSpace(spaceId: String): Space {
val user = authService.getSecurityUser()
val space = getSpace(spaceId)
// Проверяем доступ пользователя к пространству
return if (space.participants!!.none { it.id.toString() == user.id }) {
throw IllegalArgumentException("User does not have access to this Space")
} else space
}
override suspend fun getSpaces(): List<Space> {
val user = authService.getSecurityUser()
val basicAggregation = basicAggregation(user)
val ownerAndParticipantsLookup = ownerAndParticipantsLookups()
val sort = sort(Sort.by(Sort.Direction.DESC, "createdAt"))
val aggregation = newAggregation(
*basicAggregation.toTypedArray(),
*ownerAndParticipantsLookup.toTypedArray(),
sort,
)
return mongoTemplate.aggregate(aggregation, "spaces", Space::class.java).collectList().awaitSingle()
}
override suspend fun getSpace(id: String): Space {
val user = authService.getSecurityUser()
val basicAggregation = basicAggregation(user)
val ownerAndParticipantsLookup = ownerAndParticipantsLookups()
val aggregation = newAggregation(
*basicAggregation.toTypedArray(),
*ownerAndParticipantsLookup.toTypedArray(),
)
return mongoTemplate.aggregate(aggregation, "spaces", Space::class.java).awaitFirstOrNull()
?: throw NotFoundException("Space not found")
}
override suspend fun createSpace(space: SpaceDTO.CreateSpaceDTO): Space {
val owner = authService.getSecurityUser()
val createdSpace = Space(
name = space.name,
ownerId = owner.id!!,
participantsIds = listOf(owner.id!!),
)
createdSpace.owner = owner
createdSpace.participants?.toMutableList()?.add(owner)
val savedSpace = spaceRepo.save(createdSpace).awaitSingle()
return savedSpace
}
override suspend fun updateSpace(spaceId: String, space: SpaceDTO.UpdateSpaceDTO): Space {
val existingSpace = spaceRepo.findById(spaceId).awaitFirstOrNull() ?: throw NotFoundException("Space not found")
val updatedSpace = existingSpace.copy(
name = space.name,
)
updatedSpace.owner = existingSpace.owner
updatedSpace.participants = existingSpace.participants
return spaceRepo.save(updatedSpace).awaitFirst()
}
override suspend fun deleteSpace(spaceId: String) {
val space = spaceRepo.findById(spaceId).awaitFirstOrNull() ?: throw NotFoundException("Space not found")
space.isDeleted = true
spaceRepo.save(space).awaitFirst()
}
}

View File

@@ -0,0 +1,113 @@
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")
}
}
}

View File

@@ -0,0 +1,43 @@
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.models.Token
import space.luminic.finance.models.Token.TokenStatus
import space.luminic.finance.repos.TokenRepo
import java.time.LocalDateTime
@Service
class TokenService(private val tokenRepository: TokenRepo) {
@CacheEvict("tokens", allEntries = true)
suspend fun saveToken(token: String, username: String, expiresAt: LocalDateTime): Token {
val newToken = Token(
token = token,
username = username,
issuedAt = LocalDateTime.now(),
expiresAt = expiresAt
)
return tokenRepository.save(newToken).awaitSingle()
}
fun getToken(token: String): Mono<Token> {
return tokenRepository.findByToken(token)
}
fun revokeToken(token: String) {
val tokenDetail =
tokenRepository.findByToken(token).block()!!
val updatedToken = tokenDetail.copy(status = TokenStatus.REVOKED)
tokenRepository.save(updatedToken).block()
}
@CacheEvict("tokens", allEntries = true)
fun deleteExpiredTokens() {
tokenRepository.deleteByExpiresAtBefore(LocalDateTime.now())
}
}

View File

@@ -0,0 +1,20 @@
package space.luminic.finance.services
import space.luminic.finance.dtos.TransactionDTO
import space.luminic.finance.models.Transaction
import java.time.LocalDate
interface TransactionService {
data class TransactionsFilter(
val accountId: String,
val dateFrom: LocalDate? = null,
val dateTo: LocalDate? = null,
)
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)
}

View File

@@ -0,0 +1,185 @@
package space.luminic.finance.services
import kotlinx.coroutines.reactive.awaitFirstOrNull
import kotlinx.coroutines.reactive.awaitSingle
import org.bson.types.ObjectId
import org.springframework.data.domain.Sort
import org.springframework.data.domain.Sort.Direction
import org.springframework.data.mongodb.core.ReactiveMongoTemplate
import org.springframework.data.mongodb.core.aggregation.Aggregation.addFields
import org.springframework.data.mongodb.core.aggregation.Aggregation.lookup
import org.springframework.data.mongodb.core.aggregation.Aggregation.match
import org.springframework.data.mongodb.core.aggregation.Aggregation.newAggregation
import org.springframework.data.mongodb.core.aggregation.Aggregation.sort
import org.springframework.data.mongodb.core.aggregation.Aggregation.unwind
import org.springframework.data.mongodb.core.aggregation.AggregationOperation
import org.springframework.data.mongodb.core.aggregation.ConvertOperators
import org.springframework.data.mongodb.core.query.Criteria
import org.springframework.stereotype.Service
import space.luminic.finance.dtos.TransactionDTO
import space.luminic.finance.models.NotFoundException
import space.luminic.finance.models.Transaction
import space.luminic.finance.repos.TransactionRepo
@Service
class TransactionServiceImpl(
private val mongoTemplate: ReactiveMongoTemplate,
private val transactionRepo: TransactionRepo,
private val categoryService: CategoryService,
) : TransactionService {
private fun basicAggregation(spaceId: String): List<AggregationOperation> {
val addFieldsOI = addFields()
.addField("createdByOI")
.withValue(ConvertOperators.valueOf("createdById").convertToObjectId())
.addField("updatedByOI")
.withValue(ConvertOperators.valueOf("updatedById").convertToObjectId())
.addField("fromAccountIdOI")
.withValue(ConvertOperators.valueOf("fromAccountId").convertToObjectId())
.addField("toAccountIdOI")
.withValue(ConvertOperators.valueOf("toAccountId").convertToObjectId())
.addField("categoryIdOI")
.withValue(ConvertOperators.valueOf("categoryId").convertToObjectId())
.build()
val lookupFromAccount = lookup("accounts", "fromAccountIdOI", "_id", "fromAccount")
val unwindFromAccount = unwind("fromAccount")
val lookupToAccount = lookup("accounts", "toAccountIdOI", "_id", "toAccount")
val unwindToAccount = unwind("toAccount", true)
val lookupCategory = lookup("categories", "categoryIdOI", "_id", "category")
val unwindCategory = unwind("category")
val lookupCreatedBy = lookup("users", "createdByOI", "_id", "createdBy")
val unwindCreatedBy = unwind("createdBy")
val lookupUpdatedBy = lookup("users", "updatedByOI", "_id", "updatedBy")
val unwindUpdatedBy = unwind("updatedBy")
val matchCriteria = mutableListOf<Criteria>()
matchCriteria.add(Criteria.where("spaceId").`is`(spaceId))
val matchStage = match(Criteria().andOperator(*matchCriteria.toTypedArray()))
return listOf(
matchStage,
addFieldsOI,
lookupFromAccount,
unwindFromAccount,
lookupToAccount,
unwindToAccount,
lookupCategory,
unwindCategory,
lookupCreatedBy,
unwindCreatedBy,
lookupUpdatedBy,
unwindUpdatedBy
)
}
override suspend fun getTransactions(
spaceId: String,
filter: TransactionService.TransactionsFilter,
sortBy: String,
sortDirection: String
): List<Transaction> {
val allowedSortFields = setOf("date", "amount", "category.name", "createdAt")
require(sortBy in allowedSortFields) { "Invalid sort field: $sortBy" }
val direction = when (sortDirection.uppercase()) {
"ASC" -> Direction.ASC
"DESC" -> Direction.DESC
else -> throw IllegalArgumentException("Sort direction must be 'ASC' or 'DESC'")
}
val basicAggregation = basicAggregation(spaceId)
val sort = sort(Sort.by(direction, sortBy))
val matchCriteria = mutableListOf<Criteria>()
filter.dateFrom?.let { matchCriteria.add(Criteria.where("date").gte(it)) }
filter.dateTo?.let { matchCriteria.add(Criteria.where("date").lte(it)) }
val matchStage = match(Criteria().andOperator(*matchCriteria.toTypedArray()))
val aggregation =
newAggregation(
matchStage,
*basicAggregation.toTypedArray(),
sort
)
return mongoTemplate.aggregate(aggregation, "transactions", Transaction::class.java)
.collectList()
.awaitSingle()
}
override suspend fun getTransaction(
spaceId: String,
transactionId: String
): Transaction {
val matchCriteria = mutableListOf<Criteria>()
matchCriteria.add(Criteria.where("spaceId").`is`(spaceId))
matchCriteria.add(Criteria.where("_id").`is`(ObjectId(transactionId)))
val matchStage = match(Criteria().andOperator(*matchCriteria.toTypedArray()))
val aggregation =
newAggregation(
matchStage,
)
return mongoTemplate.aggregate(aggregation, "transactions", Transaction::class.java)
.awaitFirstOrNull() ?: throw NotFoundException("Transaction with ID $transactionId not found")
}
override suspend fun createTransaction(
spaceId: String,
transaction: TransactionDTO.CreateTransactionDTO
): Transaction {
if (transaction.type == Transaction.TransactionType.TRANSFER && transaction.toAccountId == null) {
throw IllegalArgumentException("Cannot create a transaction with type TRANSFER without a toAccountId")
}
val category = categoryService.getCategory(spaceId, transaction.categoryId)
if (transaction.type != Transaction.TransactionType.TRANSFER && transaction.type.name != category.type.name) {
throw IllegalArgumentException("Transaction type should match with category type")
}
val transaction = Transaction(
spaceId = spaceId,
type = transaction.type,
kind = transaction.kind,
categoryId = transaction.categoryId,
comment = transaction.comment,
amount = transaction.amount,
fees = transaction.fees,
date = transaction.date,
fromAccountId = transaction.fromAccountId,
toAccountId = transaction.toAccountId,
)
return transactionRepo.save(transaction).awaitSingle()
}
override suspend fun updateTransaction(
spaceId: String,
transaction: TransactionDTO.UpdateTransactionDTO
): Transaction {
if (transaction.type == Transaction.TransactionType.TRANSFER && transaction.toAccountId == null) {
throw IllegalArgumentException("Cannot edit a transaction with type TRANSFER without a toAccountId")
}
val exitingTx = getTransaction(spaceId, transaction.id)
val transaction = exitingTx.copy(
spaceId = exitingTx.spaceId,
type = transaction.type,
kind = transaction.kind,
categoryId = transaction.category,
comment = transaction.comment,
amount = transaction.amount,
fees = transaction.fees,
date = transaction.date,
fromAccountId = transaction.fromAccountId,
toAccountId = transaction.toAccountId,
)
return transactionRepo.save(transaction).awaitSingle()
}
override suspend fun deleteTransaction(spaceId: String, transactionId: String) {
val transaction = getTransaction(spaceId, transactionId)
transaction.isDeleted = true
transactionRepo.save(transaction).awaitSingle()
}
}

View File

@@ -0,0 +1,52 @@
package space.luminic.finance.services
import kotlinx.coroutines.reactor.awaitSingle
import kotlinx.coroutines.reactor.awaitSingleOrNull
import org.slf4j.LoggerFactory
import org.springframework.cache.annotation.Cacheable
import org.springframework.stereotype.Service
import space.luminic.finance.mappers.UserMapper
import space.luminic.finance.models.NotFoundException
import space.luminic.finance.models.User
import space.luminic.finance.repos.UserRepo
@Service
class UserService(val userRepo: UserRepo) {
val logger = LoggerFactory.getLogger(javaClass)
@Cacheable("users", key = "#username")
suspend fun getByUsername(username: String): User {
return userRepo.findByUsername(username).awaitSingleOrNull()
?: throw NotFoundException("User with username: $username not found")
}
suspend fun getById(id: String): User {
return userRepo.findById(id).awaitSingleOrNull()
?: throw NotFoundException("User with id: $id not found")
}
suspend fun getUserByTelegramId(telegramId: Long): User {
return userRepo.findByTgId(telegramId.toString()).awaitSingleOrNull()
?: throw NotFoundException("User with telegramId: $telegramId not found")
}
@Cacheable("users", key = "#username")
suspend fun getByUserNameWoPass(username: String): User {
return userRepo.findByUsernameWOPassword(username).awaitSingleOrNull()
?: throw NotFoundException("User with username: $username not found")
}
@Cacheable("usersList")
suspend fun getUsers(): List<User> {
return userRepo.findAll()
.collectList()
.awaitSingle()
}
}

View File

@@ -0,0 +1,32 @@
package space.luminic.finance.utils
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.security.Keys
import org.springframework.stereotype.Component
import space.luminic.finance.services.TokenService
import java.time.LocalDateTime
import java.time.ZoneId
import java.util.*
@Component
class JWTUtil(private val tokenService: TokenService) {
private val key = Keys.hmacShaKeyFor("MyTusimMyFleximMyEstSilaNasNeVzlomayutEtoNevozmozhno".toByteArray())
fun generateToken(username: String): String {
val expireAt = Date(System.currentTimeMillis() + 1000 * 60 * 60 * 24 * 10)
val token = Jwts.builder()
.setSubject(username)
.setIssuedAt(Date())
.setExpiration(expireAt) // 10 дней
.signWith(key)
.compact()
return token
}
}

View File

@@ -0,0 +1,35 @@
package space.luminic.finance.utils
import org.slf4j.LoggerFactory
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component
import space.luminic.finance.models.PushMessage
import space.luminic.finance.services.SubscriptionService
import space.luminic.finance.services.TokenService
@Component
class ScheduledTasks(private val subscriptionService: SubscriptionService,
private val tokenService: TokenService) {
private val logger = LoggerFactory.getLogger(ScheduledTasks::class.java)
@Scheduled(cron = "0 30 19 * * *")
suspend fun sendNotificationOfMoneyFilling() {
subscriptionService.sendToAll(
PushMessage(
title = "Время заполнять траты!🤑",
body = "Проверь все ли траты внесены!",
icon = "/apple-touch-icon.png",
badge = "/apple-touch-icon.png",
url = "https://luminic.space/transactions/create"
)
)
}
@Scheduled(cron = "0 0 0 * * *")
fun deleteExpiredTokens() {
tokenService.deleteExpiredTokens()
}
}

View File

@@ -0,0 +1,20 @@
spring.application.name=budger-app
spring.data.mongodb.host=localhost
spring.data.mongodb.port=27018
spring.data.mongodb.database=budger-app
#spring.data.mongodb.username=budger-app
#spring.data.mongodb.password=BA1q2w3e4r!
#spring.data.mongodb.authentication-database=admin
management.endpoints.web.exposure.include=*
management.endpoint.health.show-details=always
spring.datasource.url=jdbc:postgresql://213.183.51.243/familybudget_app
spring.datasource.username=familybudget_app
spring.datasource.password=FB1q2w3e4r!
telegram.bot.token = 6972242509:AAGyXuL3T-BNE4XMoo_qvtaYxw_SuiS_dDs

View File

@@ -0,0 +1,15 @@
spring.application.name=budger-app
spring.data.mongodb.uri=mongodb://budger-app:BA1q2w3e4r!@luminic.space:27017/budger-app-v2?authSource=admin&minPoolSize=10&maxPoolSize=100
logging.level.org.springframework.web=DEBUG
logging.level.org.springframework.data = DEBUG
logging.level.org.springframework.data.mongodb.core.ReactiveMongoTemplate=DEBUG
logging.level.org.springframework.security = DEBUG
logging.level.org.springframework.data.mongodb.code = DEBUG
logging.level.org.springframework.web.reactive=DEBUG
logging.level.org.mongodb.driver.protocol.command = DEBUG
management.endpoints.web.exposure.include=*
management.endpoint.health.show-details=always
telegram.bot.token=6972242509:AAGyXuL3T-BNE4XMoo_qvtaYxw_SuiS_dDs
nlp.address=http://127.0.0.1:8000

View File

@@ -0,0 +1,21 @@
spring.application.name=budger-app
spring.data.mongodb.uri=mongodb://budger-app:BA1q2w3e4r!@luminic.space:27017/budger-app-v2?authSource=admin&minPoolSize=10&maxPoolSize=100
logging.level.org.springframework.web=INFO
logging.level.org.springframework.data = INFO
logging.level.org.springframework.data.mongodb.core.ReactiveMongoTemplate=INFO
logging.level.org.springframework.security = INFO
logging.level.org.springframework.data.mongodb.code = INFO
logging.level.org.springframework.web.reactive=INFO
logging.level.org.mongodb.driver.protocol.command = INFO
#management.endpoints.web.exposure.include=*
#management.endpoint.metrics.access=read_only
telegram.bot.token = 6662300972:AAFXjk_h0AUCy4bORC12UcdXbYnh2QSVKAY
nlp.address=https://nlp.luminic.space

View File

@@ -0,0 +1,29 @@
spring.application.name=budger-app
server.port=8082
#server.servlet.context-path=/api
spring.webflux.base-path=/api
spring.profiles.active=prod
spring.main.web-application-type=reactive
server.compression.enabled=true
server.compression.mime-types=application/json
# ???????????? ?????? ????? (?? ????????? 1 ??)
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=10MB
storage.location: static
# Expose prometheus, health, and info endpoints
#management.endpoints.web.exposure.include=prometheus,health,info
management.endpoints.web.exposure.include=*
# Enable Prometheus metrics export
management.prometheus.metrics.export.enabled=true
telegram.bot.username = expenses_diary_bot

View File

@@ -0,0 +1,596 @@
[{
"id": "677bc767c7857460a491bd52",
"space": {
"id": "67af3c0f652da946a7dd9931",
"name": null,
"description": null,
"owner": null,
"users": [],
"invites": [],
"createdAt": "2025-10-09"
},
"type": {
"code": "EXPENSE",
"name": "Траты"
},
"name": "Авто",
"description": "Расходы на обслуживание автомобиля, топливо и страховку",
"icon": "\uD83D\uDE97",
"tags": []
}, {
"id": "677bc767c7857460a491bd59",
"space": {
"id": "67af3c0f652da946a7dd9931",
"name": null,
"description": null,
"owner": null,
"users": [],
"invites": [],
"createdAt": "2025-10-09"
},
"type": {
"code": "EXPENSE",
"name": "Траты"
},
"name": "Аптеки",
"description": "Покупка лекарств и аптечных товаров",
"icon": "\uD83D\uDC8A",
"tags": []
}, {
"id": "677bc767c7857460a491bd47",
"space": {
"id": "67af3c0f652da946a7dd9931",
"name": null,
"description": null,
"owner": null,
"users": [],
"invites": [],
"createdAt": "2025-10-09"
},
"type": {
"code": "INCOME",
"name": "Поступления"
},
"name": "Возвраты",
"description": "Возврат средств за товары или услуги",
"icon": "\uD83D\uDD04",
"tags": []
}, {
"id": "67a1b8e691c96a0f177e09d2",
"space": {
"id": "67af3c0f652da946a7dd9931",
"name": null,
"description": null,
"owner": null,
"users": [],
"invites": [],
"createdAt": "2025-10-09"
},
"type": {
"code": "INCOME",
"name": "Поступления"
},
"name": "Депозиты и счета",
"description": "Доходы от размещение денежных средств на депозитах и накопительных счетах",
"icon": "\uD83C\uDFA2",
"tags": []
}, {
"id": "677bc767c7857460a491bd54",
"space": {
"id": "67af3c0f652da946a7dd9931",
"name": null,
"description": null,
"owner": null,
"users": [],
"invites": [],
"createdAt": "2025-10-09"
},
"type": {
"code": "EXPENSE",
"name": "Траты"
},
"name": "Дни рождения и подарки",
"description": "Покупка подарков и празднование дней рождения",
"icon": "\uD83C\uDF81",
"tags": []
}, {
"id": "677bc767c7857460a491bd43",
"space": {
"id": "67af3c0f652da946a7dd9931",
"name": null,
"description": null,
"owner": null,
"users": [],
"invites": [],
"createdAt": "2025-10-09"
},
"type": {
"code": "INCOME",
"name": "Поступления"
},
"name": "Доп.активности",
"description": "Доход от подработок, фриланса и других активностей",
"icon": "\uD83D\uDCBB",
"tags": []
}, {
"id": "67b83e141fc0575a3f0a383f",
"space": {
"id": "67af3c0f652da946a7dd9931",
"name": null,
"description": null,
"owner": null,
"users": [],
"invites": [],
"createdAt": "2025-10-09"
},
"type": {
"code": "EXPENSE",
"name": "Траты"
},
"name": "Другое",
"description": "Категория для других трат",
"icon": "\uD83D\uDEAE",
"tags": []
}, {
"id": "677bc767c7857460a491bd4b",
"space": {
"id": "67af3c0f652da946a7dd9931",
"name": null,
"description": null,
"owner": null,
"users": [],
"invites": [],
"createdAt": "2025-10-09"
},
"type": {
"code": "EXPENSE",
"name": "Траты"
},
"name": "ЖКХ",
"description": "Оплата коммунальных услуг, электричества, воды и газа",
"icon": "\uD83D\uDCA1",
"tags": []
}, {
"id": "677bc767c7857460a491bd41",
"space": {
"id": "67af3c0f652da946a7dd9931",
"name": null,
"description": null,
"owner": null,
"users": [],
"invites": [],
"createdAt": "2025-10-09"
},
"type": {
"code": "INCOME",
"name": "Поступления"
},
"name": "Зарплата",
"description": "Регулярный доход от основной работы или должности",
"icon": "\uD83D\uDCBC",
"tags": []
}, {
"id": "677bc767c7857460a491bd53",
"space": {
"id": "67af3c0f652da946a7dd9931",
"name": null,
"description": null,
"owner": null,
"users": [],
"invites": [],
"createdAt": "2025-10-09"
},
"type": {
"code": "EXPENSE",
"name": "Траты"
},
"name": "Интернет и связь",
"description": "Оплата за мобильную связь, интернет и ТВ",
"icon": "\uD83D\uDCE1",
"tags": []
}, {
"id": "677bc767c7857460a491bd4e",
"space": {
"id": "67af3c0f652da946a7dd9931",
"name": null,
"description": null,
"owner": null,
"users": [],
"invites": [],
"createdAt": "2025-10-09"
},
"type": {
"code": "EXPENSE",
"name": "Траты"
},
"name": "Кофе",
"description": "Расходы на покупку кофе и других напитков вне дома",
"icon": "☕",
"tags": []
}, {
"id": "677bc767c7857460a491bd50",
"space": {
"id": "67af3c0f652da946a7dd9931",
"name": null,
"description": null,
"owner": null,
"users": [],
"invites": [],
"createdAt": "2025-10-09"
},
"type": {
"code": "EXPENSE",
"name": "Траты"
},
"name": "Красота",
"description": "Расходы на уход за собой, косметику и услуги красоты",
"icon": "\uD83D\uDC84",
"tags": []
}, {
"id": "677bc767c7857460a491bd49",
"space": {
"id": "67af3c0f652da946a7dd9931",
"name": null,
"description": null,
"owner": null,
"users": [],
"invites": [],
"createdAt": "2025-10-09"
},
"type": {
"code": "EXPENSE",
"name": "Траты"
},
"name": "Кредиты и долги",
"description": "Платежи по кредитам, займам и другим долгам",
"icon": "\uD83D\uDCB3",
"tags": [{
"code": "loans",
"name": "Долги"
}]
}, {
"id": "677bc767c7857460a491bd46",
"space": {
"id": "67af3c0f652da946a7dd9931",
"name": null,
"description": null,
"owner": null,
"users": [],
"invites": [],
"createdAt": "2025-10-09"
},
"type": {
"code": "INCOME",
"name": "Поступления"
},
"name": "Кэшбек",
"description": "Возврат части потраченных денег при покупке товаров и услуг",
"icon": "\uD83D\uDCB3",
"tags": []
}, {
"id": "677bc767c7857460a491bd4c",
"space": {
"id": "67af3c0f652da946a7dd9931",
"name": null,
"description": null,
"owner": null,
"users": [],
"invites": [],
"createdAt": "2025-10-09"
},
"type": {
"code": "EXPENSE",
"name": "Траты"
},
"name": "Мебель",
"description": "Покупка мебели для дома или квартиры",
"icon": "\uD83D\uDECB",
"tags": []
}, {
"id": "677bc767c7857460a491bd5a",
"space": {
"id": "67af3c0f652da946a7dd9931",
"name": null,
"description": null,
"owner": null,
"users": [],
"invites": [],
"createdAt": "2025-10-09"
},
"type": {
"code": "EXPENSE",
"name": "Траты"
},
"name": "Медицина",
"description": "Расходы на медицинские услуги и страховку",
"icon": "\uD83C\uDFE5",
"tags": []
}, {
"id": "677bc767c7857460a491bd44",
"space": {
"id": "67af3c0f652da946a7dd9931",
"name": null,
"description": null,
"owner": null,
"users": [],
"invites": [],
"createdAt": "2025-10-09"
},
"type": {
"code": "INCOME",
"name": "Поступления"
},
"name": "Налоговый вычет",
"description": "Возврат части уплаченных налогов от государства",
"icon": "\uD83C\uDFDB",
"tags": []
}, {
"id": "677bc767c7857460a491bd5b",
"space": {
"id": "67af3c0f652da946a7dd9931",
"name": null,
"description": null,
"owner": null,
"users": [],
"invites": [],
"createdAt": "2025-10-09"
},
"type": {
"code": "EXPENSE",
"name": "Траты"
},
"name": "Одежда",
"description": "Покупка одежды и обуви",
"icon": "\uD83D\uDC57",
"tags": []
}, {
"id": "677bc767c7857460a491bd48",
"space": {
"id": "67af3c0f652da946a7dd9931",
"name": null,
"description": null,
"owner": null,
"users": [],
"invites": [],
"createdAt": "2025-10-09"
},
"type": {
"code": "INCOME",
"name": "Поступления"
},
"name": "Остатки",
"description": "Нерасходованные средства, оставшиеся с предыдущего периода",
"icon": "\uD83D\uDCE6",
"tags": []
}, {
"id": "677bc767c7857460a491bd45",
"space": {
"id": "67af3c0f652da946a7dd9931",
"name": null,
"description": null,
"owner": null,
"users": [],
"invites": [],
"createdAt": "2025-10-09"
},
"type": {
"code": "INCOME",
"name": "Поступления"
},
"name": "Пособия",
"description": "Финансовая помощь от государства или других организаций",
"icon": "\uD83D\uDCB0",
"tags": []
}, {
"id": "677bc767c7857460a491bd42",
"space": {
"id": "67af3c0f652da946a7dd9931",
"name": null,
"description": null,
"owner": null,
"users": [],
"invites": [],
"createdAt": "2025-10-09"
},
"type": {
"code": "INCOME",
"name": "Поступления"
},
"name": "Премия",
"description": "Дополнительное денежное вознаграждение за выполнение задач или проектов",
"icon": "\uD83C\uDF89",
"tags": []
}, {
"id": "677bc767c7857460a491bd4a",
"space": {
"id": "67af3c0f652da946a7dd9931",
"name": null,
"description": null,
"owner": null,
"users": [],
"invites": [],
"createdAt": "2025-10-09"
},
"type": {
"code": "EXPENSE",
"name": "Траты"
},
"name": "Продукты",
"description": "Расходы на покупку продуктов питания и напитков",
"icon": "\uD83C\uDF4E",
"tags": []
}, {
"id": "677bc767c7857460a491bd4d",
"space": {
"id": "67af3c0f652da946a7dd9931",
"name": null,
"description": null,
"owner": null,
"users": [],
"invites": [],
"createdAt": "2025-10-09"
},
"type": {
"code": "EXPENSE",
"name": "Траты"
},
"name": "Прочие услуги",
"description": "Прочие расходы на обслуживание и ремонт дома",
"icon": "\uD83D\uDD27",
"tags": []
}, {
"id": "677bc767c7857460a491bd56",
"space": {
"id": "67af3c0f652da946a7dd9931",
"name": null,
"description": null,
"owner": null,
"users": [],
"invites": [],
"createdAt": "2025-10-09"
},
"type": {
"code": "EXPENSE",
"name": "Траты"
},
"name": "Путешествия",
"description": "Расходы на путешествия, билеты, отели и экскурсии",
"icon": "✈️",
"tags": []
}, {
"id": "677bc767c7857460a491bd58",
"space": {
"id": "67af3c0f652da946a7dd9931",
"name": null,
"description": null,
"owner": null,
"users": [],
"invites": [],
"createdAt": "2025-10-09"
},
"type": {
"code": "EXPENSE",
"name": "Траты"
},
"name": "Развлечения",
"description": "Расходы на кино, концерты, игры и другие развлечения",
"icon": "\uD83C\uDFAE",
"tags": []
}, {
"id": "677bc767c7857460a491bd5d",
"space": {
"id": "67af3c0f652da946a7dd9931",
"name": null,
"description": null,
"owner": null,
"users": [],
"invites": [],
"createdAt": "2025-10-09"
},
"type": {
"code": "EXPENSE",
"name": "Траты"
},
"name": "Ребенок",
"description": "Расходы на детей: игрушки, одежда, обучение и уход",
"icon": "\uD83E\uDDF8",
"tags": []
}, {
"id": "677bc767c7857460a491bd55",
"space": {
"id": "67af3c0f652da946a7dd9931",
"name": null,
"description": null,
"owner": null,
"users": [],
"invites": [],
"createdAt": "2025-10-09"
},
"type": {
"code": "EXPENSE",
"name": "Траты"
},
"name": "Рестораны и кафе",
"description": "Расходы на посещение ресторанов и кафе",
"icon": "\uD83C\uDF7D",
"tags": []
}, {
"id": "677bc767c7857460a491bd4f",
"space": {
"id": "67af3c0f652da946a7dd9931",
"name": null,
"description": null,
"owner": null,
"users": [],
"invites": [],
"createdAt": "2025-10-09"
},
"type": {
"code": "EXPENSE",
"name": "Траты"
},
"name": "Сбережения",
"description": "Отчисления в накопления или инвестиционные счета",
"icon": "\uD83D\uDCB0",
"tags": [{
"code": "savings",
"name": "Сбережения"
}]
}, {
"id": "677bc767c7857460a491bd57",
"space": {
"id": "67af3c0f652da946a7dd9931",
"name": null,
"description": null,
"owner": null,
"users": [],
"invites": [],
"createdAt": "2025-10-09"
},
"type": {
"code": "EXPENSE",
"name": "Траты"
},
"name": "Спорт",
"description": "Расходы на спортивные мероприятия, тренировки и спорттовары",
"icon": "\uD83C\uDFCB",
"tags": []
}, {
"id": "677bc767c7857460a491bd51",
"space": {
"id": "67af3c0f652da946a7dd9931",
"name": null,
"description": null,
"owner": null,
"users": [],
"invites": [],
"createdAt": "2025-10-09"
},
"type": {
"code": "EXPENSE",
"name": "Траты"
},
"name": "Транспорт",
"description": "Расходы на общественный транспорт и такси",
"icon": "\uD83D\uDE8C",
"tags": []
}, {
"id": "677bc767c7857460a491bd5c",
"space": {
"id": "67af3c0f652da946a7dd9931",
"name": null,
"description": null,
"owner": null,
"users": [],
"invites": [],
"createdAt": "2025-10-09"
},
"type": {
"code": "EXPENSE",
"name": "Траты"
},
"name": "Электроника",
"description": "Покупка гаджетов, бытовой техники и другой электроники",
"icon": "\uD83D\uDCF1",
"tags": []
}]