fix(#3): make ProcessDueRecurringExpenses atomic via withTransaction

Address review: insert + advance pair must run in one DB transaction to
prevent duplicate expenses if the process is killed mid-loop.
This commit is contained in:
Achmad Setyabudi Susilo
2026-06-28 16:52:07 +07:00
parent 6a11284212
commit 7bb65025a2
2 changed files with 28 additions and 26 deletions
@@ -22,5 +22,5 @@ val domainModule = module {
factory { GetRecurringExpenses(get()) } factory { GetRecurringExpenses(get()) }
factory { UpsertRecurringExpense(get()) } factory { UpsertRecurringExpense(get()) }
factory { DeleteRecurringExpense(get()) } factory { DeleteRecurringExpense(get()) }
factory { ProcessDueRecurringExpenses(get(), get()) } factory { ProcessDueRecurringExpenses(get()) }
} }
@@ -1,36 +1,38 @@
package dev.achmad.ledgerr.domain.recurring.interactor package dev.achmad.ledgerr.domain.recurring.interactor
import dev.achmad.ledgerr.data.local.dao.ExpenseDao import androidx.room.withTransaction
import dev.achmad.ledgerr.data.local.dao.RecurringExpenseDao import dev.achmad.ledgerr.data.local.AppDatabase
import dev.achmad.ledgerr.data.local.mapper.toEntity import dev.achmad.ledgerr.data.local.mapper.toEntity
import dev.achmad.ledgerr.data.local.mapper.toModel import dev.achmad.ledgerr.data.local.mapper.toModel
import dev.achmad.ledgerr.domain.expense.model.Expense import dev.achmad.ledgerr.domain.expense.model.Expense
import java.time.LocalDate import java.time.LocalDate
class ProcessDueRecurringExpenses( class ProcessDueRecurringExpenses(
private val recurringDao: RecurringExpenseDao, private val database: AppDatabase,
private val expenseDao: ExpenseDao,
) { ) {
suspend fun await(today: LocalDate = LocalDate.now()): List<Expense> { suspend fun await(today: LocalDate = LocalDate.now()): List<Expense> =
val dueTemplates = recurringDao.getDue(today.toEpochDay()) database.withTransaction {
if (dueTemplates.isEmpty()) return emptyList() val recurringDao = database.recurringExpenseDao()
val created = mutableListOf<Expense>() val expenseDao = database.expenseDao()
for (templateEntity in dueTemplates) { val dueTemplates = recurringDao.getDue(today.toEpochDay())
val template = templateEntity.toModel() if (dueTemplates.isEmpty()) return@withTransaction emptyList()
val expense = Expense( val created = mutableListOf<Expense>()
amount = template.amount, for (templateEntity in dueTemplates) {
categoryId = template.categoryId, val template = templateEntity.toModel()
date = template.nextDueDate, val expense = Expense(
note = template.note, amount = template.amount,
recurringExpenseId = template.id, categoryId = template.categoryId,
) date = template.nextDueDate,
val newId = expenseDao.insert(expense.toEntity()) note = template.note,
created += expense.copy(id = newId) recurringExpenseId = template.id,
val advanced = template.copy( )
nextDueDate = template.interval.advance(template.nextDueDate), val newId = expenseDao.insert(expense.toEntity())
) created += expense.copy(id = newId)
recurringDao.update(advanced.toEntity()) val advanced = template.copy(
nextDueDate = template.interval.advance(template.nextDueDate),
)
recurringDao.update(advanced.toEntity())
}
created
} }
return created
}
} }