ADR-0036: Budget limits carry into later months until changed or cleared
- Status: Accepted
- Date: 2026-09-09
- Deciders: maintainers
- Related: ADR-0006, ADR-0009, ADR-0015, ADR-0022, ADR-0025
Context
A category budget set for one month silently disappeared on the 1st of the
next: BudgetDao.observeForMonth read WHERE monthStart = :monthStart (exact
equality), so a new month had no budgets row and the UI showed "No limit
set". Users reported their budgets "reset" every month.
There is no copy-on-rollover code anywhere (rollover|carry|copyBudget|Worker
find nothing) — old budgets rows were never read across months. The app is
offline-first with no workers or alarms (ADR-0015), so an invented monthly
trigger for copying rows would be non-deterministic and could miss months.
Decision
Read-time carry-forward: the effective limit for a category in a viewed
month is the budgets row with the greatest monthStart <= viewed month.
No rows are copied or deleted on a month boundary.
BudgetDao.observeEffectiveForMonth(monthStart)returns, per category, the row withMAX(monthStart) WHERE categoryId = :cat AND monthStart <= :monthStart(one correlated subquery per category, one row per category).BudgetDao.effectiveForCategoryis the suspend single-category form used by the editor prefill.BudgetViewModelandCategoryEditViewModelmap carried rows intolimitMillis/ the editor's budget field. A carried limit keeps its own currency and is folded through FX into base units (Fx.toBase) for comparison, per ADR-0025; when unconvertible it reads as no limit.- Exact-month rows still win (
MAX(monthStart) <= monthStart), so "different limit each month" keeps working and viewing past months via theMonthSelectorstays correct. - Clearing is a sentinel, not a delete: an empty editor field or
swipe-delete of a default category's budget upserts a row with
amountMillis = 0(BudgetRepository.CLEARED_LIMIT_MILLIS) at the editor's selected month (default-category swipe-delete uses the viewed month).0is unreachable fromAmountParser(it rejects amounts<= 0), so it unambiguously means "no limit from this month onward" and stops an older real limit from resurrecting in later months.BudgetRepository.clearno-ops when there is nothing effective to clear and keeps the replaced row's currency.
Why this shape:
- Deterministic and idempotent — read-time fallback needs no trigger and recomputes the same result for any month, forever.
- Zero migration — schema,
@Database(version)andapp/schemas/are untouched; existing installs get persistence retroactively from their last-set row. - Own-month rows still win — per-month overrides keep working, including when navigating past months.
Known edge (accepted): users who cleared a limit before this change will see the older limit reappear once; after clearing again it persists.
Explicit editor scope (2026-10-15)
- Budgets passes its viewed month on both edit and create; plain category routes default to the current month using an injected clock. Lookup, save and clear all target that month. The editor shows the month, ISO currency, until-changed scope, inherited source month, and a clear action.
- A positive effective limit owns the editable currency; changing Settings base currency cannot relabel or rescale it. With no effective limit, the editor captures the base currency on open. Currency conversion remains an aggregation concern (ADR-0025); this editor offers no implicit conversion.
- Saving category metadata with unchanged budget text writes no budget row, preserving inherited history. An edited amount writes a selected-month row; blanking writes the existing sentinel. Later explicit rows still win.
Consequences
- Budgets persist from their set month into every later month until the user changes (own-month row) or clears (sentinel row) the limit.
- The editor prefills the carried (effective) value when the viewed month has no row of its own, so saving without editing does not silently clear it.
- Swipe-delete of a default category clears the effective limit from the viewed month onward; fresh categories with no history still show "No limit set".
- Budget persistence is a pure SQL/read concern — no workers, alarms, or background work (ADR-0015 unchanged).
Rejected alternatives
- Copy-on-rollover — needs an invented, non-deterministic trigger (worker / alarm / first-launch-of-month) and can miss months; also risks diverging from user edits made between copies.
- Deleting on clear (status quo, inverted) — deleting the exact-month row cannot express "cleared from now on" once rows carry forward: an older real limit would resurrect.