Skip to content

ADR-0036: Budget limits carry into later months until changed or cleared

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 with MAX(monthStart) WHERE categoryId = :cat AND monthStart <= :monthStart (one correlated subquery per category, one row per category). BudgetDao.effectiveForCategory is the suspend single-category form used by the editor prefill.
  • BudgetViewModel and CategoryEditViewModel map carried rows into limitMillis / 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 the MonthSelector stays 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). 0 is unreachable from AmountParser (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.clear no-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) and app/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.