diff --git a/src/export/json.test.ts b/src/export/json.test.ts index eaef723..ab11369 100644 --- a/src/export/json.test.ts +++ b/src/export/json.test.ts @@ -8,6 +8,7 @@ import { } from "./json.ts"; import { createSeedDataset } from "../domain/seed.ts"; import { monthView, yearView } from "../domain/views.ts"; +import { occurrencesByMonth } from "../domain/occurrences.ts"; import type { Dataset } from "../domain/types.ts"; function populated(): Dataset { @@ -663,3 +664,77 @@ describe("startDate granularity under the anchored model", () => { expect(() => parseDatasetJson(withCost("lastCharge", "everyNMonths", "2026-09-31"))).toThrow(/impossible/i); }); }); + +/** + * The other half of the same rule, and the half nobody checked: an anchored + * cost has no slots, so `anchoredOccurrences` steps forward from its latest + * CHARGE's own `Purchase.date`. `PURCHASE_DATE` allows "YYYY-MM" — right for + * an ordinary purchase, wrong for this one — so a month-only anchored charge + * imported clean and threw out of the very first fold, from a frame naming no + * cost. That falsified this file's own claim to validate far enough that the + * fold cannot throw, and made restoring a backup reproduce the crash. + */ +describe("an anchored cost's CHARGE dates need a day at the import boundary", () => { + function withCharge(anchoring: string, kind: string, purchaseDate: string): string { + const data: any = createSeedDataset("2026-09"); + data.posts[0].id = "housing"; + data.recurring = [{ + id: "r1", name: "Bill", order: 0, archived: false, + amount: { amount: 300, currency: "DKK" }, + // Before the charge, so the charge's own date is the anchor the walk + // uses rather than being masked by `max(latest, startDate)`. + startDate: anchoring === "lastCharge" ? "2026-08-04" : "2026-08", + recurrence: kind === "everyNMonths" ? { kind, n: 1 } : { kind, n: 30 }, + anchoring, splitMode: "percent", + splits: [{ postId: "housing", value: 100, absorbsRemainder: true }], + }]; + data.purchases = [{ + id: "p1", date: purchaseDate, description: "Bill", + total: { amount: 300, currency: "DKK" }, splitMode: "percent", + splits: [{ postId: "housing", value: 100, absorbsRemainder: true }], + schedule: null, + source: anchoring === "lastCharge" + ? { recurringId: "r1" } + : { recurringId: "r1", occurrenceDate: "2026-09" }, + }]; + return JSON.stringify(data); + } + + test("a month-only date on an anchored monthly charge is refused, naming the cost", () => { + expect(() => parseDatasetJson(withCharge("lastCharge", "everyNMonths", "2026-09"))).toThrow( + /Bill.*day-granular/, + ); + }); + + test("a month-only date on an anchored DAY-granular charge is refused too", () => { + expect(() => parseDatasetJson(withCharge("lastCharge", "everyNDays", "2026-09"))).toThrow( + /Bill.*day-granular/, + ); + }); + + test("a day-granular date on an anchored charge is accepted, and folds", () => { + const dataset = parseDatasetJson(withCharge("lastCharge", "everyNMonths", "2026-09-20")); + expect(() => occurrencesByMonth(dataset, "2026-12")).not.toThrow(); + }); + + test("a day-SHAPED but calendar-impossible date on an anchored charge is refused", () => { + expect(() => parseDatasetJson(withCharge("lastCharge", "everyNMonths", "2026-02-30"))).toThrow( + /impossible/i, + ); + }); + + test("a month-only date on a CALENDAR cost's charge is still accepted", () => { + // It settles the slot in `occurrenceDate`; its own date is never stepped + // from, so there is nothing here to require a day. + const dataset = parseDatasetJson(withCharge("calendar", "everyNMonths", "2026-09")); + expect(() => occurrencesByMonth(dataset, "2026-12")).not.toThrow(); + }); + + test("every dataset this boundary accepts can be folded", () => { + // The claim in `parseDatasetJson`'s own doc, asserted rather than stated. + for (const date of ["2026-09-01", "2026-09-20", "2026-09-30"]) { + const dataset = parseDatasetJson(withCharge("lastCharge", "everyNMonths", date)); + expect(() => occurrencesByMonth(dataset, "2027-06")).not.toThrow(); + } + }); +}); diff --git a/src/export/json.ts b/src/export/json.ts index ff6537a..0dc78f7 100644 --- a/src/export/json.ts +++ b/src/export/json.ts @@ -2,6 +2,7 @@ import type { Currency, CurrencyDef, Dataset, MonthId } from "../domain/types.ts import { migrate } from "../store/migrations.ts"; import { monthOf } from "../domain/months.ts"; import { toDayOrdinal } from "../domain/days.ts"; +import { chargeDateNeedsDay, startDateNeedsDay } from "../domain/granularity.ts"; export class ImportValidationError extends Error { constructor(message: string) { @@ -39,10 +40,13 @@ 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]))?$/; /** * 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. + * form. WHICH values need this narrower shape is `startDateNeedsDay` / + * `chargeDateNeedsDay` in `src/domain/granularity.ts`, not a rule this file + * restates: `everyNDays` and `everyNWeeks` hand `startDate` straight to + * `addDays`, `everyNMonths` anchored to `lastCharge` hands it to + * `addMonthsToDate`, and an anchored cost's CHARGE dates are stepped from the + * same way (`src/domain/occurrences.ts`). All of those throw on a month-only + * date, so it is refused 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])$/; @@ -228,11 +232,19 @@ 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, every rule version resolves unambiguously - * to a number rather than to NaN or a missing rate, and every recurring - * cost's startDate is both granular enough for its recurrence kind - * (day-granular for everyNDays/everyNWeeks, since occurrencesOf hands it - * straight to addDays) and calendar-valid (no "2026-09-31"), since shape and - * range alone let an impossible date through. + * to a number rather than to NaN or a missing rate, and every date the + * projection walk will STEP FROM is both granular enough for the schedule + * that reads it and calendar-valid (no "2026-09-31"), since shape and range + * alone let an impossible date through. That last one covers a recurring + * cost's own `startDate` and, for an anchored (`lastCharge`) cost, the `date` + * of every purchase charged to it — an anchored cost has no slots, so its + * charges' own dates are the anchors, and a month-only one there used to + * import clean and take down every money route on the next fold. + * + * Note the order this runs in: `migrate` first, so what is validated is the + * upgraded dataset. A file the migration produces must therefore satisfy + * every rule below — see the 7 -> 8 step, which expands exactly the + * month-only charge dates this function refuses. */ export function parseDatasetJson(text: string): Dataset { let raw: unknown; @@ -357,8 +369,7 @@ export function parseDatasetJson(text: string): Dataset { // always need a day-granular startDate. `endedFrom` needs no such check — // it is only ever compared lexicographically, never fed to `addDays`. const dayGranular = DAY_GRANULAR_DATE.test(cost.startDate); - const needsDay = cost.recurrence.kind !== "everyNMonths" || cost.anchoring === "lastCharge"; - if (needsDay && !dayGranular) { + if (startDateNeedsDay(cost.recurrence.kind, cost.anchoring) && !dayGranular) { throw new ImportValidationError( `${label} has a "${cost.recurrence.kind}" recurrence anchored to "${cost.anchoring}" with a ` + `month-only start date "${cost.startDate}"; it needs a day-granular startDate ("YYYY-MM-DD")`, @@ -468,6 +479,34 @@ export function parseDatasetJson(text: string): Dataset { ); } } + + // An anchored cost has no slots, so this purchase's OWN date is the + // anchor `anchoredOccurrences` steps forward from — under every + // recurrence kind, day-precisely. `PURCHASE_DATE` above allows + // "YYYY-MM", which is right for an ordinary purchase and wrong here: a + // month-only date would import clean and then throw out of the very + // first fold, from a frame that names no cost. The cost's own + // `startDate` has been checked this way since the anchored model + // landed; its charges were the half nobody checked. + if (chargeDateNeedsDay(cost.anchoring)) { + if (!DAY_GRANULAR_DATE.test(purchase.date)) { + throw new ImportValidationError( + `${label} is a charge for "${cost.name}", which is anchored to its last charge, so its ` + + `date is what the next charge is measured from; it needs a day-granular date ` + + `("YYYY-MM-DD"), not "${purchase.date}"`, + ); + } + // Day-shaped is not day-valid — same reason `startDate` is routed + // through the domain's own parser above. + try { + toDayOrdinal(purchase.date); + } catch (error) { + throw new ImportValidationError( + `${label} is a charge for "${cost.name}" and has a calendar-impossible date ` + + `"${purchase.date}" (${(error as Error).message})`, + ); + } + } } } diff --git a/src/store/actions.test.ts b/src/store/actions.test.ts index 3836940..38aa78c 100644 --- a/src/store/actions.test.ts +++ b/src/store/actions.test.ts @@ -1055,6 +1055,129 @@ describe("addPurchase/updatePurchase enforce the source/occurrenceDate pairing", }); }); +/** + * Every guard this model shipped with checked the COST's `startDate` + * granularity. Nobody checked the CHARGE's own date — and an anchored cost has + * no slots, so its latest charge's `Purchase.date` is exactly what + * `anchoredOccurrences` steps forward from. A month-only one was accepted by + * both write paths and then threw out of the very next fold, taking every + * money route down: `emptyDraft` seeds `date: monthId`, so month-only was the + * DEFAULT for a new purchase, and no UI route could undo it afterwards. + * + * Each test folds afterwards rather than only asserting the throw: the point + * is not that a message appears, it is that no dataset the store accepts can + * take the fold down. + */ +describe("a charge for an anchored cost needs a day-granular date", () => { + function seeded(recurrence: Parameters[1]["recurrence"]) { + const d = createSeedDataset("2026-09"); + const post = d.posts[0]!; + // Starts BEFORE the charge, so the charge's own date is what the walk + // anchors on — `max(latest, startDate)` would otherwise hide the bug + // behind a day-granular startDate. + const cost = actions.addRecurringCost(d, { + name: "Phone", archived: false, + amount: { amount: 300, currency: "DKK" }, + startDate: "2026-08-04", + recurrence, + anchoring: "lastCharge", splitMode: "percent", + splits: [{ postId: post.id, value: 100, absorbsRemainder: true }], + }); + return { d, post, cost }; + } + + function charge(postId: string, recurringId: string, date: string) { + return { + date, + description: "Phone", + total: { amount: 300, currency: "DKK" }, + splitMode: "percent" as const, + splits: [{ postId, value: 100, absorbsRemainder: true }], + schedule: null, + source: { recurringId }, + }; + } + + test("addPurchase refuses a month-only date on a monthly anchored cost", () => { + const { d, post, cost } = seeded({ kind: "everyNMonths", n: 1 }); + expect(() => actions.addPurchase(d, charge(post.id, cost.id, "2026-09"))).toThrow( + /Phone.*day-granular|day \("YYYY-MM-DD"\)/, + ); + expect(d.purchases).toHaveLength(0); + expect(() => occurrencesByMonth(d, "2026-12")).not.toThrow(); + }); + + test("addPurchase refuses a month-only date on a DAY-granular anchored cost too", () => { + // The precheck in `anchoredOccurrences` used to cover `everyNMonths` + // only, so this shape fell through to a bare "Not a day-granular IsoDate" + // out of `addDays`, naming no cost at all. + const { d, post, cost } = seeded({ kind: "everyNDays", n: 30 }); + expect(() => actions.addPurchase(d, charge(post.id, cost.id, "2026-09"))).toThrow(/Phone/); + expect(() => occurrencesByMonth(d, "2026-12")).not.toThrow(); + }); + + test("addPurchase refuses a month-only date on a WEEKLY anchored cost too", () => { + const { d, post, cost } = seeded({ kind: "everyNWeeks", n: 2, weekday: 4 }); + expect(() => actions.addPurchase(d, charge(post.id, cost.id, "2026-09"))).toThrow(/Phone/); + expect(() => occurrencesByMonth(d, "2026-12")).not.toThrow(); + }); + + test("addPurchase accepts a day-granular date on an anchored cost", () => { + const { d, post, cost } = seeded({ kind: "everyNMonths", n: 1 }); + expect(() => actions.addPurchase(d, charge(post.id, cost.id, "2026-09-20"))).not.toThrow(); + expect(() => occurrencesByMonth(d, "2026-12")).not.toThrow(); + }); + + test("addPurchase refuses a day-SHAPED but impossible date on an anchored cost", () => { + const { d, post, cost } = seeded({ kind: "everyNMonths", n: 1 }); + expect(() => actions.addPurchase(d, charge(post.id, cost.id, "2026-09-31"))).toThrow(/Phone/); + }); + + test("updatePurchase refuses truncating a stored anchored charge's date to the month", () => { + // The purchase dialog's "use the month only" toggle resets the NEW-cost + // pairing but leaves an already-selected anchored cost selected, so this + // is the shape that reached `updatePurchase` from the UI. + const { d, post, cost } = seeded({ kind: "everyNMonths", n: 1 }); + const purchase = actions.addPurchase(d, charge(post.id, cost.id, "2026-09-20")); + expect(() => actions.updatePurchase(d, purchase.id, { date: "2026-09" })).toThrow(/Phone/); + expect(d.purchases[0]!.date).toBe("2026-09-20"); + expect(() => occurrencesByMonth(d, "2026-12")).not.toThrow(); + }); + + test("updatePurchase refuses tagging an existing month-only purchase to an anchored cost", () => { + const { d, post, cost } = seeded({ kind: "everyNMonths", n: 1 }); + const purchase = actions.addPurchase(d, { + ...charge(post.id, cost.id, "2026-09"), + source: undefined, + }); + expect(() => + actions.updatePurchase(d, purchase.id, { source: { recurringId: cost.id } }), + ).toThrow(/Phone/); + expect(d.purchases[0]!.source).toBeUndefined(); + expect(() => occurrencesByMonth(d, "2026-12")).not.toThrow(); + }); + + test("a month-only charge date on a CALENDAR cost is still fine — it settles a slot, it is not an anchor", () => { + const d = createSeedDataset("2026-09"); + const post = d.posts[0]!; + const rent = actions.addRecurringCost(d, { + name: "Rent", archived: false, + amount: { amount: 8000, currency: "DKK" }, + startDate: "2026-09", + recurrence: { kind: "everyNMonths", n: 1 }, + anchoring: "calendar", splitMode: "percent", + splits: [{ postId: post.id, value: 100, absorbsRemainder: true }], + }); + expect(() => + actions.addPurchase(d, { + ...charge(post.id, rent.id, "2026-09"), + source: { recurringId: rent.id, occurrenceDate: "2026-09" }, + }), + ).not.toThrow(); + expect(() => occurrencesByMonth(d, "2026-12")).not.toThrow(); + }); +}); + // REVIEW ROUND 1: pre-existing asymmetries with json.ts, confirmed clean // against the baseline — neither `requireRecurrence` nor `addRecurringCost`/ // `updateRecurringCost` validated `recurrence.kind`/`anchoring` against their diff --git a/src/store/actions.ts b/src/store/actions.ts index 6926dda..319dc10 100755 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -1,6 +1,11 @@ import { newId } from "../domain/seed.ts"; import { compareMonths, monthOf } from "../domain/months.ts"; import { toDayOrdinal } from "../domain/days.ts"; +import { + chargeDateNeedsDay, + isDayGranularDate, + startDateNeedsDay, +} from "../domain/granularity.ts"; import { roundMoney } from "../domain/money.ts"; import { currencyUsage, normalizeCurrencyCode } from "../domain/currencies.ts"; import type { @@ -204,7 +209,7 @@ export function addPurchase( draft: Dataset, purchase: Omit, ): Purchase { - requireSourcePairing(draft, purchase.source); + requireSourcePairing(draft, purchase.source, purchase.date); const created: Purchase = { ...purchase, id: newId(), @@ -234,7 +239,7 @@ export function updatePurchase( // one, else the one already stored — so touching an unrelated field still // re-validates whatever source is already there, and a `source` this // update DOES set is checked against the cost it now names. - requireSourcePairing(draft, changes.source ?? purchase.source); + requireSourcePairing(draft, changes.source ?? purchase.source, changes.date ?? purchase.date); if (changes.total) { resolved.total = roundMoneyValue(draft, changes.total); } @@ -398,8 +403,20 @@ function requireRecurringCost(draft: Dataset, id: RecurringCostId): RecurringCos * FORBIDS one (an anchored cost has no grid, so a slot id would describe a * schedule this app will not compute). `undefined` (no source at all) is * always fine and skips this entirely. + * + * `date` is the purchase's OWN date — the resolved one, so an update that + * touches only the date is checked against the source already stored. An + * anchored cost's next due date is measured from it (`chargeDateNeedsDay`), so + * a month-only date there is not a display preference but a schedule the + * anchored walk cannot compute: it would reach `addDays`/`addMonthsToDate` + * from the fold and take every money route down. Refuse it here, where the + * owner is still looking at the purchase. */ -function requireSourcePairing(draft: Dataset, source: Purchase["source"]): void { +function requireSourcePairing( + draft: Dataset, + source: Purchase["source"], + date: IsoDate, +): void { if (source === undefined) return; const cost = requireRecurringCost(draft, source.recurringId); if (cost.anchoring === "lastCharge") { @@ -415,6 +432,26 @@ function requireSourcePairing(draft: Dataset, source: Purchase["source"]): void `date (slot) it settles`, ); } + + if (!chargeDateNeedsDay(cost.anchoring)) return; + if (!isDayGranularDate(date)) { + throw new Error( + `"${cost.name}" is anchored to its last charge, so this purchase's own date is what the next ` + + `charge is measured from and needs a day ("YYYY-MM-DD"), not "${date}"`, + ); + } + // Day-SHAPED is not day-VALID: "2026-09-31" would pass the check above and + // then throw out of the fold, exactly the way an impossible `startDate` + // would without `requireStartDateGranularity`'s parse below. Same domain + // parser, same translation into this file's error style. + try { + toDayOrdinal(date); + } catch (error) { + throw new Error( + `"${cost.name}" is anchored to its last charge, so this purchase needs a real date, not ` + + `"${date}" (${(error as Error).message})`, + ); + } } /** @@ -466,18 +503,13 @@ function requireRecurrence(recurrence: Recurrence): Recurrence { return recurrence; } -const DAY_GRANULAR_DATE = /^\d{4}-\d{2}-\d{2}$/; - /** - * `everyNMonths` walks the cursor through `monthOf` under `calendar` - * anchoring, so a month-only `startDate` is fine there — rent occurs IN a - * month. Under `lastCharge` anchoring the SAME kind walks day-precisely - * (`stepFrom`'s `addMonthsToDate` branch), because the next due date is - * computed from a real charge date, so it needs a real day to carry forward. - * `everyNDays` and `everyNWeeks` hand `startDate` straight to `addDays` - * regardless of anchoring, which throws on a month-only date — so those two - * kinds always require a day-granular `startDate` here, at the boundary, - * rather than three months later when a fold walks off the end of it. + * Which pairings need a day is `startDateNeedsDay` in + * `domain/granularity.ts`, stated once and called from here, from the import + * boundary and from both controls that offer these choices — the rule used to + * be written out separately in each of those four places. Enforced HERE, at + * the boundary, rather than three months later when a fold walks off the end + * of a date it cannot step from. * * Shape alone is not enough: "2026-09-31" is day-SHAPED but calendar- * impossible (September has 30 days), and would otherwise reach `addDays` (or @@ -491,9 +523,8 @@ function requireStartDateGranularity( startDate: IsoDate, anchoring: Anchoring, ): void { - const dayGranular = DAY_GRANULAR_DATE.test(startDate); - const needsDay = recurrence.kind !== "everyNMonths" || anchoring === "lastCharge"; - if (needsDay && !dayGranular) { + const dayGranular = isDayGranularDate(startDate); + if (startDateNeedsDay(recurrence.kind, anchoring) && !dayGranular) { throw new Error( `A "${recurrence.kind}" recurrence anchored to ${anchoring} needs a day-granular startDate ("YYYY-MM-DD"), not "${startDate}"`, );