Files
ledgerr/docs/03-function-todos.md
T
Achmad Setyabudi Susilo 3e30423083 docs: add architecture docs, AGENTS.md, CLAUDE.md, and copy UI components
- Add docs/01-04 covering data model, interfaces, function TODOs, and implementation plan
- Add AGENTS.md with project conventions, architecture rules, feature workflow, and git policy
- Add CLAUDE.md pointing to AGENTS.md
- Copy UI components and utils from info-krl-android (preference widgets, scrollbars, etc.)
- Delete obsolete UiModule.kt
2026-06-28 15:08:37 +07:00

175 lines
6.8 KiB
Markdown

# 03 — Function TODOs
What each interactor method does, inputs, outputs, and edge cases.
---
## expense / GetExpenses
### `subscribeAll(): Flow<List<ExpenseWithCategory>>`
JOIN expenses with categories, ordered by `date DESC`, `createdAt DESC`. Emits on any change to either table.
### `subscribeByDateRange(range: DateRange): Flow<List<ExpenseWithCategory>>`
Same join, filtered: `expense.date >= range.start AND expense.date <= range.end`. Ordered date DESC.
### `awaitOne(id: Long): ExpenseWithCategory?`
Single JOIN lookup by expense id. Returns null if not found.
### `awaitAll(query: String, range: DateRange?): List<ExpenseWithCategory>`
One-shot search. If `query` is blank and `range` is null, returns all expenses (ordered date DESC). If `query` is non-blank, filters: `note LIKE '%query%'` OR `CAST(amount AS TEXT) LIKE '%query%'` OR `category.name LIKE '%query%'`. If `range` is non-null, also filters by date range. Results ordered date DESC.
---
## expense / UpsertExpense
### `await(expense: Expense): Long`
If `expense.id == 0L`: insert and return generated id. Otherwise: update by id and return the existing id. Uses Room `@Upsert`.
---
## expense / InsertExpenses
### `awaitAll(expenses: List<Expense>)`
Bulk insert in a single Room transaction. Used after the user confirms PDF import. `expense.id` must be 0 for all items.
---
## expense / DeleteExpense
### `await(id: Long)`
Deletes expense by primary key. Does not touch any `RecurringExpense` template that generated it — those continue running independently.
---
## expense / ReassignExpenseCategory
### `await(fromCategoryId: Long, toCategoryId: Long)`
Single SQL `UPDATE expenses SET categoryId = :toCategoryId WHERE categoryId = :fromCategoryId`. Used by `DeleteCategory` before removing a category.
---
## expense / GetExpenseSummary
### `await(range: DateRange): ExpenseSummary`
1. Fetch all expenses with category in range (one-shot, uses DAO directly).
2. Sum all amounts → `totalAmount`.
3. Group by category, sum per group, sort by amount DESC → `byCategory`.
4. Return `ExpenseSummary(totalAmount, byCategory, range)`.
---
## category / GetCategories
### `subscribeAll(): Flow<List<Category>>`
All categories ordered by name ASC, emits on change.
### `awaitOne(id: Long): Category?`
Nullable lookup by primary key.
### `awaitAll(): List<Category>`
One-shot list, ordered by name ASC.
### `awaitDefault(): Category`
Returns the single category where `isDefault = true`. Throws `IllegalStateException` if not found (seeding hasn't run — should not happen in practice).
---
## category / UpsertCategory
### `await(category: Category): Long`
Insert if `id == 0`, update otherwise. Returns the id. Does not allow changing `isDefault` — if the incoming category has `isDefault = true` but the existing DB record has `isDefault = false`, the DB value wins (impl reads existing `isDefault` before update). In practice, only the seeder sets `isDefault = true`.
---
## category / DeleteCategory
### `await(id: Long)`
1. Guard: fetch category by id. If `isDefault = true`, throw `IllegalArgumentException("Cannot delete a default category")`.
2. Find the fallback: call `getCategories.awaitDefault()` to get the "Other" category id.
3. Call `reassignExpenseCategory.await(id, fallbackId)` to move orphaned expenses.
4. Delete the category from the DB.
Steps 3 and 4 run sequentially in the interactor (not a DB transaction). If step 4 fails, expenses are already reassigned to Other — acceptable, retry is safe.
---
## category / SeedDefaultCategories
### `await()`
Checks count of categories in DB. If count > 0, no-op. Otherwise inserts the 8 default categories (see `01-data-model.md`). Called from `MainApplication.onCreate` after `startKoin` completes, using a `CoroutineScope(Dispatchers.IO)`.
---
## recurring / GetRecurringExpenses
### `subscribeAll(): Flow<List<RecurringExpenseWithCategory>>`
All recurring expenses (active and inactive) joined with category, ordered by `nextDueDate ASC`. Emits on change.
### `awaitOne(id: Long): RecurringExpense?`
Nullable lookup by primary key.
---
## recurring / UpsertRecurringExpense
### `await(recurring: RecurringExpense): Long`
Insert if `id == 0`, update otherwise. Caller must set `nextDueDate = startDate` on initial insert.
---
## recurring / DeleteRecurringExpense
### `await(id: Long)`
Deletes the recurring template by id. Expense instances previously created from this template retain their `recurringExpenseId` value — they become historical records and are not deleted.
---
## recurring / ProcessDueRecurringExpenses
### `await(today: LocalDate): List<Expense>`
1. Query recurring expenses where `isActive = true AND nextDueDate <= today`.
2. For each due item:
a. Create `Expense(amount, categoryId, note from template, date = nextDueDate, recurringExpenseId = template.id)`.
b. Insert the expense.
c. Advance: `newNextDueDate = interval.advance(current nextDueDate)`.
d. Update the recurring template with `nextDueDate = newNextDueDate`.
3. Return the list of created expenses so the caller can show a banner.
Edge case — app not opened for multiple intervals: advances `nextDueDate` by one interval per call. The next app open will process it again if still overdue. This avoids flooding the expense list with back-filled entries.
---
## bankstatement / BankStatementImporter
### `val bankName: String`
Display name shown in the bank picker (e.g. `"BRI"`, `"Jago"`, `"BNI"`).
### `await(pdfUri: Uri): Result<List<PendingImportExpense>>`
Contract for all implementations:
1. Open PDF via `ContentResolver.openInputStream(pdfUri)`.
2. Extract text with PDFBox-Android (`PDDocument` + `PDFTextStripper`), page by page.
3. Parse bank-specific format into raw entry models (e.g. `BRIStatementEntry`).
4. Filter to debit-only rows (skip credits/income).
5. Map raw entries to `PendingImportExpense` — parse Indonesian number format (`1.500.000,00`), parse date strings to `LocalDate`.
6. Return `Result.success(list)` or `Result.failure(exception)`.
**`ImportBRIBankStatement.await`** — stub: `Result.failure(NotImplementedError("BRI import not yet implemented"))`.
**`ImportJagoBankStatement.await`** — stub: same.
**`ImportBNIBankStatement.await`** — stub: same.
---
## export / ExportExpensesToCsv
### `await(range: DateRange, outputUri: Uri): Result<Unit>`
1. Fetch all `ExpenseWithCategory` in range from DAO (one-shot).
2. Open `OutputStream` via `context.contentResolver.openOutputStream(outputUri)`.
3. Wrap with Okio `BufferedSink`.
4. Write UTF-8 BOM (`0xEF, 0xBB, 0xBF`) for Excel compatibility.
5. Write header: `Date,Category,Amount,Note`.
6. For each expense: `date` in ISO 8601 (`yyyy-MM-dd`), then `category.name`, `amount`, `"note"` (double-quote the note to handle embedded commas).
7. Close sink.
8. Return `Result.success(Unit)` or `Result.failure(exception)`.