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:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user