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

6.8 KiB

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).