From 0d2aa519b7796fe1f70a000f54a6403324fd779b Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 3 Sep 2026 19:03:35 +0200 Subject: [PATCH] docs(recurring): record the invariants and what they cost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- AGENTS.md | 19 ++++++++++++++++ docs/ARCHITECTURE.md | 20 +++++++++++++++++ docs/DECISIONS.md | 33 +++++++++++++++++++++++++++- docs/PRODUCT.md | 10 ++++++++- docs/TODO.md | 22 ++++++++++++++----- scripts/demo-data.ts | 52 ++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 149 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 800a96d..dcb646a 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 70da643..256a802 100755 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index f173a2e..94421db 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -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 diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 8161adf..766ef69 100755 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -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. diff --git a/docs/TODO.md b/docs/TODO.md index 52b53d3..77c81db 100755 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -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. diff --git a/scripts/demo-data.ts b/scripts/demo-data.ts index 62d5b16..46bc1bb 100755 --- a/scripts/demo-data.ts +++ b/scripts/demo-data.ts @@ -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,