diff --git a/AGENTS.md b/AGENTS.md index 800a96d..3b13707 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,6 +47,21 @@ 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, and `upToMonth` comes from the caller so `src/domain/` stays + clock-free. Nothing is ever written ahead of time — `ExpectedBand`'s + `horizonMonths` only widens how far a single read-only walk looks before + throwing its result away; it is a DISPLAY bound on one function call, not a + "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. - **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 +115,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..c77b5be 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,48 @@ 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) +- **`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 + the row, the same way an archived post stays listed rather than + disappearing. Neither touches a past occurrence. One consequence is visible + and undocumented until now: dating a cancellation in the future sets + `archived` immediately, so a bill still live for months still renders + dimmed with a "restart" button while it keeps projecting rows right up to + `endedFrom`. Defensible under the rule as chosen, not a bug. + → [`specs/2026-09-03-recurring-costs-design.md`](specs/2026-09-03-recurring-costs-design.md), + `src/store/actions.ts` + ## 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..cb93375 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 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. 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/docs/specs/2026-09-03-recurring-costs-design.md b/docs/specs/2026-09-03-recurring-costs-design.md index 41e7333..c9b8ea5 100644 --- a/docs/specs/2026-09-03-recurring-costs-design.md +++ b/docs/specs/2026-09-03-recurring-costs-design.md @@ -305,18 +305,62 @@ reference the cost. **`RecurringSection` in Settings**, alongside `PostsSection` — the same CRUD, reorder and archive idiom, because a recurring cost is defined once and then -left alone. Definition is not the repeated act. +left alone. Definition is not the repeated act. An **"Ends" column** lets the +owner type a cancellation date directly, doing exactly what the "end"/"restart" +button does; both go through `setRecurringCostEndedFrom`, the one place that +keeps `endedFrom` and `archived` paired (§9, and `DECISIONS.md`). Dating a +cancellation in the future dims the row immediately even though it keeps +projecting rows until `endedFrom` — a visible consequence of that pairing, +recorded in `DECISIONS.md` rather than treated as a bug. `updateRecurringCost` +does not accept `archived` or `endedFrom` at all, so a caller cannot split the +pair even by mistake. **An "Expected" band in the month view**, which is where the repeated act -happens. Each unconfirmed occurrence for the month is one row with a one-click -confirm that writes the purchase at the expected date and amount. The amount is -editable inline before confirming, because paying a different figure is the -common case rather than the exception; anything more unusual is edited -afterwards like any other purchase. Entry speed is a product principle, and a -dialog per bill would defeat it. +happens, in two groups: -**The overdue count** — unconfirmed occurrences from earlier months — appears in -that band. §5 is otherwise silent drift. +- **This month's unconfirmed occurrences** (`pending`). Each is one row with a + one-click confirm that writes the purchase at the expected date and amount. + The amount is editable inline before confirming, because paying a different + figure is the common case rather than the exception; anything more unusual is + edited afterwards like any other purchase. Entry speed is a product + principle, and a dialog per bill would defeat it. Confirming here records + the slot's own date — the projection assumes bills are paid on time, and a + `lastCharge` series only rebases when the recorded date says otherwise. +- **"Coming up"**: at most one row per cost, its next unconfirmed occurrence + strictly after the displayed month, out to a bounded `horizonMonths` (twelve + by default — a read-ahead bound on one function call, nothing stored; see + AGENTS.md §1). Confirming a coming-up row records TODAY, not the future + slot — paying a `lastCharge` bill early, before its projected date arrives, + is exactly what rebases the series, and recording anything else would defeat + the point. A this-month row's subtitle is just its date; a coming-up row's + says "Due `` — confirming now records it ``" so the early date + is never a surprise. `recordedDateFor(group, today)` is the one function that + decides which date a click records — extracted specifically so that decision + has a test that fails if its two branches are swapped, since nothing else in + the suite would catch it. + + A coming-up row is only ever OFFERED when confirming it would leave the + series coherent, checked before the purchase is ever written rather than + disabled-with-an-explanation after the fact: + + - **Never for a cost with an unconfirmed occurrence in the displayed month or + any earlier foldable month.** Settle the earlier bill first; offering a + later one while an earlier sits outstanding would let confirming the later + one leave the earlier projection standing, silently double-counting the + bill. + - **Never for a pairing `occurrencesOf`'s walk could not survive.** A + `lastCharge` bill recorded today for a slot far enough in the future can + rebase the series BACKWARDS — the walk's own "did not advance" throw, which + is correct and stays a throw (AGENTS.md §2). `wouldAdvancePast` (in + `domain/occurrences.ts`, built on the same `stepFrom` the walk uses) + answers this without re-deriving the rule. + + Both checks omit the row outright rather than rendering it disabled: nobody + asked for an explanation of why they can't pay early yet. + +**The overdue count** — unconfirmed occurrences from earlier, foldable +months — appears in that band too, and shares its foldability bound with the +coherence check above. Three existing rules apply directly to the inline amount field: capture `event.target.value` into a `const` before `mutate` (AGENTS.md §2, the @@ -337,12 +381,23 @@ fixed. The field itself stays `type="text"` and parses through - **`domain/fold.test.ts`** — the two convergence invariants from §6. - **`store/migrations.test.ts`** — 6 -> 7 adds the empty array and moves no figure. +- **`store/actions.test.ts`** — `updateRecurringCost` rejects a bad recurrence + or a granularity mismatch, and (its type alone) cannot touch + `archived`/`endedFrom`; `setRecurringCostEndedFrom` keeps the pair coherent + through the button and the "Ends" column alike. - **`export/json.test.ts`** — acceptance and rejection, including `n: 0`, `n: 1.5`, a `weekday` of 7, and an unknown `kind`. - **`domain/currencyDigits.test.ts`** — the file now drives the whole dataset at zero decimals and asserts that raising the setting to 2 changes what the fold produces. The `expected` track joins that sweep, so it cannot be the one path that quietly assumes 2. +- **`ui/components/ExpectedBand.test.ts`** — `resolveExpectedAmount`; + `recordedDateFor`, directly, so the this-month/coming-up date decision has a + test that fails if its two branches are swapped; `expectedGroups`'s horizon + bound and one-row-per-cost dedup; and the coherence rules above, including a + regression built from the exact reported shape (a 30-day `lastCharge` cost + confirmed today for a slot 39 days out) asserting that confirming everything + `expectedGroups` offers and then folding never throws. `scripts/demo-data.ts` seeds a monthly rent and a 28-day subscription, so the band is screenshot-verifiable. diff --git a/scripts/demo-data.ts b/scripts/demo-data.ts index 62d5b16..1792d4d 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, @@ -52,7 +104,12 @@ const buy = (date: string, description: string, amount: number, currency: "DKK" for (let m = 1; m <= 9; m++) { const mm = String(m).padStart(2, "0"); - buy(`2026-${mm}-01`, "Rent", 8200, "DKK", rent.id); + // The "Rent" recurring cost above starts 2026-06 and is left unconfirmed, + // so it already projects an expected charge for June onward. An ordinary + // Rent purchase in those same months would double-charge the post in the + // screenshots — one from `charges`, one from `expected` — so this ordinary + // purchase only covers the months before the recurring cost exists. + if (m < 6) buy(`2026-${mm}-01`, "Rent", 8200, "DKK", rent.id); buy(`2026-${mm}-04`, "Groceries", 1180 + m * 37, "DKK", food.id); buy(`2026-${mm}-18`, "Groceries", 1420 - m * 12, "DKK", food.id); buy(`2026-${mm}-06`, "Commuter pass", 1100, "DKK", transport.id); diff --git a/src/domain/allocation.test.ts b/src/domain/allocation.test.ts index c2c16a2..f18fa24 100644 --- a/src/domain/allocation.test.ts +++ b/src/domain/allocation.test.ts @@ -53,6 +53,7 @@ function dataset(overrides: Partial = {}): Dataset { { id: "2026-01", income: { amount: 20000, currency: "DKK" }, ruleOverrides: {} }, ], purchases: [], + recurring: [], ...overrides, }; } @@ -204,6 +205,7 @@ describe("the fold across a rule change", () => { ruleOverrides: {}, })), purchases: [], + recurring: [], }; test("the allocation steps up in the version's month", () => { diff --git a/src/domain/charges.test.ts b/src/domain/charges.test.ts index c5a0512..c7e91c0 100644 --- a/src/domain/charges.test.ts +++ b/src/domain/charges.test.ts @@ -4,9 +4,11 @@ import { sliceAmountForMonth, chargesForPurchaseInMonth, chargesForMonth, + expectedForMonth, } from "./charges.ts"; import { roundMoney } from "./money.ts"; -import type { Dataset, FxRate, Purchase } from "./types.ts"; +import { occurrencesByMonth } from "./occurrences.ts"; +import type { Dataset, FxRate, Purchase, RecurringCost } from "./types.ts"; /** Digits for the currencies these tests use; all real-world 2dp. */ const TEST_CURRENCIES = [ @@ -219,6 +221,7 @@ describe("chargesForMonth", () => { purchase({ id: "b", total: { amount: 50, currency: "DKK" } }), purchase({ id: "c", date: "2026-10-01" }), ], + recurring: [], }; const charges = chargesForMonth(data, "2026-09"); expect(charges.get("food")).toBe(250); @@ -233,7 +236,100 @@ describe("chargesForMonth", () => { posts: [], months: [], purchases: [], + recurring: [], }; expect(chargesForMonth(data, "2026-09").size).toBe(0); }); }); + +describe("expectedForMonth", () => { + function dataset(cost: RecurringCost, purchases: Purchase[] = []): Dataset { + return { + settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 7, digits: 2 }, + currencies: [ + { code: "DKK", symbol: "kr", name: "Danish krone" }, + { code: "USD", symbol: "$", name: "US dollar" }, + ], + fxRates: [{ currency: "USD", baseUnitsPerOne: 7, updatedAt: "2026-01-01", source: "manual" }], + posts: [], + months: [], + purchases, + recurring: [cost], + }; + } + + const rent: RecurringCost = { + id: "r1", + name: "Rent", + order: 0, + archived: false, + amount: { amount: 8000, currency: "DKK" }, + startDate: "2026-01", + recurrence: { kind: "everyNMonths", n: 1 }, + anchoring: "calendar", + splitMode: "percent", + splits: [{ postId: "housing", value: 100, absorbsRemainder: true }], + }; + + test("an unconfirmed occurrence charges its post", () => { + const data = dataset(rent); + const expected = expectedForMonth(data, occurrencesByMonth(data, "2026-01").get("2026-01")!); + expect(expected.get("housing")).toBe(8000); + }); + + test("a CONFIRMED occurrence contributes nothing — it is a real purchase now", () => { + const confirmed: Purchase = { + id: "p1", + date: "2026-01", + description: "Rent", + total: { amount: 8000, currency: "DKK" }, + splitMode: "percent", + splits: [{ postId: "housing", value: 100, absorbsRemainder: true }], + schedule: null, + source: { recurringId: "r1", occurrenceDate: "2026-01" }, + }; + const data = dataset(rent, [confirmed]); + const expected = expectedForMonth(data, occurrencesByMonth(data, "2026-01").get("2026-01")!); + expect(expected.get("housing")).toBeUndefined(); + }); + + test("a split cost divides exactly, with the remainder absorbed", () => { + const split = { + ...rent, + amount: { amount: 1000, currency: "DKK" }, + splits: [ + { postId: "a", value: 33.333, absorbsRemainder: false }, + { postId: "b", value: 33.333, absorbsRemainder: false }, + { postId: "c", value: 33.334, absorbsRemainder: true }, + ], + }; + const data = dataset(split); + const expected = expectedForMonth(data, occurrencesByMonth(data, "2026-01").get("2026-01")!); + const total = ["a", "b", "c"].reduce((sum, id) => sum + expected.get(id)!, 0); + expect(total).toBe(1000); + }); + + test("a foreign-currency cost converts to base", () => { + const inUsd = { ...rent, amount: { amount: 15, currency: "USD" } }; + const data = dataset(inUsd); + const expected = expectedForMonth(data, occurrencesByMonth(data, "2026-01").get("2026-01")!); + expect(expected.get("housing")).toBe(105); + }); + + test("two occurrences in one month sum", () => { + const fortnightly = { + ...rent, + amount: { amount: 100, currency: "DKK" }, + startDate: "2026-01-05", + recurrence: { kind: "everyNDays" as const, n: 14 }, + }; + const data = dataset(fortnightly); + const january = occurrencesByMonth(data, "2026-01").get("2026-01")!; + expect(january.length).toBe(2); + expect(expectedForMonth(data, january).get("housing")).toBe(200); + }); + + test("no occurrences means an empty map, not zeros", () => { + expect(expectedForMonth(dataset(rent), []).size).toBe(0); + }); +}); diff --git a/src/domain/charges.ts b/src/domain/charges.ts index de00426..25406b3 100644 --- a/src/domain/charges.ts +++ b/src/domain/charges.ts @@ -1,6 +1,7 @@ import { toBase } from "./fx.ts"; import { distributeByAmount, distributeByWeight, roundMoney } from "./money.ts"; import { compareMonths, monthOf } from "./months.ts"; +import type { Occurrence } from "./occurrences.ts"; import type { Currency, Dataset, @@ -9,6 +10,7 @@ import type { MonthId, PostId, Purchase, + Split, } from "./types.ts"; export interface Charge { @@ -17,27 +19,75 @@ export interface Charge { amount: number; } -export function remainderIndexOf(purchase: Purchase): number { - const index = purchase.splits.findIndex((s) => s.absorbsRemainder); +/** + * Exactly one split absorbs the remainder, which is what makes the parts sum + * exactly to the total. `label` names the owning record so the message is + * actionable — this is reachable from a hand-edited import. + */ +export function remainderIndexIn(splits: readonly Split[], label: string): number { + const index = splits.findIndex((s) => s.absorbsRemainder); if (index === -1) { - throw new Error( - `Purchase ${purchase.id} has no split flagged absorbsRemainder`, - ); + throw new Error(`${label} has no split flagged absorbsRemainder`); } return index; } +export function remainderIndexOf(purchase: Purchase): number { + return remainderIndexIn(purchase.splits, `Purchase ${purchase.id}`); +} + +/** + * How an amount divides across splits, in the currency the amount is in. + * Parts sum exactly to the total. + */ +export function splitParts( + total: number, + splits: readonly Split[], + splitMode: "percent" | "fixed", + remainderIndex: number, + digits: number, +): number[] { + const values = splits.map((s) => s.value); + return splitMode === "percent" + ? distributeByWeight(total, values, remainderIndex, digits) + : distributeByAmount(total, values, remainderIndex, digits); +} + /** * How the whole purchase divides across its posts, in the purchase's own * currency. Parts sum exactly to the total. */ export function splitPartsOfTotal(purchase: Purchase, digits: number): number[] { - const index = remainderIndexOf(purchase); - const values = purchase.splits.map((s) => s.value); - const total = purchase.total.amount; - return purchase.splitMode === "percent" - ? distributeByWeight(total, values, index, digits) - : distributeByAmount(total, values, index, digits); + return splitParts( + purchase.total.amount, + purchase.splits, + purchase.splitMode, + remainderIndexOf(purchase), + digits, + ); +} + +/** + * Converts `amount` to base and distributes it across `splits` in the + * proportions `weights` describes. + * + * Shared by real charges and expected ones deliberately. Two implementations + * could round differently or absorb the remainder differently, and the gap + * would surface as a penny of phantom drift between the fold's two tracks — + * a difference with no cause the owner could ever find. + */ +export function distributeToSplits( + amount: Money, + splits: readonly Split[], + weights: number[], + remainderIndex: number, + baseCurrency: Currency, + rates: FxRate[], + digits: number, +): Charge[] { + const base = toBase(amount, baseCurrency, rates, digits); + const parts = distributeByWeight(base, weights, remainderIndex, digits); + return splits.map((split, i) => ({ postId: split.postId, amount: parts[i]! })); } /** @@ -76,19 +126,18 @@ export function chargesForPurchaseInMonth( const slice = sliceAmountForMonth(purchase, monthId); if (!slice) return []; - const sliceBase = toBase(slice, baseCurrency, rates, digits); - const index = remainderIndexOf(purchase); - // Split proportions are computed in the PURCHASE's own currency, then the - // month's slice is distributed to those proportions in the BASE currency. - // One setting covers both roundings now; it used to be two lookups, one per - // currency, which is the only thing that changed here. - const weights = splitPartsOfTotal(purchase, digits); - const parts = distributeByWeight(sliceBase, weights, index, digits); - - return purchase.splits.map((split, i) => ({ - postId: split.postId, - amount: parts[i]!, - })); + // Split proportions come from the WHOLE purchase, so a financed purchase + // divides each slice in the same ratio as the purchase as a whole — which is + // what makes splits and finance plans compose without a special case. + return distributeToSplits( + slice, + purchase.splits, + splitPartsOfTotal(purchase, digits), + remainderIndexOf(purchase), + baseCurrency, + rates, + digits, + ); } /** Total charged per post in `monthId`, in base currency. */ @@ -116,3 +165,53 @@ export function chargesForMonth( } return totals; } + +/** + * Total EXPECTED per post in one month, in base currency. + * + * The mirror of `chargesForMonth` for money that has not moved yet. Confirmed + * occurrences are skipped: they are ordinary purchases and `chargesForMonth` + * has already counted them. Counting both would double every settled bill. + * + * Takes the month's occurrences rather than a MonthId because the fold + * computes them all in one walk — see `occurrencesByMonth`. + */ +export function expectedForMonth( + dataset: Dataset, + occurrences: readonly Occurrence[], +): Map { + const totals = new Map(); + const digits = dataset.settings.digits; + const byId = new Map(dataset.recurring.map((cost) => [cost.id, cost])); + + for (const occurrence of occurrences) { + if (occurrence.confirmedBy) continue; + const cost = byId.get(occurrence.recurringId); + if (!cost) continue; + + const index = remainderIndexIn(cost.splits, `Recurring cost "${cost.name}"`); + const weights = splitParts( + cost.amount.amount, + cost.splits, + cost.splitMode, + index, + digits, + ); + const charges = distributeToSplits( + occurrence.amount, + cost.splits, + weights, + index, + dataset.settings.baseCurrency, + dataset.fxRates, + digits, + ); + + for (const charge of charges) { + const previous = totals.get(charge.postId) ?? 0; + totals.set(charge.postId, roundMoney(previous + charge.amount, digits)); + } + } + + return totals; +} diff --git a/src/domain/currencyDigits.test.ts b/src/domain/currencyDigits.test.ts index dc15ed6..bb58637 100644 --- a/src/domain/currencyDigits.test.ts +++ b/src/domain/currencyDigits.test.ts @@ -93,7 +93,9 @@ describe("the fold at zero decimals", () => { digits: DIGITS, }, currencies: CURRENCIES, - fxRates: [], + fxRates: [ + { currency: "USD", baseUnitsPerOne: 151.234567, updatedAt: "2026-01-01", source: "manual" }, + ], posts: [ { id: "food", @@ -103,26 +105,80 @@ describe("the fold at zero decimals", () => { currency: "JPY", rules: [{ from: "2026-01", rule: { kind: "percentOfIncome", percent: 33.333 } }], }, + // Never budgeted (rules: []) — allocation is always zero, so this + // post's only figures are what the recurring cost below projects onto + // it. Isolates the expected/projected track from the allocation math + // "food" already covers. + { + id: "fun", + name: "Fun", + order: 1, + archived: false, + currency: "JPY", + rules: [], + }, ], months: [ { id: "2026-01", income: { amount: 10_000, currency: "JPY" }, ruleOverrides: {} }, { id: "2026-02", income: { amount: 10_000, currency: "JPY" }, ruleOverrides: {} }, ], purchases: [], + // Unconfirmed on purpose: a confirmed occurrence becomes an ordinary + // Purchase and would be counted as `charges`, not `expected` — the + // wrong track for this guard. $7 at 151.234567 converts to a non-whole + // 1058.641969, and split 3/4 (fixed, not percent — the OTHER split + // mode) divides unevenly too, so both the FX conversion and the split + // division are exercised at zero decimals. + recurring: [ + { + id: "gym", + name: "Gym", + order: 0, + archived: false, + amount: { amount: 7, currency: "USD" }, + startDate: "2026-01", + recurrence: { kind: "everyNMonths", n: 1 }, + anchoring: "calendar", + splitMode: "fixed", + splits: [ + { postId: "food", value: 3, absorbsRemainder: false }, + { postId: "fun", value: 0, absorbsRemainder: true }, + ], + }, + ], }; } test("no figure the fold produces has a fractional yen in it", () => { const fold = foldBalances(dataset(), "2026-02"); for (const month of ["2026-01", "2026-02"]) { - const figures = figuresFor(fold, "food", month); - for (const [name, value] of Object.entries(figures)) { - expect(Number.isInteger(value)).toBe(true); - expect(`${month} ${name} = ${value}`).toBe(`${month} ${name} = ${Math.round(value)}`); + for (const postId of ["food", "fun"]) { + const figures = figuresFor(fold, postId, month); + for (const [name, value] of Object.entries(figures)) { + expect(Number.isInteger(value)).toBe(true); + expect(`${postId} ${month} ${name} = ${value}`).toBe( + `${postId} ${month} ${name} = ${Math.round(value)}`, + ); + } } } expect(figuresFor(fold, "food", "2026-01").allocation).toBe(3333); expect(figuresFor(fold, "food", "2026-02").carriedIn).toBe(3333); + + // The recurring cost's $7 converts to ¥1059, split 3/4 (fixed) onto + // food/fun — a wrong implementation that rounds either the FX conversion + // or the split at 2 decimals produces fractional yen here, which the + // Number.isInteger sweep above would already fail; these pin the exact + // whole-unit figures so a regression names the wrong number, not just + // "not an integer". + expect(figuresFor(fold, "food", "2026-01").expected).toBe(454); + expect(figuresFor(fold, "food", "2026-01").projected).toBe(2879); + expect(figuresFor(fold, "fun", "2026-01").expected).toBe(605); + expect(figuresFor(fold, "fun", "2026-01").projected).toBe(-605); + // Second month: the cost recurs, so the projected balance keeps + // absorbing it — proof this is a running division, not a one-off. + expect(figuresFor(fold, "food", "2026-02").projected).toBe(5758); + expect(figuresFor(fold, "fun", "2026-02").projected).toBe(-1210); }); test("the month view's totals are whole units too", () => { @@ -130,6 +186,13 @@ describe("the fold at zero decimals", () => { expect(Number.isInteger(view.totalAllocation)).toBe(true); expect(Number.isInteger(view.unallocated)).toBe(true); expect(view.unallocated).toBe(10_000 - 3333); + // The view embeds each post's expected/projected figures too — same + // sweep as the fold test above, through the view's own aggregation path. + for (const row of view.rows) { + for (const [name, value] of Object.entries(row.figures)) { + expect(Number.isInteger(value)).toBe(true); + } + } }); /** @@ -140,5 +203,13 @@ describe("the fold at zero decimals", () => { const twoPlaces = { ...dataset(), settings: { ...dataset().settings, digits: 2 } }; const fold = foldBalances(twoPlaces, "2026-01"); expect(figuresFor(fold, "food", "2026-01").allocation).toBe(3333.3); + + // Same recurring cost, same $7 — at two decimals the FX conversion and + // the fixed split both leave a fraction: 3333 → 3333.3, and the projected + // track moves right along with it. + expect(figuresFor(fold, "food", "2026-01").expected).toBe(453.7); + expect(figuresFor(fold, "food", "2026-01").projected).toBe(2879.6); + expect(figuresFor(fold, "fun", "2026-01").expected).toBe(604.94); + expect(figuresFor(fold, "fun", "2026-01").projected).toBe(-604.94); }); }); diff --git a/src/domain/days.test.ts b/src/domain/days.test.ts new file mode 100644 index 0000000..b7458f4 --- /dev/null +++ b/src/domain/days.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "bun:test"; +import { addDays, fromDayOrdinal, toDayOrdinal, weekdayOf } from "./days.ts"; + +describe("day ordinals", () => { + test("the epoch is day zero", () => { + expect(toDayOrdinal("1970-01-01")).toBe(0); + }); + + test("known dates convert to known ordinals", () => { + expect(toDayOrdinal("1969-12-31")).toBe(-1); + expect(toDayOrdinal("2000-03-01")).toBe(11017); + expect(toDayOrdinal("2026-09-03")).toBe(20699); + }); + + test("every date in a four-year window round-trips", () => { + // 2024 is a leap year, 2025 and 2026 are not, so this covers both. + for (let ordinal = toDayOrdinal("2024-01-01"); ordinal <= toDayOrdinal("2027-12-31"); ordinal++) { + expect(toDayOrdinal(fromDayOrdinal(ordinal))).toBe(ordinal); + } + }); + + test("leap day exists in 2024 and not in 2026", () => { + expect(fromDayOrdinal(toDayOrdinal("2024-02-28") + 1)).toBe("2024-02-29"); + expect(fromDayOrdinal(toDayOrdinal("2026-02-28") + 1)).toBe("2026-03-01"); + }); + + test("century rules: 1900 is not a leap year, 2000 is", () => { + expect(fromDayOrdinal(toDayOrdinal("1900-02-28") + 1)).toBe("1900-03-01"); + expect(fromDayOrdinal(toDayOrdinal("2000-02-28") + 1)).toBe("2000-02-29"); + }); + + test("a month-only date is rejected, not silently treated as the first", () => { + expect(() => toDayOrdinal("2026-09")).toThrow(/day-granular/); + }); + + test("an impossible day is rejected", () => { + expect(() => toDayOrdinal("2026-02-30")).toThrow(); + expect(() => toDayOrdinal("2026-13-01")).toThrow(); + }); +}); + +describe("addDays", () => { + test("crosses a month boundary", () => { + expect(addDays("2026-01-30", 3)).toBe("2026-02-02"); + }); + + test("crosses a year boundary", () => { + expect(addDays("2026-12-30", 5)).toBe("2027-01-04"); + }); + + test("a 28-day cycle lands on the same weekday every time", () => { + let date = "2026-01-05"; + const weekday = weekdayOf(date); + for (let i = 0; i < 13; i++) { + date = addDays(date, 28); + expect(weekdayOf(date)).toBe(weekday); + } + }); + + test("a 30-day cycle drifts through the calendar", () => { + // The phone bill's shape: 30 days is not a month, so it walks backwards + // through the month over a year. + expect(addDays("2026-01-31", 30)).toBe("2026-03-02"); + }); +}); + +describe("weekdayOf", () => { + test("the epoch was a Thursday", () => { + expect(weekdayOf("1970-01-01")).toBe(4); + }); + + test("known weekdays", () => { + expect(weekdayOf("2026-09-03")).toBe(4); // Thursday + expect(weekdayOf("2026-09-06")).toBe(0); // Sunday + expect(weekdayOf("2026-09-07")).toBe(1); // Monday + }); + + test("seven consecutive days cover every weekday exactly once", () => { + const seen = new Set(); + for (let i = 0; i < 7; i++) seen.add(weekdayOf(addDays("2026-09-03", i))); + expect(seen.size).toBe(7); + }); +}); diff --git a/src/domain/days.ts b/src/domain/days.ts new file mode 100644 index 0000000..0951a4c --- /dev/null +++ b/src/domain/days.ts @@ -0,0 +1,95 @@ +import type { IsoDate } from "./types.ts"; + +/** + * Day-level arithmetic, in the same character as `months.ts`: integer maths on + * a day ordinal, never a `Date`. + * + * `months.ts` avoids `Date` to avoid timezones, and the reasoning applies here + * with more force. "2026-03-29" names the same day everywhere; the instant a + * `Date` would give it does not, and a recurrence that steps 28 days must not + * gain or lose one at a DST boundary or in a different host timezone. + * + * Only the day-granular recurrences need this. `everyNMonths` stays on + * `months.ts`, because rent occurs IN a month rather than on a date. + */ + +const DAY_DATE = /^(\d{4})-(\d{2})-(\d{2})$/; + +function isLeapYear(year: number): boolean { + return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; +} + +function daysInMonth(year: number, month: number): number { + if (month === 2) return isLeapYear(year) ? 29 : 28; + return month === 4 || month === 6 || month === 9 || month === 11 ? 30 : 31; +} + +/** + * Days since 1970-01-01, by Howard Hinnant's `days_from_civil`. Proleptic + * Gregorian, exact for every date this app can hold, and pure integer maths. + * + * The algorithm shifts the year to start in March so that the leap day is the + * LAST day of the year rather than an insertion in the middle, which is what + * removes every special case from the day-of-year term. + */ +export function toDayOrdinal(date: IsoDate): number { + const match = DAY_DATE.exec(date); + if (!match) { + throw new Error(`Not a day-granular IsoDate: ${date}`); + } + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + if (month < 1 || month > 12) { + throw new Error(`Invalid month in IsoDate: ${date}`); + } + if (day < 1 || day > daysInMonth(year, month)) { + throw new Error(`Invalid day in IsoDate: ${date}`); + } + + const shifted = month <= 2 ? year - 1 : year; + const era = Math.floor(shifted / 400); + const yearOfEra = shifted - era * 400; + const dayOfYear = + Math.floor((153 * (month + (month > 2 ? -3 : 9)) + 2) / 5) + day - 1; + const dayOfEra = + yearOfEra * 365 + + Math.floor(yearOfEra / 4) - + Math.floor(yearOfEra / 100) + + dayOfYear; + return era * 146097 + dayOfEra - 719468; +} + +/** The exact inverse of `toDayOrdinal` (Hinnant's `civil_from_days`). */ +export function fromDayOrdinal(ordinal: number): IsoDate { + const z = ordinal + 719468; + const era = Math.floor(z / 146097); + const dayOfEra = z - era * 146097; + const yearOfEra = Math.floor( + (dayOfEra - + Math.floor(dayOfEra / 1460) + + Math.floor(dayOfEra / 36524) - + Math.floor(dayOfEra / 146096)) / + 365, + ); + const year = yearOfEra + era * 400; + const dayOfYear = + dayOfEra - + (365 * yearOfEra + Math.floor(yearOfEra / 4) - Math.floor(yearOfEra / 100)); + const shiftedMonth = Math.floor((5 * dayOfYear + 2) / 153); + const day = dayOfYear - Math.floor((153 * shiftedMonth + 2) / 5) + 1; + const month = shiftedMonth + (shiftedMonth < 10 ? 3 : -9); + const calendarYear = month <= 2 ? year + 1 : year; + + return `${String(calendarYear).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; +} + +export function addDays(date: IsoDate, count: number): IsoDate { + return fromDayOrdinal(toDayOrdinal(date) + count); +} + +/** 0 = Sunday. Day 0 of the ordinal was a Thursday, hence the + 4. */ +export function weekdayOf(date: IsoDate): number { + const shifted = (toDayOrdinal(date) + 4) % 7; + return shifted < 0 ? shifted + 7 : shifted; +} diff --git a/src/domain/fold.test.ts b/src/domain/fold.test.ts index 4028d48..d3f11fb 100644 --- a/src/domain/fold.test.ts +++ b/src/domain/fold.test.ts @@ -1,5 +1,6 @@ import { test, expect, describe } from "bun:test"; -import { foldBalances, figuresFor } from "./fold.ts"; +import { foldBalances, figuresFor, EMPTY_FIGURES } from "./fold.ts"; +import { createSeedDataset } from "./seed.ts"; import type { Dataset, Post, Purchase } from "./types.ts"; /** Digits for the currencies these tests use; all real-world 2dp. */ @@ -47,6 +48,7 @@ function dataset(posts: Post[], purchases: Purchase[]): Dataset { { id: "2026-03", income: { amount: 10000, currency: "DKK" }, ruleOverrides: {} }, ], purchases, + recurring: [], }; } @@ -58,6 +60,9 @@ describe("envelope rollover", () => { allocation: 500, charges: 0, remaining: 500, + expected: 0, + projectedCarriedIn: 0, + projected: 500, }); }); @@ -70,6 +75,9 @@ describe("envelope rollover", () => { allocation: 500, charges: 0, remaining: 700, + expected: 0, + projectedCarriedIn: 200, + projected: 700, }); }); @@ -156,6 +164,9 @@ test("figuresFor returns zeroes for a month outside the fold", () => { allocation: 0, charges: 0, remaining: 0, + expected: 0, + projectedCarriedIn: 0, + projected: 0, }); }); @@ -174,3 +185,103 @@ describe("the empty-figures fallback", () => { expect(figuresFor(fold, "another-missing-post", "2026-01").allocation).toBe(0); }); }); + +function datasetWithRent(): Dataset { + return { + settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 7, digits: 2 }, + currencies: [{ code: "DKK", symbol: "kr", name: "Danish krone" }], + fxRates: [], + posts: [ + { + id: "housing", + name: "Housing", + order: 0, + archived: false, + currency: "DKK", + rules: [{ from: "2026-01", rule: { kind: "fixed", amount: { amount: 10000, currency: "DKK" } } }], + }, + ], + months: [], + purchases: [], + recurring: [ + { + id: "rent", + name: "Rent", + order: 0, + archived: false, + amount: { amount: 8000, currency: "DKK" }, + startDate: "2026-01", + recurrence: { kind: "everyNMonths", n: 1 }, + anchoring: "calendar", + splitMode: "percent", + splits: [{ postId: "housing", value: 100, absorbsRemainder: true }], + }, + ], + }; +} + +describe("the projected track", () => { + test("with no recurring costs, projected shadows remaining exactly", () => { + // The behaviour-preservation guard. Every existing dataset must fold to + // figures it already folded to, with the new track adding nothing. + const data = createSeedDataset("2026-01"); + data.months.push({ id: "2026-01", income: { amount: 20000, currency: "DKK" }, ruleOverrides: {} }); + const fold = foldBalances(data, "2026-06"); + + for (const post of data.posts) { + for (const month of ["2026-01", "2026-02", "2026-03", "2026-04", "2026-05", "2026-06"]) { + const figures = figuresFor(fold, post.id, month); + expect(figures.expected).toBe(0); + expect(figures.projectedCarriedIn).toBe(figures.carriedIn); + expect(figures.projected).toBe(figures.remaining); + } + } + }); + + test("an unconfirmed occurrence lowers projected and leaves remaining alone", () => { + const data = datasetWithRent(); + const figures = figuresFor(foldBalances(data, "2026-01"), "housing", "2026-01"); + + expect(figures.charges).toBe(0); + expect(figures.expected).toBe(8000); + expect(figures.remaining).toBe(10000); + expect(figures.projected).toBe(2000); + }); + + test("the projected shortfall carries forward on its own track", () => { + const data = datasetWithRent(); + const fold = foldBalances(data, "2026-02"); + const february = figuresFor(fold, "housing", "2026-02"); + + expect(february.carriedIn).toBe(10000); + expect(february.projectedCarriedIn).toBe(2000); + expect(february.remaining).toBe(20000); + // 2000 carried on the projected track + 10000 allocated - 8000 expected. + expect(february.projected).toBe(4000); + }); + + test("confirming every occurrence reconverges the two tracks", () => { + const data = datasetWithRent(); + data.purchases.push({ + id: "p1", + date: "2026-01", + description: "Rent", + total: { amount: 8000, currency: "DKK" }, + splitMode: "percent", + splits: [{ postId: "housing", value: 100, absorbsRemainder: true }], + schedule: null, + source: { recurringId: "rent", occurrenceDate: "2026-01" }, + }); + + const figures = figuresFor(foldBalances(data, "2026-01"), "housing", "2026-01"); + expect(figures.charges).toBe(8000); + expect(figures.expected).toBe(0); + expect(figures.projected).toBe(figures.remaining); + }); + + test("EMPTY_FIGURES carries the new fields", () => { + expect(EMPTY_FIGURES.expected).toBe(0); + expect(EMPTY_FIGURES.projected).toBe(0); + expect(EMPTY_FIGURES.projectedCarriedIn).toBe(0); + }); +}); diff --git a/src/domain/fold.ts b/src/domain/fold.ts index cfe6291..e78207f 100644 --- a/src/domain/fold.ts +++ b/src/domain/fold.ts @@ -1,7 +1,8 @@ import { allocationFor } from "./allocation.ts"; -import { chargesForMonth } from "./charges.ts"; +import { chargesForMonth, expectedForMonth } from "./charges.ts"; import { roundMoney } from "./money.ts"; import { compareMonths, monthRange } from "./months.ts"; +import { occurrencesByMonth } from "./occurrences.ts"; import type { Dataset, MonthId, PostId } from "./types.ts"; export interface PostMonthFigures { @@ -10,6 +11,21 @@ export interface PostMonthFigures { charges: number; /** carriedIn + allocation - charges. Negative means overspent. */ remaining: number; + /** + * Unconfirmed recurring occurrences landing in this month, base currency. + * Money committed but not yet moved. + */ + expected: number; + /** The previous month's `projected`. */ + projectedCarriedIn: number; + /** + * projectedCarriedIn + allocation - charges - expected. + * + * A second, parallel balance: what is left once commitments are counted. + * It never influences `remaining`, so adding a recurring cost cannot move a + * figure the owner already reconciled against their bank. + */ + projected: number; } /** @@ -22,6 +38,9 @@ export const EMPTY_FIGURES: PostMonthFigures = Object.freeze({ allocation: 0, charges: 0, remaining: 0, + expected: 0, + projectedCarriedIn: 0, + projected: 0, }); export type Fold = Map>; @@ -35,31 +54,48 @@ export type Fold = Map>; * has to fold. */ export function foldBalances(dataset: Dataset, upToMonth: MonthId): Fold { - const { foldStartMonth, baseCurrency } = dataset.settings; + const { foldStartMonth } = dataset.settings; const baseDigits = dataset.settings.digits; const fold: Fold = new Map(); if (compareMonths(upToMonth, foldStartMonth) < 0) return fold; + // One walk for every recurring cost, because under `lastCharge` anchoring a + // month's occurrences depend on every confirmation before it. + const occurrences = occurrencesByMonth(dataset, upToMonth); + const carried = new Map(); + const carriedProjected = new Map(); for (const monthId of monthRange(foldStartMonth, upToMonth)) { const charges = chargesForMonth(dataset, monthId); + const expected = expectedForMonth(dataset, occurrences.get(monthId) ?? []); const monthFigures = new Map(); for (const post of dataset.posts) { const carriedIn = carried.get(post.id) ?? 0; + const projectedCarriedIn = carriedProjected.get(post.id) ?? 0; const allocation = allocationFor(dataset, post.id, monthId); const spent = charges.get(post.id) ?? 0; + const due = expected.get(post.id) ?? 0; + const remaining = roundMoney(carriedIn + allocation - spent, baseDigits); + const projected = roundMoney( + projectedCarriedIn + allocation - spent - due, + baseDigits, + ); monthFigures.set(post.id, { carriedIn, allocation, charges: spent, remaining, + expected: due, + projectedCarriedIn, + projected, }); carried.set(post.id, remaining); + carriedProjected.set(post.id, projected); } fold.set(monthId, monthFigures); diff --git a/src/domain/index.ts b/src/domain/index.ts index 94c7962..ed838dc 100644 --- a/src/domain/index.ts +++ b/src/domain/index.ts @@ -3,9 +3,11 @@ export const DOMAIN_READY = true; export * from "./types.ts"; export * from "./money.ts"; export * from "./months.ts"; +export * from "./days.ts"; export * from "./fx.ts"; export * from "./allocation.ts"; export * from "./charges.ts"; +export * from "./occurrences.ts"; export * from "./fold.ts"; export * from "./views.ts"; export * from "./seed.ts"; diff --git a/src/domain/months.test.ts b/src/domain/months.test.ts index b04d05e..119b171 100644 --- a/src/domain/months.test.ts +++ b/src/domain/months.test.ts @@ -108,3 +108,12 @@ test("monthOf still rejects out-of-range and malformed month-only values", () => expect(() => monthOf("2026-00")).toThrow(/2026-00/); expect(() => monthOf("2026-9")).toThrow(/2026-9/); }); + +// Residual C1: the regex used to be `/^(\d{4})-(\d{2})/` with no trailing +// anchor, so it matched a "YYYY-MM" PREFIX of a longer string and silently +// dropped the rest — "2026-091" read back as September 2026. Such a value +// would then be un-re-importable, since PURCHASE_DATE (src/export/json.ts) +// rejects it outright. +test("monthOf rejects a value that is only a prefix match, not a full one", () => { + expect(() => monthOf("2026-091")).toThrow(/2026-091/); +}); diff --git a/src/domain/months.ts b/src/domain/months.ts index 6fb0e2c..ddccf7c 100644 --- a/src/domain/months.ts +++ b/src/domain/months.ts @@ -23,8 +23,14 @@ function fromOrdinal(ordinal: number): MonthId { return format(Math.floor(ordinal / 12), (ordinal % 12) + 1); } +// Anchored at both ends: "YYYY-MM" or "YYYY-MM-DD" only, never a prefix of +// something longer. Dual acceptance (with or without a day) is deliberate and +// load-bearing — do not narrow it to one form. An unanchored match once let +// "2026-091" through as September 2026, silently dropping the trailing "1". +const MONTH_OF_DATE = /^(\d{4})-(\d{2})(?:-\d{2})?$/; + export function monthOf(date: IsoDate): MonthId { - const match = /^(\d{4})-(\d{2})/.exec(date); + const match = MONTH_OF_DATE.exec(date); if (!match) throw new Error(`Invalid IsoDate: ${date}`); const month = Number(match[2]); if (month < 1 || month > 12) throw new Error(`Invalid IsoDate: ${date}`); diff --git a/src/domain/occurrences.test.ts b/src/domain/occurrences.test.ts new file mode 100644 index 0000000..235ebd4 --- /dev/null +++ b/src/domain/occurrences.test.ts @@ -0,0 +1,287 @@ +import { describe, expect, test } from "bun:test"; +import { occurrencesByMonth, occurrencesOf, stepFrom } from "./occurrences.ts"; +import type { Dataset, Purchase, RecurringCost } from "./types.ts"; + +function cost(overrides: Partial = {}): RecurringCost { + return { + id: "r1", + name: "Rent", + order: 0, + archived: false, + amount: { amount: 8000, currency: "DKK" }, + startDate: "2026-01", + recurrence: { kind: "everyNMonths", n: 1 }, + anchoring: "calendar", + splitMode: "percent", + splits: [{ postId: "housing", value: 100, absorbsRemainder: true }], + ...overrides, + }; +} + +function confirmation(occurrenceDate: string, paidOn: string, id = "p1"): Purchase { + return { + id, + date: paidOn, + description: "Rent", + total: { amount: 8000, currency: "DKK" }, + splitMode: "percent", + splits: [{ postId: "housing", value: 100, absorbsRemainder: true }], + schedule: null, + source: { recurringId: "r1", occurrenceDate }, + }; +} + +const none = new Map(); + +describe("stepFrom", () => { + test("everyNMonths steps whole months and stays month-granular", () => { + expect(stepFrom("2026-01", { kind: "everyNMonths", n: 1 })).toBe("2026-02"); + expect(stepFrom("2026-11", { kind: "everyNMonths", n: 3 })).toBe("2027-02"); + }); + + test("everyNMonths from a day-granular date returns the month", () => { + // Reachable under lastCharge anchoring, where the step runs from an actual + // purchase date. + expect(stepFrom("2026-01-17", { kind: "everyNMonths", n: 1 })).toBe("2026-02"); + }); + + test("everyNDays steps days", () => { + expect(stepFrom("2026-01-05", { kind: "everyNDays", n: 28 })).toBe("2026-02-02"); + expect(stepFrom("2026-01-31", { kind: "everyNDays", n: 30 })).toBe("2026-03-02"); + }); + + test("everyNWeeks lands on the named weekday", () => { + // 2026-09-03 is a Thursday (4). Two weeks on is 2026-09-17, also Thursday. + expect(stepFrom("2026-09-03", { kind: "everyNWeeks", n: 2, weekday: 4 })).toBe("2026-09-17"); + }); + + test("everyNWeeks snaps forward when the cursor is off-weekday", () => { + // Reachable under lastCharge: paid on a Saturday, but the series is Thursdays. + // 2026-09-05 is a Saturday; +1 week is 2026-09-12 (Saturday), snapping + // forward to the next Thursday gives 2026-09-17. + expect(stepFrom("2026-09-05", { kind: "everyNWeeks", n: 1, weekday: 4 })).toBe("2026-09-17"); + }); +}); + +describe("occurrencesOf — calendar anchoring", () => { + test("a monthly cost yields one occurrence per month", () => { + const dates = occurrencesOf(cost(), none, "2026-04").map((o) => o.date); + expect(dates).toEqual(["2026-01", "2026-02", "2026-03", "2026-04"]); + }); + + test("a 28-day cost puts two occurrences in some months", () => { + const subscription = cost({ + startDate: "2026-01-05", + recurrence: { kind: "everyNDays", n: 28 }, + }); + const dates = occurrencesOf(subscription, none, "2026-03").map((o) => o.date); + expect(dates).toEqual(["2026-01-05", "2026-02-02", "2026-03-02", "2026-03-30"]); + }); + + test("nothing is produced before the start date", () => { + const later = cost({ startDate: "2026-03" }); + expect(occurrencesOf(later, none, "2026-04").map((o) => o.date)).toEqual([ + "2026-03", + "2026-04", + ]); + }); + + test("endedFrom stops the series, and the boundary date itself is excluded", () => { + const ended = cost({ endedFrom: "2026-03" }); + expect(occurrencesOf(ended, none, "2026-06").map((o) => o.date)).toEqual([ + "2026-01", + "2026-02", + ]); + }); + + test("a confirmation does NOT move a calendar-anchored series", () => { + const paidLate = new Map([["2026-01", confirmation("2026-01", "2026-01-28")]]); + const dates = occurrencesOf(cost(), paidLate, "2026-03").map((o) => o.date); + expect(dates).toEqual(["2026-01", "2026-02", "2026-03"]); + }); + + test("archived does not stop projection", () => { + // Archiving is a UI flag. Stopping a bill is endedFrom, because archiving + // must not retroactively remove a past expected charge. + const dates = occurrencesOf(cost({ archived: true }), none, "2026-02").map((o) => o.date); + expect(dates).toEqual(["2026-01", "2026-02"]); + }); +}); + +describe("occurrencesOf — confirmation", () => { + test("a confirmed slot carries the purchase id", () => { + const claimed = new Map([["2026-02", confirmation("2026-02", "2026-02", "pX")]]); + const found = occurrencesOf(cost(), claimed, "2026-03"); + expect(found.map((o) => o.confirmedBy)).toEqual([null, "pX", null]); + }); + + test("a slot is claimed by occurrenceDate, not by the purchase's own date", () => { + // Paid on the 28th for the slot dated 2026-01. The slot is claimed; a new + // one does not appear. + const claimed = new Map([["2026-01", confirmation("2026-01", "2026-01-28", "pY")]]); + const found = occurrencesOf(cost(), claimed, "2026-02"); + expect(found[0]!.confirmedBy).toBe("pY"); + expect(found[1]!.confirmedBy).toBeNull(); + }); +}); + +describe("occurrencesOf — lastCharge anchoring: the phone bill", () => { + const phone = cost({ + id: "r1", + name: "Phone", + startDate: "2026-01-01", + recurrence: { kind: "everyNDays", n: 30 }, + anchoring: "lastCharge", + }); + + test("unconfirmed, it behaves exactly like a calendar series", () => { + // Each occurrence steps from its own projected date: the projection assumes + // bills are paid on time. + const dates = occurrencesOf(phone, none, "2026-03").map((o) => o.date); + expect(dates).toEqual(["2026-01-01", "2026-01-31", "2026-03-02"]); + }); + + test("hitting the data cap early rebases the whole series", () => { + // The slot sits at 2026-01-31. The cap is hit on the 12th, so the owner + // confirms THAT slot with a purchase dated 2026-01-12. The next occurrence + // is 30 days after the 12th, not after the 31st. + const capHit = new Map([ + ["2026-01-01", confirmation("2026-01-01", "2026-01-01", "pA")], + ["2026-01-31", confirmation("2026-01-31", "2026-01-12", "pB")], + ]); + const dates = occurrencesOf(phone, capHit, "2026-03").map((o) => o.date); + expect(dates).toEqual(["2026-01-01", "2026-01-31", "2026-02-11", "2026-03-13"]); + }); + + test("the cap can be hit twice", () => { + const twice = new Map([ + ["2026-01-01", confirmation("2026-01-01", "2026-01-01", "pA")], + ["2026-01-31", confirmation("2026-01-31", "2026-01-12", "pB")], + ["2026-02-11", confirmation("2026-02-11", "2026-01-20", "pC")], + ]); + const dates = occurrencesOf(phone, twice, "2026-03").map((o) => o.date); + expect(dates).toEqual(["2026-01-01", "2026-01-31", "2026-02-11", "2026-02-19", "2026-03-21"]); + }); + + test("deleting the confirming purchase un-confirms the slot and restores the series", () => { + // CONTROLLER RULING: the brief's original version of this test was + // byte-identical to "unconfirmed, it behaves exactly like a calendar + // series" above and asserted nothing new. Rewritten to actually contrast + // the two states: walk WITH the capHit confirmations (rebased dates), + // then walk the SAME cost with an empty map (as if the confirming + // purchase had been deleted) and assert the dates differ and match the + // plain unconfirmed series. + const capHit = new Map([ + ["2026-01-01", confirmation("2026-01-01", "2026-01-01", "pA")], + ["2026-01-31", confirmation("2026-01-31", "2026-01-12", "pB")], + ]); + const rebased = occurrencesOf(phone, capHit, "2026-03").map((o) => o.date); + expect(rebased).toEqual(["2026-01-01", "2026-01-31", "2026-02-11", "2026-03-13"]); + + // Deleting the confirming purchase leaves no entry in the confirmations + // map for that slot — this is what an empty map models here, since the + // slot in question (2026-01-31) is the only one whose confirmation + // affects downstream dates. + const afterDelete = occurrencesOf(phone, none, "2026-03").map((o) => o.date); + expect(afterDelete).not.toEqual(rebased); + expect(afterDelete).toEqual(["2026-01-01", "2026-01-31", "2026-03-02"]); + }); +}); + +describe("occurrencesOf — lastCharge anchoring: a monthly bill paid early (I3)", () => { + const rent = cost({ + startDate: "2026-08", + recurrence: { kind: "everyNMonths", n: 1 }, + anchoring: "lastCharge", + }); + + test("paying a few days before month-end does not throw or rebase", () => { + // The slot is 2026-09; the owner pays on the 28th of August, four days + // early. That is completely ordinary and must not move the series + // backwards or make it fail to advance. + const paidEarly = new Map([["2026-09", confirmation("2026-09", "2026-08-28", "pE")]]); + const dates = occurrencesOf(rent, paidEarly, "2026-10").map((o) => o.date); + expect(dates).toEqual(["2026-08", "2026-09", "2026-10"]); + }); + + test("paying after the month it was for rebases forward, same as the phone bill", () => { + // Paid on 2026-09-05 for the 2026-08 slot: the confirmation's month is + // AFTER the cursor, so (mirroring the day-granular cap-hit case) the + // series rebases from there. + const paidLate = new Map([["2026-08", confirmation("2026-08", "2026-09-05", "pL")]]); + const dates = occurrencesOf(rent, paidLate, "2026-11").map((o) => o.date); + expect(dates).toEqual(["2026-08", "2026-10", "2026-11"]); + }); +}); + +describe("occurrencesOf — termination", () => { + test("a confirmation dated absurdly early throws rather than walking backwards", () => { + // The ONLY way a valid recurrence fails to advance. Under lastCharge the + // step runs from the confirmation's own date, so a payment recorded more + // than n days before the slot it claims would move the cursor backwards. + // A visible error beats a fold that silently emits duplicate months. + const phone = cost({ + startDate: "2026-01-01", + recurrence: { kind: "everyNDays", n: 30 }, + anchoring: "lastCharge", + }); + const outOfOrder = new Map([ + ["2026-01-31", confirmation("2026-01-31", "2025-11-02", "pBad")], + ]); + expect(() => occurrencesOf(phone, outOfOrder, "2026-03")).toThrow(/did not advance/); + }); + + test("a recurrence that cannot advance throws rather than hanging", () => { + const broken = cost({ recurrence: { kind: "everyNDays", n: 0 }, startDate: "2026-01-01" }); + expect(() => occurrencesOf(broken, none, "2026-02")).toThrow(/did not advance/); + }); + + test("a negative step throws too", () => { + const backwards = cost({ recurrence: { kind: "everyNDays", n: -7 }, startDate: "2026-01-01" }); + expect(() => occurrencesOf(backwards, none, "2026-02")).toThrow(/did not advance/); + }); +}); + +describe("occurrencesByMonth", () => { + function dataset(recurring: RecurringCost[], purchases: Purchase[] = []): Dataset { + return { + settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 7, digits: 2 }, + currencies: [{ code: "DKK", symbol: "kr", name: "Danish krone" }], + fxRates: [], + posts: [], + months: [], + purchases, + recurring, + }; + } + + test("groups every cost's occurrences by the month they land in", () => { + const byMonth = occurrencesByMonth( + dataset([ + cost({ id: "rent", startDate: "2026-01" }), + cost({ id: "wow", startDate: "2026-01-05", recurrence: { kind: "everyNDays", n: 28 } }), + ]), + "2026-02", + ); + + expect(byMonth.get("2026-01")!.map((o) => o.recurringId).sort()).toEqual(["rent", "wow"]); + expect(byMonth.get("2026-02")!.map((o) => o.recurringId).sort()).toEqual(["rent", "wow"]); + }); + + test("a purchase confirming one cost does not claim another cost's slot", () => { + const shared = confirmation("2026-01", "2026-01", "pZ"); + const byMonth = occurrencesByMonth( + dataset([cost({ id: "r1" }), cost({ id: "r2" })], [shared]), + "2026-01", + ); + const january = byMonth.get("2026-01")!; + expect(january.find((o) => o.recurringId === "r1")!.confirmedBy).toBe("pZ"); + expect(january.find((o) => o.recurringId === "r2")!.confirmedBy).toBeNull(); + }); + + test("months with no occurrence have no entry at all", () => { + const byMonth = occurrencesByMonth(dataset([cost({ endedFrom: "2026-02" })]), "2026-04"); + expect(byMonth.has("2026-01")).toBe(true); + expect(byMonth.has("2026-02")).toBe(false); + }); +}); diff --git a/src/domain/occurrences.ts b/src/domain/occurrences.ts new file mode 100644 index 0000000..3462c44 --- /dev/null +++ b/src/domain/occurrences.ts @@ -0,0 +1,224 @@ +import { addDays, weekdayOf } from "./days.ts"; +import { addMonths, compareMonths, monthOf } from "./months.ts"; +import type { + Dataset, + IsoDate, + Money, + MonthId, + Purchase, + PurchaseId, + Recurrence, + RecurringCost, + RecurringCostId, +} from "./types.ts"; + +/** + * One projected charge of one recurring cost. + * + * Occurrences are COMPUTED on every fold and never stored. The dataset holds + * the rule and the confirmations; everything here is derived from those two. + */ +export interface Occurrence { + recurringId: RecurringCostId; + /** + * The slot this walk generated. IDENTITY: a confirmation claims a slot by + * this date, which is not necessarily the date it was paid. + */ + date: IsoDate; + /** Expected amount, in the cost's own currency. */ + amount: Money; + /** The purchase that confirmed this slot, or null while it is still expected. */ + confirmedBy: PurchaseId | null; +} + +/** + * ISO dates compare correctly as strings, and mixed granularity falls out + * right: "2026-09" < "2026-09-17" < "2026-10", so a month-only date reads as + * the start of that month. That is exactly what `endedFrom` needs when a + * monthly cost is cancelled. + */ +function isBefore(a: IsoDate, b: IsoDate): boolean { + return a < b; +} + +/** Moves forward to the next occurrence of `weekday`, or stays put if already on it. */ +function snapForward(date: IsoDate, weekday: number): IsoDate { + const delta = (weekday - weekdayOf(date) + 7) % 7; + return delta === 0 ? date : addDays(date, delta); +} + +/** + * Where the next occurrence falls, given the one before it. + * + * Adding a recurrence kind means adding a case here and a variant to + * `Recurrence`. Nothing else in the app switches on the kind. + */ +export function stepFrom(date: IsoDate, recurrence: Recurrence): IsoDate { + switch (recurrence.kind) { + case "everyNMonths": + // Month-granular in, month-granular out. `monthOf` also accepts a + // day-granular date, which is what a lastCharge rebase supplies. + return addMonths(monthOf(date), recurrence.n); + case "everyNDays": + return addDays(date, recurrence.n); + case "everyNWeeks": + return snapForward(addDays(date, 7 * recurrence.n), recurrence.weekday); + } +} + +/** + * The purchases confirming one cost, keyed by the slot each one claims. + * + * Keyed by `source.occurrenceDate` rather than by `Purchase.date`, because the + * slot is what a confirmation claims. The two differ whenever a bill is paid + * off schedule. + */ +export function confirmationsFor( + purchases: readonly Purchase[], + recurringId: RecurringCostId, +): Map { + const found = new Map(); + for (const purchase of purchases) { + if (purchase.source?.recurringId === recurringId) { + found.set(purchase.source.occurrenceDate, purchase); + } + } + return found; +} + +/** + * The date `occurrencesOf`'s walk would step from after confirming `cost`'s + * slot `cursor` as having been paid on `confirmedDate` — the same rebase rule + * the walk applies inline. Extracted so a caller can ask "would this pairing + * advance the series" (see `wouldAdvancePast` below) without re-deriving it. + */ +function rebasedFrom(cost: RecurringCost, cursor: IsoDate, confirmedDate: IsoDate): IsoDate { + if (cost.anchoring !== "lastCharge") return cursor; + if (cost.recurrence.kind === "everyNMonths") { + return compareMonths(monthOf(confirmedDate), cursor) > 0 ? confirmedDate : cursor; + } + return confirmedDate; +} + +/** + * Whether recording `cost`'s slot `slot` as paid on `recordedDate` would + * leave `occurrencesOf` able to step strictly past `slot` — i.e. whether that + * pairing is one the walk can survive at all, checked BEFORE a purchase + * carrying it is ever written. + * + * Calendar anchoring always advances, because it never rebases from the + * confirmation date; `everyNMonths` under `lastCharge` also always advances, + * because it only rebases forward within the slot's own month. The one case + * that can fail is a day/week-granular `lastCharge` cost recorded far enough + * before its slot that stepping from `recordedDate` still lands at or before + * `slot` — exactly the shape of `occurrencesOf`'s "did not advance" throw. + * + * Exported for `ExpectedBand.tsx`'s "Coming up" group, whose whole premise is + * recording a slot's confirmation on a date OTHER than the slot itself + * (today, because paying early). The walk's throw is correct — a step that + * lands backwards really is bad data — so the band, which chooses + * `recordedDate`, must not offer a row that would produce that pairing. + */ +export function wouldAdvancePast(cost: RecurringCost, slot: IsoDate, recordedDate: IsoDate): boolean { + return isBefore(slot, stepFrom(rebasedFrom(cost, slot, recordedDate), cost.recurrence)); +} + +/** + * Every occurrence of one cost from its start through `upToMonth`, in order. + * + * A walk rather than a per-month query, because under `lastCharge` anchoring + * where the series sits in a given month depends on every confirmation before + * it. The walk always begins at `startDate` even when that precedes the fold + * start, because the phase depends on the whole history; callers read only the + * months they need. The cost is trivial — thirty-five years of a 28-day cycle + * is about thirteen thousand integer additions. + * + * `upToMonth` comes from the caller. This module reads no clock, which is what + * keeps `src/domain/` pure. + */ +export function occurrencesOf( + cost: RecurringCost, + confirmations: ReadonlyMap, + upToMonth: MonthId, +): Occurrence[] { + const found: Occurrence[] = []; + + // A monthly cost is month-granular throughout, even if its start date was + // typed with a day. Rent occurs IN a month. + let cursor = + cost.recurrence.kind === "everyNMonths" ? monthOf(cost.startDate) : cost.startDate; + + while (compareMonths(monthOf(cursor), upToMonth) <= 0) { + if (cost.endedFrom && !isBefore(cursor, cost.endedFrom)) break; + + const confirmation = confirmations.get(cursor) ?? null; + found.push({ + recurringId: cost.id, + date: cursor, + amount: cost.amount, + confirmedBy: confirmation?.id ?? null, + }); + + // `lastCharge` measures from when the money ACTUALLY moved, which is what + // makes an early charge rebase the series. An unconfirmed occurrence steps + // from its own projected date: the projection assumes bills are paid on + // time, and self-corrects as they are confirmed. + // + // For `everyNMonths`, "moved" means the confirmation's MONTH, and paying + // a few days before the month turns over is ordinary — it must not rebase + // the series backwards. So a month-granular step runs from whichever is + // later, the cursor or the confirmation's month; an early payment within + // the same month simply does not rebase. Day/week-granular kinds keep + // stepping from the confirmation's exact date, unchanged. + const from = confirmation ? rebasedFrom(cost, cursor, confirmation.date) : cursor; + const next = stepFrom(from, cost.recurrence); + + // The loop terminates only if the step strictly increases. `n >= 1` is + // enforced at both write boundaries, so reaching this is a bug rather than + // bad input — and a hang is the one failure mode worse than a visible + // error. Never soften this into a break: silently dropping a bill would + // put a wrong number on the screen, which AGENTS.md forbids outright. + if (!isBefore(cursor, next)) { + // Two causes, and the message names both because they need different + // fixes. With a valid `n >= 1` a CALENDAR series can never reach this: + // every kind adds at least one day. Only `lastCharge` can, and only for + // a day/week-granular kind whose confirmation is dated far enough + // before the slot it claims that stepping from it still lands at or + // before the cursor — `everyNMonths` never reaches this branch, because + // it never steps from a confirmation earlier than the cursor. + throw new Error( + `Recurring cost "${cost.name}" did not advance past ${cursor} (produced ${next}). ` + + (confirmation + ? `Purchase ${confirmation.id} claims that slot but is dated ${confirmation.date}, ` + + `which does not move the series past ${cursor} — correct its date so the series advances.` + : `A recurrence must move strictly forward; check that n is at least 1.`), + ); + } + cursor = next; + } + + return found; +} + +/** + * Every cost's occurrences through `upToMonth`, grouped by the month they land + * in. The fold calls this once and reads a month at a time. + */ +export function occurrencesByMonth( + dataset: Dataset, + upToMonth: MonthId, +): Map { + const byMonth = new Map(); + + for (const cost of dataset.recurring) { + const confirmations = confirmationsFor(dataset.purchases, cost.id); + for (const occurrence of occurrencesOf(cost, confirmations, upToMonth)) { + const month = monthOf(occurrence.date); + const existing = byMonth.get(month); + if (existing) existing.push(occurrence); + else byMonth.set(month, [occurrence]); + } + } + + return byMonth; +} diff --git a/src/domain/seed.ts b/src/domain/seed.ts index d345d12..77b99f7 100644 --- a/src/domain/seed.ts +++ b/src/domain/seed.ts @@ -1,7 +1,7 @@ import { DEFAULT_DIGITS, SEED_CURRENCIES } from "./types.ts"; import type { CurrencyDef, Dataset, FxRate, MonthId, Post } from "./types.ts"; -export const SCHEMA_VERSION = 6; +export const SCHEMA_VERSION = 7; const SEED_POST_NAMES = ["Video Games", "Food", "Events and Social"] as const; @@ -47,5 +47,9 @@ export function createSeedDataset( { id: startMonth, income: { amount: 0, currency: "DKK" }, ruleOverrides: {} }, ], purchases: [], + // A new dataset seeds no recurring costs. Unlike currencies and FX rates, + // there is no sensible default bill — the app cannot guess what the owner + // pays. + recurring: [], }; } diff --git a/src/domain/types.ts b/src/domain/types.ts index ae735c9..193c5e7 100755 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -105,6 +105,65 @@ export interface Schedule { cancelledFromMonth?: MonthId; } +export type RecurringCostId = string; + +/** + * How occurrence dates are generated. + * + * Extensible on purpose: a new variant here plus one case in `stepFrom` + * (`occurrences.ts`) is the whole change — nothing else in the app switches on + * this. `n` must be an integer >= 1 in every variant, which is what makes the + * projection walk terminate; both write boundaries enforce it. + */ +export type Recurrence = + | { kind: "everyNMonths"; n: number } + | { kind: "everyNDays"; n: number } + /** `weekday` is 0 = Sunday, matching `weekdayOf` in `days.ts`. */ + | { kind: "everyNWeeks"; n: number; weekday: number }; + +/** + * Where the NEXT occurrence is measured from. + * + * `calendar` — from the previous slot, wherever it fell. Rent, and a + * subscription that bills on its own schedule whatever you do. + * + * `lastCharge` — from the date the previous charge ACTUALLY happened. This is + * the phone bill: hitting the data cap makes a charge happen early, and the + * next 30 days run from there. The domain never learns what a data cap is; + * confirming the occurrence early is the whole mechanism. + */ +export type Anchoring = "calendar" | "lastCharge"; + +export interface RecurringCost { + id: RecurringCostId; + name: string; + order: number; + /** + * A UI flag ONLY. Projection is governed by `startDate` and `endedFrom`, + * exactly as an archived post still folds — archiving must not retroactively + * remove past expected charges, because that would move a historical figure. + */ + archived: boolean; + /** + * Expected amount, in its own currency, converted to base at fold time. + * + * A single mutable value with no dated series, unlike `Post.rules`. A rent + * increase edits it, and history survives because every past occurrence is + * a confirmed `Purchase` holding what was actually paid. Only unconfirmed + * future occurrences move. + */ + amount: Money; + /** The first occurrence. "YYYY-MM" for everyNMonths, "YYYY-MM-DD" otherwise. */ + startDate: IsoDate; + /** Occurrences on or after this date are not projected. This is cancellation. */ + endedFrom?: IsoDate; + recurrence: Recurrence; + anchoring: Anchoring; + /** Same shape and same invariant as Purchase: exactly one absorbsRemainder. */ + splitMode: "percent" | "fixed"; + splits: Split[]; +} + export interface Purchase { id: PurchaseId; date: IsoDate; @@ -117,6 +176,16 @@ export interface Purchase { splits: Split[]; /** null means the whole total is charged in the month of `date`. */ schedule: Schedule | null; + /** + * Present when this purchase confirms a projected occurrence. + * + * `occurrenceDate` is IDENTITY — the slot the projector generated, which + * this purchase claims so the slot stops being projected. `Purchase.date` is + * TRUTH — when the money moved. They differ whenever a bill is paid off + * schedule, and under `lastCharge` anchoring it is `date`, not + * `occurrenceDate`, that the next occurrence is measured from. + */ + source?: { recurringId: RecurringCostId; occurrenceDate: IsoDate }; } export interface FxRate { @@ -155,4 +224,10 @@ export interface Dataset { posts: Post[]; months: Month[]; purchases: Purchase[]; + /** + * Bills that repeat. Their occurrences are COMPUTED on every fold and never + * stored — the original design rejected materialised snapshots, and an + * unbounded recurrence would otherwise need an arbitrary horizon. + */ + recurring: RecurringCost[]; } diff --git a/src/domain/views.test.ts b/src/domain/views.test.ts index e86c04e..b6ac8da 100644 --- a/src/domain/views.test.ts +++ b/src/domain/views.test.ts @@ -49,6 +49,7 @@ const data: Dataset = { }, ], purchases: [spend("a", "food", 500, "2026-01-10"), spend("b", "games", 300, "2026-02-10")], + recurring: [], }; describe("monthView", () => { @@ -121,6 +122,40 @@ describe("monthView", () => { "old", ]); }); + + test("an archived post with only an unconfirmed occurrence still appears, with its projected figure (I4)", () => { + // "old" has a zero-percent rule (no allocation) and no purchases at all, + // so carriedIn/allocation/charges are all zero this month — its only + // figure is the unconfirmed recurring occurrence's `expected`/`projected`. + // Before I4's fix, hasActivity ignored those two fields and dropped the + // row, even though ExpectedBand lists the same occurrence. + const withRecurring: Dataset = { + ...data, + posts: [...data.posts, post("old", 2, 0, true)], + recurring: [ + { + id: "r1", + name: "Storage unit", + order: 0, + archived: false, + amount: { amount: 400, currency: "DKK" }, + startDate: "2026-01", + recurrence: { kind: "everyNMonths", n: 1 }, + anchoring: "calendar", + splitMode: "percent", + splits: [{ postId: "old", value: 100, absorbsRemainder: true }], + }, + ], + }; + const view = monthView(withRecurring, "2026-01"); + const row = view.rows.find((r) => r.post.id === "old"); + expect(row).toBeDefined(); + expect(row!.figures.carriedIn).toBe(0); + expect(row!.figures.allocation).toBe(0); + expect(row!.figures.charges).toBe(0); + expect(row!.figures.expected).toBe(400); + expect(row!.figures.projected).toBe(-400); + }); }); describe("yearView", () => { diff --git a/src/domain/views.ts b/src/domain/views.ts index 13d239a..d5f3c21 100644 --- a/src/domain/views.ts +++ b/src/domain/views.ts @@ -28,8 +28,18 @@ export interface MonthViewModel { } function hasActivity(figures: PostMonthFigures): boolean { + // `remaining` and `projected` are both fully derived from these five raw + // fields, so checking all five is what keeps a post visible whenever either + // column would show a nonzero figure. `expected` and `projectedCarriedIn` + // are the two the recurring-costs feature added: an archived post whose + // only activity this month is an unconfirmed occurrence has a zero + // `remaining` but a nonzero `projected`, and must not be dropped. return ( - figures.carriedIn !== 0 || figures.allocation !== 0 || figures.charges !== 0 + figures.carriedIn !== 0 || + figures.allocation !== 0 || + figures.charges !== 0 || + figures.expected !== 0 || + figures.projectedCarriedIn !== 0 ); } diff --git a/src/export/json.test.ts b/src/export/json.test.ts index 7e25dee..cd8b2ef 100644 --- a/src/export/json.test.ts +++ b/src/export/json.test.ts @@ -62,6 +62,14 @@ describe("validation", () => { expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/purchases/); }); + test("rejects a non-array recurring field instead of silently importing it as empty (M7)", () => { + // The other four sibling collections already go through requireArray; + // recurring used to fall back to [] instead of being rejected here. + const data = populated() as any; + data.recurring = "not an array"; + expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/recurring/); + }); + test("rejects a purchase whose splits reference an unknown post", () => { const data = populated(); data.purchases[0]!.splits[0]!.postId = "ghost"; @@ -253,3 +261,305 @@ describe("the decimal-places setting on import", () => { expect(out.settings.digits).toBe(2); }); }); + +describe("recurring cost validation", () => { + function withRecurring(recurring: unknown[]): string { + const data: any = createSeedDataset("2026-01"); + data.posts[0].id = "housing"; + data.recurring = recurring; + return JSON.stringify(data); + } + + const valid = { + id: "r1", + name: "Rent", + order: 0, + archived: false, + amount: { amount: 8000, currency: "DKK" }, + startDate: "2026-01", + recurrence: { kind: "everyNMonths", n: 1 }, + anchoring: "calendar", + splitMode: "percent", + splits: [{ postId: "housing", value: 100, absorbsRemainder: true }], + }; + + test("a well-formed cost is accepted", () => { + expect(parseDatasetJson(withRecurring([valid])).recurring.length).toBe(1); + }); + + test("an undefined currency is refused", () => { + expect(() => parseDatasetJson(withRecurring([{ ...valid, amount: { amount: 1, currency: "XYZ" } }]))) + .toThrow(/Unsupported currency/); + }); + + test("a malformed start date is refused", () => { + expect(() => parseDatasetJson(withRecurring([{ ...valid, startDate: "2026-13" }]))) + .toThrow(/start date/); + }); + + test("a malformed endedFrom is refused", () => { + expect(() => parseDatasetJson(withRecurring([{ ...valid, endedFrom: "nope" }]))) + .toThrow(/ended-from date/); + }); + + // Same residual-C1 gap as startDate: PURCHASE_DATE checks shape and digit + // range, not calendar validity, so "2026-02-30" passes it. §10: endedFrom + // carries no granularity rule, but it must still be a real date. + test("a calendar-impossible endedFrom is refused (day- or month-shaped)", () => { + expect(() => parseDatasetJson(withRecurring([{ ...valid, endedFrom: "2026-02-30" }]))) + .toThrow(/calendar-impossible ended-from/); + expect(() => parseDatasetJson(withRecurring([{ ...valid, endedFrom: "2026-02-29" }]))) // not a leap year + .toThrow(/calendar-impossible ended-from/); + }); + + test("a valid endedFrom of either granularity is accepted, regardless of recurrence kind", () => { + expect( + parseDatasetJson(withRecurring([{ ...valid, endedFrom: "2026-06" }])).recurring.length, + ).toBe(1); + expect( + parseDatasetJson( + withRecurring([{ ...valid, endedFrom: "2026-06-17" }]), // month-only recurrence, day-granular end + ).recurring.length, + ).toBe(1); + }); + + test("an interval below 1 is refused, not clamped", () => { + // Clamping would silently change the bill's schedule. The walk in + // occurrences.ts cannot terminate without this. + expect(() => parseDatasetJson(withRecurring([{ ...valid, recurrence: { kind: "everyNDays", n: 0 } }]))) + .toThrow(/at least 1/); + }); + + test("a fractional interval is refused", () => { + expect(() => parseDatasetJson(withRecurring([{ ...valid, recurrence: { kind: "everyNDays", n: 1.5 } }]))) + .toThrow(/whole number/); + }); + + test("everyNMonths accepts either startDate granularity (C1)", () => { + expect(parseDatasetJson(withRecurring([{ ...valid, startDate: "2026-01" }])).recurring.length).toBe(1); + expect(parseDatasetJson(withRecurring([{ ...valid, startDate: "2026-01-15" }])).recurring.length).toBe(1); + }); + + test("everyNDays with a month-only startDate is refused (C1)", () => { + // This is the exact reachable bug: occurrencesOf hands a month-only + // startDate straight to addDays for a day-granular kind and throws deep + // inside the fold. Reject it here instead, at the import boundary. + expect(() => + parseDatasetJson( + withRecurring([{ ...valid, startDate: "2026-01", recurrence: { kind: "everyNDays", n: 30 } }]), + ), + ).toThrow(/month-only start date/); + }); + + test("everyNWeeks with a month-only startDate is refused (C1)", () => { + expect(() => + parseDatasetJson( + withRecurring([ + { ...valid, startDate: "2026-01", recurrence: { kind: "everyNWeeks", n: 1, weekday: 2 } }, + ]), + ), + ).toThrow(/month-only start date/); + }); + + test("everyNDays with a day-granular startDate is accepted (C1)", () => { + expect( + parseDatasetJson( + withRecurring([{ ...valid, startDate: "2026-01-05", recurrence: { kind: "everyNDays", n: 30 } }]), + ).recurring.length, + ).toBe(1); + }); + + // Residual C1: PURCHASE_DATE/DAY_GRANULAR_DATE check shape and range + // ("31" is a valid day-of-month digit pair), not calendar validity. + // September has 30 days, so "2026-09-31" passed both regexes and the + // fold later threw "Invalid day in IsoDate" out of addDays/toDayOrdinal. + test("an impossible day on a day-granular kind is refused, not just shape-checked (residual C1)", () => { + expect(() => + parseDatasetJson( + withRecurring([ + { ...valid, startDate: "2026-02-30", recurrence: { kind: "everyNDays", n: 1 } }, + ]), + ), + ).toThrow(/calendar-impossible/); + }); + + test("an impossible day is refused even under everyNMonths (residual C1)", () => { + expect(() => + parseDatasetJson(withRecurring([{ ...valid, startDate: "2026-09-31" }])), + ).toThrow(/calendar-impossible/); + }); + + test("leap-year and non-leap-year Februaries are accepted (residual C1)", () => { + expect( + parseDatasetJson( + withRecurring([ + { ...valid, startDate: "2024-02-29", recurrence: { kind: "everyNDays", n: 1 } }, // 2024 is a leap year + ]), + ).recurring.length, + ).toBe(1); + expect( + parseDatasetJson( + withRecurring([ + { ...valid, startDate: "2026-02-28", recurrence: { kind: "everyNDays", n: 1 } }, // 2026 is not + ]), + ).recurring.length, + ).toBe(1); + }); + + test("an unknown recurrence kind is refused", () => { + expect(() => parseDatasetJson(withRecurring([{ ...valid, recurrence: { kind: "everyFullMoon", n: 1 } }]))) + .toThrow(/unknown kind/); + }); + + test("a weekday outside 0-6 is refused", () => { + expect(() => + parseDatasetJson(withRecurring([{ ...valid, recurrence: { kind: "everyNWeeks", n: 1, weekday: 7 } }])), + ).toThrow(/weekday/); + }); + + test("an unknown anchoring is refused", () => { + expect(() => parseDatasetJson(withRecurring([{ ...valid, anchoring: "whenever" }]))) + .toThrow(/anchoring/); + }); + + test("splits must have exactly one remainder absorber", () => { + expect(() => + parseDatasetJson(withRecurring([{ + ...valid, + splits: [ + { postId: "housing", value: 50, absorbsRemainder: true }, + { postId: "housing", value: 50, absorbsRemainder: true }, + ], + }])), + ).toThrow(/absorbsRemainder/); + }); + + test("a split naming an unknown post is refused", () => { + expect(() => + parseDatasetJson(withRecurring([{ + ...valid, + splits: [{ postId: "ghost", value: 100, absorbsRemainder: true }], + }])), + ).toThrow(/unknown post/); + }); + + test("no splits at all is refused", () => { + expect(() => parseDatasetJson(withRecurring([{ ...valid, splits: [] }]))) + .toThrow(/no splits/); + }); + + test("a null recurring cost entry is refused, not a raw TypeError", () => { + // `recurring: [null]` used to reach `cost.id`/`cost.name` unguarded and + // throw TypeError instead of the file's own ImportValidationError. + expect(() => parseDatasetJson(withRecurring([null]))).toThrow(ImportValidationError); + }); + + test("an unknown splitMode is refused", () => { + // charges.ts picks distributeByWeight/distributeByAmount on + // `splitMode === "percent"`; any other string silently takes the "fixed" + // branch and treats a percentage as an absolute amount. + expect(() => parseDatasetJson(withRecurring([{ ...valid, splitMode: "ratio" }]))) + .toThrow(/splitMode/); + }); + + test("a non-numeric split value is refused", () => { + // A non-finite value would otherwise propagate as NaN through the fold, + // and roundMoney(NaN, digits) returns NaN rather than throwing. + expect(() => + parseDatasetJson(withRecurring([{ + ...valid, + splits: [{ postId: "housing", value: "oops", absorbsRemainder: true }], + }])), + ).toThrow(/finite number/); + }); + + test("a negative split value is still accepted", () => { + // Deliberately unchecked per AGENTS.md §3: a refund is a normal line. + // Only non-numbers are rejected, never a negative or over-100 value. + expect(() => + parseDatasetJson(withRecurring([{ + ...valid, + splits: [{ postId: "housing", value: -50, absorbsRemainder: true }], + }])), + ).not.toThrow(); + }); +}); + +describe("Purchase.source validation", () => { + function withSource(source: unknown): string { + const data: any = createSeedDataset("2026-01"); + data.posts[0].id = "housing"; + data.recurring = []; + data.purchases = [{ + id: "p1", + date: "2026-01", + description: "Rent", + total: { amount: 8000, currency: "DKK" }, + splitMode: "percent", + splits: [{ postId: "housing", value: 100, absorbsRemainder: true }], + schedule: null, + source, + }]; + return JSON.stringify(data); + } + + test("a purchase with no source is fine", () => { + const data: any = createSeedDataset("2026-01"); + data.recurring = []; + expect(() => parseDatasetJson(JSON.stringify(data))).not.toThrow(); + }); + + test("a source naming an unknown cost is refused", () => { + expect(() => parseDatasetJson(withSource({ recurringId: "ghost", occurrenceDate: "2026-01" }))) + .toThrow(/unknown recurring cost/); + }); + + test("a source with a malformed occurrence date is refused", () => { + const data: any = JSON.parse(withSource({ recurringId: "r1", occurrenceDate: "nope" })); + data.recurring = [{ + id: "r1", name: "Rent", order: 0, archived: false, + amount: { amount: 8000, currency: "DKK" }, + startDate: "2026-01", + recurrence: { kind: "everyNMonths", n: 1 }, + anchoring: "calendar", + splitMode: "percent", + splits: [{ postId: "housing", value: 100, absorbsRemainder: true }], + }]; + expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/occurrence date/); + }); + + test("a source of null is refused, not a raw TypeError", () => { + // `source: null` used to reach `null.recurringId` unguarded — a plausible + // hand-edit, since the sibling field on the same object literal IS + // `schedule: null`. `source` is typed absent-or-object, never null, so + // this must fail as ImportValidationError, not TypeError. + expect(() => parseDatasetJson(withSource(null))).toThrow(ImportValidationError); + }); +}); + +describe("purchase splits validation", () => { + // I1/I2 apply identically to purchases: the shared requireSplits() helper + // must not check purchases to a lower standard than recurring costs. + test("an unknown splitMode on a purchase is refused", () => { + const data: any = populated(); + data.purchases[0].splitMode = "ratio"; + expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/splitMode/); + }); + + test("a non-numeric split value on a purchase is refused", () => { + const data: any = populated(); + data.purchases[0].splits[0].value = "oops"; + expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/finite number/); + }); + + test("a negative split value on a purchase is still accepted", () => { + const data: any = populated(); + data.purchases[0].splits[0].value = -70; + expect(() => parseDatasetJson(JSON.stringify(data))).not.toThrow(); + }); + + test("a well-formed purchase still round-trips through the shared helper", () => { + const data = populated(); + expect(parseDatasetJson(exportDatasetJson(data)).purchases.length).toBe(1); + }); +}); diff --git a/src/export/json.ts b/src/export/json.ts index 6158d19..7ae743d 100644 --- a/src/export/json.ts +++ b/src/export/json.ts @@ -1,5 +1,7 @@ import type { Currency, CurrencyDef, Dataset, MonthId } from "../domain/types.ts"; import { migrate } from "../store/migrations.ts"; +import { monthOf } from "../domain/months.ts"; +import { toDayOrdinal } from "../domain/days.ts"; export class ImportValidationError extends Error { constructor(message: string) { @@ -35,6 +37,18 @@ const MONTH_ID = /^\d{4}-(0[1-9]|1[0-2])$/; * throwing out of `monthOf()` deep inside the balance fold. */ const PURCHASE_DATE = /^\d{4}-(0[1-9]|1[0-2])(-(0[1-9]|[12]\d|3[01]))?$/; +/** + * The day-granular subset of `PURCHASE_DATE`: "YYYY-MM-DD" only, no month-only + * form. `everyNDays` and `everyNWeeks` hand `startDate` straight to `addDays` + * (`src/domain/occurrences.ts`), which throws on a month-only date — so those + * kinds need this narrower shape, checked here rather than three months later + * inside a fold. + */ +const DAY_GRANULAR_DATE = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/; + +const ANCHORINGS = new Set(["calendar", "lastCharge"]); +const RECURRENCE_KINDS = new Set(["everyNMonths", "everyNDays", "everyNWeeks"]); +const SPLIT_MODES = new Set(["percent", "fixed"]); /** * `Currency` is an open string now, so this — not the type system — is what @@ -118,11 +132,107 @@ function requireArray(data: Record, key: string): unknown[] { return value; } +/** + * `n` is checked hard rather than clamped. The projection walk in + * `domain/occurrences.ts` terminates only if every step strictly advances, and + * a clamped zero would silently give the owner a schedule their file does not + * describe. Rejecting tells them the file is wrong; clamping tells them + * nothing and quietly changes a bill. + */ +function requireRecurrence(recurrence: any, label: string): void { + if (!recurrence || !RECURRENCE_KINDS.has(recurrence.kind)) { + throw new ImportValidationError( + `${label} has a recurrence of unknown kind "${String(recurrence?.kind)}"`, + ); + } + if (typeof recurrence.n !== "number" || !Number.isInteger(recurrence.n)) { + throw new ImportValidationError( + `${label} has a recurrence interval that is not a whole number: ${String(recurrence.n)}`, + ); + } + if (recurrence.n < 1) { + throw new ImportValidationError( + `${label} has a recurrence interval of ${recurrence.n}; it must be at least 1`, + ); + } + if (recurrence.kind === "everyNWeeks") { + const { weekday } = recurrence; + if (typeof weekday !== "number" || !Number.isInteger(weekday) || weekday < 0 || weekday > 6) { + throw new ImportValidationError( + `${label} has a weekday of ${String(weekday)}; it must be 0-6, where 0 is Sunday`, + ); + } + } +} + +/** + * Shared by the purchases loop and the recurring-costs loop below — the only + * two places a `splitMode`/`splits` pair occurs, and both feed the identical + * `distributeByWeight`/`distributeByAmount` branch in `domain/charges.ts`. + * One helper means the two write paths cannot silently drift into checking + * to different standards; the two loops used to each carry their own copy of + * the "no splits" / "one absorber" / "unknown post" checks, so this replaces + * both rather than adding a third copy. + * + * `splitMode` must be one of the two known modes: `charges.ts` picks its + * distribution function with `splitMode === "percent" ? ... : ...`, so any + * OTHER string silently takes the "fixed" branch and treats an intended + * percentage as an absolute amount — a silently wrong number, not a throw. + * + * Every split's `value` must be a finite number. A non-finite value (missing, + * a string, NaN, Infinity) would otherwise propagate through + * `distributeByWeight`/`distributeByAmount` as NaN, and `roundMoney(NaN, + * digits)` returns NaN rather than throwing — it would corrupt a total + * silently, deep inside the fold. + * + * NEGATIVE values, values over 100, and splits that do not sum to the total + * are all deliberately UNCHECKED — AGENTS.md lists these as intentional + * (a refund is a normal line, and the absorbing split reconciles the sum by + * design). Only non-numbers are rejected here. + */ +function requireSplits( + splits: unknown, + splitMode: unknown, + label: string, + postIds: ReadonlySet, +): void { + if (!SPLIT_MODES.has(splitMode as string)) { + throw new ImportValidationError( + `${label} has an unknown splitMode "${String(splitMode)}"`, + ); + } + if (!Array.isArray(splits) || splits.length === 0) { + throw new ImportValidationError(`${label} has no splits; at least one split is required`); + } + const absorbers = (splits as Array>).filter( + (s) => s?.absorbsRemainder, + ).length; + if (absorbers !== 1) { + throw new ImportValidationError( + `${label} has ${absorbers} splits flagged absorbsRemainder; exactly one is required`, + ); + } + for (const split of splits as Array>) { + if (!postIds.has(split?.postId as string)) { + throw new ImportValidationError(`${label} references unknown post "${String(split?.postId)}"`); + } + if (typeof split?.value !== "number" || !Number.isFinite(split.value)) { + throw new ImportValidationError( + `${label} has a split value that is not a finite number: ${String(split?.value)}`, + ); + } + } +} + /** * Validates far enough that the fold cannot throw on the imported data: every * split points at a real post, every purchase has exactly one remainder - * absorber, every MonthId parses, and every rule version resolves - * unambiguously to a number rather than to NaN or a missing rate. + * absorber, every MonthId parses, every rule version resolves unambiguously + * to a number rather than to NaN or a missing rate, and every recurring + * cost's startDate is both granular enough for its recurrence kind + * (day-granular for everyNDays/everyNWeeks, since occurrencesOf hands it + * straight to addDays) and calendar-valid (no "2026-09-31"), since shape and + * range alone let an impossible date through. */ export function parseDatasetJson(text: string): Dataset { let raw: unknown; @@ -210,6 +320,75 @@ export function parseDatasetJson(text: string): Dataset { } } + const recurring = requireArray(data, "recurring") as any[]; + dataset.recurring = recurring; + // Optional chaining, not `.id` directly: a null/non-object entry must fall + // through to the loop below and fail there with an ImportValidationError, + // not throw a raw TypeError here while merely collecting ids. + const recurringIds = new Set(recurring.map((cost: any) => cost?.id)); + + for (const cost of recurring) { + if (!cost || typeof cost !== "object") { + throw new ImportValidationError( + `A recurring cost is missing or not an object: ${JSON.stringify(cost)}`, + ); + } + const label = `Recurring cost "${cost.name}"`; + requireCurrency(cost.amount?.currency, label, defined); + + if (!PURCHASE_DATE.test(cost.startDate)) { + throw new ImportValidationError(`${label} has an invalid start date "${cost.startDate}"`); + } + if (cost.endedFrom !== undefined && !PURCHASE_DATE.test(cost.endedFrom)) { + throw new ImportValidationError(`${label} has an invalid ended-from date "${cost.endedFrom}"`); + } + requireRecurrence(cost.recurrence, label); + // `everyNMonths` walks through `monthOf`, which accepts either + // granularity; the other two kinds reach `addDays` and need a + // day-granular startDate specifically. `endedFrom` needs no such check — + // it is only ever compared lexicographically, never fed to `addDays`. + const dayGranular = DAY_GRANULAR_DATE.test(cost.startDate); + if (cost.recurrence.kind !== "everyNMonths" && !dayGranular) { + throw new ImportValidationError( + `${label} has a "${cost.recurrence.kind}" recurrence but a month-only start date "${cost.startDate}"; it needs "YYYY-MM-DD"`, + ); + } + // Shape and range (checked above by PURCHASE_DATE/DAY_GRANULAR_DATE) still + // let a calendar-impossible date through, e.g. "2026-09-31" (September has + // 30 days) or "2026-02-30". Route it through the same domain parsers the + // fold itself uses, so an impossible date is refused here instead of + // throwing three months later out of `foldBalances`. + try { + if (dayGranular) toDayOrdinal(cost.startDate); + else monthOf(cost.startDate); + } catch (error) { + throw new ImportValidationError( + `${label} has a calendar-impossible start date "${cost.startDate}" (${(error as Error).message})`, + ); + } + + // `endedFrom` carries no granularity rule — checked above by not + // requiring it to match the recurrence kind's day-granularity — but it + // still has to be a real calendar date, the same way `startDate` does. + if (cost.endedFrom !== undefined) { + try { + if (DAY_GRANULAR_DATE.test(cost.endedFrom)) toDayOrdinal(cost.endedFrom); + else monthOf(cost.endedFrom); + } catch (error) { + throw new ImportValidationError( + `${label} has a calendar-impossible ended-from date "${cost.endedFrom}" (${(error as Error).message})`, + ); + } + } + + if (!ANCHORINGS.has(cost.anchoring)) { + throw new ImportValidationError( + `${label} has an unknown anchoring "${String(cost.anchoring)}"`, + ); + } + requireSplits(cost.splits, cost.splitMode, label, postIds); + } + for (const month of dataset.months) { if (!MONTH_ID.test(month.id)) { throw new ImportValidationError(`Invalid month id "${month.id}"`); @@ -224,28 +403,37 @@ export function parseDatasetJson(text: string): Dataset { if (!PURCHASE_DATE.test(purchase.date)) { throw new ImportValidationError(`${label} has an invalid date "${purchase.date}"`); } - if (purchase.splits.length === 0) { - throw new ImportValidationError(`${label} has no splits; at least one split is required`); - } - const absorbers = purchase.splits.filter((s) => s.absorbsRemainder).length; - if (absorbers !== 1) { - throw new ImportValidationError( - `${label} has ${absorbers} splits flagged absorbsRemainder; exactly one is required`, - ); - } - for (const split of purchase.splits) { - if (!postIds.has(split.postId)) { - throw new ImportValidationError( - `${label} references unknown post "${split.postId}"`, - ); - } - } + requireSplits(purchase.splits, purchase.splitMode, label, postIds); for (const slice of purchase.schedule?.slices ?? []) { if (!MONTH_ID.test(slice.month)) { throw new ImportValidationError(`${label} has invalid slice month "${slice.month}"`); } requireCurrency(slice.amount.currency, `${label} slice ${slice.month}`, defined); } + + if (purchase.source !== undefined) { + // `null` is not `undefined`, so it reaches here without being "no + // source". The field is typed as absent-or-object, never null, so a + // null (or any other non-object) is malformed input, not a missing + // field — reject it cleanly rather than dereferencing it into a raw + // TypeError. `schedule?.slices ?? []` two blocks up is the same + // defensive style applied to a sibling field that IS legitimately null. + if (!purchase.source || typeof purchase.source !== "object") { + throw new ImportValidationError( + `${label} has a source that is not an object: ${JSON.stringify(purchase.source)}`, + ); + } + if (!recurringIds.has(purchase.source.recurringId)) { + throw new ImportValidationError( + `${label} names unknown recurring cost "${purchase.source.recurringId}"`, + ); + } + if (!PURCHASE_DATE.test(purchase.source.occurrenceDate)) { + throw new ImportValidationError( + `${label} has an invalid occurrence date "${purchase.source.occurrenceDate}"`, + ); + } + } } return dataset; diff --git a/src/store/actions.test.ts b/src/store/actions.test.ts index 3bc7c1b..aef132a 100644 --- a/src/store/actions.test.ts +++ b/src/store/actions.test.ts @@ -1,6 +1,7 @@ import { test, expect, describe } from "bun:test"; import * as actions from "./actions.ts"; import { createSeedDataset } from "../domain/seed.ts"; +import { occurrencesByMonth } from "../domain/occurrences.ts"; import type { Dataset } from "../domain/types.ts"; /** @@ -453,3 +454,468 @@ describe("split values are rounded by what they MEAN", () => { }); }); }); + +describe("recurring costs", () => { + function draft(): Dataset { + const data = createSeedDataset("2026-01"); + data.posts[0]!.id = "housing"; + return data; + } + + const rentInput = { + name: "Rent", + archived: false, + amount: { amount: 8000, currency: "DKK" }, + startDate: "2026-01", + recurrence: { kind: "everyNMonths" as const, n: 1 }, + anchoring: "calendar" as const, + splitMode: "percent" as const, + splits: [{ postId: "housing", value: 100, absorbsRemainder: true }], + }; + + test("adding assigns an id and an order", () => { + const data = draft(); + const first = actions.addRecurringCost(data, rentInput); + const second = actions.addRecurringCost(data, { ...rentInput, name: "Phone" }); + + expect(first.id).not.toBe(second.id); + expect(first.order).toBe(0); + expect(second.order).toBe(1); + expect(data.recurring.length).toBe(2); + }); + + test("the amount is rounded to the dataset's digits before it lands", () => { + const data = draft(); + data.settings.digits = 0; + const cost = actions.addRecurringCost(data, { ...rentInput, amount: { amount: 8000.7, currency: "DKK" } }); + expect(cost.amount.amount).toBe(8001); + }); + + test("a recurrence with n below 1 is refused", () => { + // The projection walk terminates only if every step advances, and the + // importer is not the only write path. + const data = draft(); + expect(() => actions.addRecurringCost(data, { ...rentInput, recurrence: { kind: "everyNDays", n: 0 } })) + .toThrow(/at least 1/); + expect(() => actions.addRecurringCost(data, { ...rentInput, recurrence: { kind: "everyNDays", n: 1.5 } })) + .toThrow(/whole number/); + }); + + test("a weekday outside 0-6 is refused", () => { + const data = draft(); + expect(() => + actions.addRecurringCost(data, { ...rentInput, recurrence: { kind: "everyNWeeks", n: 1, weekday: 7 } }), + ).toThrow(/weekday/); + }); + + test("updating validates the new recurrence too", () => { + const data = draft(); + const cost = actions.addRecurringCost(data, rentInput); + expect(() => + actions.updateRecurringCost(data, cost.id, { recurrence: { kind: "everyNDays", n: 0 } }), + ).toThrow(/at least 1/); + }); + + describe("startDate granularity (C1)", () => { + test("adding a day-granular kind with a month-only startDate is refused", () => { + const data = draft(); + expect(() => + actions.addRecurringCost(data, { + ...rentInput, + startDate: "2026-01", + recurrence: { kind: "everyNDays", n: 30 }, + }), + ).toThrow(/day-granular/); + expect(() => + actions.addRecurringCost(data, { + ...rentInput, + startDate: "2026-01", + recurrence: { kind: "everyNWeeks", n: 1, weekday: 1 }, + }), + ).toThrow(/day-granular/); + }); + + test("adding a day-granular kind with a day-granular startDate succeeds", () => { + const data = draft(); + const cost = actions.addRecurringCost(data, { + ...rentInput, + startDate: "2026-01-05", + recurrence: { kind: "everyNDays", n: 30 }, + }); + expect(cost.startDate).toBe("2026-01-05"); + }); + + test("everyNMonths accepts either granularity", () => { + const data = draft(); + expect(() => actions.addRecurringCost(data, { ...rentInput, startDate: "2026-01" })).not.toThrow(); + expect(() => + actions.addRecurringCost(data, { ...rentInput, startDate: "2026-01-15" }), + ).not.toThrow(); + }); + + // This is the exact bug: a monthly cost is created (startDate "2026-09"), + // then the unit dropdown switches it to a day-granular kind WITHOUT + // touching startDate — the only way a real edit reaches this, since the + // add form always starts as everyNMonths. + test("updating just the recurrence kind against an untouched month-only startDate is refused", () => { + const data = draft(); + const cost = actions.addRecurringCost(data, { ...rentInput, startDate: "2026-09" }); + expect(() => + actions.updateRecurringCost(data, cost.id, { recurrence: { kind: "everyNDays", n: 30 } }), + ).toThrow(/day-granular/); + expect(() => + actions.updateRecurringCost(data, cost.id, { + recurrence: { kind: "everyNWeeks", n: 1, weekday: 3 }, + }), + ).toThrow(/day-granular/); + // Refused, so nothing committed — still the original month-granular kind. + expect(data.recurring[0]!.recurrence.kind).toBe("everyNMonths"); + }); + + test("updating just startDate to a month-only value against a stored day-granular kind is refused", () => { + const data = draft(); + const cost = actions.addRecurringCost(data, { + ...rentInput, + startDate: "2026-01-05", + recurrence: { kind: "everyNDays", n: 30 }, + }); + expect(() => actions.updateRecurringCost(data, cost.id, { startDate: "2026-01" })).toThrow( + /day-granular/, + ); + }); + + test("updating recurrence and startDate together to a consistent day-granular pair succeeds", () => { + const data = draft(); + const cost = actions.addRecurringCost(data, { ...rentInput, startDate: "2026-09" }); + actions.updateRecurringCost(data, cost.id, { + recurrence: { kind: "everyNDays", n: 30 }, + startDate: "2026-09-01", + }); + expect(data.recurring[0]!.startDate).toBe("2026-09-01"); + expect(data.recurring[0]!.recurrence.kind).toBe("everyNDays"); + }); + + // Residual C1: shape alone ("YYYY-MM-DD") is not calendar validity. + // September has 30 days, so "2026-09-31" is day-SHAPED but impossible. + // `requireStartDateGranularity` used a shape-only regex and let this + // through even under everyNMonths; downstream, foldBalances throws + // "Invalid day in IsoDate" out of addDays/toDayOrdinal. + test("a calendar-impossible startDate is refused, even under everyNMonths", () => { + const data = draft(); + expect(() => + actions.addRecurringCost(data, { ...rentInput, startDate: "2026-09-31" }), + ).toThrow(/2026-09-31/); + + const cost = actions.addRecurringCost(data, rentInput); + expect(() => + actions.updateRecurringCost(data, cost.id, { startDate: "2026-09-31" }), + ).toThrow(/2026-09-31/); + // Refused, so nothing committed beyond the one successful add above. + expect(data.recurring.length).toBe(1); + expect(data.recurring[0]!.startDate).toBe(rentInput.startDate); + }); + + // Second half of the same reproduction: even if a calendar-impossible + // startDate is already sitting in the draft (e.g. from data written + // before this fix existed), switching the recurrence kind must not wave + // it through — requireStartDateGranularity re-validates on every update, + // not only ones that touch startDate. + test("switching kind against an already-impossible stored startDate is refused", () => { + const data = draft(); + const cost = actions.addRecurringCost(data, rentInput); + // Bypass the public API to simulate a bad value already on disk. + data.recurring[0]!.startDate = "2026-09-31"; + expect(() => + actions.updateRecurringCost(data, cost.id, { recurrence: { kind: "everyNDays", n: 7 } }), + ).toThrow(/2026-09-31/); + expect(data.recurring[0]!.recurrence.kind).toBe("everyNMonths"); + }); + + // Residual C1: monthOf's regex used to be unanchored (`/^(\d{4})-(\d{2})/` + // with no trailing `$`), so it matched a "YYYY-MM" PREFIX of a longer, + // malformed string and silently dropped the rest. + test("an unanchored-month-shaped garbage value is refused", () => { + const data = draft(); + expect(() => + actions.addRecurringCost(data, { ...rentInput, startDate: "2026-091" }), + ).toThrow(/2026-091/); + + const cost = actions.addRecurringCost(data, rentInput); + expect(() => + actions.updateRecurringCost(data, cost.id, { startDate: "2026-091" }), + ).toThrow(/2026-091/); + }); + + test("legitimate calendar-valid dates still pass", () => { + const data = draft(); + expect(() => actions.addRecurringCost(data, { ...rentInput, startDate: "2026-09" })).not.toThrow(); + expect(() => + actions.addRecurringCost(data, { + ...rentInput, + startDate: "2024-02-29", // 2024 is a leap year + recurrence: { kind: "everyNDays", n: 1 }, + }), + ).not.toThrow(); + expect(() => + actions.addRecurringCost(data, { + ...rentInput, + startDate: "2026-02-28", // 2026 is not a leap year + recurrence: { kind: "everyNDays", n: 1 }, + }), + ).not.toThrow(); + }); + }); + + test("ending sets both endedFrom and archived", () => { + const data = draft(); + const cost = actions.addRecurringCost(data, rentInput); + actions.endRecurringCost(data, cost.id, "2026-06"); + + expect(data.recurring[0]!.endedFrom).toBe("2026-06"); + expect(data.recurring[0]!.archived).toBe(true); + }); + + test("restoring clears both", () => { + const data = draft(); + const cost = actions.addRecurringCost(data, rentInput); + actions.endRecurringCost(data, cost.id, "2026-06"); + actions.restoreRecurringCost(data, cost.id); + + expect(data.recurring[0]!.endedFrom).toBeUndefined(); + expect(data.recurring[0]!.archived).toBe(false); + }); + + describe("setRecurringCostEndedFrom", () => { + test("setting a date ends AND archives, whether it is past or future", () => { + const data = draft(); + const cost = actions.addRecurringCost(data, rentInput); + + actions.setRecurringCostEndedFrom(data, cost.id, "2020-01"); // past + expect(data.recurring[0]!.endedFrom).toBe("2020-01"); + expect(data.recurring[0]!.archived).toBe(true); + + actions.setRecurringCostEndedFrom(data, cost.id, "2099-06"); // future + expect(data.recurring[0]!.endedFrom).toBe("2099-06"); + expect(data.recurring[0]!.archived).toBe(true); + }); + + test("clearing (undefined) un-ends AND un-archives — the column doubles as un-end", () => { + const data = draft(); + const cost = actions.addRecurringCost(data, rentInput); + actions.setRecurringCostEndedFrom(data, cost.id, "2026-06"); + + actions.setRecurringCostEndedFrom(data, cost.id, undefined); + expect(data.recurring[0]!.endedFrom).toBeUndefined(); + expect(data.recurring[0]!.archived).toBe(false); + }); + + test("a past or future endedFrom set through the store action takes effect in the projection", () => { + // Integration check for the "Ends" column: the store write and the + // domain walk agree, for both directions — a backdated cancellation + // (import-only before this change) and a forward-dated one. + const past = draft(); + const pastCost = actions.addRecurringCost(past, rentInput); // starts 2026-01 + actions.setRecurringCostEndedFrom(past, pastCost.id, "2026-03"); // backdated + const pastOccurrences = occurrencesByMonth(past, "2026-06").get("2026-01") ?? []; + expect(pastOccurrences.length).toBe(1); // Jan still occurred + expect(occurrencesByMonth(past, "2026-06").has("2026-03")).toBe(false); // Mar+ stopped + + const future = draft(); + const futureCost = actions.addRecurringCost(future, rentInput); + actions.setRecurringCostEndedFrom(future, futureCost.id, "2026-08"); // forward-dated + const futureByMonth = occurrencesByMonth(future, "2026-09"); + expect(futureByMonth.has("2026-07")).toBe(true); // still occurs right up to the cutoff + expect(futureByMonth.has("2026-08")).toBe(false); // stopped from the cutoff onward + }); + + test("endRecurringCost and restoreRecurringCost now delegate here, so all three stay coherent", () => { + const data = draft(); + const cost = actions.addRecurringCost(data, rentInput); + + actions.endRecurringCost(data, cost.id, "2026-06"); + expect(data.recurring[0]!.archived).toBe(true); + + actions.setRecurringCostEndedFrom(data, cost.id, undefined); + expect(data.recurring[0]!.archived).toBe(false); + + actions.setRecurringCostEndedFrom(data, cost.id, "2027-01"); + expect(data.recurring[0]!.archived).toBe(true); + + actions.restoreRecurringCost(data, cost.id); + expect(data.recurring[0]!.endedFrom).toBeUndefined(); + expect(data.recurring[0]!.archived).toBe(false); + }); + }); + + test("moving swaps order with its neighbour", () => { + const data = draft(); + const first = actions.addRecurringCost(data, rentInput); + const second = actions.addRecurringCost(data, { ...rentInput, name: "Phone" }); + actions.moveRecurringCost(data, second.id, -1); + + expect(data.recurring.find((c) => c.id === second.id)!.order).toBe(0); + expect(data.recurring.find((c) => c.id === first.id)!.order).toBe(1); + }); +}); + +describe("confirmOccurrence", () => { + function draft(): Dataset { + const data = createSeedDataset("2026-01"); + data.posts[0]!.id = "housing"; + actions.addRecurringCost(data, { + name: "Rent", + archived: false, + amount: { amount: 8000, currency: "DKK" }, + startDate: "2026-01", + recurrence: { kind: "everyNMonths", n: 1 }, + anchoring: "calendar", + splitMode: "percent", + splits: [{ postId: "housing", value: 100, absorbsRemainder: true }], + }); + return data; + } + + test("writes an ordinary purchase carrying the slot it claims", () => { + const data = draft(); + const purchase = actions.confirmOccurrence(data, data.recurring[0]!.id, "2026-03"); + + expect(purchase.source).toEqual({ recurringId: data.recurring[0]!.id, occurrenceDate: "2026-03" }); + expect(purchase.date).toBe("2026-03"); + expect(purchase.total).toEqual({ amount: 8000, currency: "DKK" }); + expect(purchase.description).toBe("Rent"); + expect(purchase.schedule).toBeNull(); + expect(data.purchases.length).toBe(1); + }); + + test("the purchase date may differ from the slot — this is the data-cap case", () => { + const data = draft(); + const purchase = actions.confirmOccurrence(data, data.recurring[0]!.id, "2026-03", { date: "2026-02-12" }); + + expect(purchase.source!.occurrenceDate).toBe("2026-03"); + expect(purchase.date).toBe("2026-02-12"); + }); + + test("an overridden amount is what gets recorded", () => { + const data = draft(); + const purchase = actions.confirmOccurrence(data, data.recurring[0]!.id, "2026-03", { + amount: { amount: 8250, currency: "DKK" }, + }); + expect(purchase.total.amount).toBe(8250); + }); + + test("the month the purchase lands in is created, so income can be entered", () => { + const data = draft(); + actions.confirmOccurrence(data, data.recurring[0]!.id, "2026-05"); + expect(data.months.some((m) => m.id === "2026-05")).toBe(true); + }); + + test("splits are copied, not shared with the cost", () => { + const data = draft(); + const purchase = actions.confirmOccurrence(data, data.recurring[0]!.id, "2026-03"); + purchase.splits[0]!.value = 50; + expect(data.recurring[0]!.splits[0]!.value).toBe(100); + }); + + test("an unknown cost throws", () => { + expect(() => actions.confirmOccurrence(draft(), "nope", "2026-03")).toThrow(/Unknown recurring cost/); + }); +}); + +/** + * Confirming a slot writes an ORDINARY purchase, and the ordinary purchase + * dialog can then edit its date — an action with nothing on its face to do + * with recurring costs. Under `lastCharge` anchoring that date is what the + * walk measures the next occurrence from, so moving it back far enough makes + * the walk step backwards and throw out of `foldBalances`, taking every month + * route, the year view and the summary down together. The write is refused + * here so that state cannot be reached. + */ +describe("editing a confirmation's date", () => { + function carDraft(anchoring: "calendar" | "lastCharge" = "lastCharge"): Dataset { + const data = createSeedDataset("2026-08"); + data.posts[0]!.id = "housing"; + actions.addRecurringCost(data, { + name: "Car insurance", + archived: false, + amount: { amount: 900, currency: "DKK" }, + startDate: "2026-08-14", + recurrence: { kind: "everyNDays", n: 30 }, + anchoring, + splitMode: "percent", + splits: [{ postId: "housing", value: 100, absorbsRemainder: true }], + }); + return data; + } + + /** The walk every month route, the year view and the summary go through. */ + function folds(data: Dataset): () => unknown { + return () => occurrencesByMonth(data, "2026-12"); + } + + function dateOf(data: Dataset, purchaseId: string): string { + return data.purchases.find((p) => p.id === purchaseId)!.date; + } + + test("a date more than one cycle before the slot it claims is refused", () => { + const data = carDraft(); + const purchase = actions.confirmOccurrence(data, data.recurring[0]!.id, "2026-09-13", { + date: "2026-09-13", + }); + expect(folds(data)).not.toThrow(); + + expect(() => actions.updatePurchase(data, purchase.id, { date: "2026-08-01" })).toThrow( + /Car insurance/, + ); + expect(dateOf(data, purchase.id)).toBe("2026-09-13"); + expect(folds(data)).not.toThrow(); + }); + + test("a date that keeps the series advancing is still allowed", () => { + const data = carDraft(); + const purchase = actions.confirmOccurrence(data, data.recurring[0]!.id, "2026-09-13", { + date: "2026-09-13", + }); + + actions.updatePurchase(data, purchase.id, { date: "2026-09-08" }); + expect(dateOf(data, purchase.id)).toBe("2026-09-08"); + expect(folds(data)).not.toThrow(); + }); + + test("a purchase with no slot is an ordinary purchase and moves freely", () => { + const data = carDraft(); + const purchase = actions.addPurchase(data, { + date: "2026-09-13", + description: "Sofa", + total: { amount: 100, currency: "DKK" }, + splitMode: "percent", + splits: [{ postId: "housing", value: 100, absorbsRemainder: true }], + schedule: null, + }); + + actions.updatePurchase(data, purchase.id, { date: "2026-01-01" }); + expect(dateOf(data, purchase.id)).toBe("2026-01-01"); + expect(folds(data)).not.toThrow(); + }); + + test("a calendar-anchored cost's confirmation moves freely, because it never rebases", () => { + const data = carDraft("calendar"); + const purchase = actions.confirmOccurrence(data, data.recurring[0]!.id, "2026-09-13", { + date: "2026-09-13", + }); + + actions.updatePurchase(data, purchase.id, { date: "2026-08-01" }); + expect(dateOf(data, purchase.id)).toBe("2026-08-01"); + expect(folds(data)).not.toThrow(); + }); + + test("a slot naming a cost that no longer exists does not block the edit", () => { + const data = carDraft(); + const purchase = actions.confirmOccurrence(data, data.recurring[0]!.id, "2026-09-13", { + date: "2026-09-13", + }); + data.recurring = []; + + actions.updatePurchase(data, purchase.id, { date: "2026-08-01" }); + expect(dateOf(data, purchase.id)).toBe("2026-08-01"); + }); +}); diff --git a/src/store/actions.ts b/src/store/actions.ts index 8f834e4..59093ac 100755 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -1,12 +1,15 @@ import { newId } from "../domain/seed.ts"; import { compareMonths, monthOf } from "../domain/months.ts"; +import { toDayOrdinal } from "../domain/days.ts"; import { roundMoney } from "../domain/money.ts"; import { currencyUsage, normalizeCurrencyCode } from "../domain/currencies.ts"; +import { wouldAdvancePast } from "../domain/occurrences.ts"; import type { Currency, CurrencyDef, Dataset, FxRate, + IsoDate, Money, Month, MonthId, @@ -14,6 +17,9 @@ import type { PostId, Purchase, PurchaseId, + Recurrence, + RecurringCost, + RecurringCostId, Rule, RuleVersion, Schedule, @@ -215,12 +221,58 @@ export function addPurchase( return created; } +/** + * A confirmation's `date` is TRUTH, and under `lastCharge` anchoring it is + * what `occurrencesOf` measures the NEXT occurrence from. Moved back far + * enough, the step from it lands at or before the slot the purchase claims, + * and the walk throws its "did not advance" error — out of `foldBalances`, so + * every month route, the year view and the summary go down at once, from an + * edit made in the ordinary purchase dialog that never mentioned recurring + * costs. Refusing the write is the only place that state can be prevented: + * the walk's throw is correct and must not be softened, and by the time it + * fires the bad data is already saved. + * + * `wouldAdvancePast` answers exactly this question and is what `ExpectedBand` + * asks before OFFERING a row; both must agree with the walk, so neither + * re-derives the condition. + * + * Two deliberate pass-throughs. A purchase with no slot is an ordinary + * purchase and is not checked at all — this is the hot path for every + * ordinary edit. A slot naming a cost that no longer exists is not checked + * either: a dangling source is a separate concern, and refusing here would + * only strand the purchase. + */ +function requireConfirmationAdvances( + draft: Dataset, + purchase: Purchase, + source: Purchase["source"], + date: IsoDate, +): void { + if (!source) return; + const cost = draft.recurring.find((c) => c.id === source.recurringId); + if (!cost) return; + if (wouldAdvancePast(cost, source.occurrenceDate, date)) return; + throw new Error( + `Purchase ${purchase.id} confirms "${cost.name}" for ${source.occurrenceDate}, and ` + + `dating it ${date} would not move the series past that occurrence — ` + + `pick a later date, or delete the purchase to un-confirm the occurrence`, + ); +} + export function updatePurchase( draft: Dataset, purchaseId: PurchaseId, changes: Partial>, ): void { const purchase = requirePurchase(draft, purchaseId); + if (changes.date !== undefined && changes.date !== purchase.date) { + requireConfirmationAdvances( + draft, + purchase, + "source" in changes ? changes.source : purchase.source, + changes.date, + ); + } const resolved: Partial> = { ...changes }; if (changes.total) { resolved.total = roundMoneyValue(draft, changes.total); @@ -362,3 +414,215 @@ export function setBaseCurrency(draft: Dataset, currency: Currency): void { draft.settings.baseCurrency = currency; draft.fxRates = draft.fxRates.filter((r) => r.currency !== currency); } + +function requireRecurringCost(draft: Dataset, id: RecurringCostId): RecurringCost { + const cost = draft.recurring.find((c) => c.id === id); + if (!cost) throw new Error(`Unknown recurring cost: ${id}`); + return cost; +} + +/** + * The projection walk in `domain/occurrences.ts` terminates only if every step + * strictly advances, and `n` is what guarantees that. The JSON importer checks + * the same thing; this is the other write path, and both have to hold or the + * fold can hang. + */ +function requireRecurrence(recurrence: Recurrence): Recurrence { + if (!Number.isInteger(recurrence.n)) { + throw new Error(`A recurrence interval must be a whole number, not ${recurrence.n}`); + } + if (recurrence.n < 1) { + throw new Error(`A recurrence interval must be at least 1, not ${recurrence.n}`); + } + if (recurrence.kind === "everyNWeeks") { + if (!Number.isInteger(recurrence.weekday) || recurrence.weekday < 0 || recurrence.weekday > 6) { + throw new Error(`A weekday must be 0-6 (0 is Sunday), not ${recurrence.weekday}`); + } + } + return recurrence; +} + +const DAY_GRANULAR_DATE = /^\d{4}-\d{2}-\d{2}$/; + +/** + * `everyNMonths` walks the cursor through `monthOf`, so it tolerates either + * granularity of `startDate`. `everyNDays` and `everyNWeeks` hand it straight + * to `addDays`, which throws on a month-only date — so those kinds require a + * day-granular `startDate` here, at the boundary, rather than three months + * later when a fold walks off the end of it. + * + * Shape alone is not enough: "2026-09-31" is day-SHAPED but calendar- + * impossible (September has 30 days), and would otherwise reach `addDays` (or + * sit unnoticed under `everyNMonths`) and throw three months later out of the + * fold instead of here. Route a day-granular value through `toDayOrdinal` and + * a month-granular one through `monthOf` — both already throw on exactly + * that — and translate whatever they throw into this file's own error style. + */ +function requireStartDateGranularity(recurrence: Recurrence, startDate: IsoDate): void { + const dayGranular = DAY_GRANULAR_DATE.test(startDate); + if (recurrence.kind !== "everyNMonths" && !dayGranular) { + throw new Error( + `A "${recurrence.kind}" recurrence needs a day-granular startDate ("YYYY-MM-DD"), not "${startDate}"`, + ); + } + try { + if (dayGranular) toDayOrdinal(startDate); + else monthOf(startDate); + } catch (error) { + throw new Error( + `A "${recurrence.kind}" recurrence needs a valid startDate, not "${startDate}" (${(error as Error).message})`, + ); + } +} + +export function addRecurringCost( + draft: Dataset, + cost: Omit, +): RecurringCost { + const recurrence = requireRecurrence(cost.recurrence); + requireStartDateGranularity(recurrence, cost.startDate); + const created: RecurringCost = { + ...cost, + id: newId(), + order: draft.recurring.length, + recurrence, + amount: roundMoneyValue(draft, cost.amount), + splits: roundSplits(draft, cost.splits, cost.splitMode, cost.amount.currency), + }; + draft.recurring.push(created); + return created; +} + +/** + * `archived` and `endedFrom` are excluded on purpose: they are a coherent + * pair (`archived` mirrors "`endedFrom` is set") maintained ONLY by + * `setRecurringCostEndedFrom`, and a caller able to pass either through here + * could desynchronise them — a bill that reads as live but is dimmed, or + * ended but not. Route a change to either field through + * `setRecurringCostEndedFrom` (or its `endRecurringCost`/`restoreRecurringCost` + * convenience wrappers) instead. + */ +export function updateRecurringCost( + draft: Dataset, + id: RecurringCostId, + changes: Partial>, +): void { + const cost = requireRecurringCost(draft, id); + const resolved: Partial> = { ...changes }; + + if (changes.recurrence) { + resolved.recurrence = requireRecurrence(changes.recurrence); + } + // Runs on every update, not just one that touches recurrence or startDate: + // either field alone can break the pairing (a kind flip against an + // untouched month-only startDate is exactly how this bug was reached). + requireStartDateGranularity( + resolved.recurrence ?? cost.recurrence, + resolved.startDate ?? cost.startDate, + ); + if (changes.amount) { + resolved.amount = roundMoneyValue(draft, changes.amount); + } + if (changes.splits) { + // Either field may be absent from a partial update; the stored cost + // supplies whichever one is, so the mode and currency always agree with + // the values being rounded. + resolved.splits = roundSplits( + draft, + changes.splits, + changes.splitMode ?? cost.splitMode, + (resolved.amount ?? cost.amount).currency, + ); + } + + Object.assign(cost, resolved); +} + +export function moveRecurringCost( + draft: Dataset, + id: RecurringCostId, + direction: -1 | 1, +): void { + const ordered = [...draft.recurring].sort((a, b) => a.order - b.order); + const index = ordered.findIndex((c) => c.id === id); + const target = index + direction; + if (index === -1 || target < 0 || target >= ordered.length) return; + const a = ordered[index]!; + const b = ordered[target]!; + [a.order, b.order] = [b.order, a.order]; +} + +/** + * Sets or clears `endedFrom` directly — what the Settings "Ends" column edits + * — keeping `archived` coherent with it. The rule: `archived` mirrors + * "`endedFrom` is set", same as `endRecurringCost`/`restoreRecurringCost` + * below, which now both delegate here. That is what stops the two fields + * diverging into a bill that reads as live but is dimmed, or ended but not. + * + * Both fields, because they mean different things: `endedFrom` stops the + * PROJECTION, and `archived` only flags the row — same as a post, it stays + * listed (sorted in, rendered at reduced opacity) rather than being filtered + * out. Archiving alone would leave the bill projecting forever; setting + * `endedFrom` alone would leave a dead bill undimmed in the list. Neither + * touches a past occurrence, so no historical figure moves. + * + * `endedFrom` itself is unchecked here — no granularity rule applies to it + * (§10; it is only ever compared lexicographically, never reaches + * `addDays`) — so the caller validates it is a real `IsoDate` before this is + * called, the same way the "Starts" field already does for `startDate`. + */ +export function setRecurringCostEndedFrom( + draft: Dataset, + id: RecurringCostId, + endedFrom: IsoDate | undefined, +): void { + const cost = requireRecurringCost(draft, id); + if (endedFrom === undefined) { + delete cost.endedFrom; + cost.archived = false; + } else { + cost.endedFrom = endedFrom; + cost.archived = true; + } +} + +/** The one-click convenience: stop a bill from `from` onward and dim it. */ +export function endRecurringCost(draft: Dataset, id: RecurringCostId, from: IsoDate): void { + setRecurringCostEndedFrom(draft, id, from); +} + +/** The one-click convenience: clear the cancellation and un-dim the row. */ +export function restoreRecurringCost(draft: Dataset, id: RecurringCostId): void { + setRecurringCostEndedFrom(draft, id, undefined); +} + +/** + * Turns one projected occurrence into a real purchase. + * + * `occurrenceDate` is the slot being claimed and goes into `source`; + * `overrides.date` is when the money actually moved and goes on the purchase. + * They differ whenever a bill is paid off schedule, and under `lastCharge` + * anchoring it is the latter that rebases the series — which is the whole of + * the phone-bill behaviour. + * + * Deleting the purchase later un-confirms the slot, with nothing to reconcile. + */ +export function confirmOccurrence( + draft: Dataset, + recurringId: RecurringCostId, + occurrenceDate: IsoDate, + overrides: { date?: IsoDate; amount?: Money } = {}, +): Purchase { + const cost = requireRecurringCost(draft, recurringId); + return addPurchase(draft, { + date: overrides.date ?? occurrenceDate, + description: cost.name, + total: overrides.amount ?? cost.amount, + splitMode: cost.splitMode, + // Copied, not shared: editing the purchase's splits must not rewrite the + // cost's, and `mutate` clones the draft rather than deep-freezing it. + splits: cost.splits.map((split) => ({ ...split })), + schedule: null, + source: { recurringId, occurrenceDate }, + }); +} diff --git a/src/store/index.ts b/src/store/index.ts index dd98131..0ac5dda 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -2,10 +2,20 @@ import { monthOf } from "../domain/months.ts"; import { readDataset, writeDataset } from "./db.ts"; import { createSnapshotStore } from "./snapshot.ts"; import { BAKED_FX_RATES } from "./bakedRates.ts"; +import type { IsoDate } from "../domain/types.ts"; /** The current month, read at module load. The UI owns the clock; the domain does not. */ export const currentMonth = monthOf(new Date().toISOString().slice(0, 10)); +/** + * Today, day-granular, read at module load. The UI owns the clock; the domain + * does not. `ExpectedBand`'s "Coming up" group needs the actual day — a + * coming-up occurrence is confirmed EARLY, so the purchase it writes must be + * dated today rather than the future slot, which is exactly what rebases a + * `lastCharge` series. + */ +export const today: IsoDate = new Date().toISOString().slice(0, 10); + export const store = createSnapshotStore( { read: readDataset, write: writeDataset }, currentMonth, diff --git a/src/store/migrations.test.ts b/src/store/migrations.test.ts index 9f2b948..d1f27ce 100644 --- a/src/store/migrations.test.ts +++ b/src/store/migrations.test.ts @@ -1,6 +1,7 @@ import { test, expect, describe } from "bun:test"; import { SCHEMA_VERSION, createSeedDataset } from "../domain/seed.ts"; import { migrate, UnsupportedSchemaError } from "./migrations.ts"; +import { figuresFor, foldBalances } from "../domain/fold.ts"; describe("migrate", () => { test("passes a current-version dataset through unchanged", () => { @@ -443,3 +444,44 @@ test("no migration step imports a live default that a later commit could change" ); expect(offenders).toEqual([]); }); + +describe("6 -> 7: recurring costs join the dataset", () => { + test("a v6 dataset gains an empty recurring array", () => { + const v6 = { ...createSeedDataset("2026-09"), recurring: undefined }; + delete (v6 as any).recurring; + (v6 as any).settings = { ...v6.settings, schemaVersion: 6 }; + + const migrated = migrate(v6 as any); + + expect(migrated.recurring).toEqual([]); + expect(migrated.settings.schemaVersion).toBe(SCHEMA_VERSION); + }); + + test("recurring costs the owner already has are left alone", () => { + // A hand-edited file could already carry them; discarding would be data loss. + // Typed loosely on purpose: a hand-edited entry is not guaranteed to be a + // fully-shaped RecurringCost either, and this step must preserve it as-is + // regardless. + const existing: any[] = [{ id: "r1", name: "Rent" }]; + const v6: any = { ...createSeedDataset("2026-09"), recurring: existing }; + v6.settings = { ...v6.settings, schemaVersion: 6 }; + + expect(migrate(v6).recurring).toEqual(existing); + }); + + test("NO figure changes: every post in every month folds identically", () => { + const v6: any = { ...createSeedDataset("2026-09"), settings: { ...createSeedDataset("2026-09").settings, schemaVersion: 6 } }; + delete v6.recurring; + + const migrated = migrate(v6); + const fold = foldBalances(migrated, "2026-12"); + + for (const post of migrated.posts) { + for (const month of ["2026-09", "2026-10", "2026-11", "2026-12"]) { + const figures = figuresFor(fold, post.id, month); + expect(figures.projected).toBe(figures.remaining); + expect(figures.expected).toBe(0); + } + } + }); +}); diff --git a/src/store/migrations.ts b/src/store/migrations.ts index ae82068..de9ba06 100644 --- a/src/store/migrations.ts +++ b/src/store/migrations.ts @@ -174,6 +174,25 @@ const MIGRATIONS: Array<(data: any) => any> = [ currencies: currencies.map(({ digits, ...rest }: any) => rest), }; }, + + // 6 -> 7: recurring costs join the dataset. + // + // Behaviour-preserving by construction, and more strongly than most: an + // empty array projects no occurrences, so `expected` is zero everywhere and + // `projected` equals `remaining` in every month of every existing dataset. + // Not one stored figure is read, let alone written. + // + // An existing `recurring` field is left alone rather than overwritten, for + // the same reason the 2 -> 3 step leaves `currencies` alone: a hand-edited + // file could already carry them, and discarding them would be data loss. + // + // Nothing is frozen here because nothing is copied — the step writes a + // literal empty array and reads no default, live or otherwise. + (data: any) => ({ + ...data, + settings: { ...data.settings, schemaVersion: 7 }, + recurring: Array.isArray(data.recurring) ? data.recurring : [], + }), ]; export function migrate(raw: unknown): Dataset { diff --git a/src/ui/components/ExpectedBand.test.ts b/src/ui/components/ExpectedBand.test.ts new file mode 100644 index 0000000..7f9ec59 --- /dev/null +++ b/src/ui/components/ExpectedBand.test.ts @@ -0,0 +1,352 @@ +import { describe, expect, test } from "bun:test"; +import { expectedGroups, recordedDateFor, resolveExpectedAmount } from "./ExpectedBand.tsx"; +import { occurrencesByMonth, wouldAdvancePast } from "../../domain/occurrences.ts"; +import { confirmOccurrence } from "../../store/actions.ts"; +import { createSeedDataset } from "../../domain/seed.ts"; +import type { Dataset, Purchase, RecurringCost } from "../../domain/types.ts"; + +const DKK_USD = [ + { code: "DKK", symbol: "kr" }, + { code: "USD", symbol: "$" }, +]; + +// I5: an unrecognised currency must not silently confirm at the cost's own +// amount. `resolveExpectedAmount` is the logic ExpectedBand's Confirm button +// uses to decide whether to write a purchase at all. +describe("resolveExpectedAmount", () => { + test("untouched (typed undefined) is not invalid, and parses to nothing — the fast path", () => { + const result = resolveExpectedAmount(undefined, DKK_USD, "DKK"); + expect(result.invalid).toBe(false); + expect(result.parsed).toBeNull(); + }); + + test("edited text that parses is not invalid", () => { + const result = resolveExpectedAmount("450", DKK_USD, "DKK"); + expect(result.invalid).toBe(false); + expect(result.parsed).toEqual({ amount: 450, currency: "DKK" }); + }); + + test("edited text naming a currency the dataset doesn't have is invalid, not a silent fallback", () => { + // This is the exact bug: "30 GBP" in a DKK/USD dataset used to fall back + // to the cost's own amount via `parsed ?? undefined`. + const result = resolveExpectedAmount("30 GBP", DKK_USD, "DKK"); + expect(result.invalid).toBe(true); + expect(result.parsed).toBeNull(); + }); + + test("edited text that is empty or otherwise unparseable is invalid", () => { + expect(resolveExpectedAmount("", DKK_USD, "DKK").invalid).toBe(true); + expect(resolveExpectedAmount("thirty", DKK_USD, "DKK").invalid).toBe(true); + }); +}); + +// X2: the load-bearing decision behind the phone-bill behaviour is which date +// a Confirm click records. Nothing else in the suite would catch the two +// cases being swapped — this test must FAIL if `recordedDateFor`'s two arms +// are exchanged (verified by hand: swapping them and re-running does fail). +describe("recordedDateFor", () => { + test("a this-month ('pending') row records at the slot's own date — null, confirmOccurrence's default", () => { + expect(recordedDateFor("pending", "2026-09-04")).toBeNull(); + }); + + test("a 'coming up' row records at today — paying early is the whole point", () => { + expect(recordedDateFor("comingUp", "2026-09-04")).toBe("2026-09-04"); + }); +}); + +function baseDataset(): Dataset { + const data = createSeedDataset("2026-02"); + data.posts[0]!.id = "housing"; + return data; +} + +function cost(overrides: Partial = {}): RecurringCost { + return { + id: "r1", + name: "Rent", + order: 0, + archived: false, + amount: { amount: 8000, currency: "DKK" }, + startDate: "2026-01", + recurrence: { kind: "everyNMonths", n: 1 }, + anchoring: "calendar", + splitMode: "percent", + splits: [{ postId: "housing", value: 100, absorbsRemainder: true }], + ...overrides, + }; +} + +function confirmed(recurringId: string, occurrenceDate: string, id: string): Purchase { + return { + id, + date: occurrenceDate, + description: "x", + total: { amount: 8000, currency: "DKK" }, + splitMode: "percent", + splits: [{ postId: "housing", value: 100, absorbsRemainder: true }], + schedule: null, + source: { recurringId, occurrenceDate }, + }; +} + +// A fixed "today" for tests where its exact value doesn't matter (any +// calendar-anchored cost advances regardless of it). +const TODAY = "2026-02-15"; + +// Gap 1 (spec §5): the band offers confirmation on the next pending +// occurrence, not only ones landing in the displayed month. +describe("expectedGroups (Coming up)", () => { + test("a dataset with no recurring costs shows nothing — the component must return null", () => { + const data = baseDataset(); + const groups = expectedGroups(data, "2026-02", TODAY); + expect(groups.pending).toEqual([]); + expect(groups.overdue).toBe(0); + expect(groups.comingUp).toEqual([]); + }); + + test("a lastCharge bill's next occurrence next month is offered as 'coming up', not hidden until you navigate", () => { + // The flagship scenario: displayed month is February, the pending slot + // sits in March because rent-like accounting isn't in play here — a + // monthly cost's only occurrence for a month IS next month once this + // month's is confirmed. + const data = baseDataset(); + data.recurring = [cost({ id: "phone", anchoring: "lastCharge" })]; + data.purchases = [confirmed("phone", "2026-02", "p1")]; // this month's is done + + const groups = expectedGroups(data, "2026-02", TODAY); + expect(groups.pending).toEqual([]); + expect(groups.comingUp.length).toBe(1); + expect(groups.comingUp[0]!.recurringId).toBe("phone"); + expect(groups.comingUp[0]!.date).toBe("2026-03"); + }); + + test("at most one row per cost, even when several future occurrences fall within the horizon", () => { + // A weekly cost produces many unconfirmed occurrences across the next + // several months. Only the soonest one past the displayed month may + // appear — never a list of everything the future holds. February's own + // occurrences are confirmed first so X3 doesn't suppress the row this + // test exists to check (a cost with an outstanding slot this month must + // settle it before "coming up" offers a later one — covered separately + // below). + const data = baseDataset(); + data.recurring = [ + cost({ + id: "gym", + startDate: "2026-02-02", + recurrence: { kind: "everyNDays", n: 7 }, + }), + ]; + data.purchases = [ + confirmed("gym", "2026-02-02", "p1"), + confirmed("gym", "2026-02-09", "p2"), + confirmed("gym", "2026-02-16", "p3"), + confirmed("gym", "2026-02-23", "p4"), + ]; + + const groups = expectedGroups(data, "2026-02", TODAY); + expect(groups.pending).toEqual([]); + // Many more unconfirmed occurrences exist in March, April, ... within the + // horizon — but comingUp holds exactly one. + const forThisCost = groups.comingUp.filter((o) => o.recurringId === "gym"); + expect(forThisCost.length).toBe(1); + expect(forThisCost[0]!.date).toBe("2026-03-02"); // the soonest one past February + }); + + test("the horizon is bounded: a cost whose next occurrence lands beyond it does not appear", () => { + const data = baseDataset(); + // Interval bigger than the default horizon: confirmed this month, the + // next occurrence is 15 months out — past the 12-month default. + data.recurring = [ + cost({ + id: "insurance", + startDate: "2026-02", + recurrence: { kind: "everyNMonths", n: 15 }, + }), + ]; + data.purchases = [confirmed("insurance", "2026-02", "p1")]; + + expect(expectedGroups(data, "2026-02", TODAY).comingUp).toEqual([]); // default (12) horizon + // Raising the horizon far enough reveals the same occurrence, proving the + // emptiness above is the bound, not a bug elsewhere. + const wider = expectedGroups(data, "2026-02", TODAY, 16).comingUp; + expect(wider.length).toBe(1); + expect(wider[0]!.date).toBe("2027-05"); + }); + + test("a confirmed future occurrence never appears in comingUp", () => { + const data = baseDataset(); + // Ends right after March, so April onward is never generated at all — + // isolating the point of the test (a confirmed slot is excluded) from an + // unrelated, genuinely-unconfirmed one the walk would otherwise reach. + data.recurring = [cost({ id: "rent", endedFrom: "2026-04" })]; + data.purchases = [confirmed("rent", "2026-02", "p1"), confirmed("rent", "2026-03", "p2")]; + + const groups = expectedGroups(data, "2026-02", TODAY); + expect(groups.pending).toEqual([]); + expect(groups.comingUp).toEqual([]); + }); + + test("overdue counts only earlier, foldable months and is unaffected by extending the walk for comingUp", () => { + const data = baseDataset(); + data.settings.foldStartMonth = "2026-01"; + data.recurring = [cost({ id: "rent", startDate: "2026-01" })]; // Jan unconfirmed + + const groups = expectedGroups(data, "2026-02", TODAY); + expect(groups.overdue).toBe(1); + }); +}); + +// X3: a cost must never appear in both "Expected" and "Coming up" at once — +// that is the path into X1 (confirming the later row while the earlier one +// is still outstanding writes a real purchase AND leaves the earlier +// projection standing, double-counting the bill). +describe("expectedGroups — X3 (a cost with an outstanding slot never also shows a coming-up row)", () => { + test("this month's slot still pending suppresses the coming-up row for the SAME cost", () => { + const data = baseDataset(); + data.recurring = [ + cost({ + id: "car", + name: "Car insurance", + amount: { amount: 349, currency: "DKK" }, + startDate: "2026-08-14", + recurrence: { kind: "everyNDays", n: 30 }, + anchoring: "lastCharge", + }), + ]; + // Matches the reported demo shape exactly: no purchases at all, viewed + // 2026-09. 2026-09-13 is pending; 2026-10-13 would otherwise be the + // soonest future occurrence. + data.settings.foldStartMonth = "2026-01"; + + const groups = expectedGroups(data, "2026-09", "2026-09-04"); + expect(groups.pending.map((o) => o.date)).toEqual(["2026-09-13"]); + expect(groups.comingUp).toEqual([]); // suppressed — settle September first + }); + + test("once the pending slot is settled, a coming-up row reappears — for a pairing that is itself safe", () => { + // A monthly lastCharge cost, unlike the 30-day Car insurance above, is + // always safe to confirm early (X1 never suppresses `everyNMonths`), so + // this isolates X3's own effect: suppressed while this month is + // outstanding, offered again once it is settled. + const data = baseDataset(); + data.recurring = [cost({ id: "phone", anchoring: "lastCharge" })]; + + let groups = expectedGroups(data, "2026-02", TODAY); + expect(groups.pending.map((o) => o.date)).toEqual(["2026-02"]); + expect(groups.comingUp).toEqual([]); + + confirmOccurrence(data, "phone", "2026-02", { date: "2026-02" }); + groups = expectedGroups(data, "2026-02", TODAY); + expect(groups.pending).toEqual([]); + expect(groups.comingUp.map((o) => o.date)).toEqual(["2026-03"]); + }); + + test("an outstanding occurrence in an earlier FOLDABLE month also suppresses the row", () => { + const data = baseDataset(); + data.settings.foldStartMonth = "2026-01"; + data.recurring = [ + cost({ id: "rent", startDate: "2026-01", endedFrom: "2026-04" }), + ]; + // January is unconfirmed; February (displayed month) and March are + // confirmed, so nothing is `pending` and the only reason a coming-up row + // would be suppressed is the outstanding January slot. + data.purchases = [confirmed("rent", "2026-02", "p1"), confirmed("rent", "2026-03", "p2")]; + + const groups = expectedGroups(data, "2026-02", TODAY); + expect(groups.pending).toEqual([]); + expect(groups.comingUp).toEqual([]); // January is still outstanding + }); +}); + +// X1: a coming-up row confirms at today's date rather than its own slot, and +// `occurrencesOf` throws — permanently, since the purchase persists — if that +// pairing cannot advance the series. The band must never OFFER such a row. +describe("expectedGroups — X1 (a coming-up row that cannot be confirmed coherently is never offered)", () => { + test("wouldAdvancePast identifies the exact reported pairing as unsafe", () => { + const car = cost({ + id: "car", + name: "Car insurance", + recurrence: { kind: "everyNDays", n: 30 }, + anchoring: "lastCharge", + }); + // 39 days between "today" and the slot — more than one 30-day cycle. + expect(wouldAdvancePast(car, "2026-10-13", "2026-09-04")).toBe(false); + // A slot close enough to today (a few days, the "paid early" case) is fine. + expect(wouldAdvancePast(car, "2026-09-13", "2026-09-04")).toBe(true); + }); + + test("stays suppressed even once X3 no longer applies — settling this month doesn't make a too-early payment safe", () => { + // Same Car insurance shape, but September's slot is settled ON TIME + // first, so X3 no longer has anything to suppress. The October row is + // still 39 days from `today` — more than one 30-day cycle — so X1 alone + // must keep it off the list. + const data = baseDataset(); + data.recurring = [ + cost({ + id: "car", + name: "Car insurance", + amount: { amount: 349, currency: "DKK" }, + startDate: "2026-08-14", + recurrence: { kind: "everyNDays", n: 30 }, + anchoring: "lastCharge", + }), + ]; + confirmOccurrence(data, "car", "2026-09-13", { date: "2026-09-13" }); + + const groups = expectedGroups(data, "2026-09", "2026-09-04"); + expect(groups.pending).toEqual([]); + expect(groups.comingUp).toEqual([]); + }); + + test("confirming an unsafe pairing directly still throws — the domain guard is untouched", () => { + const data = baseDataset(); + data.recurring = [ + cost({ + id: "car", + name: "Car insurance", + amount: { amount: 349, currency: "DKK" }, + startDate: "2026-08-14", + recurrence: { kind: "everyNDays", n: 30 }, + anchoring: "lastCharge", + }), + ]; + confirmOccurrence(data, "car", "2026-10-13", { date: "2026-09-04" }); + expect(() => occurrencesByMonth(data, "2026-10")).toThrow( + /did not advance past 2026-10-13 \(produced 2026-10-04\)/, + ); + }); + + test("regression: whatever expectedGroups offers as 'coming up', confirming it all today and folding never throws (demo Car insurance shape)", () => { + const data = baseDataset(); + data.recurring = [ + cost({ + id: "car", + name: "Car insurance", + amount: { amount: 349, currency: "DKK" }, + startDate: "2026-08-14", + recurrence: { kind: "everyNDays", n: 30 }, + anchoring: "lastCharge", + }), + cost({ + id: "phone", + name: "Phone", + startDate: "2026-01", + recurrence: { kind: "everyNMonths", n: 1 }, + anchoring: "lastCharge", + }), + ]; + data.purchases = [confirmed("phone", "2026-02", "p0")]; // settled through Feb, unlike Car + const today = "2026-09-04"; + + const groups = expectedGroups(data, "2026-09", today); + // Car insurance is suppressed entirely (X3: 2026-09-13 is still pending), + // and even confirming everything else offered must never break the fold. + expect(groups.comingUp.some((o) => o.recurringId === "car")).toBe(false); + + for (const occurrence of groups.comingUp) { + confirmOccurrence(data, occurrence.recurringId, occurrence.date, { date: today }); + } + + expect(() => occurrencesByMonth(data, "2026-10")).not.toThrow(); + }); +}); diff --git a/src/ui/components/ExpectedBand.tsx b/src/ui/components/ExpectedBand.tsx new file mode 100644 index 0000000..ee7ba8f --- /dev/null +++ b/src/ui/components/ExpectedBand.tsx @@ -0,0 +1,301 @@ +import { useMemo, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useDataset } from "../hooks/useDataset.ts"; +import { useMutate } from "../hooks/useMutate.ts"; +import { today } from "../../store/index.ts"; +import { confirmOccurrence } from "../../store/actions.ts"; +import { + occurrencesByMonth, + wouldAdvancePast, + type Occurrence, +} from "../../domain/occurrences.ts"; +import { addMonths, compareMonths } from "../../domain/months.ts"; +import { parseMoneyInput, type CurrencyOption, type ParsedMoney } from "../moneyInput.ts"; +import { Section } from "./Section.tsx"; +import type { Currency, Dataset, IsoDate, MonthId } from "../../domain/types.ts"; + +/** + * `typed` is `undefined` when the field has never been edited — that always + * confirms at the cost's own amount, the designed one-click fast path. + * Once edited, though, text that does not parse (an unrecognised currency + * like "30 GBP" in a DKK/USD dataset, or anything else `parseMoneyInput` + * rejects) must NOT be conflated with "untouched" and silently fall back to + * the cost's amount — that would write a figure the owner never typed. + * `invalid` is what tells the two apart; the caller must refuse to confirm + * while it is true. + * + * Exported for `ExpectedBand.test.ts` (I5). + */ +export function resolveExpectedAmount( + typed: string | undefined, + currencies: readonly CurrencyOption[], + fallbackCurrency: Currency, +): { parsed: ParsedMoney | null; invalid: boolean } { + if (typed === undefined) return { parsed: null, invalid: false }; + const parsed = parseMoneyInput(typed, currencies, fallbackCurrency); + return { parsed, invalid: parsed === null }; +} + +export type ExpectedRowGroup = "pending" | "comingUp"; + +/** + * What a Confirm click should record, given which group the row is in. A + * this-month row confirms at the slot's own date — `null`, `confirmOccurrence`'s + * default — and a coming-up row confirms at `today` instead, because it is + * being paid early on purpose (see `ExpectedBand`'s doc comment). + * + * This one branch (previously inlined as `row(occurrence, null)` versus + * `row(occurrence, today)`) is the whole of the phone-bill behaviour, and + * nothing else in the suite would catch the two being swapped — a DOM test + * isn't needed for that, a direct one is. Exported for `ExpectedBand.test.ts` + * (X2). + */ +export function recordedDateFor(group: ExpectedRowGroup, today: IsoDate): IsoDate | null { + return group === "comingUp" ? today : null; +} + +export interface ExpectedGroups { + /** This month's unconfirmed occurrences. */ + pending: Occurrence[]; + /** Count of unconfirmed occurrences from earlier, foldable months. */ + overdue: number; + /** + * At most ONE row per recurring cost: its next unconfirmed occurrence + * strictly after `monthId`, out to `horizonMonths` ahead. Never a list of + * everything the future holds. + */ + comingUp: Occurrence[]; +} + +/** + * Everything the band has to show, in three groups. §5 offers confirmation on + * "the next pending occurrence, not only on ones whose date has passed" — the + * flagship case is a `lastCharge` bill whose pending slot sits next month, and + * `occurrencesByMonth` only walks TO the month it is given, so this walks past + * the displayed month on purpose. + * + * The walk stays bounded — `horizonMonths` ahead, twelve by default, which is + * enough to catch an annual `everyNMonths` cost — and is a band-local concern. + * `foldBalances`'s own `upToMonth` bound is untouched. + * + * `today` is what a "Coming up" row would record if confirmed (see `row` in + * `ExpectedBand` below) — passed in rather than read from a clock, because + * this function is exercised directly by tests with no DOM. + * + * Two rules keep a "coming up" row from ever being offered when confirming it + * cannot go well (X1, X3): + * + * - A cost with an unresolved occurrence in `monthId` or any earlier + * FOLDABLE month (same bound `overdue` uses) is skipped entirely. Settle + * the earlier bill first — offering a later one while an earlier sits + * outstanding would let confirming the later one leave the earlier + * projection standing, double-counting the bill. + * - Otherwise, the row is offered only if recording it at `today` would + * leave `occurrencesOf`'s walk able to advance past that slot + * (`wouldAdvancePast`). A bill confirmed today for a slot far enough in + * the future can rebase the series backwards, which is exactly what + * `occurrencesOf` throws on — so a row that would do that must never be + * offered in the first place, not offered-and-disabled. + * + * Exported for `ExpectedBand.test.ts`. + */ +export function expectedGroups( + dataset: Dataset, + monthId: MonthId, + today: IsoDate, + horizonMonths = 12, +): ExpectedGroups { + const byMonth = occurrencesByMonth(dataset, addMonths(monthId, horizonMonths)); + const pending = (byMonth.get(monthId) ?? []).filter((o) => !o.confirmedBy); + + let overdue = 0; + const seen = new Set(); + // Costs with an outstanding slot at or before `monthId` — X3: none of + // these may get a "coming up" row until that slot is settled. + const unresolved = new Set(pending.map((o) => o.recurringId)); + const comingUp: Occurrence[] = []; + + for (const month of [...byMonth.keys()].sort(compareMonths)) { + const cmp = compareMonths(month, monthId); + if (cmp === 0) continue; // already `pending`, above. + + const occurrences = byMonth.get(month)!; + if (cmp < 0) { + // Everything unconfirmed in an EARLIER month, but not before + // foldStartMonth: foldBalances never folds those, so an occurrence out + // there never held the projected balance down in the first place, and + // counting it here would make the sentence below claim a gap that isn't + // in the number the owner is looking at. The same bound applies to X3's + // suppression, for the same reason — a slot the fold never tracked was + // never "outstanding" in any number the owner can see. + if (compareMonths(month, dataset.settings.foldStartMonth) < 0) continue; + for (const o of occurrences) { + if (o.confirmedBy) continue; + overdue += 1; + unresolved.add(o.recurringId); + } + continue; + } + + // A future month within the horizon. Months are visited in ascending + // order, and a single cost's own occurrences are already emitted in date + // order, so the first unconfirmed one seen for a given recurringId is + // that cost's soonest pending occurrence beyond the displayed month. + for (const occurrence of occurrences) { + if (occurrence.confirmedBy) continue; + if (seen.has(occurrence.recurringId)) continue; + seen.add(occurrence.recurringId); + if (unresolved.has(occurrence.recurringId)) continue; // X3 + const cost = dataset.recurring.find((c) => c.id === occurrence.recurringId); + if (!cost || !wouldAdvancePast(cost, occurrence.date, today)) continue; // X1 + comingUp.push(occurrence); + } + } + + return { pending, overdue, comingUp }; +} + +/** + * The month's expected charges, and the one click that turns each into a real + * purchase. + * + * Confirmation is offered on any unconfirmed occurrence in this month, + * INCLUDING one whose date has not arrived yet. That is not an oversight: a + * bill anchored to its last charge can charge early — a phone plan whose data + * cap is hit on the 12th when the slot sat on the 30th — and confirming the + * pending slot with the real date is exactly how the series rebases. + * + * The same is true one month further out: "Coming up" offers the next + * pending occurrence even when it lands after the displayed month, because + * confirming it EARLY — dated today rather than the future slot — is what + * rebases a `lastCharge` series. A this-month row still confirms at the + * slot's own date; only a coming-up row records today instead. + */ +export function ExpectedBand({ monthId }: { monthId: MonthId }) { + const dataset = useDataset(); + const { mutate } = useMutate(); + const [amounts, setAmounts] = useState>({}); + + const { pending, overdue, comingUp } = useMemo( + () => expectedGroups(dataset, monthId, today), + [dataset, monthId, today], + ); + + if (pending.length === 0 && overdue === 0 && comingUp.length === 0) return null; + + const nameOf = (recurringId: string) => + dataset.recurring.find((c) => c.id === recurringId)?.name ?? "Unknown"; + + /** + * One row, shared by "Expected" and "Coming up" — they differ only in the + * subtitle and in what date confirming records, per `recordedDateFor`. + */ + function row(occurrence: Occurrence, recordedDate: IsoDate | null) { + const key = `${occurrence.recurringId}:${occurrence.date}`; + const typed = amounts[key]; + const { parsed, invalid } = resolveExpectedAmount( + typed, + dataset.currencies, + occurrence.amount.currency, + ); + return ( +
  • +
    +
    {nameOf(occurrence.recurringId)}
    +
    + {recordedDate ? ( + <> + Due {occurrence.date} — confirming now records it {recordedDate} + + ) : ( + occurrence.date + )} +
    +
    +
    +
    + { + const next = event.target.value; + setAmounts((current) => ({ ...current, [key]: next })); + }} + /> + {invalid && ( +
    + Doesn't look like an amount — Confirm is disabled. +
    + )} +
    + {occurrence.amount.currency} + +
    +
  • + ); + } + + return ( +
    + {pending.length > 0 && ( +
      + {pending.map((occurrence) => row(occurrence, recordedDateFor("pending", today)))} +
    + )} + + {comingUp.length > 0 && ( +
    0 ? "mt-4" : ""}> +

    + Coming up +

    +
      + {comingUp.map((occurrence) => row(occurrence, recordedDateFor("comingUp", today)))} +
    +
    + )} + + {overdue > 0 && ( +

    + {overdue} earlier {overdue === 1 ? "occurrence has" : "occurrences have"} never + been confirmed. Until {overdue === 1 ? "it is" : "they are"}, the projected + balance stays lower than the real one by that amount. +

    + )} +
    + ); +} diff --git a/src/ui/components/PostTable.tsx b/src/ui/components/PostTable.tsx index 66b90b8..0690c38 100644 --- a/src/ui/components/PostTable.tsx +++ b/src/ui/components/PostTable.tsx @@ -38,6 +38,10 @@ interface RowCells { charges: string; remaining: string; remainingTone: string; + /** What is left once unconfirmed recurring bills are counted too. Always + * rendered in the muted ink token, never toned, so `remaining` stays the + * one figure the eye lands on first. */ + projected: string; segments: MeterSegment[]; } @@ -59,6 +63,7 @@ function postRowCells({ figures }: MonthPostRow, fmt: MoneyFormat): RowCells { charges: fmt.amount(figures.charges), remaining: fmt.signedAmount(figures.remaining), remainingTone: figures.remaining < 0 ? "text-overspend" : "", + projected: fmt.signedAmount(figures.projected), segments: carryMeterSegments(figures), }; } @@ -70,7 +75,9 @@ function postRowCells({ figures }: MonthPostRow, fmt: MoneyFormat): RowCells { export function PostTableLegend({ baseCurrency }: { baseCurrency: Currency }) { return ( - All figures in {baseCurrency}. + + All figures in {baseCurrency}. Projected subtracts bills not yet confirmed. + @@ -178,6 +185,7 @@ export function PostTable({ monthId, rows, onChangeRule }: Props) { Allocated Spent Remaining + Projected {/* One tbody per post, holding the figures AND their meter. Multiple @@ -214,11 +222,14 @@ export function PostTable({ monthId, rows, onChangeRule }: Props) { > {cells.remaining} + + {cells.projected} + {/* The meter IS the row divider: tight under its own figures, with the gap below it. */} - + @@ -255,6 +266,9 @@ export function PostTable({ monthId, rows, onChangeRule }: Props) { spent {cells.charges} + + projected {cells.projected} + {/* Pushed to the right end of the line it wraps onto, so six of these down a phone screen read as a quiet affordance rather than as each row's primary action. */} diff --git a/src/ui/components/PurchaseDialog.tsx b/src/ui/components/PurchaseDialog.tsx index d924217..ec4a814 100644 --- a/src/ui/components/PurchaseDialog.tsx +++ b/src/ui/components/PurchaseDialog.tsx @@ -43,7 +43,7 @@ interface Props { export function PurchaseDialog({ monthId, purchase, trigger }: Props) { const dataset = useDataset(); - const { mutate } = useMutate(); + const { mutate, error, clearError } = useMutate(); const activePosts = dataset.posts .filter((p) => !p.archived) .sort((a, b) => a.order - b.order); @@ -74,23 +74,25 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) { setBulk(emptyBulkDraft(monthId, activePosts[0]?.id ?? "")); } + // A write can be REFUSED, not merely fail on a full disk: editing the date + // of a purchase that confirms a recurring occurrence is rejected when the + // new date would stop the series advancing. Closing the dialog regardless + // would show the user a save that silently did nothing, so the dialog stays + // open and shows `error` instead. `mutate` never rejects. function save() { if (errors.length > 0) return; - if (bulkMode) { - // One mutate for the whole batch: a single write, one queue entry, and no - // way to half-commit a shopping trip. - const created = toPurchases(bulk); - mutate((data) => { - for (const p of created) addPurchase(data, p); - }); - } else { - mutate((data) => { - if (purchase) updatePurchase(data, purchase.id, toPurchase(draft)); - else addPurchase(data, toPurchase(draft)); - }); - } - setOpen(false); - if (!purchase) reset(); + // One mutate for the whole batch: a single write, one queue entry, and no + // way to half-commit a shopping trip. + const created = bulkMode ? toPurchases(bulk) : null; + void mutate((data) => { + if (created) for (const p of created) addPurchase(data, p); + else if (purchase) updatePurchase(data, purchase.id, toPurchase(draft)); + else addPurchase(data, toPurchase(draft)); + }).then((saved) => { + if (!saved) return; + setOpen(false); + if (!purchase) reset(); + }); } return ( @@ -98,7 +100,10 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) { open={open} onOpenChange={(next) => { setOpen(next); - if (next) reset(); + if (next) { + reset(); + clearError(); + } }} > {trigger} @@ -321,6 +326,12 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) { ))} )} + + {error && ( +

    + Could not save: {error} +

    + )} diff --git a/src/ui/hooks/useMutate.ts b/src/ui/hooks/useMutate.ts index 57c4a99..c076f04 100644 --- a/src/ui/hooks/useMutate.ts +++ b/src/ui/hooks/useMutate.ts @@ -6,14 +6,24 @@ import type { Dataset } from "../../domain/types.ts"; * Wraps store.mutate so a failed write surfaces to the user instead of * vanishing into an unhandled rejection. The snapshot is unchanged on failure, * so the UI keeps showing the last saved state. + * + * `mutate` returns whether the write landed, and the returned promise never + * rejects — the error is already in `error` by the time it settles. A caller + * that only fires and forgets can ignore it; one that would otherwise dismiss + * its own UI (a dialog, say) can wait and keep it open, because a refusal the + * user cannot see is barely better than one that never happened. */ export function useMutate() { const [error, setError] = useState(null); - const mutate = useCallback((fn: (draft: Dataset) => void) => { - store.mutate(fn).catch((cause: unknown) => { - setError(cause instanceof Error ? cause.message : String(cause)); - }); + const mutate = useCallback((fn: (draft: Dataset) => void): Promise => { + return store.mutate(fn).then( + () => true, + (cause: unknown) => { + setError(cause instanceof Error ? cause.message : String(cause)); + return false; + }, + ); }, []); return { mutate, error, clearError: useCallback(() => setError(null), []) }; diff --git a/src/ui/meterSegments.test.ts b/src/ui/meterSegments.test.ts index e189104..6b0024b 100644 --- a/src/ui/meterSegments.test.ts +++ b/src/ui/meterSegments.test.ts @@ -12,11 +12,15 @@ function figures(partial: Partial): PostMonthFigures { const carriedIn = partial.carriedIn ?? 0; const allocation = partial.allocation ?? 0; const charges = partial.charges ?? 0; + const remaining = partial.remaining ?? carriedIn + allocation - charges; return { carriedIn, allocation, charges, - remaining: partial.remaining ?? carriedIn + allocation - charges, + remaining, + expected: partial.expected ?? 0, + projectedCarriedIn: partial.projectedCarriedIn ?? carriedIn, + projected: partial.projected ?? remaining, }; } diff --git a/src/ui/routes/MonthRoute.tsx b/src/ui/routes/MonthRoute.tsx index 61a66b9..d62d41b 100644 --- a/src/ui/routes/MonthRoute.tsx +++ b/src/ui/routes/MonthRoute.tsx @@ -15,6 +15,7 @@ import { sliceAmountForMonth } from "../../domain/charges.ts"; import { useMoneyFormat } from "../hooks/useMoneyFormat.ts"; import { PostTable, PostTableLegend } from "../components/PostTable.tsx"; import { PurchaseDialog } from "../components/PurchaseDialog.tsx"; +import { ExpectedBand } from "../components/ExpectedBand.tsx"; import { Meter } from "../components/Meter.tsx"; import { Section, Stat } from "../components/Section.tsx"; import { allocatedPercentOfIncome, allocationMeterSegments } from "../meterSegments.ts"; @@ -124,6 +125,8 @@ export function MonthRoute() { + +
    } diff --git a/src/ui/routes/SettingsRoute.tsx b/src/ui/routes/SettingsRoute.tsx index f64a699..cb3becf 100644 --- a/src/ui/routes/SettingsRoute.tsx +++ b/src/ui/routes/SettingsRoute.tsx @@ -1,5 +1,6 @@ import { CurrencySection } from "./settings/CurrencySection.tsx"; import { PostsSection } from "./settings/PostsSection.tsx"; +import { RecurringSection } from "./settings/RecurringSection.tsx"; import { DataSection } from "./settings/DataSection.tsx"; import { ColophonSection } from "./settings/ColophonSection.tsx"; @@ -21,6 +22,7 @@ export function SettingsRoute() {

    Settings

    + diff --git a/src/ui/routes/settings/RecurringSection.test.ts b/src/ui/routes/settings/RecurringSection.test.ts new file mode 100644 index 0000000..c276506 --- /dev/null +++ b/src/ui/routes/settings/RecurringSection.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from "bun:test"; +import { isValidEndedFrom, isValidStartDate, normalizedStartDateFor } from "./RecurringSection.tsx"; + +// C2: the "Starts" field must never be able to persist an empty or +// malformed value — `isValidStartDate` is the guard the onBlur handler uses +// to decide whether to call `mutate` at all. +describe("isValidStartDate", () => { + test("an empty string is never valid, for any kind", () => { + expect(isValidStartDate("", "everyNMonths")).toBe(false); + expect(isValidStartDate("", "everyNDays")).toBe(false); + expect(isValidStartDate("", "everyNWeeks")).toBe(false); + }); + + test("everyNMonths accepts either granularity", () => { + expect(isValidStartDate("2026-09", "everyNMonths")).toBe(true); + expect(isValidStartDate("2026-09-17", "everyNMonths")).toBe(true); + }); + + test("everyNMonths still rejects garbage", () => { + expect(isValidStartDate("not-a-date", "everyNMonths")).toBe(false); + expect(isValidStartDate("2026-13", "everyNMonths")).toBe(false); + }); + + test("a day-granular kind rejects a month-only value", () => { + // This is the exact C1/C2 crash: monthOf() would happily hand this back, + // but occurrencesOf hands it to addDays, which throws. + expect(isValidStartDate("2026-09", "everyNDays")).toBe(false); + expect(isValidStartDate("2026-09", "everyNWeeks")).toBe(false); + }); + + test("a day-granular kind accepts a day-granular value", () => { + expect(isValidStartDate("2026-09-17", "everyNDays")).toBe(true); + expect(isValidStartDate("2026-09-17", "everyNWeeks")).toBe(true); + }); + + test("a day-granular kind rejects a malformed day-granular-shaped value", () => { + expect(isValidStartDate("2026-02-30", "everyNDays")).toBe(false); // no Feb 30 + }); + + // Residual C1: `monthOf` only extracts the month and never checks the day, + // so a day-SHAPED but calendar-impossible value ("2026-09-31" — September + // has 30 days) used to pass here even under everyNMonths, and could then be + // stored, only for foldBalances to throw three months later. + test("everyNMonths still rejects a day-granular value with an impossible day", () => { + expect(isValidStartDate("2026-09-31", "everyNMonths")).toBe(false); + }); + + test("everyNMonths accepts a real leap-day and rejects a non-leap Feb 29", () => { + expect(isValidStartDate("2024-02-29", "everyNMonths")).toBe(true); // 2024 is a leap year + expect(isValidStartDate("2026-02-29", "everyNMonths")).toBe(false); // 2026 is not + expect(isValidStartDate("2026-02-28", "everyNMonths")).toBe(true); + }); +}); + +// GAP 2 (spec §10): "Ends" needs no granularity rule (unlike "Starts"), but +// still has to be a real calendar date, and empty must be accepted since it +// is how the field un-ends a cost. +describe("isValidEndedFrom", () => { + test("an empty string is valid — it means 'not ended'", () => { + expect(isValidEndedFrom("")).toBe(true); + }); + + test("both granularities are valid, unconditionally — no recurrence kind is involved", () => { + expect(isValidEndedFrom("2026-09")).toBe(true); + expect(isValidEndedFrom("2026-09-17")).toBe(true); + }); + + test("garbage is rejected", () => { + expect(isValidEndedFrom("not-a-date")).toBe(false); + expect(isValidEndedFrom("2026-13")).toBe(false); + }); + + test("a calendar-impossible date is rejected, day- or month-shaped", () => { + expect(isValidEndedFrom("2026-02-30")).toBe(false); // no Feb 30 + expect(isValidEndedFrom("2026-02-29")).toBe(false); // 2026 is not a leap year + }); + + test("a real leap day is accepted", () => { + expect(isValidEndedFrom("2024-02-29")).toBe(true); + }); +}); + +// C1: switching the unit dropdown to a day-granular kind must expand a +// month-only startDate in the same mutate call, never leaving the pairing +// unvalidated for even one render. +describe("normalizedStartDateFor", () => { + test("expands a month-only date when switching to a day-granular kind", () => { + expect(normalizedStartDateFor("everyNDays", "2026-09")).toBe("2026-09-01"); + expect(normalizedStartDateFor("everyNWeeks", "2026-09")).toBe("2026-09-01"); + }); + + test("leaves an already day-granular date untouched", () => { + expect(normalizedStartDateFor("everyNDays", "2026-09-17")).toBe("2026-09-17"); + }); + + test("leaves the date untouched when switching to everyNMonths", () => { + expect(normalizedStartDateFor("everyNMonths", "2026-09")).toBe("2026-09"); + expect(normalizedStartDateFor("everyNMonths", "2026-09-17")).toBe("2026-09-17"); + }); +}); diff --git a/src/ui/routes/settings/RecurringSection.tsx b/src/ui/routes/settings/RecurringSection.tsx new file mode 100644 index 0000000..1b2fb0d --- /dev/null +++ b/src/ui/routes/settings/RecurringSection.tsx @@ -0,0 +1,395 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { NativeSelect } from "@/components/ui/native-select"; +import { useDataset } from "../../hooks/useDataset.ts"; +import { useMutate } from "../../hooks/useMutate.ts"; +import { + addRecurringCost, + endRecurringCost, + moveRecurringCost, + restoreRecurringCost, + setRecurringCostEndedFrom, + updateRecurringCost, +} from "../../../store/actions.ts"; +import { parseMoneyInput } from "../../moneyInput.ts"; +import { currentMonth } from "../../../store/index.ts"; +import { Section } from "../../components/Section.tsx"; +import { monthOf } from "../../../domain/months.ts"; +import { toDayOrdinal } from "../../../domain/days.ts"; +import type { IsoDate, Recurrence, RecurringCost } from "../../../domain/types.ts"; + +const WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + +/** "YYYY-MM" is 7 characters; "YYYY-MM-DD" is 10. Same idiom PurchaseDialog uses. */ +export function isDayGranular(date: IsoDate): boolean { + return date.length > 7; +} + +/** + * True only for a string this cost's recurrence kind could actually walk: + * `everyNMonths` tolerates either granularity (the walk normalises through + * `monthOf`), the other two require a day-granular date because they reach + * `addDays` directly. Reuses the domain's own parsers rather than a second, + * possibly-diverging regex. + * + * A day-SHAPED value is routed through `toDayOrdinal` even under + * `everyNMonths`: `monthOf` only extracts the month and never checks the day, + * so without this a calendar-impossible date like "2026-09-31" (September has + * 30 days) would pass here and could still be stored, only to throw three + * months later out of the fold. + * + * Exported for `RecurringSection.test.ts` — this is the one thing standing + * between the "Starts" field and C2 (an empty or malformed value bricking + * every route the fold touches), so it is tested directly rather than only + * through a DOM interaction. + */ +export function isValidStartDate(value: string, kind: Recurrence["kind"]): boolean { + try { + if (kind === "everyNMonths" && !isDayGranular(value)) monthOf(value); + else toDayOrdinal(value); + return true; + } catch { + return false; + } +} + +/** + * Validates the "Ends" column. Unlike `startDate`, `endedFrom` carries NO + * granularity rule (§10 of the design) — it is only ever compared + * lexicographically via `isBefore` in `domain/occurrences.ts` and never + * reaches `addDays`, so a month-only cancellation is exactly as valid as a + * day-granular one regardless of the cost's recurrence kind. It still has to + * be a real calendar date: "2026-02-30" is refused the same way an + * impossible `startDate` already is. + * + * An empty string is valid — it means "clear the cancellation" — and is what + * lets the field double as the un-end control. + * + * Exported for `RecurringSection.test.ts`. + */ +export function isValidEndedFrom(value: string): boolean { + if (value === "") return true; + try { + if (isDayGranular(value)) toDayOrdinal(value); + else monthOf(value); + return true; + } catch { + return false; + } +} + +/** + * What the "Starts" value becomes when the owner switches to a day-granular + * unit: a month-only value expands to the first of that month, in the SAME + * mutate call that changes the kind (C1) — the walk must never see the old + * pairing even for one render. `everyNMonths` and an already day-granular + * value pass through untouched. + * + * Exported for `RecurringSection.test.ts`. + */ +export function normalizedStartDateFor(kind: Recurrence["kind"], startDate: IsoDate): IsoDate { + if (kind === "everyNMonths" || isDayGranular(startDate)) return startDate; + return `${startDate}-01`; +} + +export function RecurringSection() { + const dataset = useDataset(); + const { mutate } = useMutate(); + const [newName, setNewName] = useState(""); + const ordered = [...dataset.recurring].sort((a, b) => a.order - b.order); + const base = dataset.settings.baseCurrency; + const firstPost = [...dataset.posts].sort((a, b) => a.order - b.order)[0]; + + function amountCell(cost: RecurringCost) { + return ( + { + // Captured before mutate: mutate defers behind the write queue and + // React resets the DOM value first, so reading event.target.value + // inside the callback would commit the previous keystroke. + const typed = event.target.value; + // (text, currency options, fallback) — the same three arguments + // PurchaseDialog passes. The options list is what lets "30$" resolve + // to USD; the fallback is used when no symbol or code is typed. + const parsed = parseMoneyInput(typed, dataset.currencies, cost.amount.currency); + if (!parsed) return; + mutate((draft) => updateRecurringCost(draft, cost.id, { amount: parsed })); + }} + /> + ); + } + + function intervalCell(cost: RecurringCost) { + return ( +
    + { + // Captured BEFORE mutate: mutate defers behind the write queue and + // React resets the DOM value first, so reading it inside the + // callback commits the previous keystroke. + const n = Number(event.target.value); + if (!Number.isInteger(n) || n < 1) return; + mutate((draft) => + updateRecurringCost(draft, cost.id, { recurrence: { ...cost.recurrence, n } }), + ); + }} + /> + { + const kind = event.target.value as Recurrence["kind"]; + const recurrence: Recurrence = + kind === "everyNWeeks" + ? { kind, n: cost.recurrence.n, weekday: 1 } + : { kind, n: cost.recurrence.n }; + // Switching to a day-granular kind against a month-only + // startDate is exactly how C1 bricked the app: the walk hands + // "2026-09" to addDays and throws. Expand it to the first of + // that month IN THIS SAME mutate call, so the pairing the store + // now validates never has a chance to go wrong. + const startDate = normalizedStartDateFor(kind, cost.startDate); + mutate((draft) => + updateRecurringCost(draft, cost.id, { recurrence, startDate }), + ); + }} + > + + + + + {cost.recurrence.kind === "everyNWeeks" && ( + { + const weekday = Number(event.target.value); + mutate((draft) => + updateRecurringCost(draft, cost.id, { + recurrence: { kind: "everyNWeeks", n: cost.recurrence.n, weekday }, + }), + ); + }} + > + {WEEKDAYS.map((day, index) => ( + + ))} + + )} +
    + ); + } + + return ( +
    + {ordered.length === 0 ? ( +

    + No recurring costs yet. Add rent, a subscription, or a phone bill below. +

    + ) : ( +
    + + + + + + + + + + + + + {ordered.map((cost, index) => ( + + + + + + + + + + ))} + +
    NameAmountEveryMeasured fromStartsEnds +
    + { + const name = event.target.value; + mutate((draft) => updateRecurringCost(draft, cost.id, { name })); + }} + /> + {amountCell(cost)}{intervalCell(cost)} + { + const anchoring = event.target.value as RecurringCost["anchoring"]; + mutate((draft) => updateRecurringCost(draft, cost.id, { anchoring })); + }} + > + + + + + {/* Uncontrolled: committing only happens on blur, and only + when the text parses as an IsoDate of the granularity + this cost's recurrence kind requires. Keyed on the + stored value so an external change (kind flip + auto-expanding it, or a rejected edit) is reflected + rather than left showing stale typed text. */} + { + const typed = event.target.value; + if (!isValidStartDate(typed, cost.recurrence.kind)) { + event.target.value = cost.startDate; + return; + } + mutate((draft) => + updateRecurringCost(draft, cost.id, { startDate: typed }), + ); + }} + /> + + {/* Same idiom as "Starts": uncontrolled, committed on + blur, keyed on the stored value so an external change + (the end/restart button, or a rejected edit) shows up + rather than leaving stale typed text. Empty means "not + ended" and is itself a valid value — clearing it here + does the same thing as the "restart" button. */} + { + const typed = event.target.value.trim(); + if (!isValidEndedFrom(typed)) { + event.target.value = cost.endedFrom ?? ""; + return; + } + mutate((draft) => + setRecurringCostEndedFrom( + draft, + cost.id, + typed === "" ? undefined : typed, + ), + ); + }} + /> + +
    + + + +
    +
    +
    + )} + +

    + Ending a cost — from the "end" button, or by typing a date into + "Ends" directly — stops it from that date onward and leaves every past + occurrence exactly as it was; clearing the date restarts it. Changing + the amount only moves occurrences that have not been confirmed yet — + what you already paid is recorded on the purchases themselves, so a + price rise never rewrites history. +

    + +
    + setNewName(event.target.value)} + /> + + {!firstPost && ( + Create a post first. + )} +
    +
    + ); +}