docs(recurring): record the invariants and what they cost

Two rules that only review catches, so they go in AGENTS.md §1: occurrences are
computed and never stored, and a confirmation's date and its slot are different
facts. Collapsing the second pair into one field would silently delete the
phone-bill behaviour, which is why it is written down rather than left to the
types.

§2 gets the termination guard. n >= 1 is rejected at both write boundaries
rather than clamped, and the walk throws rather than breaking, because a bill
silently dropped from a budget is worse than a visible error.

Demo data seeds a monthly rent and a 28-day subscription — the second is what
puts two occurrences in one month, which is the case worth looking at. It also
seeds a biweekly cost and a lastCharge-anchored one, so every recurrence kind
and both anchorings show up in Settings and the month view.

DECISIONS.md, PRODUCT.md, ARCHITECTURE.md and TODO.md are updated to match:
schema is at 7, the month view carries a second Projected balance beside
Remaining, and TODO.md records what a week of real use — not a test — will
have to answer.
This commit is contained in:
Lucas Winther
2026-09-03 19:03:35 +02:00
parent 824e94a9bd
commit 0d2aa519b7
6 changed files with 149 additions and 7 deletions
+19
View File
@@ -47,6 +47,17 @@ and `docs/specs/`, and is not repeated here.
the owner never chose.
- **`MonthId` is the string `"YYYY-MM"`.** Stored data never contains `Date`
objects.
- **A recurring cost's occurrences are COMPUTED, never stored.** `Dataset`
holds the rule and the confirmations; `occurrencesByMonth` derives the rest
on every fold. There is no horizon and no "generate ahead" step, and
`upToMonth` comes from the caller so `src/domain/` stays clock-free. Storing
them would reintroduce the materialised snapshot the original design rejected.
- **On a confirmation, `Purchase.date` is TRUTH and `source.occurrenceDate` is
IDENTITY.** The slot is what a confirmation claims, so the projector stops
emitting it; the date is when the money moved, so the fold counts it there.
They differ whenever a bill is paid off schedule, and under `lastCharge`
anchoring it is `date` that rebases the series. Do not "simplify" them into
one field — that erases the phone-bill behaviour entirely.
- **The envelope rollover fold has exactly one implementation**, in
`src/domain/fold.ts`. Year and summary views aggregate over it — they never
reimplement the math.
@@ -100,6 +111,14 @@ ship twice — re-introducing one is a regression, not a style choice.
fold and the month view, and asserts that raising the setting to 2 changes
what the fold produces. It cannot prove digits vary per currency, because
they no longer do.
- **A recurrence interval must be an integer `>= 1`, rejected rather than
clamped**, at BOTH write boundaries — `requireRecurrence` in
`src/store/actions.ts` and in `src/export/json.ts`. The projection walk
terminates only because every step strictly advances, and a clamped zero
would hand the owner a schedule their data does not describe. The walk throws
if a step fails to advance; do not soften that into a `break`.
→ `src/domain/occurrences.test.ts`, `src/store/actions.test.ts`,
`src/export/json.test.ts`
- **`process.env.BUN_PUBLIC_*` must be read via `try/catch`** — see
`readBasePathEnv`. Bun inlines the literal only when the variable is set;
unset, the bare reference reaches the browser and throws before the app boots.
+20
View File
@@ -27,6 +27,11 @@ server.ts the Docker image's static file server.
The dependency rule is one-directional: `ui -> store -> domain`. `domain` imports
nothing from the others; `store` never imports from `ui`.
Two `src/domain/` modules worth naming individually: `days.ts` is day-ordinal
arithmetic with no `Date` at all, and `occurrences.ts` walks a recurring cost's
schedule forward from `startDate`, computing its occurrences fresh on every
fold rather than storing them.
`src/domain/smoke.test.ts` asserts the domain is importable with no DOM. That is
why happy-dom is registered **per test file** rather than as a global bunfig
preload — a global DOM would make that assertion vacuously true and silently
@@ -45,6 +50,21 @@ The month view, year matrix, summary, per-post views and the ODS export all
aggregate over `foldBalances` / `figuresFor`. A closing balance is read from
`byMonth[11].remaining`, never re-summed.
A recurring cost adds a SECOND balance beside it, on its own carry:
```
projected = projectedCarriedIn + allocation − charges − expected
```
`remaining` never changes shape or value because of a recurring cost — it is
still the figure the owner reconciles against their bank statement. `projected`
runs beside it, additionally subtracting this month's unconfirmed occurrences,
so the month view shows both a `Remaining` and a `Projected` column per post
rather than only a separate list of upcoming bills. With no recurring costs the
two tracks are identical in every month; once every occurrence in a month is
confirmed they reconverge, because a confirmation is an ordinary purchase
counted in `charges` and skipped by `expected`.
If you find yourself writing `carriedIn + allocation - charges` outside
`fold.ts`, that is the invariant breaking. It matters because two screens
silently disagreeing about someone's money is the worst failure this app has.
+32 -1
View File
@@ -18,7 +18,7 @@ record.
no household mode. → [`PRODUCT.md`](PRODUCT.md)
- **Schema migrations are mandatory, not optional.** `schemaVersion` plus
ordered migration functions applied on load, because IndexedDB holds the
user's only copy of their financial data. Now at version 6.
user's only copy of their financial data. Now at version 7.
→ [`specs/2026-09-01-budget-app-design.md`](specs/2026-09-01-budget-app-design.md)
- **A destructive write downloads a backup first.** Import and reset both export
before replacing, and abort if the export fails. → commit `ac3d18a`
@@ -75,6 +75,37 @@ record.
- **Posts are archived, never deleted**, because purchases reference them.
Archived posts still fold. → [`ARCHITECTURE.md`](ARCHITECTURE.md)
## Recurring costs
- **Two-stage confirmation, over projection-only or generation-only.** A
recurring cost projects expected occurrences that stay expected until the
owner confirms one into an ordinary `Purchase`. Projection-only would charge
automatically with no reconciliation and would also force a dated amount
series, since nothing else would hold what was actually paid; generation-only
needs a horizon and a notion of "today" the domain is built to avoid.
→ [`specs/2026-09-03-recurring-costs-design.md`](specs/2026-09-03-recurring-costs-design.md)
- **A new `RecurringCost` entity, over extending `Purchase.schedule` with a
generative variant.** Under two-stage confirmation a template's only job is
to spawn purchases, so every consumer of `Purchase` would have to learn to
skip templates — a different case from splits and finance plans composing on
one entity.
→ [`specs/2026-09-03-recurring-costs-design.md`](specs/2026-09-03-recurring-costs-design.md)
- **`lastCharge` anchoring, over a `dataCap` concept for the phone-bill case.**
Rebasing the series on the confirmation date reproduces the behaviour exactly,
with no usage tracking and no third recurrence kind.
→ [`specs/2026-09-03-recurring-costs-design.md`](specs/2026-09-03-recurring-costs-design.md)
- **Day-less `everyNMonths`, over `monthlyOnDay(day)` with short-month
clamping.** The owner does not think of rent as landing on a date, `IsoDate`
already supports month-only values, and it keeps day arithmetic out of the
most common case entirely.
→ [`specs/2026-09-03-recurring-costs-design.md`](specs/2026-09-03-recurring-costs-design.md)
- **A mutable `amount`, over a dated series like `Post.rules`.** Safe only
because two-stage confirmation already holds every past amount in a confirmed
`Purchase`; the rule describes what is expected next, the ledger holds what
happened. Under projection-only this would not have held, and a dated series
would have been required.
→ [`specs/2026-09-03-recurring-costs-design.md`](specs/2026-09-03-recurring-costs-design.md)
## Design system
- **Two token layers.** `--budget-*` is the app's own vocabulary; shadcn's
+9 -1
View File
@@ -67,10 +67,18 @@ Confirmed and in place:
browser.
- Offline-capable installable PWA. Currently deploys as one static artifact to
both GitHub Pages (under a subpath) and a self-hosted Docker image.
- Recurring costs — a bill that repeats monthly, every N days, or every N
weeks on a weekday, split across posts like any purchase. Each due date is a
projected occurrence the owner confirms into a real purchase; the month view
shows what is still expected alongside a second, projected balance per post,
so a known bill is visible before it is paid without touching the reconciled
figure a bank statement is checked against.
Terminology: an envelope is called a **post**. A **finance plan** is a purchase
spread across months. An **override** is a one-month exception to a post's
allocation.
allocation. A **recurring cost** is a bill expected to repeat; each due date it
projects is an **occurrence**; **confirming** an occurrence turns it into a
real purchase.
Undecided: nothing outstanding at the product level.
+17 -5
View File
@@ -8,15 +8,16 @@ decisions behind it are in `docs/specs/` — one spec per sub-project,
each recording the alternatives that were rejected. Rules that outlived their
task moved to `AGENTS.md`.
**Current state:** merged to `main`. 464 tests passing,
**Current state:** merged to `main`. 583 tests passing,
`bunx tsc --noEmit` clean, `bun run build` succeeds on both the live-fetch and
offline paths.
**Schema is at version 5.** v1 -> v2 made allocation rules a dated series;
**Schema is at version 7.** v1 -> v2 made allocation rules a dated series;
v2 -> v3 moved currencies into the dataset; v3 -> v4 drops a stored rate-service
URL that can no longer work; v4 -> v5 adds sterling to an existing dataset. All
four are behaviour-preserving by construction — not one changes a stored figure
— and are tested as such.
URL that can no longer work; v4 -> v5 adds sterling to an existing dataset;
v5 -> v6 collapses per-currency decimals into one dataset-wide setting; v6 -> v7
adds `Dataset.recurring`. All six are behaviour-preserving by construction —
not one changes a stored figure — and are tested as such.
A migration applies **on load, in memory**; it is written back on the next
mutation, not by `load()` itself. So a freshly migrated dataset shows the new
@@ -140,3 +141,14 @@ Genuinely outstanding:
focus placement after a row auto-appends. Drivable over CDP in principle; not
yet done.
- **GitHub Actions** has never been executed.
- **Whether two-stage confirmation matches how the owner actually reconciles a
bank statement.** Recurring costs (`docs/specs/2026-09-03-recurring-costs-design.md`)
project expected occurrences and require a click to turn one into a real
purchase, rather than charging automatically. That is a product bet a week of
real use answers and no test can.
- **The overdue count against a large, real backlog.** `ExpectedBand` counts
unconfirmed occurrences from earlier months, and demo data does now
accumulate a modest one (a few months of an unconfirmed monthly bill plus a
biweekly one, ~14 occurrences by September) that renders without incident.
Nobody has seen the count or the layout against years of neglect or dozens of
distinct bills.
+52
View File
@@ -41,6 +41,58 @@ actions.setRuleFrom(d, transport.id, "2026-01", { kind: "fixed", amount: { amoun
actions.setRuleFrom(d, savings.id, "2026-03", { kind: "percentOfIncome", percent: 10 });
actions.setRuleOverride(d, "2026-09", social.id, { kind: "fixed", amount: { amount: 2400, currency: "DKK" } });
// Recurring costs: bills that project their own expected charges, shown as a
// second balance ("Projected") beside the allocation-rule figures above. All
// three recurrence kinds and both anchorings appear, so the screenshots show
// every combination at once.
const wowSub = actions.addRecurringCost(d, {
name: "World of Warcraft",
archived: false,
amount: { amount: 13, currency: "USD" },
startDate: "2026-06-05",
recurrence: { kind: "everyNDays", n: 28 },
anchoring: "calendar",
splitMode: "percent",
splits: [{ postId: games.id, value: 100, absorbsRemainder: true }],
});
actions.addRecurringCost(d, {
name: "Rent",
archived: false,
amount: { amount: 8200, currency: "DKK" },
startDate: "2026-06",
recurrence: { kind: "everyNMonths", n: 1 },
anchoring: "calendar",
splitMode: "percent",
splits: [{ postId: rent.id, value: 100, absorbsRemainder: true }],
});
actions.addRecurringCost(d, {
name: "Veg box",
archived: false,
amount: { amount: 175, currency: "DKK" },
startDate: "2026-06-06",
recurrence: { kind: "everyNWeeks", n: 2, weekday: 6 },
anchoring: "calendar",
splitMode: "percent",
splits: [{ postId: food.id, value: 100, absorbsRemainder: true }],
});
// Rebases on the actual charge date rather than the calendar, so an
// off-schedule payment moves the whole series — the phone-bill behaviour.
actions.addRecurringCost(d, {
name: "Car insurance",
archived: false,
amount: { amount: 349, currency: "DKK" },
startDate: "2026-08-14",
recurrence: { kind: "everyNDays", n: 30 },
anchoring: "lastCharge",
splitMode: "percent",
splits: [{ postId: transport.id, value: 100, absorbsRemainder: true }],
});
// One confirmed occurrence, so a screenshot shows both states at once: the
// expected band lists what is still due, and the fold's two tracks have
// already reconverged for this one.
actions.confirmOccurrence(d, wowSub.id, "2026-06-05", { date: "2026-06-05" });
const buy = (date: string, description: string, amount: number, currency: "DKK" | "EUR" | "USD", postId: string, note?: string) =>
actions.addPurchase(d, {
date, description, note,