From de333685a2649e0a663bccab93df2a54cfc77b1e Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 3 Sep 2026 03:34:32 +0200 Subject: [PATCH 01/28] feat(domain): day arithmetic without Date Recurrences of 28 and 30 days do not align to months, so the domain needs day-level maths for the first time. It gets it the way months.ts does: an integer ordinal, no Date anywhere. A Date would make 2026-03-29 a different instant per timezone while being the same day everywhere, and a 28-day step must not gain or lose a day at a DST boundary. Hinnant's days_from_civil, which shifts the year to start in March so the leap day is the last day rather than an insertion, removing every special case. No isDayGranular export: nothing in the plan consumes it, later tasks branch on a recurrence kind and import validation uses a regex directly. --- src/domain/days.test.ts | 83 +++++++++++++++++++++++++++++++++++ src/domain/days.ts | 95 +++++++++++++++++++++++++++++++++++++++++ src/domain/index.ts | 1 + 3 files changed, 179 insertions(+) create mode 100644 src/domain/days.test.ts create mode 100644 src/domain/days.ts 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/index.ts b/src/domain/index.ts index 94c7962..f9804be 100644 --- a/src/domain/index.ts +++ b/src/domain/index.ts @@ -3,6 +3,7 @@ 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"; From a456aadf3522df7cc071938de4c26baf0fee95e2 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 3 Sep 2026 03:41:48 +0200 Subject: [PATCH 02/28] feat(types): recurring costs in the dataset, schema 7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recurrence is two orthogonal fields, not a list of special cases: a kind (everyNMonths / everyNDays / everyNWeeks) and an anchoring (calendar / lastCharge). The phone bill that recharges early when its data cap is hit is everyNDays(30) anchored to lastCharge — the domain never learns what a data cap is, because confirming the occurrence early already records the date. Purchase.source pairs occurrenceDate (identity: which slot this claims) with Purchase.date (truth: when the money moved). They differ whenever a bill is paid off schedule, and lastCharge measures from the latter. RecurringCost.amount is a single mutable Money with no RuleVersion-style series. Safe only because confirmed purchases hold what was actually paid; under projection-only it would destroy history. The 6 -> 7 step writes an empty array and reads nothing. Decimals took 6. --- src/domain/allocation.test.ts | 2 + src/domain/charges.test.ts | 2 + src/domain/currencyDigits.test.ts | 1 + src/domain/fold.test.ts | 1 + src/domain/seed.ts | 6 ++- src/domain/types.ts | 75 +++++++++++++++++++++++++++++++ src/domain/views.test.ts | 1 + src/store/migrations.test.ts | 50 +++++++++++++++++++++ src/store/migrations.ts | 19 ++++++++ 9 files changed, 156 insertions(+), 1 deletion(-) 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..26e9589 100644 --- a/src/domain/charges.test.ts +++ b/src/domain/charges.test.ts @@ -219,6 +219,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,6 +234,7 @@ describe("chargesForMonth", () => { posts: [], months: [], purchases: [], + recurring: [], }; expect(chargesForMonth(data, "2026-09").size).toBe(0); }); diff --git a/src/domain/currencyDigits.test.ts b/src/domain/currencyDigits.test.ts index dc15ed6..83733c7 100644 --- a/src/domain/currencyDigits.test.ts +++ b/src/domain/currencyDigits.test.ts @@ -109,6 +109,7 @@ describe("the fold at zero decimals", () => { { id: "2026-02", income: { amount: 10_000, currency: "JPY" }, ruleOverrides: {} }, ], purchases: [], + recurring: [], }; } diff --git a/src/domain/fold.test.ts b/src/domain/fold.test.ts index 4028d48..864ed7f 100644 --- a/src/domain/fold.test.ts +++ b/src/domain/fold.test.ts @@ -47,6 +47,7 @@ function dataset(posts: Post[], purchases: Purchase[]): Dataset { { id: "2026-03", income: { amount: 10000, currency: "DKK" }, ruleOverrides: {} }, ], purchases, + recurring: [], }; } 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..2b2f96f 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", () => { diff --git a/src/store/migrations.test.ts b/src/store/migrations.test.ts index 9f2b948..efd2430 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,52 @@ 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); + // `projected` and `expected` don't exist on PostMonthFigures until + // Task 5 adds them — this is the deliberate known-red guard the task + // brief calls for. The ts-expect-error, not a weaker cast, means this + // line breaks loudly (as an unused-suppression compile error) the + // moment Task 5 lands the fields, forcing it to be revisited rather + // than silently starting to pass. + // @ts-expect-error PostMonthFigures gains `projected` in Task 5. + expect(figures.projected).toBe(figures.remaining); + // @ts-expect-error PostMonthFigures gains `expected` in Task 5. + 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 { From b41b219d94a80385ba1a526f1e745a83888352ff Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 3 Sep 2026 03:47:21 +0200 Subject: [PATCH 03/28] feat(domain): project recurring occurrences, never store them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A walk rather than a per-month query: under lastCharge anchoring, where a series sits in a given month depends on every confirmation before it, so the projection starts at startDate and steps forward. Bounded by upToMonth, which the caller supplies — the domain still reads no clock. The data-cap behaviour is one line. `from` is the confirmation's own date under lastCharge, so confirming a slot early moves every later occurrence with it. Confirm the 2026-01-31 slot with a purchase dated the 12th and the next lands on 2026-02-11, not 2026-03-02. The walk throws if a step fails to advance. n >= 1 is enforced at both write boundaries so this should be unreachable, but a hang is worse than a throw and dropping a bill silently is worse than both. Rewrote the "deleting the confirming purchase" test per controller ruling: the brief's version was byte-identical to the unconfirmed-series test above it and asserted nothing new. It now walks the phone cost with the capHit confirmations, then walks the same cost with an empty map, and asserts the dates differ and match the unconfirmed series — which is what "deleting the purchase restores the series" actually means. Co-Authored-By: Claude Opus 5 (1M context) --- src/domain/index.ts | 1 + src/domain/occurrences.test.ts | 261 +++++++++++++++++++++++++++++++++ src/domain/occurrences.ts | 178 ++++++++++++++++++++++ 3 files changed, 440 insertions(+) create mode 100644 src/domain/occurrences.test.ts create mode 100644 src/domain/occurrences.ts diff --git a/src/domain/index.ts b/src/domain/index.ts index f9804be..ed838dc 100644 --- a/src/domain/index.ts +++ b/src/domain/index.ts @@ -7,6 +7,7 @@ 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/occurrences.test.ts b/src/domain/occurrences.test.ts new file mode 100644 index 0000000..44aa658 --- /dev/null +++ b/src/domain/occurrences.test.ts @@ -0,0 +1,261 @@ +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 — 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..443be4e --- /dev/null +++ b/src/domain/occurrences.ts @@ -0,0 +1,178 @@ +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; +} + +/** + * 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. + const from = + cost.anchoring === "lastCharge" && confirmation ? 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 when + // a confirmation is dated more than one step before the slot it claims. + 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}, ` + + `more than one step earlier — correct its date to move the series forward.` + : `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; +} From 324b6bd27242bb591b49f2e21f3a64dce80b3746 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 3 Sep 2026 17:57:34 +0200 Subject: [PATCH 04/28] feat(domain): expected charges, sharing one split implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit expectedForMonth mirrors chargesForMonth for money that has not moved yet, and skips confirmed occurrences — those are ordinary purchases that chargesForMonth has already counted, so counting both would double every settled bill. The split machinery is extracted rather than duplicated. Two implementations could round differently or absorb the remainder differently, and the gap would show up as a penny of drift between the fold's two tracks with no cause the owner could find. remainderIndexOf and splitPartsOfTotal keep their signatures as wrappers, so no existing caller changes. Smaller than planned: 8d2b2ca already made these functions take digits rather than a currency table, so the shared helper needs no currency plumbing. --- src/domain/charges.test.ts | 96 +++++++++++++++++++++++- src/domain/charges.ts | 147 +++++++++++++++++++++++++++++++------ 2 files changed, 218 insertions(+), 25 deletions(-) diff --git a/src/domain/charges.test.ts b/src/domain/charges.test.ts index 26e9589..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 = [ @@ -239,3 +241,95 @@ describe("chargesForMonth", () => { 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; +} From 3d3121aca2cea540750cbb262c617f1c6cfbd171 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 3 Sep 2026 18:02:49 +0200 Subject: [PATCH 05/28] feat(fold): a second balance for money committed but not moved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit remaining is untouched: carriedIn + allocation - charges, exactly as before, so no figure the owner has reconciled against their bank can move when they add a recurring cost. projected runs beside it on its own carry, subtracting unconfirmed occurrences as well. Two invariants pin it, both tested. With no recurring costs the tracks are identical in every month — that is the behaviour-preservation guard for every dataset that exists today. Once a month's occurrences are all confirmed they reconverge there, because a confirmation is an ordinary purchase counted in charges and skipped by expected. An unconfirmed occurrence in a PAST month therefore holds projected below remaining forever. That is the honest reading — an unreconciled commitment — and the month view surfaces the count so it is not silent. Also required to typecheck once PostMonthFigures gains these fields: - src/store/migrations.test.ts: delete the two @ts-expect-error directives that stood over the "NO figure changes" guard test since Task 2. That test is now green — the point of this task. - src/ui/meterSegments.test.ts: the figures() test helper builds a complete PostMonthFigures literal; default expected to 0, projectedCarriedIn to carriedIn, and projected to remaining, matching its existing partial.X ?? style. Co-Authored-By: Claude Opus 5 (1M context) --- src/domain/fold.test.ts | 112 ++++++++++++++++++++++++++++++++++- src/domain/fold.ts | 40 ++++++++++++- src/store/migrations.test.ts | 8 --- src/ui/meterSegments.test.ts | 6 +- 4 files changed, 154 insertions(+), 12 deletions(-) diff --git a/src/domain/fold.test.ts b/src/domain/fold.test.ts index 864ed7f..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. */ @@ -59,6 +60,9 @@ describe("envelope rollover", () => { allocation: 500, charges: 0, remaining: 500, + expected: 0, + projectedCarriedIn: 0, + projected: 500, }); }); @@ -71,6 +75,9 @@ describe("envelope rollover", () => { allocation: 500, charges: 0, remaining: 700, + expected: 0, + projectedCarriedIn: 200, + projected: 700, }); }); @@ -157,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, }); }); @@ -175,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/store/migrations.test.ts b/src/store/migrations.test.ts index efd2430..d1f27ce 100644 --- a/src/store/migrations.test.ts +++ b/src/store/migrations.test.ts @@ -479,15 +479,7 @@ describe("6 -> 7: recurring costs join the dataset", () => { 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); - // `projected` and `expected` don't exist on PostMonthFigures until - // Task 5 adds them — this is the deliberate known-red guard the task - // brief calls for. The ts-expect-error, not a weaker cast, means this - // line breaks loudly (as an unused-suppression compile error) the - // moment Task 5 lands the fields, forcing it to be revisited rather - // than silently starting to pass. - // @ts-expect-error PostMonthFigures gains `projected` in Task 5. expect(figures.projected).toBe(figures.remaining); - // @ts-expect-error PostMonthFigures gains `expected` in Task 5. expect(figures.expected).toBe(0); } } 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, }; } From bbe313b009ad8832098a2591f73fbb5680c3e4df Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 3 Sep 2026 18:08:32 +0200 Subject: [PATCH 06/28] feat(store): recurring cost actions, and confirming an occurrence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit confirmOccurrence writes an ordinary Purchase: the slot it claims goes in source.occurrenceDate, the date the money moved goes on the purchase, and the two differ whenever a bill is paid off schedule. Splits are copied rather than shared, so editing the purchase cannot rewrite the cost. endRecurringCost sets endedFrom AND archived, because they do different jobs — one stops the projection, the other only hides the row. Archiving alone would leave a cancelled bill projecting forever. Neither touches a past occurrence, so no historical figure moves. requireRecurrence rejects n below 1 and non-integers rather than clamping. The walk terminates only if every step advances, and the importer is not the only write path. Co-Authored-By: Claude Opus 5 (1M context) --- src/store/actions.test.ts | 154 ++++++++++++++++++++++++++++++++++++++ src/store/actions.ts | 142 +++++++++++++++++++++++++++++++++++ 2 files changed, 296 insertions(+) diff --git a/src/store/actions.test.ts b/src/store/actions.test.ts index 3bc7c1b..59620b6 100644 --- a/src/store/actions.test.ts +++ b/src/store/actions.test.ts @@ -453,3 +453,157 @@ 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/); + }); + + 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); + }); + + 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/); + }); +}); diff --git a/src/store/actions.ts b/src/store/actions.ts index 8f834e4..9a3d57e 100755 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -7,6 +7,7 @@ import type { CurrencyDef, Dataset, FxRate, + IsoDate, Money, Month, MonthId, @@ -14,6 +15,9 @@ import type { PostId, Purchase, PurchaseId, + Recurrence, + RecurringCost, + RecurringCostId, Rule, RuleVersion, Schedule, @@ -362,3 +366,141 @@ 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; +} + +export function addRecurringCost( + draft: Dataset, + cost: Omit, +): RecurringCost { + const created: RecurringCost = { + ...cost, + id: newId(), + order: draft.recurring.length, + recurrence: requireRecurrence(cost.recurrence), + amount: roundMoneyValue(draft, cost.amount), + splits: roundSplits(draft, cost.splits, cost.splitMode, cost.amount.currency), + }; + draft.recurring.push(created); + return created; +} + +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); + } + 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]; +} + +/** + * Stops a bill from `from` onward and hides it from the list. + * + * Both fields, because they mean different things: `endedFrom` stops the + * PROJECTION, and `archived` only hides the row. Archiving alone would leave + * the bill projecting forever; setting `endedFrom` alone would leave a dead + * bill cluttering the list. Neither touches a past occurrence, so no + * historical figure moves. + */ +export function endRecurringCost(draft: Dataset, id: RecurringCostId, from: IsoDate): void { + const cost = requireRecurringCost(draft, id); + cost.endedFrom = from; + cost.archived = true; +} + +export function restoreRecurringCost(draft: Dataset, id: RecurringCostId): void { + const cost = requireRecurringCost(draft, id); + delete cost.endedFrom; + cost.archived = false; +} + +/** + * 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 }, + }); +} From 74ae4b21e7f983e5d4cd2e093000a345c483ee21 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 3 Sep 2026 18:14:21 +0200 Subject: [PATCH 07/28] feat(import): validate recurring costs at the boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The one check that is load-bearing rather than tidy: a recurrence interval below 1, or a fractional one, is REFUSED rather than clamped. The projection walk terminates only if every step strictly advances, and a clamped zero would hand the owner a schedule their file does not describe — silently. Purchase.source is checked too: the cost it names must exist, and the occurrence date must parse. PURCHASE_DATE already accepts both YYYY-MM and YYYY-MM-DD, which is exactly the range a slot date can take. --- src/export/json.test.ts | 139 ++++++++++++++++++++++++++++++++++++++++ src/export/json.ts | 86 +++++++++++++++++++++++++ 2 files changed, 225 insertions(+) diff --git a/src/export/json.test.ts b/src/export/json.test.ts index 7e25dee..8f482ee 100644 --- a/src/export/json.test.ts +++ b/src/export/json.test.ts @@ -253,3 +253,142 @@ 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/); + }); + + 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("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/); + }); +}); + +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/); + }); +}); diff --git a/src/export/json.ts b/src/export/json.ts index 6158d19..cbdd6a3 100644 --- a/src/export/json.ts +++ b/src/export/json.ts @@ -36,6 +36,9 @@ const MONTH_ID = /^\d{4}-(0[1-9]|1[0-2])$/; */ const PURCHASE_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"]); + /** * `Currency` is an open string now, so this — not the type system — is what * stops a code the dataset never defined reaching the fold, where it would @@ -118,6 +121,39 @@ 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`, + ); + } + } +} + /** * 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 @@ -210,6 +246,43 @@ export function parseDatasetJson(text: string): Dataset { } } + const recurring = Array.isArray(data.recurring) ? (data.recurring as any[]) : []; + dataset.recurring = recurring; + const recurringIds = new Set(recurring.map((cost: any) => cost.id)); + + for (const cost of recurring) { + 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); + + if (!ANCHORINGS.has(cost.anchoring)) { + throw new ImportValidationError( + `${label} has an unknown anchoring "${String(cost.anchoring)}"`, + ); + } + if (!Array.isArray(cost.splits) || cost.splits.length === 0) { + throw new ImportValidationError(`${label} has no splits; at least one split is required`); + } + const absorbers = cost.splits.filter((s: any) => s.absorbsRemainder).length; + if (absorbers !== 1) { + throw new ImportValidationError( + `${label} has ${absorbers} splits flagged absorbsRemainder; exactly one is required`, + ); + } + for (const split of cost.splits) { + if (!postIds.has(split.postId)) { + throw new ImportValidationError(`${label} references unknown post "${split.postId}"`); + } + } + } + for (const month of dataset.months) { if (!MONTH_ID.test(month.id)) { throw new ImportValidationError(`Invalid month id "${month.id}"`); @@ -246,6 +319,19 @@ export function parseDatasetJson(text: string): Dataset { } requireCurrency(slice.amount.currency, `${label} slice ${slice.month}`, defined); } + + if (purchase.source !== undefined) { + 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; From 65543eb37b751f5edb8570b3aa04c0a10eecc7c6 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 3 Sep 2026 18:24:25 +0200 Subject: [PATCH 08/28] fix(import): close three validation gaps found in review round 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I3: purchase.source: null (and a null entry in recurring[]) reached a bare property access and threw a raw TypeError instead of ImportValidationError. null is not undefined, so the existing `!== undefined` guard let it through; source: null is a plausible hand-edit since the sibling field on the same purchase literal is schedule: null. Both paths now check for null/non-object explicitly and refuse with the file's own wording, matching the defensive style already used two lines away (cost.amount?.currency, schedule?.slices ?? []). I1/I2: splitMode and splits[].value were unchecked on both purchases and recurring costs. An unrecognised splitMode silently takes charges.ts's "fixed" branch instead of "percent" (or vice versa), turning an intended percentage into an absolute amount with no error. A non-finite split value propagates as NaN through distributeByWeight/distributeByAmount, and roundMoney(NaN, digits) returns NaN rather than throwing. Both are now checked in one new requireSplits() helper shared by the purchases loop and the recurring-costs loop, replacing each loop's separate copy of the no-splits/one-absorber/unknown-post checks rather than adding a third copy. Per AGENTS.md §3, requireSplits only rejects non-numbers: negative values, values over 100, and splits that don't sum to the total stay deliberately unchecked. --- src/export/json.test.ts | 71 +++++++++++++++++++++++++ src/export/json.ts | 113 +++++++++++++++++++++++++++++----------- 2 files changed, 153 insertions(+), 31 deletions(-) diff --git a/src/export/json.test.ts b/src/export/json.test.ts index 8f482ee..7f7c861 100644 --- a/src/export/json.test.ts +++ b/src/export/json.test.ts @@ -347,6 +347,42 @@ describe("recurring cost validation", () => { 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", () => { @@ -391,4 +427,39 @@ describe("Purchase.source validation", () => { }]; 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 cbdd6a3..b9d0c05 100644 --- a/src/export/json.ts +++ b/src/export/json.ts @@ -38,6 +38,7 @@ const PURCHASE_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 @@ -154,6 +155,65 @@ function requireRecurrence(recurrence: any, label: string): void { } } +/** + * 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 @@ -248,9 +308,17 @@ export function parseDatasetJson(text: string): Dataset { const recurring = Array.isArray(data.recurring) ? (data.recurring as any[]) : []; dataset.recurring = recurring; - const recurringIds = new Set(recurring.map((cost: any) => cost.id)); + // 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); @@ -267,20 +335,7 @@ export function parseDatasetJson(text: string): Dataset { `${label} has an unknown anchoring "${String(cost.anchoring)}"`, ); } - if (!Array.isArray(cost.splits) || cost.splits.length === 0) { - throw new ImportValidationError(`${label} has no splits; at least one split is required`); - } - const absorbers = cost.splits.filter((s: any) => s.absorbsRemainder).length; - if (absorbers !== 1) { - throw new ImportValidationError( - `${label} has ${absorbers} splits flagged absorbsRemainder; exactly one is required`, - ); - } - for (const split of cost.splits) { - if (!postIds.has(split.postId)) { - throw new ImportValidationError(`${label} references unknown post "${split.postId}"`); - } - } + requireSplits(cost.splits, cost.splitMode, label, postIds); } for (const month of dataset.months) { @@ -297,22 +352,7 @@ 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}"`); @@ -321,6 +361,17 @@ export function parseDatasetJson(text: string): Dataset { } 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}"`, From 72471914ba89f122c638a2b8fede2d51b68df25a Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 3 Sep 2026 18:30:22 +0200 Subject: [PATCH 09/28] feat(settings): define recurring costs Follows PostsSection: same table, same xs ghost row actions, same add row. Definition is the rare act, so it lives here rather than in the month view. Placed after Posts because a cost's splits reference posts, and before the data section so the destructive import controls stay last. 'end' sets endedFrom to the current month and archives in one action, which is what the owner means by cancelling a subscription. Past occurrences are untouched, so no historical figure moves. --- src/ui/routes/SettingsRoute.tsx | 2 + src/ui/routes/settings/RecurringSection.tsx | 266 ++++++++++++++++++++ 2 files changed, 268 insertions(+) create mode 100644 src/ui/routes/settings/RecurringSection.tsx 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.tsx b/src/ui/routes/settings/RecurringSection.tsx new file mode 100644 index 0000000..237ae5a --- /dev/null +++ b/src/ui/routes/settings/RecurringSection.tsx @@ -0,0 +1,266 @@ +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, + updateRecurringCost, +} from "../../../store/actions.ts"; +import { parseMoneyInput } from "../../moneyInput.ts"; +import { currentMonth } from "../../../store/index.ts"; +import { Section } from "../../components/Section.tsx"; +import type { Recurrence, RecurringCost } from "../../../domain/types.ts"; + +const WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + +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 }; + mutate((draft) => updateRecurringCost(draft, cost.id, { recurrence })); + }} + > + + + + + {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 fromStarts +
+ { + 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 })); + }} + > + + + + + { + const startDate = event.target.value; + mutate((draft) => updateRecurringCost(draft, cost.id, { startDate })); + }} + /> + +
+ + + +
+
+
+ )} + +

+ Ending a cost stops it from this month onward and leaves every past + occurrence exactly as it was. 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. + )} +
+
+ ); +} From b0324cb3e2b28ff8668fd3a9b49c7f3003636e41 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 3 Sep 2026 18:41:43 +0200 Subject: [PATCH 10/28] feat(month): confirm expected charges in one click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repeated act, so it is a row and a button rather than a dialog — entry speed is a product principle and a modal per bill would defeat it. The amount is editable inline because paying a different figure is common; anything more unusual is edited afterwards like any other purchase. Confirmation is offered on a pending occurrence whose date has not arrived yet, deliberately. A bill anchored to its last charge can charge EARLY, and confirming the pending slot with the real date is how the series rebases. The overdue count is not decoration. An occurrence nobody ever confirms holds the projected balance down forever, which is the honest reading of an unreconciled commitment but is invisible without it. Returns null when nothing is expected, so a dataset with no recurring costs sees no change to the month view at all. --- src/ui/components/ExpectedBand.tsx | 125 +++++++++++++++++++++++++++++ src/ui/routes/MonthRoute.tsx | 3 + 2 files changed, 128 insertions(+) create mode 100644 src/ui/components/ExpectedBand.tsx diff --git a/src/ui/components/ExpectedBand.tsx b/src/ui/components/ExpectedBand.tsx new file mode 100644 index 0000000..a930a9f --- /dev/null +++ b/src/ui/components/ExpectedBand.tsx @@ -0,0 +1,125 @@ +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 { confirmOccurrence } from "../../store/actions.ts"; +import { occurrencesByMonth } from "../../domain/occurrences.ts"; +import { compareMonths } from "../../domain/months.ts"; +import { parseMoneyInput } from "../moneyInput.ts"; +import { Section } from "./Section.tsx"; +import type { MonthId } from "../../domain/types.ts"; + +/** + * 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. + */ +export function ExpectedBand({ monthId }: { monthId: MonthId }) { + const dataset = useDataset(); + const { mutate } = useMutate(); + const [amounts, setAmounts] = useState>({}); + + const { pending, overdue } = useMemo(() => { + const byMonth = occurrencesByMonth(dataset, monthId); + const thisMonth = (byMonth.get(monthId) ?? []).filter((o) => !o.confirmedBy); + + // Everything unconfirmed in an EARLIER month. An occurrence nobody ever + // confirms holds the projected balance down forever, which is the honest + // reading of an unreconciled commitment — but it is silent unless counted. + let earlier = 0; + for (const [month, occurrences] of byMonth) { + if (compareMonths(month, monthId) >= 0) continue; + earlier += occurrences.filter((o) => !o.confirmedBy).length; + } + + return { pending: thisMonth, overdue: earlier }; + }, [dataset, monthId]); + + if (pending.length === 0 && overdue === 0) return null; + + const nameOf = (recurringId: string) => + dataset.recurring.find((c) => c.id === recurringId)?.name ?? "Unknown"; + + return ( +
+ {pending.length > 0 && ( +
    + {pending.map((occurrence) => { + const key = `${occurrence.recurringId}:${occurrence.date}`; + const typed = amounts[key]; + return ( +
  • +
    +
    {nameOf(occurrence.recurringId)}
    +
    {occurrence.date}
    +
    +
    + { + const next = event.target.value; + setAmounts((current) => ({ ...current, [key]: next })); + }} + /> + + {occurrence.amount.currency} + + +
    +
  • + ); + })} +
+ )} + + {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/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() { + +
} From 824e94a9bdf683d32944eadd6903270162cd086a Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 3 Sep 2026 18:52:25 +0200 Subject: [PATCH 11/28] feat(ui): surface the projected balance and fix interval clipping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PostTable gained a Projected column (both the sm: table and the mobile block markup) so the fold's second balance — what is left after unconfirmed recurring bills — is finally visible, muted so Remaining stays the primary figure. The legend now explains it. RecurringSection's interval input widened from w-14 to w-20 so two-digit values like 28 and 30 no longer collide with the native number spinner. Co-Authored-By: Claude Opus 5 (1M context) --- src/ui/components/PostTable.tsx | 18 ++++++++++++++++-- src/ui/routes/settings/RecurringSection.tsx | 2 +- 2 files changed, 17 insertions(+), 3 deletions(-) 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/routes/settings/RecurringSection.tsx b/src/ui/routes/settings/RecurringSection.tsx index 237ae5a..1917e70 100644 --- a/src/ui/routes/settings/RecurringSection.tsx +++ b/src/ui/routes/settings/RecurringSection.tsx @@ -55,7 +55,7 @@ export function RecurringSection() { return (
Date: Thu, 3 Sep 2026 19:03:35 +0200 Subject: [PATCH 12/28] docs(recurring): record the invariants and what they cost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two rules that only review catches, so they go in AGENTS.md §1: occurrences are computed and never stored, and a confirmation's date and its slot are different facts. Collapsing the second pair into one field would silently delete the phone-bill behaviour, which is why it is written down rather than left to the types. §2 gets the termination guard. n >= 1 is rejected at both write boundaries rather than clamped, and the walk throws rather than breaking, because a bill silently dropped from a budget is worse than a visible error. Demo data seeds a monthly rent and a 28-day subscription — the second is what puts two occurrences in one month, which is the case worth looking at. It also seeds a biweekly cost and a lastCharge-anchored one, so every recurrence kind and both anchorings show up in Settings and the month view. DECISIONS.md, PRODUCT.md, ARCHITECTURE.md and TODO.md are updated to match: schema is at 7, the month view carries a second Projected balance beside Remaining, and TODO.md records what a week of real use — not a test — will have to answer. --- AGENTS.md | 19 ++++++++++++++++ docs/ARCHITECTURE.md | 20 +++++++++++++++++ docs/DECISIONS.md | 33 +++++++++++++++++++++++++++- docs/PRODUCT.md | 10 ++++++++- docs/TODO.md | 22 ++++++++++++++----- scripts/demo-data.ts | 52 ++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 149 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 800a96d..dcb646a 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,6 +47,17 @@ and `docs/specs/`, and is not repeated here. the owner never chose. - **`MonthId` is the string `"YYYY-MM"`.** Stored data never contains `Date` objects. +- **A recurring cost's occurrences are COMPUTED, never stored.** `Dataset` + holds the rule and the confirmations; `occurrencesByMonth` derives the rest + on every fold. There is no horizon and no "generate ahead" step, and + `upToMonth` comes from the caller so `src/domain/` stays clock-free. Storing + them would reintroduce the materialised snapshot the original design rejected. +- **On a confirmation, `Purchase.date` is TRUTH and `source.occurrenceDate` is + IDENTITY.** The slot is what a confirmation claims, so the projector stops + emitting it; the date is when the money moved, so the fold counts it there. + They differ whenever a bill is paid off schedule, and under `lastCharge` + anchoring it is `date` that rebases the series. Do not "simplify" them into + one field — that erases the phone-bill behaviour entirely. - **The envelope rollover fold has exactly one implementation**, in `src/domain/fold.ts`. Year and summary views aggregate over it — they never reimplement the math. @@ -100,6 +111,14 @@ ship twice — re-introducing one is a regression, not a style choice. fold and the month view, and asserts that raising the setting to 2 changes what the fold produces. It cannot prove digits vary per currency, because they no longer do. +- **A recurrence interval must be an integer `>= 1`, rejected rather than + clamped**, at BOTH write boundaries — `requireRecurrence` in + `src/store/actions.ts` and in `src/export/json.ts`. The projection walk + terminates only because every step strictly advances, and a clamped zero + would hand the owner a schedule their data does not describe. The walk throws + if a step fails to advance; do not soften that into a `break`. + → `src/domain/occurrences.test.ts`, `src/store/actions.test.ts`, + `src/export/json.test.ts` - **`process.env.BUN_PUBLIC_*` must be read via `try/catch`** — see `readBasePathEnv`. Bun inlines the literal only when the variable is set; unset, the bare reference reaches the browser and throws before the app boots. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 70da643..256a802 100755 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -27,6 +27,11 @@ server.ts the Docker image's static file server. The dependency rule is one-directional: `ui -> store -> domain`. `domain` imports nothing from the others; `store` never imports from `ui`. +Two `src/domain/` modules worth naming individually: `days.ts` is day-ordinal +arithmetic with no `Date` at all, and `occurrences.ts` walks a recurring cost's +schedule forward from `startDate`, computing its occurrences fresh on every +fold rather than storing them. + `src/domain/smoke.test.ts` asserts the domain is importable with no DOM. That is why happy-dom is registered **per test file** rather than as a global bunfig preload — a global DOM would make that assertion vacuously true and silently @@ -45,6 +50,21 @@ The month view, year matrix, summary, per-post views and the ODS export all aggregate over `foldBalances` / `figuresFor`. A closing balance is read from `byMonth[11].remaining`, never re-summed. +A recurring cost adds a SECOND balance beside it, on its own carry: + +``` +projected = projectedCarriedIn + allocation − charges − expected +``` + +`remaining` never changes shape or value because of a recurring cost — it is +still the figure the owner reconciles against their bank statement. `projected` +runs beside it, additionally subtracting this month's unconfirmed occurrences, +so the month view shows both a `Remaining` and a `Projected` column per post +rather than only a separate list of upcoming bills. With no recurring costs the +two tracks are identical in every month; once every occurrence in a month is +confirmed they reconverge, because a confirmation is an ordinary purchase +counted in `charges` and skipped by `expected`. + If you find yourself writing `carriedIn + allocation - charges` outside `fold.ts`, that is the invariant breaking. It matters because two screens silently disagreeing about someone's money is the worst failure this app has. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index f173a2e..94421db 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -18,7 +18,7 @@ record. no household mode. → [`PRODUCT.md`](PRODUCT.md) - **Schema migrations are mandatory, not optional.** `schemaVersion` plus ordered migration functions applied on load, because IndexedDB holds the - user's only copy of their financial data. Now at version 6. + user's only copy of their financial data. Now at version 7. → [`specs/2026-09-01-budget-app-design.md`](specs/2026-09-01-budget-app-design.md) - **A destructive write downloads a backup first.** Import and reset both export before replacing, and abort if the export fails. → commit `ac3d18a` @@ -75,6 +75,37 @@ record. - **Posts are archived, never deleted**, because purchases reference them. Archived posts still fold. → [`ARCHITECTURE.md`](ARCHITECTURE.md) +## Recurring costs + +- **Two-stage confirmation, over projection-only or generation-only.** A + recurring cost projects expected occurrences that stay expected until the + owner confirms one into an ordinary `Purchase`. Projection-only would charge + automatically with no reconciliation and would also force a dated amount + series, since nothing else would hold what was actually paid; generation-only + needs a horizon and a notion of "today" the domain is built to avoid. + → [`specs/2026-09-03-recurring-costs-design.md`](specs/2026-09-03-recurring-costs-design.md) +- **A new `RecurringCost` entity, over extending `Purchase.schedule` with a + generative variant.** Under two-stage confirmation a template's only job is + to spawn purchases, so every consumer of `Purchase` would have to learn to + skip templates — a different case from splits and finance plans composing on + one entity. + → [`specs/2026-09-03-recurring-costs-design.md`](specs/2026-09-03-recurring-costs-design.md) +- **`lastCharge` anchoring, over a `dataCap` concept for the phone-bill case.** + Rebasing the series on the confirmation date reproduces the behaviour exactly, + with no usage tracking and no third recurrence kind. + → [`specs/2026-09-03-recurring-costs-design.md`](specs/2026-09-03-recurring-costs-design.md) +- **Day-less `everyNMonths`, over `monthlyOnDay(day)` with short-month + clamping.** The owner does not think of rent as landing on a date, `IsoDate` + already supports month-only values, and it keeps day arithmetic out of the + most common case entirely. + → [`specs/2026-09-03-recurring-costs-design.md`](specs/2026-09-03-recurring-costs-design.md) +- **A mutable `amount`, over a dated series like `Post.rules`.** Safe only + because two-stage confirmation already holds every past amount in a confirmed + `Purchase`; the rule describes what is expected next, the ledger holds what + happened. Under projection-only this would not have held, and a dated series + would have been required. + → [`specs/2026-09-03-recurring-costs-design.md`](specs/2026-09-03-recurring-costs-design.md) + ## Design system - **Two token layers.** `--budget-*` is the app's own vocabulary; shadcn's diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 8161adf..766ef69 100755 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -67,10 +67,18 @@ Confirmed and in place: browser. - Offline-capable installable PWA. Currently deploys as one static artifact to both GitHub Pages (under a subpath) and a self-hosted Docker image. +- Recurring costs — a bill that repeats monthly, every N days, or every N + weeks on a weekday, split across posts like any purchase. Each due date is a + projected occurrence the owner confirms into a real purchase; the month view + shows what is still expected alongside a second, projected balance per post, + so a known bill is visible before it is paid without touching the reconciled + figure a bank statement is checked against. Terminology: an envelope is called a **post**. A **finance plan** is a purchase spread across months. An **override** is a one-month exception to a post's -allocation. +allocation. A **recurring cost** is a bill expected to repeat; each due date it +projects is an **occurrence**; **confirming** an occurrence turns it into a +real purchase. Undecided: nothing outstanding at the product level. diff --git a/docs/TODO.md b/docs/TODO.md index 52b53d3..77c81db 100755 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -8,15 +8,16 @@ decisions behind it are in `docs/specs/` — one spec per sub-project, each recording the alternatives that were rejected. Rules that outlived their task moved to `AGENTS.md`. -**Current state:** merged to `main`. 464 tests passing, +**Current state:** merged to `main`. 583 tests passing, `bunx tsc --noEmit` clean, `bun run build` succeeds on both the live-fetch and offline paths. -**Schema is at version 5.** v1 -> v2 made allocation rules a dated series; +**Schema is at version 7.** v1 -> v2 made allocation rules a dated series; v2 -> v3 moved currencies into the dataset; v3 -> v4 drops a stored rate-service -URL that can no longer work; v4 -> v5 adds sterling to an existing dataset. All -four are behaviour-preserving by construction — not one changes a stored figure -— and are tested as such. +URL that can no longer work; v4 -> v5 adds sterling to an existing dataset; +v5 -> v6 collapses per-currency decimals into one dataset-wide setting; v6 -> v7 +adds `Dataset.recurring`. All six are behaviour-preserving by construction — +not one changes a stored figure — and are tested as such. A migration applies **on load, in memory**; it is written back on the next mutation, not by `load()` itself. So a freshly migrated dataset shows the new @@ -140,3 +141,14 @@ Genuinely outstanding: focus placement after a row auto-appends. Drivable over CDP in principle; not yet done. - **GitHub Actions** has never been executed. +- **Whether two-stage confirmation matches how the owner actually reconciles a + bank statement.** Recurring costs (`docs/specs/2026-09-03-recurring-costs-design.md`) + project expected occurrences and require a click to turn one into a real + purchase, rather than charging automatically. That is a product bet a week of + real use answers and no test can. +- **The overdue count against a large, real backlog.** `ExpectedBand` counts + unconfirmed occurrences from earlier months, and demo data does now + accumulate a modest one (a few months of an unconfirmed monthly bill plus a + biweekly one, ~14 occurrences by September) that renders without incident. + Nobody has seen the count or the layout against years of neglect or dozens of + distinct bills. diff --git a/scripts/demo-data.ts b/scripts/demo-data.ts index 62d5b16..46bc1bb 100755 --- a/scripts/demo-data.ts +++ b/scripts/demo-data.ts @@ -41,6 +41,58 @@ actions.setRuleFrom(d, transport.id, "2026-01", { kind: "fixed", amount: { amoun actions.setRuleFrom(d, savings.id, "2026-03", { kind: "percentOfIncome", percent: 10 }); actions.setRuleOverride(d, "2026-09", social.id, { kind: "fixed", amount: { amount: 2400, currency: "DKK" } }); +// Recurring costs: bills that project their own expected charges, shown as a +// second balance ("Projected") beside the allocation-rule figures above. All +// three recurrence kinds and both anchorings appear, so the screenshots show +// every combination at once. +const wowSub = actions.addRecurringCost(d, { + name: "World of Warcraft", + archived: false, + amount: { amount: 13, currency: "USD" }, + startDate: "2026-06-05", + recurrence: { kind: "everyNDays", n: 28 }, + anchoring: "calendar", + splitMode: "percent", + splits: [{ postId: games.id, value: 100, absorbsRemainder: true }], +}); +actions.addRecurringCost(d, { + name: "Rent", + archived: false, + amount: { amount: 8200, currency: "DKK" }, + startDate: "2026-06", + recurrence: { kind: "everyNMonths", n: 1 }, + anchoring: "calendar", + splitMode: "percent", + splits: [{ postId: rent.id, value: 100, absorbsRemainder: true }], +}); +actions.addRecurringCost(d, { + name: "Veg box", + archived: false, + amount: { amount: 175, currency: "DKK" }, + startDate: "2026-06-06", + recurrence: { kind: "everyNWeeks", n: 2, weekday: 6 }, + anchoring: "calendar", + splitMode: "percent", + splits: [{ postId: food.id, value: 100, absorbsRemainder: true }], +}); +// Rebases on the actual charge date rather than the calendar, so an +// off-schedule payment moves the whole series — the phone-bill behaviour. +actions.addRecurringCost(d, { + name: "Car insurance", + archived: false, + amount: { amount: 349, currency: "DKK" }, + startDate: "2026-08-14", + recurrence: { kind: "everyNDays", n: 30 }, + anchoring: "lastCharge", + splitMode: "percent", + splits: [{ postId: transport.id, value: 100, absorbsRemainder: true }], +}); + +// One confirmed occurrence, so a screenshot shows both states at once: the +// expected band lists what is still due, and the fold's two tracks have +// already reconverged for this one. +actions.confirmOccurrence(d, wowSub.id, "2026-06-05", { date: "2026-06-05" }); + const buy = (date: string, description: string, amount: number, currency: "DKK" | "EUR" | "USD", postId: string, note?: string) => actions.addPurchase(d, { date, description, note, From 8078057daae6aff2a1cc1a9c2511189493e53c30 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 4 Sep 2026 07:34:39 +0200 Subject: [PATCH 13/28] test(currency-digits): cover the recurring-cost expected/projected track The zero-decimals guard drove every money path through rounding except expectedForMonth and fold.ts's projected balance, added by the recurring costs feature with the fixture's recurring array still empty. Adds an unconfirmed, non-base-currency, fixed-split recurring cost so the FX conversion and the split division both land on a non-whole amount at zero digits, and extends the fold/month-view/two-decimals assertions to cover it. Co-Authored-By: Claude Opus 5 (1M context) --- src/domain/currencyDigits.test.ts | 82 ++++++++++++++++++++++++++++--- 1 file changed, 76 insertions(+), 6 deletions(-) diff --git a/src/domain/currencyDigits.test.ts b/src/domain/currencyDigits.test.ts index 83733c7..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,27 +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: [], - recurring: [], + // 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", () => { @@ -131,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); + } + } }); /** @@ -141,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); }); }); From d1ccc65d7209501d80e97b613f8e9b649b7f9840 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 4 Sep 2026 07:57:23 +0200 Subject: [PATCH 14/28] fix(recurring): validate startDate granularity at both write paths (C1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit addRecurringCost and updateRecurringCost now reject a day-granular recurrence (everyNDays/everyNWeeks) paired with a month-only startDate. occurrencesOf hands startDate straight to addDays for those kinds, which throws "Not a day-granular IsoDate" — reachable in two clicks by adding a cost (always created as everyNMonths, startDate "YYYY-MM") and then switching its unit in the dropdown without touching Starts. updateRecurringCost checks the resolved pair (changes merged onto the stored cost) on every call, not just one that touches recurrence or startDate, since either field alone can break the pairing. Also corrects endRecurringCost's doc comment: archiving does not hide a recurring cost from the list, it dims it — the code already matches PostsSection, only the comment was wrong (M6). --- src/store/actions.test.ts | 80 +++++++++++++++++++++++++++++++++++++++ src/store/actions.ts | 40 +++++++++++++++++--- 2 files changed, 114 insertions(+), 6 deletions(-) diff --git a/src/store/actions.test.ts b/src/store/actions.test.ts index 59620b6..8c97ddd 100644 --- a/src/store/actions.test.ts +++ b/src/store/actions.test.ts @@ -515,6 +515,86 @@ describe("recurring costs", () => { ).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"); + }); + }); + test("ending sets both endedFrom and archived", () => { const data = draft(); const cost = actions.addRecurringCost(data, rentInput); diff --git a/src/store/actions.ts b/src/store/actions.ts index 9a3d57e..edbefe9 100755 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -394,15 +394,35 @@ function requireRecurrence(recurrence: Recurrence): Recurrence { 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. + */ +function requireStartDateGranularity(recurrence: Recurrence, startDate: IsoDate): void { + if (recurrence.kind === "everyNMonths") return; + if (!DAY_GRANULAR_DATE.test(startDate)) { + throw new Error( + `A "${recurrence.kind}" recurrence needs a day-granular startDate ("YYYY-MM-DD"), not "${startDate}"`, + ); + } +} + 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: requireRecurrence(cost.recurrence), + recurrence, amount: roundMoneyValue(draft, cost.amount), splits: roundSplits(draft, cost.splits, cost.splitMode, cost.amount.currency), }; @@ -421,6 +441,13 @@ export function updateRecurringCost( 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); } @@ -454,13 +481,14 @@ export function moveRecurringCost( } /** - * Stops a bill from `from` onward and hides it from the list. + * Stops a bill from `from` onward and dims it in the list. * * Both fields, because they mean different things: `endedFrom` stops the - * PROJECTION, and `archived` only hides the row. Archiving alone would leave - * the bill projecting forever; setting `endedFrom` alone would leave a dead - * bill cluttering the list. Neither touches a past occurrence, so no - * historical figure moves. + * 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. */ export function endRecurringCost(draft: Dataset, id: RecurringCostId, from: IsoDate): void { const cost = requireRecurringCost(draft, id); From 397bb245d8c40ca6d2ca2f9cd6773454fc618194 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 4 Sep 2026 07:57:30 +0200 Subject: [PATCH 15/28] fix(import): reject a startDate/kind granularity mismatch, and a non-array recurring field (C1, M7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseDatasetJson now enforces the same rule as the store: everyNDays and everyNWeeks require a day-granular startDate, since occurrencesOf hands it straight to addDays. Without this, a hand-edited or exported-then-mutated v7 file could import a cost that bricks every route touching the fold. Updates the module docstring's claim that it "validates far enough that the fold cannot throw" to actually cover this case. Also switches the recurring field to requireArray, matching its four sibling collections (posts/months/purchases/fxRates) — a non-array `recurring` used to silently import as empty instead of being rejected. --- src/export/json.test.ts | 42 +++++++++++++++++++++++++++++++++++++++++ src/export/json.ts | 26 ++++++++++++++++++++++--- 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/src/export/json.test.ts b/src/export/json.test.ts index 7f7c861..ce87074 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"; @@ -306,6 +314,40 @@ describe("recurring cost validation", () => { .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); + }); + test("an unknown recurrence kind is refused", () => { expect(() => parseDatasetJson(withRecurring([{ ...valid, recurrence: { kind: "everyFullMoon", n: 1 } }]))) .toThrow(/unknown kind/); diff --git a/src/export/json.ts b/src/export/json.ts index b9d0c05..babaf50 100644 --- a/src/export/json.ts +++ b/src/export/json.ts @@ -35,6 +35,14 @@ 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"]); @@ -217,8 +225,11 @@ function requireSplits( /** * 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 granular enough for its recurrence kind (day-granular + * for everyNDays/everyNWeeks, since occurrencesOf hands it straight to + * addDays). */ export function parseDatasetJson(text: string): Dataset { let raw: unknown; @@ -306,7 +317,7 @@ export function parseDatasetJson(text: string): Dataset { } } - const recurring = Array.isArray(data.recurring) ? (data.recurring as any[]) : []; + 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, @@ -329,6 +340,15 @@ export function parseDatasetJson(text: string): Dataset { 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`. + if (cost.recurrence.kind !== "everyNMonths" && !DAY_GRANULAR_DATE.test(cost.startDate)) { + throw new ImportValidationError( + `${label} has a "${cost.recurrence.kind}" recurrence but a month-only start date "${cost.startDate}"; it needs "YYYY-MM-DD"`, + ); + } if (!ANCHORINGS.has(cost.anchoring)) { throw new ImportValidationError( From eb13f986103df2301166902747b506e67ab514f4 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 4 Sep 2026 07:57:37 +0200 Subject: [PATCH 16/28] fix(ui): normalise startDate on a kind switch, validate the Starts field (C1 UI, C2) The unit dropdown could rewrite a cost's recurrence kind to a day-granular one without touching its month-only startDate, committing exactly the pairing the store now refuses. It now expands startDate to the first of the month in the SAME mutate call that changes the kind. The Starts field itself was free text committed on every keystroke, including an emptied field ("" reaches monthOf() and throws with the same blast radius). It is now an uncontrolled input that only commits on blur, and only when the text parses as an IsoDate of the granularity the cost's recurrence kind requires; an invalid edit is discarded rather than stored. isValidStartDate and normalizedStartDateFor are exported and covered directly in RecurringSection.test.ts, since this is what stands between this field and a repeat of C1/C2. --- .../routes/settings/RecurringSection.test.ts | 58 ++++++++++++++ src/ui/routes/settings/RecurringSection.tsx | 76 +++++++++++++++++-- 2 files changed, 128 insertions(+), 6 deletions(-) create mode 100644 src/ui/routes/settings/RecurringSection.test.ts diff --git a/src/ui/routes/settings/RecurringSection.test.ts b/src/ui/routes/settings/RecurringSection.test.ts new file mode 100644 index 0000000..4b011b9 --- /dev/null +++ b/src/ui/routes/settings/RecurringSection.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "bun:test"; +import { 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 + }); +}); + +// 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 index 1917e70..ae8c7c8 100644 --- a/src/ui/routes/settings/RecurringSection.tsx +++ b/src/ui/routes/settings/RecurringSection.tsx @@ -14,10 +14,53 @@ import { import { parseMoneyInput } from "../../moneyInput.ts"; import { currentMonth } from "../../../store/index.ts"; import { Section } from "../../components/Section.tsx"; -import type { Recurrence, RecurringCost } from "../../../domain/types.ts"; +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. + * + * 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") monthOf(value); + else toDayOrdinal(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(); @@ -82,7 +125,15 @@ export function RecurringSection() { kind === "everyNWeeks" ? { kind, n: cost.recurrence.n, weekday: 1 } : { kind, n: cost.recurrence.n }; - mutate((draft) => updateRecurringCost(draft, cost.id, { recurrence })); + // 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 }), + ); }} > @@ -169,14 +220,27 @@ export function RecurringSection() { + {/* 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 startDate = event.target.value; - mutate((draft) => updateRecurringCost(draft, cost.id, { startDate })); + defaultValue={cost.startDate} + onBlur={(event) => { + 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 }), + ); }} /> From 5a047f53de8fcf5471a63ff8b280a33bc04a0070 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 4 Sep 2026 07:57:51 +0200 Subject: [PATCH 17/28] fix(domain): a monthly lastCharge bill paid a few days early no longer throws (I3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under lastCharge anchoring, an everyNMonths cost stepped from the confirmation's exact date even when that date fell earlier in the same month as the slot it claims (e.g. rent for 2026-09 paid 2026-08-28). Since stepFrom normalises through monthOf, that produced the same month as the cursor, which the walk read as "did not advance" and threw — for a completely ordinary early payment. The month-granular step now runs from whichever is later, the cursor or the confirmation's month, so a payment within the same month simply does not rebase. A payment recorded in a LATER month still rebases the series forward, symmetric with the day-granular cap-hit-early behaviour. Also corrects the termination error's message, which claimed a purchase was dated "more than one step earlier" regardless of how early it actually was. --- src/domain/occurrences.test.ts | 26 ++++++++++++++++++++++++++ src/domain/occurrences.ts | 24 ++++++++++++++++++++---- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/domain/occurrences.test.ts b/src/domain/occurrences.test.ts index 44aa658..235ebd4 100644 --- a/src/domain/occurrences.test.ts +++ b/src/domain/occurrences.test.ts @@ -188,6 +188,32 @@ describe("occurrencesOf — lastCharge anchoring: the phone bill", () => { }); }); +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 diff --git a/src/domain/occurrences.ts b/src/domain/occurrences.ts index 443be4e..f5a55a8 100644 --- a/src/domain/occurrences.ts +++ b/src/domain/occurrences.ts @@ -126,8 +126,21 @@ export function occurrencesOf( // 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 = - cost.anchoring === "lastCharge" && confirmation ? confirmation.date : cursor; + cost.anchoring === "lastCharge" && confirmation + ? cost.recurrence.kind === "everyNMonths" + ? compareMonths(monthOf(confirmation.date), cursor) > 0 + ? confirmation.date + : cursor + : confirmation.date + : cursor; const next = stepFrom(from, cost.recurrence); // The loop terminates only if the step strictly increases. `n >= 1` is @@ -138,13 +151,16 @@ export function occurrencesOf( 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 when - // a confirmation is dated more than one step before the slot it claims. + // 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}, ` + - `more than one step earlier — correct its date to move the series forward.` + `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.`), ); } From 093521b2c81778ea6d4afdcab40a6f1f028ee2da Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 4 Sep 2026 07:57:56 +0200 Subject: [PATCH 18/28] fix(views): keep a post visible when its only figure is an unconfirmed occurrence (I4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hasActivity only checked carriedIn/allocation/charges, so an archived post whose sole activity this month was an unconfirmed recurring occurrence (zero remaining, nonzero expected/projected) was dropped from monthView.rows — its Projected figure never reached the month view even though ExpectedBand listed the same occurrence. remaining and projected are both fully derived from five raw fields; the check now covers all five. --- src/domain/views.test.ts | 34 ++++++++++++++++++++++++++++++++++ src/domain/views.ts | 12 +++++++++++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/domain/views.test.ts b/src/domain/views.test.ts index 2b2f96f..b6ac8da 100644 --- a/src/domain/views.test.ts +++ b/src/domain/views.test.ts @@ -122,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 ); } From 08abe64b5543862d350886cb3762746d60153112 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 4 Sep 2026 07:58:03 +0200 Subject: [PATCH 19/28] fix(ui): refuse to confirm an unparsed amount; stop counting pre-fold occurrences as overdue (I5, M9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `parsed ?? undefined` conflated "field never touched" with "field edited to text that doesn't parse" — both produced null, so typing "30 GBP" in a DKK/USD dataset silently confirmed the purchase at the cost's own amount instead, with no feedback. resolveExpectedAmount now distinguishes the two: untouched still confirms at the cost's amount in one click (the fast path is unchanged), but edited-and-unparseable disables Confirm and shows an inline message. Also excludes months before settings.foldStartMonth from the "earlier" overdue count: foldBalances never folds those, so an occurrence out there was never actually holding the projected balance down, and counting it made the explanatory sentence below the count claim a gap that wasn't in the number being shown. --- src/ui/components/ExpectedBand.test.ts | 37 ++++++++++++ src/ui/components/ExpectedBand.tsx | 81 +++++++++++++++++++------- 2 files changed, 97 insertions(+), 21 deletions(-) create mode 100644 src/ui/components/ExpectedBand.test.ts diff --git a/src/ui/components/ExpectedBand.test.ts b/src/ui/components/ExpectedBand.test.ts new file mode 100644 index 0000000..890e32c --- /dev/null +++ b/src/ui/components/ExpectedBand.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test"; +import { resolveExpectedAmount } from "./ExpectedBand.tsx"; + +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); + }); +}); diff --git a/src/ui/components/ExpectedBand.tsx b/src/ui/components/ExpectedBand.tsx index a930a9f..085ee29 100644 --- a/src/ui/components/ExpectedBand.tsx +++ b/src/ui/components/ExpectedBand.tsx @@ -6,9 +6,31 @@ import { useMutate } from "../hooks/useMutate.ts"; import { confirmOccurrence } from "../../store/actions.ts"; import { occurrencesByMonth } from "../../domain/occurrences.ts"; import { compareMonths } from "../../domain/months.ts"; -import { parseMoneyInput } from "../moneyInput.ts"; +import { parseMoneyInput, type CurrencyOption, type ParsedMoney } from "../moneyInput.ts"; import { Section } from "./Section.tsx"; -import type { MonthId } from "../../domain/types.ts"; +import type { Currency, 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 }; +} /** * The month's expected charges, and the one click that turns each into a real @@ -29,12 +51,15 @@ export function ExpectedBand({ monthId }: { monthId: MonthId }) { const byMonth = occurrencesByMonth(dataset, monthId); const thisMonth = (byMonth.get(monthId) ?? []).filter((o) => !o.confirmedBy); - // Everything unconfirmed in an EARLIER month. An occurrence nobody ever - // confirms holds the projected balance down forever, which is the honest - // reading of an unreconciled commitment — but it is silent unless counted. + // 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. let earlier = 0; for (const [month, occurrences] of byMonth) { if (compareMonths(month, monthId) >= 0) continue; + if (compareMonths(month, dataset.settings.foldStartMonth) < 0) continue; earlier += occurrences.filter((o) => !o.confirmedBy).length; } @@ -56,6 +81,11 @@ export function ExpectedBand({ monthId }: { monthId: MonthId }) { {pending.map((occurrence) => { const key = `${occurrence.recurringId}:${occurrence.date}`; const typed = amounts[key]; + const { parsed, invalid } = resolveExpectedAmount( + typed, + dataset.currencies, + occurrence.amount.currency, + ); return (
  • {occurrence.date}
  • - { - const next = event.target.value; - setAmounts((current) => ({ ...current, [key]: next })); - }} - /> +
    + { + 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) => { - const key = `${occurrence.recurringId}:${occurrence.date}`; - const typed = amounts[key]; - const { parsed, invalid } = resolveExpectedAmount( - typed, - dataset.currencies, - occurrence.amount.currency, - ); - return ( -
    • -
      -
      {nameOf(occurrence.recurringId)}
      -
      {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} - - -
      -
    • - ); - })} + {pending.map((occurrence) => row(occurrence, null))}
    )} + {comingUp.length > 0 && ( +
    0 ? "mt-4" : ""}> +

    + Coming up +

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

    {overdue} earlier {overdue === 1 ? "occurrence has" : "occurrences have"} never From b276573ffebb76e09eda27e46857f376075af19e Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 4 Sep 2026 08:46:57 +0200 Subject: [PATCH 23/28] feat(recurring): datable cancellation via an "Ends" column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gap 2 (spec §10): endedFrom is typed as any IsoDate and the spec calls it "cancellation", but Settings only ever wrote currentMonth — backdating or forward-dating a cancellation was import-only. Add an "Ends" column beside "Starts" in the Recurring costs table, edited and validated the same way (isValidEndedFrom — no granularity rule, per §10, but still refuses a calendar-impossible date like "2026-02-30"). Empty means "not ended" and clears the cancellation. Coherence rule: `archived` mirrors "endedFrom is set". setRecurringCostEndedFrom is the one place that now enforces it — endRecurringCost and restoreRecurringCost (the one-click row buttons) delegate to it, so the column and the buttons can never leave a bill dimmed-but-live or ended-but-undimmed. Also closes the same calendar-impossible gap in the JSON importer, which checked endedFrom's shape but not its calendar validity, unlike startDate. --- src/export/json.test.ts | 21 ++++++ src/export/json.ts | 14 ++++ src/store/actions.test.ts | 63 +++++++++++++++++ src/store/actions.ts | 36 ++++++++-- .../routes/settings/RecurringSection.test.ts | 30 ++++++++- src/ui/routes/settings/RecurringSection.tsx | 67 +++++++++++++++++-- 6 files changed, 219 insertions(+), 12 deletions(-) diff --git a/src/export/json.test.ts b/src/export/json.test.ts index 4e436d7..cd8b2ef 100644 --- a/src/export/json.test.ts +++ b/src/export/json.test.ts @@ -302,6 +302,27 @@ describe("recurring cost validation", () => { .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. diff --git a/src/export/json.ts b/src/export/json.ts index 3f6c17b..7ae743d 100644 --- a/src/export/json.ts +++ b/src/export/json.ts @@ -367,6 +367,20 @@ export function parseDatasetJson(text: string): Dataset { ); } + // `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)}"`, diff --git a/src/store/actions.test.ts b/src/store/actions.test.ts index 30b9f44..a6a9c60 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"; /** @@ -684,6 +685,68 @@ describe("recurring costs", () => { 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); diff --git a/src/store/actions.ts b/src/store/actions.ts index 0ef951f..0652f29 100755 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -497,7 +497,11 @@ export function moveRecurringCost( } /** - * Stops a bill from `from` onward and dims it in the list. + * 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 @@ -505,17 +509,35 @@ export function moveRecurringCost( * 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 endRecurringCost(draft: Dataset, id: RecurringCostId, from: IsoDate): void { +export function setRecurringCostEndedFrom( + draft: Dataset, + id: RecurringCostId, + endedFrom: IsoDate | undefined, +): void { const cost = requireRecurringCost(draft, id); - cost.endedFrom = from; - cost.archived = true; + 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 { - const cost = requireRecurringCost(draft, id); - delete cost.endedFrom; - cost.archived = false; + setRecurringCostEndedFrom(draft, id, undefined); } /** diff --git a/src/ui/routes/settings/RecurringSection.test.ts b/src/ui/routes/settings/RecurringSection.test.ts index e6f3dad..c276506 100644 --- a/src/ui/routes/settings/RecurringSection.test.ts +++ b/src/ui/routes/settings/RecurringSection.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { isValidStartDate, normalizedStartDateFor } from "./RecurringSection.tsx"; +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 @@ -52,6 +52,34 @@ describe("isValidStartDate", () => { }); }); +// 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. diff --git a/src/ui/routes/settings/RecurringSection.tsx b/src/ui/routes/settings/RecurringSection.tsx index fbaba4f..1b2fb0d 100644 --- a/src/ui/routes/settings/RecurringSection.tsx +++ b/src/ui/routes/settings/RecurringSection.tsx @@ -9,6 +9,7 @@ import { endRecurringCost, moveRecurringCost, restoreRecurringCost, + setRecurringCostEndedFrom, updateRecurringCost, } from "../../../store/actions.ts"; import { parseMoneyInput } from "../../moneyInput.ts"; @@ -53,6 +54,31 @@ export function isValidStartDate(value: string, kind: Recurrence["kind"]): boole } } +/** + * 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 @@ -190,6 +216,7 @@ export function RecurringSection() { Every Measured from Starts + Ends @@ -250,6 +277,36 @@ export function RecurringSection() { }} /> + + {/* 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, + ), + ); + }} + /> +