3e30423083
- 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
175 lines
6.8 KiB
Markdown
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)`.
|