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:
1 parent
824e94a9bd
commit
0d2aa519b7
6 files changed
+149
-7
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
Reference in new issue
Block a user