diff --git a/AGENTS.md b/AGENTS.md index 3b13707..1085f3c 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,12 +56,15 @@ and `docs/specs/`, and is not repeated here. "generate ahead" step, and nothing it produces is persisted. Storing occurrences 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. +- **A recurring cost has one of two schedules, and they are not the same + shape.** A `calendar` cost is a fixed grid: `source.occurrenceDate` is the + slot a charge settles, and the grid never moves. A `lastCharge` cost has NO + grid — its next due date is one cycle after the latest charge, so a charge + carries no slot at all and its own `Purchase.date` is the whole story. That + is what lets a phone plan charge many times in one cycle, each restarting + the clock. Do not "unify" them: the grid is right for rent and wrong for a + bill that restarts on payment, and collapsing them reintroduces the bug + where paying twice in September pushed the next charge to November. - **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. @@ -117,12 +120,28 @@ ship twice — re-introducing one is a regression, not a style choice. 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/store/actions.ts` and in `src/export/json.ts`. That is what makes both + projection walks terminate: the grid walk never rebases, and the anchored + walk only ever steps forward from a real date (the latest charge, or + `startDate` if there is none), so with `n >= 1` enforced at both boundaries + every step strictly advances. Each walk still throws if a step ever fails to + advance — a defensive assert against a state the two boundaries should + already have made unreachable, not a condition either walk can currently + hit — and that throw must never be softened into a `break`: a clamped zero, + or a silently dropped bill, would hand the owner a schedule their data does + not describe. → `src/domain/occurrences.test.ts`, `src/store/actions.test.ts`, `src/export/json.test.ts` +- **A recurring cost's `anchoring` cannot change once it has a charge**, in + `updateRecurringCost`. `calendar` -> `lastCharge` could strip the + now-forbidden slot ids mechanically, but the reverse cannot: there is no + honest way to invent which slot each existing charge settled. Allowing it + would let the write succeed and leave charges that silently fail + `requireSourcePairing`'s pairing rule the next time anything touches them, + or reach `confirmationsFor`, which throws by design on exactly this shape + (see §1's schedule invariant) — several frames below the edit that caused + it. End the cost and start a new one instead; history stays intact either + way. → `src/store/actions.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 256a802..1eac6e6 100755 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -28,9 +28,20 @@ The dependency rule is one-directional: `ui -> store -> domain`. `domain` import 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. +arithmetic with no `Date` at all, and `occurrences.ts` computes a recurring +cost's occurrences fresh on every fold rather than storing them, by one of two +walks chosen on `cost.anchoring`. A `calendar` cost (`gridOccurrences`) is a +fixed grid of slots stepped forward from `startDate`; nothing a payment does +moves it, so paying September AND October's rent correctly leaves nothing due +until November. A `lastCharge` cost (`anchoredOccurrences`) has no grid at +all — its next due date is one cycle after whichever charge is most recent, so +it can be charged any number of times in one cycle and each charge restarts +the clock, which is what a phone plan that recharges when its data runs out +actually does. The two walks share only `stepFrom`, which is also where +`everyNMonths` splits: month-granular (`addMonths`) under `calendar`, day-precise +and clamping (`addMonthsToDate`, `days.ts`) under `lastCharge`, because a plan +refreshed on the 20th is next due on the 20th, not at the start of the +following month. `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 @@ -63,7 +74,12 @@ 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`. +counted in `charges` and skipped by `expected`. A `lastCharge` cost reconverges +the same way but by a different mechanism: it has no `confirmedBy` to skip, so +it reconverges because the anchored walk itself never re-projects a cycle that +has already been charged — it starts one cycle after the latest charge, full +stop. Whether a still-open cycle's next due date lands in this month or a +later one depends on the recurrence, same as it always has. If you find yourself writing `carriedIn + allocation - charges` outside `fold.ts`, that is the invariant breaking. It matters because two screens diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index c77b5be..823a87c 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 7. + user's only copy of their financial data. Now at version 8. → [`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` @@ -105,6 +105,21 @@ record. 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) +- **Two projection models, one per anchoring, over a single unified model.** + `calendar` walks a fixed grid of slots; `lastCharge` has no grid and steps + from whichever charge is most recent. Chosen over one model where a payment + never claims a slot and a `calendar` occurrence counts as settled by falling + in its period (rejected: two rent payments in a month would settle one slot + and leave the other floating), and over keeping slots but letting one hold + several payments (rejected: it caps a phone plan at one extra charge per + cycle rather than the "infinite number of times a month" the owner + described). Deleted three guards that existed only to stop a confirmation + landing behind the slot it claims — `wouldAdvancePast` and `rebasedFrom` in + `src/domain/occurrences.ts`, the "Coming up" offerability guard in + `src/ui/components/ExpectedBand.tsx`, and `requireConfirmationAdvances` in + `src/store/actions.ts` — because with no slots on the anchored path the + situation they guarded against cannot arise. + → [`specs/2026-09-05-recurring-costs-anchored-model.md`](specs/2026-09-05-recurring-costs-anchored-model.md) - **`archived` MIRRORS "`endedFrom` is set"**, maintained in one place (`setRecurringCostEndedFrom`) rather than two independent fields a caller could desynchronise. `endedFrom` stops the projection; `archived` only dims diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index cb93375..90739e2 100755 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -68,11 +68,19 @@ Confirmed and in place: - 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 every N months, 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. + every N weeks on a weekday, split across posts like any purchase. A cost is + anchored one of two ways: `calendar` holds a fixed grid of due dates, so + paying two months at once still leaves nothing due until the one after; a + cost anchored to its **last charge** has no grid at all and can be charged + any number of times in one cycle, its next due date always one cycle after + whichever charge was most recent — a phone plan that recharges early when + its data runs out is the case this exists for. Each due date on the + calendar model is a projected occurrence the owner confirms into a real + purchase; a recurring cost can also be tagged onto a purchase, or created + from one, while it is being entered, rather than only from Settings. 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 diff --git a/docs/TODO.md b/docs/TODO.md index 77c81db..bd6efe0 100755 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -8,16 +8,20 @@ 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`. 583 tests passing, +**Current state:** merged to `main`. 730 tests passing, `bunx tsc --noEmit` clean, `bun run build` succeeds on both the live-fetch and offline paths. -**Schema is at version 7.** v1 -> v2 made allocation rules a dated series; +**Schema is at version 8.** 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; 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. +adds `Dataset.recurring`; v7 -> v8 moves a `lastCharge` cost off slots — it +strips `occurrenceDate` from every anchored charge and expands a month-only +`startDate` on an anchored `everyNMonths` cost to a day-granular one. Unlike +the first six, this step is NOT behaviour-preserving by construction: an +anchored cost's projected due dates genuinely change shape, because they no +longer come from a grid at all. It is still tested, the same as the others. 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 @@ -146,6 +150,13 @@ Genuinely outstanding: 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. +- **Whether the anchored (`lastCharge`) model actually matches how the + owner's phone plan bills.** The design + (`docs/specs/2026-09-05-recurring-costs-anchored-model.md`) rebases the + series on whatever was last charged, restarting the clock every time — a + reasonable model of "refreshes when the data runs out", built from the + owner's own description rather than from watching a real bill arrive. Only + a cycle or two of real use confirms the shape is right. - **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 diff --git a/docs/specs/2026-09-03-recurring-costs-design.md b/docs/specs/2026-09-03-recurring-costs-design.md index c9b8ea5..8202795 100644 --- a/docs/specs/2026-09-03-recurring-costs-design.md +++ b/docs/specs/2026-09-03-recurring-costs-design.md @@ -1,5 +1,12 @@ # Recurring costs +> **Note (2026-09-05):** the `lastCharge` design this document describes — +> confirming an occurrence early rebases the whole series from that slot — was +> replaced. `lastCharge` costs no longer have slots at all; see +> `docs/specs/2026-09-05-recurring-costs-anchored-model.md`. This document is +> left as written: an accurate record of what was decided and built at the +> time, not a description of the system as it stands today. + Bills that repeat: rent every month, a subscription every 28 days, a phone bill every 30 days whose clock restarts whenever the data cap is hit early. diff --git a/scripts/demo-data.ts b/scripts/demo-data.ts index 1792d4d..ccbd0e1 100755 --- a/scripts/demo-data.ts +++ b/scripts/demo-data.ts @@ -77,7 +77,7 @@ actions.addRecurringCost(d, { }); // 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, { +const carInsurance = actions.addRecurringCost(d, { name: "Car insurance", archived: false, amount: { amount: 349, currency: "DKK" }, @@ -93,6 +93,16 @@ actions.addRecurringCost(d, { // already reconverged for this one. actions.confirmOccurrence(d, wowSub.id, "2026-06-05", { date: "2026-06-05" }); +// TWO CHARGES IN ONE MONTH on an anchored cost — the reported case. Car +// insurance rebases on whatever it last actually charged, not on a calendar +// slot, so charging twice in September must not push the next due date two +// cycles out to November; it lands one cycle (30 days) after the LATER +// charge, in October. Confirming these through `confirmOccurrence` carries no +// `occurrenceDate` (anchored costs have no slots to settle) — only +// `Purchase.date` matters here. +actions.confirmOccurrence(d, carInsurance.id, "2026-09-03"); +actions.confirmOccurrence(d, carInsurance.id, "2026-09-19"); + const buy = (date: string, description: string, amount: number, currency: "DKK" | "EUR" | "USD", postId: string, note?: string) => actions.addPurchase(d, { date, description, note,