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