From 7bb65025a21cab2835ee08b9f089fb2ad63b836a Mon Sep 17 00:00:00 2001 From: Achmad Setyabudi Susilo Date: Sun, 28 Jun 2026 16:52:07 +0700 Subject: [PATCH] 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. --- .../dev/achmad/ledgerr/di/DomainModule.kt | 2 +- .../interactor/ProcessDueRecurringExpenses.kt | 52 ++++++++++--------- 2 files changed, 28 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/dev/achmad/ledgerr/di/DomainModule.kt b/app/src/main/java/dev/achmad/ledgerr/di/DomainModule.kt index 37af15b..35f8070 100644 --- a/app/src/main/java/dev/achmad/ledgerr/di/DomainModule.kt +++ b/app/src/main/java/dev/achmad/ledgerr/di/DomainModule.kt @@ -22,5 +22,5 @@ val domainModule = module { factory { GetRecurringExpenses(get()) } factory { UpsertRecurringExpense(get()) } factory { DeleteRecurringExpense(get()) } - factory { ProcessDueRecurringExpenses(get(), get()) } + factory { ProcessDueRecurringExpenses(get()) } } diff --git a/app/src/main/java/dev/achmad/ledgerr/domain/recurring/interactor/ProcessDueRecurringExpenses.kt b/app/src/main/java/dev/achmad/ledgerr/domain/recurring/interactor/ProcessDueRecurringExpenses.kt index b85c8aa..4a05ca2 100644 --- a/app/src/main/java/dev/achmad/ledgerr/domain/recurring/interactor/ProcessDueRecurringExpenses.kt +++ b/app/src/main/java/dev/achmad/ledgerr/domain/recurring/interactor/ProcessDueRecurringExpenses.kt @@ -1,36 +1,38 @@ package dev.achmad.ledgerr.domain.recurring.interactor -import dev.achmad.ledgerr.data.local.dao.ExpenseDao -import dev.achmad.ledgerr.data.local.dao.RecurringExpenseDao +import androidx.room.withTransaction +import dev.achmad.ledgerr.data.local.AppDatabase import dev.achmad.ledgerr.data.local.mapper.toEntity import dev.achmad.ledgerr.data.local.mapper.toModel import dev.achmad.ledgerr.domain.expense.model.Expense import java.time.LocalDate class ProcessDueRecurringExpenses( - private val recurringDao: RecurringExpenseDao, - private val expenseDao: ExpenseDao, + private val database: AppDatabase, ) { - suspend fun await(today: LocalDate = LocalDate.now()): List { - val dueTemplates = recurringDao.getDue(today.toEpochDay()) - if (dueTemplates.isEmpty()) return emptyList() - val created = mutableListOf() - for (templateEntity in dueTemplates) { - val template = templateEntity.toModel() - val expense = Expense( - amount = template.amount, - categoryId = template.categoryId, - date = template.nextDueDate, - note = template.note, - recurringExpenseId = template.id, - ) - val newId = expenseDao.insert(expense.toEntity()) - created += expense.copy(id = newId) - val advanced = template.copy( - nextDueDate = template.interval.advance(template.nextDueDate), - ) - recurringDao.update(advanced.toEntity()) + suspend fun await(today: LocalDate = LocalDate.now()): List = + database.withTransaction { + val recurringDao = database.recurringExpenseDao() + val expenseDao = database.expenseDao() + val dueTemplates = recurringDao.getDue(today.toEpochDay()) + if (dueTemplates.isEmpty()) return@withTransaction emptyList() + val created = mutableListOf() + for (templateEntity in dueTemplates) { + val template = templateEntity.toModel() + val expense = Expense( + amount = template.amount, + categoryId = template.categoryId, + date = template.nextDueDate, + note = template.note, + recurringExpenseId = template.id, + ) + val newId = expenseDao.insert(expense.toEntity()) + created += expense.copy(id = newId) + val advanced = template.copy( + nextDueDate = template.interval.advance(template.nextDueDate), + ) + recurringDao.update(advanced.toEntity()) + } + created } - return created - } }