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 committed 2026-09-03 19:03:35 +02:00
1 parent 824e94a9bd
commit 0d2aa519b7
6 files changed
+149 -7

No files matched your search

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