Implement recurring interactors (#3) #10
@@ -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.SeedDefaultCategories
|
||||||
import dev.achmad.ledgerr.domain.category.interactor.UpsertCategory
|
import dev.achmad.ledgerr.domain.category.interactor.UpsertCategory
|
||||||
import dev.achmad.ledgerr.domain.expense.interactor.ReassignExpenseCategory
|
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
|
import org.koin.dsl.module
|
||||||
|
|
||||||
val domainModule = module {
|
val domainModule = module {
|
||||||
@@ -14,4 +18,9 @@ val domainModule = module {
|
|||||||
factory { SeedDefaultCategories(get()) }
|
factory { SeedDefaultCategories(get()) }
|
||||||
|
|
||||||
factory { ReassignExpenseCategory(get()) }
|
factory { ReassignExpenseCategory(get()) }
|
||||||
|
|
||||||
|
factory { GetRecurringExpenses(get()) }
|
||||||
|
|
|||||||
|
factory { UpsertRecurringExpense(get()) }
|
||||||
|
factory { DeleteRecurringExpense(get()) }
|
||||||
|
factory { ProcessDueRecurringExpenses(get()) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<List<RecurringExpenseWithCategory>> =
|
||||||
|
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()
|
||||||
|
admin
commented
`awaitOne` returns `RecurringExpense?` without a category. This matches `docs/02-interfaces.md:109` and `docs/03-function-todos.md:108`, so it's intentional — but worth a quick check before this lands: does the edit-recurring screen (`AddEditRecurringScreen` from issue #6) load the category separately via `GetCategories`, or doesn't it need it on this path? If the screen reads the category from its own `GetCategories` flow, no change is needed; if it relied on this returning a category, that's a downstream bug.
|
|||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package dev.achmad.ledgerr.domain.recurring.interactor
|
||||||
|
|
||||||
|
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 database: AppDatabase,
|
||||||
|
) {
|
||||||
|
suspend fun await(today: LocalDate = LocalDate.now()): List<Expense> =
|
||||||
|
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<Expense>()
|
||||||
|
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),
|
||||||
|
admin
commented
The insert + advance pair is not atomic. If the process is killed (or a coroutine is cancelled) between The codebase already uses Suggested fixInject Then Trade-offPer AGENTS.md, interactors normally take DAOs and not the database. The insert + advance pair is **not atomic**. If the process is killed (or a coroutine is cancelled) between `expenseDao.insert` and `recurringDao.update`, the template's `nextDueDate` is not advanced and the next call will re-process the same template → **duplicate expense** with no idempotency key (the only link back to the template is `recurringExpenseId`, which is not unique).
The codebase already uses `database.withTransaction { }` for `ClearAllData` (per `docs/03-function-todos.md:181`), so the pattern is established.
### Suggested fix
Inject `AppDatabase` and wrap the per-row loop in `database.withTransaction { ... }`. The cleanest shape drops the two DAO constructor params and resolves them from the database inside the transaction block:
```kotlin
class ProcessDueRecurringExpenses(
private val database: AppDatabase,
) {
suspend fun await(today: LocalDate = LocalDate.now()): List<Expense> =
database.withTransaction {
val recurringDao = database.recurringExpenseDao()
val expenseDao = database.expenseDao()
// ... existing loop body, unchanged ...
}
}
```
Then `DomainModule` becomes `factory { ProcessDueRecurringExpenses(get()) }` (the `AppDatabase` is already a `single` in `DataModule`).
### Trade-off
Per AGENTS.md, interactors normally take DAOs and not the database. `ClearAllData` is the existing exception because it also needs cross-DAO atomicity. Same justification applies here.
|
|||||||
|
)
|
||||||
|
recurringDao.update(advanced.toEntity())
|
||||||
|
}
|
||||||
|
created
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
admin
commented
Same gap exists in PR #2's Suggested follow-up (out of scope here per the no-implementation rule)Open a follow-up issue to converge the two: either
This PR is fine as-is — flagging for traceability. `AGENTS.md:302` says "Use `@Upsert` Room annotation for insert-or-update operations" and `docs/03-function-todos.md:26` says the upsert path "Uses Room `@Upsert`", but neither `RecurringExpenseDao` nor `ExpenseDao` defines an `@Upsert` method. The manual `if (id == 0L) insert else update` pattern works, but it diverges from the documented convention.
Same gap exists in PR #2's `UpsertExpense.kt:10-16` (both DAOs have the same `@Insert(REPLACE)` + `@Update` only, no `@Upsert`).
### Suggested follow-up (out of scope here per the no-implementation rule)
Open a follow-up issue to converge the two: either
- add `@Upsert` methods to both `ExpenseDao` and `RecurringExpenseDao` and use them in `UpsertExpense` / `UpsertRecurringExpense` (and `expenseDao.upsert(...)` inside the `ProcessDueRecurringExpenses` loop), or
- relax `AGENTS.md:302` and `docs/03-function-todos.md:26` to match the actual `insert`/`update` pattern.
This PR is fine as-is — flagging for traceability.
|
|||||||
|
}
|
||||||
This is single-DAO, but
docs/02-interfaces.md:107and the original issue #3 step 3 both specifyGetRecurringExpenses(get(), get())with aCategoryDao. The simplification is correct —RecurringExpenseDao.subscribeAll()uses@Relationto join the category in one query (RecurringExpenseDao.kt:27-29), andawaitOneonly needs the recurring entity — so a secondCategoryDaowould be redundant.GetExpenseskeeps its two-DAO shape becauseExpenseDao.getById/searchdo not use@Relation, so the asymmetry between the two interactors is justified by the DAO design, not a doc bug.Suggested follow-up (out of scope here per the no-implementation rule)
Open a follow-up issue to update
docs/02-interfaces.md:107(and the original issue #3 if it is still open) to match the actual signature. Optionally add a one-line note in the doc explaining whyGetRecurringExpensesis one-DAO whileGetExpensesis two-DAO.