diff --git a/src/export/json.test.ts b/src/export/json.test.ts index cd8b2ef..ce223e3 100644 --- a/src/export/json.test.ts +++ b/src/export/json.test.ts @@ -563,3 +563,79 @@ describe("purchase splits validation", () => { expect(parseDatasetJson(exportDatasetJson(data)).purchases.length).toBe(1); }); }); + +describe("the occurrenceDate / anchoring pairing", () => { + function withCostAndCharge(anchoring: string, source: any): 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" }, + startDate: anchoring === "lastCharge" ? "2026-09-01" : "2026-09", + recurrence: { kind: "everyNMonths", n: 1 }, + anchoring, splitMode: "percent", + splits: [{ postId: "housing", value: 100, absorbsRemainder: true }], + }]; + data.purchases = [{ + id: "p1", date: "2026-09-20", description: "Bill", + total: { amount: 300, currency: "DKK" }, splitMode: "percent", + splits: [{ postId: "housing", value: 100, absorbsRemainder: true }], + schedule: null, source, + }]; + return JSON.stringify(data); + } + + test("a calendar charge REQUIRES occurrenceDate", () => { + expect(() => parseDatasetJson(withCostAndCharge("calendar", { recurringId: "r1" }))) + .toThrow(/occurrence date/i); + }); + + test("a calendar charge with occurrenceDate is accepted", () => { + expect(() => + parseDatasetJson(withCostAndCharge("calendar", { recurringId: "r1", occurrenceDate: "2026-09" })), + ).not.toThrow(); + }); + + test("an anchored charge FORBIDS occurrenceDate", () => { + // Its presence describes a schedule the app will not compute. + expect(() => + parseDatasetJson(withCostAndCharge("lastCharge", { recurringId: "r1", occurrenceDate: "2026-09" })), + ).toThrow(/does not settle a slot|no slots|anchored/i); + }); + + test("an anchored charge without occurrenceDate is accepted", () => { + expect(() => parseDatasetJson(withCostAndCharge("lastCharge", { recurringId: "r1" }))).not.toThrow(); + }); +}); + +describe("startDate granularity under the anchored model", () => { + function withCost(anchoring: string, kind: string, startDate: 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" }, + startDate, recurrence: kind === "everyNMonths" ? { kind, n: 1 } : { kind, n: 30 }, + anchoring, splitMode: "percent", + splits: [{ postId: "housing", value: 100, absorbsRemainder: true }], + }]; + data.purchases = []; + return JSON.stringify(data); + } + + test("anchored + everyNMonths now needs a DAY", () => { + expect(() => parseDatasetJson(withCost("lastCharge", "everyNMonths", "2026-09"))).toThrow(/day-granular/); + }); + + test("calendar + everyNMonths still accepts a month", () => { + expect(() => parseDatasetJson(withCost("calendar", "everyNMonths", "2026-09"))).not.toThrow(); + }); + + test("calendar + everyNMonths still accepts a day", () => { + expect(() => parseDatasetJson(withCost("calendar", "everyNMonths", "2026-09-20"))).not.toThrow(); + }); + + test("a calendar-impossible date is still refused", () => { + expect(() => parseDatasetJson(withCost("lastCharge", "everyNMonths", "2026-09-31"))).toThrow(/impossible/i); + }); +}); diff --git a/src/export/json.ts b/src/export/json.ts index c0767de..0fed439 100644 --- a/src/export/json.ts +++ b/src/export/json.ts @@ -322,10 +322,6 @@ export function parseDatasetJson(text: string): Dataset { const recurring = requireArray(data, "recurring") as any[]; dataset.recurring = recurring; - // Optional chaining, not `.id` directly: a null/non-object entry must fall - // through to the loop below and fail there with an ImportValidationError, - // not throw a raw TypeError here while merely collecting ids. - const recurringIds = new Set(recurring.map((cost: any) => cost?.id)); for (const cost of recurring) { if (!cost || typeof cost !== "object") { @@ -336,6 +332,16 @@ export function parseDatasetJson(text: string): Dataset { const label = `Recurring cost "${cost.name}"`; requireCurrency(cost.amount?.currency, label, defined); + // Validated before the startDate/granularity checks below, because the + // granularity rule for `everyNMonths` now depends on which anchoring the + // cost carries — read it here rather than deferring, so a malformed + // anchoring string does not silently read as "calendar" a few lines down. + if (!ANCHORINGS.has(cost.anchoring)) { + throw new ImportValidationError( + `${label} has an unknown anchoring "${String(cost.anchoring)}"`, + ); + } + if (!PURCHASE_DATE.test(cost.startDate)) { throw new ImportValidationError(`${label} has an invalid start date "${cost.startDate}"`); } @@ -343,14 +349,19 @@ 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 — + // `everyNMonths` walks through `monthOf` under `calendar` anchoring, which + // accepts either granularity — rent occurs IN a month. Under `lastCharge` + // the same kind is computed day-precisely from a real charge date + // (`stepFrom`'s `addMonthsToDate` branch), so it needs a real day too. + // `everyNDays`/`everyNWeeks` reach `addDays` regardless of anchoring and + // 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); - if (cost.recurrence.kind !== "everyNMonths" && !dayGranular) { + const needsDay = cost.recurrence.kind !== "everyNMonths" || cost.anchoring === "lastCharge"; + if (needsDay && !dayGranular) { throw new ImportValidationError( - `${label} has a "${cost.recurrence.kind}" recurrence but a month-only start date "${cost.startDate}"; it needs "YYYY-MM-DD"`, + `${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")`, ); } // Shape and range (checked above by PURCHASE_DATE/DAY_GRANULAR_DATE) still @@ -381,11 +392,6 @@ export function parseDatasetJson(text: string): Dataset { } } - if (!ANCHORINGS.has(cost.anchoring)) { - throw new ImportValidationError( - `${label} has an unknown anchoring "${String(cost.anchoring)}"`, - ); - } requireSplits(cost.splits, cost.splitMode, label, postIds); } @@ -419,24 +425,36 @@ export function parseDatasetJson(text: string): Dataset { // 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`); + } + // Captured into a local: narrowing `purchase.source` above does not + // survive into the `find` callback below, which TypeScript treats as a + // fresh closure over a possibly-reassigned property. + const source = purchase.source; + const cost = recurring.find((c: any) => c?.id === source.recurringId); + if (!cost) { throw new ImportValidationError( - `${label} has a source that is not an object: ${JSON.stringify(purchase.source)}`, + `${label} names unknown recurring cost "${source.recurringId}"`, ); } - if (!recurringIds.has(purchase.source.recurringId)) { - throw new ImportValidationError( - `${label} names unknown recurring cost "${purchase.source.recurringId}"`, - ); - } - // occurrenceDate is absent for a lastCharge purchase — Task 6 rewrites - // this to check that against the cost's own anchoring. - if ( - purchase.source.occurrenceDate !== undefined && // rewritten in Task 6 - !PURCHASE_DATE.test(purchase.source.occurrenceDate) - ) { - throw new ImportValidationError( - `${label} has an invalid occurrence date "${purchase.source.occurrenceDate}"`, - ); + // A calendar cost has a grid, and a charge against it settles one slot, + // so it must name that slot. An anchored (lastCharge) cost has no grid + // at all — its next due date comes from the charge's OWN date, not from + // any slot — so an occurrence date there describes a schedule this app + // will not compute; refuse it rather than silently ignore it. + if (cost.anchoring === "lastCharge") { + if (source.occurrenceDate !== undefined) { + throw new ImportValidationError( + `${label} carries an occurrence date, but "${cost.name}" is anchored to its last ` + + `charge and has no slots to settle`, + ); + } + } else { + if (!PURCHASE_DATE.test(source.occurrenceDate as string)) { + throw new ImportValidationError( + `${label} has an invalid occurrence date "${String(source.occurrenceDate)}"`, + ); + } } } } diff --git a/src/store/actions.test.ts b/src/store/actions.test.ts index 63fd846..596fbeb 100644 --- a/src/store/actions.test.ts +++ b/src/store/actions.test.ts @@ -886,3 +886,64 @@ describe("editing a charge's date backwards re-anchors instead of throwing", () expect(dates[0]).toBe("2026-09-19"); // 2026-08-20 + 30 days }); }); + +describe("anchoring cannot change once a cost has charges", () => { + function seeded(anchoring: "calendar" | "lastCharge") { + const d = createSeedDataset("2026-09"); + const post = d.posts[0]!; + const cost = actions.addRecurringCost(d, { + name: "Bill", archived: false, + amount: { amount: 300, currency: "DKK" }, + startDate: anchoring === "lastCharge" ? "2026-09-01" : "2026-09", + recurrence: { kind: "everyNMonths", n: 1 }, + anchoring, splitMode: "percent", + splits: [{ postId: post.id, value: 100, absorbsRemainder: true }], + }); + return { d, cost }; + } + + test("changing anchoring is allowed while the cost has no charges", () => { + const { d, cost } = seeded("calendar"); + actions.updateRecurringCost(d, cost.id, { anchoring: "lastCharge", startDate: "2026-09-01" }); + expect(d.recurring[0]!.anchoring).toBe("lastCharge"); + }); + + test("changing anchoring is REFUSED once a charge exists", () => { + // There is no honest way to invent which slot each existing charge + // settled, and guessing would leave real money settling nothing. + const { d, cost } = seeded("lastCharge"); + actions.confirmOccurrence(d, cost.id, "2026-09-01", {}); + expect(() => actions.updateRecurringCost(d, cost.id, { anchoring: "calendar" })) + .toThrow(/charge/i); + }); + + test("other fields are still editable on a cost with charges", () => { + const { d, cost } = seeded("lastCharge"); + actions.confirmOccurrence(d, cost.id, "2026-09-01", {}); + actions.updateRecurringCost(d, cost.id, { name: "Renamed" }); + expect(d.recurring[0]!.name).toBe("Renamed"); + }); + + test("setting anchoring to its CURRENT value is not a change", () => { + const { d, cost } = seeded("lastCharge"); + actions.confirmOccurrence(d, cost.id, "2026-09-01", {}); + expect(() => actions.updateRecurringCost(d, cost.id, { anchoring: "lastCharge" })).not.toThrow(); + }); +}); + +describe("confirmOccurrence by anchoring", () => { + test("an anchored confirmation records NO occurrenceDate", () => { + const d = createSeedDataset("2026-09"); + const post = d.posts[0]!; + const cost = actions.addRecurringCost(d, { + name: "Phone", archived: false, + amount: { amount: 300, currency: "DKK" }, startDate: "2026-09-01", + recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "lastCharge", + splitMode: "percent", + splits: [{ postId: post.id, value: 100, absorbsRemainder: true }], + }); + const p = actions.confirmOccurrence(d, cost.id, "2026-09-01", {}); + expect(p.source).toEqual({ recurringId: cost.id }); + expect(p.date).toBe("2026-09-01"); + }); +}); diff --git a/src/store/actions.ts b/src/store/actions.ts index c117c63..27414ab 100755 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -4,6 +4,7 @@ import { toDayOrdinal } from "../domain/days.ts"; import { roundMoney } from "../domain/money.ts"; import { currencyUsage, normalizeCurrencyCode } from "../domain/currencies.ts"; import type { + Anchoring, Currency, CurrencyDef, Dataset, @@ -398,11 +399,15 @@ function requireRecurrence(recurrence: Recurrence): 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. + * `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. * * Shape alone is not enough: "2026-09-31" is day-SHAPED but calendar- * impossible (September has 30 days), and would otherwise reach `addDays` (or @@ -411,11 +416,16 @@ const DAY_GRANULAR_DATE = /^\d{4}-\d{2}-\d{2}$/; * a month-granular one through `monthOf` — both already throw on exactly * that — and translate whatever they throw into this file's own error style. */ -function requireStartDateGranularity(recurrence: Recurrence, startDate: IsoDate): void { +function requireStartDateGranularity( + recurrence: Recurrence, + startDate: IsoDate, + anchoring: Anchoring, +): void { const dayGranular = DAY_GRANULAR_DATE.test(startDate); - if (recurrence.kind !== "everyNMonths" && !dayGranular) { + const needsDay = recurrence.kind !== "everyNMonths" || anchoring === "lastCharge"; + if (needsDay && !dayGranular) { throw new Error( - `A "${recurrence.kind}" recurrence needs a day-granular startDate ("YYYY-MM-DD"), not "${startDate}"`, + `A "${recurrence.kind}" recurrence anchored to ${anchoring} needs a day-granular startDate ("YYYY-MM-DD"), not "${startDate}"`, ); } try { @@ -433,7 +443,7 @@ export function addRecurringCost( cost: Omit, ): RecurringCost { const recurrence = requireRecurrence(cost.recurrence); - requireStartDateGranularity(recurrence, cost.startDate); + requireStartDateGranularity(recurrence, cost.startDate, cost.anchoring); const created: RecurringCost = { ...cost, id: newId(), @@ -468,11 +478,30 @@ export function updateRecurringCost( } // 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). + // untouched month-only startDate is exactly how this bug was reached), and + // an anchoring flip against an untouched month-only startDate is the same + // shape of bug one level up. requireStartDateGranularity( resolved.recurrence ?? cost.recurrence, resolved.startDate ?? cost.startDate, + changes.anchoring ?? cost.anchoring, ); + + if (changes.anchoring && changes.anchoring !== cost.anchoring) { + // Switching calendar -> lastCharge could strip the now-forbidden slot ids + // mechanically, but the reverse cannot: there is no honest way to invent + // which slot each existing charge settled, and guessing would leave real + // money claiming slots that are not on the new grid. Ending the cost and + // creating a new one keeps the history and states the change plainly. + const charged = draft.purchases.some((p) => p.source?.recurringId === cost.id); + if (charged) { + throw new Error( + `"${cost.name}" already has charges recorded, so its anchoring cannot change. ` + + `End this cost and create a new one with the anchoring you want — its history stays intact.`, + ); + } + } + if (changes.amount) { resolved.amount = roundMoneyValue(draft, changes.amount); } @@ -558,6 +587,12 @@ export function restoreRecurringCost(draft: Dataset, id: RecurringCostId): void * anchoring it is the latter that rebases the series — which is the whole of * the phone-bill behaviour. * + * A `lastCharge` cost has no grid, so its charge names no slot: `source` + * carries `recurringId` alone, and `occurrenceDate` — the caller's projected + * cursor, not a real slot — is dropped rather than stored. Recording it would + * describe a schedule the app will not compute (`confirmationsFor` in + * `occurrences.ts` is calendar-only and throws on exactly this shape). + * * Deleting the purchase later un-confirms the slot, with nothing to reconcile. */ export function confirmOccurrence( @@ -576,6 +611,9 @@ export function confirmOccurrence( // cost's, and `mutate` clones the draft rather than deep-freezing it. splits: cost.splits.map((split) => ({ ...split })), schedule: null, - source: { recurringId, occurrenceDate }, + source: + cost.anchoring === "lastCharge" + ? { recurringId } + : { recurringId, occurrenceDate }, }); }