From 6a1128421271673ab1b163de78cc491dac733076 Mon Sep 17 00:00:00 2001 From: Achmad Setyabudi Susilo Date: Sun, 28 Jun 2026 16:33:41 +0700 Subject: [PATCH 1/2] feat(#3): implement recurring interactors --- .../dev/achmad/ledgerr/di/DomainModule.kt | 9 +++++ .../interactor/DeleteRecurringExpense.kt | 11 ++++++ .../interactor/GetRecurringExpenses.kt | 25 +++++++++++++ .../interactor/ProcessDueRecurringExpenses.kt | 36 +++++++++++++++++++ .../interactor/UpsertRecurringExpense.kt | 19 ++++++++++ 5 files changed, 100 insertions(+) create mode 100644 app/src/main/java/dev/achmad/ledgerr/domain/recurring/interactor/DeleteRecurringExpense.kt create mode 100644 app/src/main/java/dev/achmad/ledgerr/domain/recurring/interactor/GetRecurringExpenses.kt create mode 100644 app/src/main/java/dev/achmad/ledgerr/domain/recurring/interactor/ProcessDueRecurringExpenses.kt create mode 100644 app/src/main/java/dev/achmad/ledgerr/domain/recurring/interactor/UpsertRecurringExpense.kt 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 56481b8..37af15b 100644 --- a/app/src/main/java/dev/achmad/ledgerr/di/DomainModule.kt +++ b/app/src/main/java/dev/achmad/ledgerr/di/DomainModule.kt @@ -5,6 +5,10 @@ import dev.achmad.ledgerr.domain.category.interactor.GetCategories import dev.achmad.ledgerr.domain.category.interactor.SeedDefaultCategories import dev.achmad.ledgerr.domain.category.interactor.UpsertCategory import dev.achmad.ledgerr.domain.expense.interactor.ReassignExpenseCategory +import dev.achmad.ledgerr.domain.recurring.interactor.DeleteRecurringExpense +import dev.achmad.ledgerr.domain.recurring.interactor.GetRecurringExpenses +import dev.achmad.ledgerr.domain.recurring.interactor.ProcessDueRecurringExpenses +import dev.achmad.ledgerr.domain.recurring.interactor.UpsertRecurringExpense import org.koin.dsl.module val domainModule = module { @@ -14,4 +18,9 @@ val domainModule = module { factory { SeedDefaultCategories(get()) } factory { ReassignExpenseCategory(get()) } + + factory { GetRecurringExpenses(get()) } + factory { UpsertRecurringExpense(get()) } + factory { DeleteRecurringExpense(get()) } + factory { ProcessDueRecurringExpenses(get(), get()) } } diff --git a/app/src/main/java/dev/achmad/ledgerr/domain/recurring/interactor/DeleteRecurringExpense.kt b/app/src/main/java/dev/achmad/ledgerr/domain/recurring/interactor/DeleteRecurringExpense.kt new file mode 100644 index 0000000..aaaf91a --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/domain/recurring/interactor/DeleteRecurringExpense.kt @@ -0,0 +1,11 @@ +package dev.achmad.ledgerr.domain.recurring.interactor + +import dev.achmad.ledgerr.data.local.dao.RecurringExpenseDao + +class DeleteRecurringExpense( + private val dao: RecurringExpenseDao, +) { + suspend fun await(id: Long) { + dao.deleteById(id) + } +} diff --git a/app/src/main/java/dev/achmad/ledgerr/domain/recurring/interactor/GetRecurringExpenses.kt b/app/src/main/java/dev/achmad/ledgerr/domain/recurring/interactor/GetRecurringExpenses.kt new file mode 100644 index 0000000..418038b --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/domain/recurring/interactor/GetRecurringExpenses.kt @@ -0,0 +1,25 @@ +package dev.achmad.ledgerr.domain.recurring.interactor + +import dev.achmad.ledgerr.data.local.dao.RecurringExpenseDao +import dev.achmad.ledgerr.data.local.mapper.toModel +import dev.achmad.ledgerr.domain.recurring.model.RecurringExpense +import dev.achmad.ledgerr.domain.recurring.model.RecurringExpenseWithCategory +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class GetRecurringExpenses( + private val recurringDao: RecurringExpenseDao, +) { + fun subscribeAll(): Flow> = + recurringDao.subscribeAll().map { rows -> + rows.map { row -> + RecurringExpenseWithCategory( + recurring = row.recurring.toModel(), + category = row.category.toModel(), + ) + } + } + + suspend fun awaitOne(id: Long): RecurringExpense? = + recurringDao.getById(id)?.toModel() +} 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 new file mode 100644 index 0000000..b85c8aa --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/domain/recurring/interactor/ProcessDueRecurringExpenses.kt @@ -0,0 +1,36 @@ +package dev.achmad.ledgerr.domain.recurring.interactor + +import dev.achmad.ledgerr.data.local.dao.ExpenseDao +import dev.achmad.ledgerr.data.local.dao.RecurringExpenseDao +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, +) { + 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()) + } + return created + } +} diff --git a/app/src/main/java/dev/achmad/ledgerr/domain/recurring/interactor/UpsertRecurringExpense.kt b/app/src/main/java/dev/achmad/ledgerr/domain/recurring/interactor/UpsertRecurringExpense.kt new file mode 100644 index 0000000..6c84104 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/domain/recurring/interactor/UpsertRecurringExpense.kt @@ -0,0 +1,19 @@ +package dev.achmad.ledgerr.domain.recurring.interactor + +import dev.achmad.ledgerr.data.local.dao.RecurringExpenseDao +import dev.achmad.ledgerr.data.local.mapper.toEntity +import dev.achmad.ledgerr.domain.recurring.model.RecurringExpense + +class UpsertRecurringExpense( + private val dao: RecurringExpenseDao, +) { + suspend fun await(recurring: RecurringExpense): Long { + val entity = recurring.toEntity() + return if (recurring.id == 0L) { + dao.insert(entity) + } else { + dao.update(entity) + recurring.id + } + } +} From 7bb65025a21cab2835ee08b9f089fb2ad63b836a Mon Sep 17 00:00:00 2001 From: Achmad Setyabudi Susilo Date: Sun, 28 Jun 2026 16:52:07 +0700 Subject: [PATCH 2/2] 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 - } }