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