Implement recurring interactors (#3) #10
Reference in New Issue
Block a user
Delete Branch "feat/3-implement-recurring-interactors"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
GetRecurringExpenses,UpsertRecurringExpense,DeleteRecurringExpense,ProcessDueRecurringExpenses) underdomain/recurring/interactor/di/DomainModule.ktTest plan
./gradlew assembleDebugsucceedsCloses #3
Solid PR — DAO contract is respected, signatures match
docs/03-function-todos.md, and the implementation is consistent with PR #2's established patterns. Left inline comments for the items that need to be addressed or followed up on before this merges cleanly.Must address:
ProcessDueRecurringExpenses.awaitis not atomic — see inline comment at the insert + advance pair.Worth a quick check:
2.
GetRecurringExpenses.awaitOnereturnsRecurringExpense?without a category — confirm the edit screen handles it.Follow-up issues (out of scope for this PR per the no-implementation rule):
3.
UpsertRecurringExpense(andUpsertExpensein PR #2) diverge from the@Upsertconvention inAGENTS.md:302/docs/03-function-todos.md:26. Converge the DAOs and the docs in a follow-up.4.
GetRecurringExpensesis single-DAO butdocs/02-interfaces.md:107and the original issue #3 step 3 specify two-DAO. The simplification is correct, but the docs are stale. Update in a follow-up.5. No unit tests for
ProcessDueRecurringExpenses— the per-row advance and theisActivefilter ingetDuewould benefit from in-memory Room tests. Worth a follow-up.@@ -15,2 +19,4 @@factory { ReassignExpenseCategory(get()) }factory { GetRecurringExpenses(get()) }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.@@ -0,0 +21,4 @@}suspend fun awaitOne(id: Long): RecurringExpense? =recurringDao.getById(id)?.toModel()awaitOnereturnsRecurringExpense?without a category. This matchesdocs/02-interfaces.md:109anddocs/03-function-todos.md:108, so it's intentional — but worth a quick check before this lands: does the edit-recurring screen (AddEditRecurringScreenfrom issue #6) load the category separately viaGetCategories, or doesn't it need it on this path? If the screen reads the category from its ownGetCategoriesflow, no change is needed; if it relied on this returning a category, that's a downstream bug.@@ -0,0 +29,4 @@val advanced = template.copy(nextDueDate = template.interval.advance(template.nextDueDate),)recurringDao.update(advanced.toEntity())The insert + advance pair is not atomic. If the process is killed (or a coroutine is cancelled) between
expenseDao.insertandrecurringDao.update, the template'snextDueDateis 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 isrecurringExpenseId, which is not unique).The codebase already uses
database.withTransaction { }forClearAllData(perdocs/03-function-todos.md:181), so the pattern is established.Suggested fix
Inject
AppDatabaseand wrap the per-row loop indatabase.withTransaction { ... }. The cleanest shape drops the two DAO constructor params and resolves them from the database inside the transaction block:Then
DomainModulebecomesfactory { ProcessDueRecurringExpenses(get()) }(theAppDatabaseis already asingleinDataModule).Trade-off
Per AGENTS.md, interactors normally take DAOs and not the database.
ClearAllDatais the existing exception because it also needs cross-DAO atomicity. Same justification applies here.@@ -0,0 +15,4 @@dao.update(entity)recurring.id}}AGENTS.md:302says "Use@UpsertRoom annotation for insert-or-update operations" anddocs/03-function-todos.md:26says the upsert path "Uses Room@Upsert", but neitherRecurringExpenseDaonorExpenseDaodefines an@Upsertmethod. The manualif (id == 0L) insert else updatepattern 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)+@Updateonly, no@Upsert).Suggested follow-up (out of scope here per the no-implementation rule)
Open a follow-up issue to converge the two: either
@Upsertmethods to bothExpenseDaoandRecurringExpenseDaoand use them inUpsertExpense/UpsertRecurringExpense(andexpenseDao.upsert(...)inside theProcessDueRecurringExpensesloop), orAGENTS.md:302anddocs/03-function-todos.md:26to match the actualinsert/updatepattern.This PR is fine as-is — flagging for traceability.