diff --git a/src/store/migrations.test.ts b/src/store/migrations.test.ts index 8dbce6d..f671a50 100644 --- a/src/store/migrations.test.ts +++ b/src/store/migrations.test.ts @@ -2,6 +2,8 @@ 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"; +import { occurrencesByMonth } from "../domain/occurrences.ts"; +import { parseDatasetJson } from "../export/json.ts"; describe("migrate", () => { test("passes a current-version dataset through unchanged", () => { @@ -554,3 +556,153 @@ describe("7 -> 8: the anchored model", () => { expect(out.purchases.length).toBe(1); }); }); + +/** + * The half of 7 -> 8 that matters most, because it is the owner's OWN data. + * + * An anchored cost has no slots: `anchoredOccurrences` takes the latest + * charge's own `Purchase.date` as its anchor and steps forward from it. Under + * schema 7 a monthly cost's grid was month-granular, and `confirmOccurrence` + * wrote `date: overrides.date ?? occurrenceDate` — so every one-click + * confirmation of a month-granular anchored bill was stored MONTH-ONLY. That + * is a phone bill confirmed from the Expected band: the ordinary case. + * + * Without the expansion below, such a dataset migrated cleanly and then threw + * out of the first fold after the upgrade, taking every money route down on + * first load. So these tests migrate and then FOLD, rather than only + * inspecting the shape. + */ +describe("7 -> 8 expands a month-only charge date on an anchored cost", () => { + /** + * A schema-7 dataset the way the app itself would have written one: real + * posts, a monthly phone plan anchored to its last charge with a + * month-granular startDate, and three months of one-click confirmations + * whose dates are month-only because the slots they settled were. + */ + function realisticV7() { + const base = createSeedDataset("2026-06"); + const post = base.posts[0]!; + const confirmation = (month: string, slot: string) => ({ + id: `pay-${month}`, + date: month, + description: "Phone plan", + total: { amount: 299, currency: "DKK" }, + splitMode: "percent", + splits: [{ postId: post.id, value: 100, absorbsRemainder: true }], + schedule: null, + source: { recurringId: "phone", occurrenceDate: slot }, + }); + return { + ...base, + settings: { ...base.settings, schemaVersion: 7 }, + months: ["2026-06", "2026-07", "2026-08", "2026-09"].map((id) => ({ + id, + income: { amount: 22000, currency: "DKK" }, + ruleOverrides: {}, + })), + posts: base.posts.map((p) => + p.id === post.id + ? { ...p, rules: [{ from: "2026-06", rule: { kind: "fixed", amount: { amount: 1000, currency: "DKK" } } }] } + : p, + ), + recurring: [ + { + id: "phone", + name: "Phone plan", + order: 0, + archived: false, + amount: { amount: 299, currency: "DKK" }, + startDate: "2026-01", + recurrence: { kind: "everyNMonths", n: 1 }, + anchoring: "lastCharge", + splitMode: "percent", + splits: [{ postId: post.id, value: 100, absorbsRemainder: true }], + }, + ], + purchases: [ + confirmation("2026-06", "2026-06"), + confirmation("2026-07", "2026-07"), + confirmation("2026-08", "2026-08"), + ], + }; + } + + test("the owner's real dataset migrates and then FOLDS, instead of taking every money route down", () => { + const out = migrate(realisticV7()); + // Asserted FIRST, because it is the symptom: without the expansion this + // throws `Recurring cost "Phone plan" is anchored (lastCharge) ... got + // "2026-08"` out of every money route, on first load after the upgrade. + expect(() => foldBalances(out, "2026-12")).not.toThrow(); + expect(out.purchases.map((p) => p.date)).toEqual([ + "2026-06-01", "2026-07-01", "2026-08-01", + ]); + expect(out.recurring[0]!.startDate).toBe("2026-01-01"); + // And the projection actually runs: one cycle after the latest charge. + const projected = occurrencesByMonth(out, "2026-10").get("2026-09"); + expect(projected?.map((o) => o.date)).toEqual(["2026-09-01"]); + }); + + test("NO recorded figure moves month — the expansion stays inside the purchase's own month", () => { + const before = realisticV7(); + const out = migrate(before); + expect(out.purchases.map((p) => p.total)).toEqual( + before.purchases.map((p) => p.total), + ); + expect(out.purchases.map((p) => p.date.slice(0, 7))).toEqual( + before.purchases.map((p) => p.date), + ); + }); + + test("the day comes from the occurrenceDate it is about to strip, when that names one", () => { + // Reachable in schema 7 by editing a confirmed purchase's date down to + // "use the month only": `Purchase.date` lost its day, `occurrenceDate` + // kept it. That day is the one the owner actually settled on. + const data = realisticV7(); + data.purchases[2]!.date = "2026-08"; + data.purchases[2]!.source.occurrenceDate = "2026-08-04"; + const out = migrate(data); + expect(out.purchases[2]!.date).toBe("2026-08-04"); + }); + + test("a slot in a DIFFERENT month is not borrowed from — that would move the purchase", () => { + const data = realisticV7(); + data.purchases[2]!.date = "2026-08"; + data.purchases[2]!.source.occurrenceDate = "2026-07-04"; // paid August, settling July + const out = migrate(data); + expect(out.purchases[2]!.date).toBe("2026-08-01"); + }); + + test("an already day-granular charge date is untouched", () => { + const data = realisticV7(); + data.purchases[0]!.date = "2026-06-17"; + expect(migrate(data).purchases[0]!.date).toBe("2026-06-17"); + }); + + test("a CALENDAR cost's charge keeps its month-only date — it settles a slot, it is no anchor", () => { + const data = realisticV7(); + data.recurring[0]!.anchoring = "calendar"; + const out = migrate(data); + expect(out.purchases.map((p) => p.date)).toEqual(["2026-06", "2026-07", "2026-08"]); + expect(() => foldBalances(out, "2026-12")).not.toThrow(); + }); + + /** + * The migration and the import boundary have to AGREE: `parseDatasetJson` + * runs `migrate` first, so anything the migration produces must satisfy + * every rule the boundary enforces. A migration that expanded these dates + * to something the boundary refuses would make restoring a backup + * impossible; one that left them alone would make the fold throw. Assert + * both directions on the same dataset. + */ + test("what the migration produces is exactly what the import boundary accepts", () => { + const migrated = migrate(realisticV7()); + expect(() => parseDatasetJson(JSON.stringify(migrated))).not.toThrow(); + // Round-tripping the ORIGINAL schema-7 file through import — the "restore + // my backup" path — must work too, since import migrates first. + const restored = parseDatasetJson(JSON.stringify(realisticV7())); + expect(restored.purchases.map((p) => p.date)).toEqual([ + "2026-06-01", "2026-07-01", "2026-08-01", + ]); + expect(() => foldBalances(restored, "2026-12")).not.toThrow(); + }); +}); diff --git a/src/store/migrations.ts b/src/store/migrations.ts index 1bfe29c..fc9b17b 100644 --- a/src/store/migrations.ts +++ b/src/store/migrations.ts @@ -201,8 +201,22 @@ const MIGRATIONS: Array<(data: any) => any> = [ // charges is meaningless, and leaving it would invite a future reader to // trust it. A `calendar` cost keeps its slots and is not touched. // - // Day precision also now requires a real day, so an anchored MONTHLY cost - // whose startDate was month-only is expanded to the 1st. + // Day precision also now requires a real day, and on BOTH sides of an + // anchored cost. Its `startDate`, if it was month-only, is expanded to the + // 1st. So is the `date` of every purchase charged to it — an anchored cost + // has no slots, so `anchoredOccurrences` steps forward from the latest + // charge's own date, and a month-only one there throws out of the first + // fold after the upgrade. Those dates are REAL in schema 7: the old + // `confirmOccurrence` wrote `date: overrides.date ?? occurrenceDate`, so a + // month-granular monthly cost's one-click confirmations were stored + // month-only. That is a phone bill confirmed from the Expected band — the + // ordinary case, not a hand-edited file. + // + // The day comes from the `occurrenceDate` about to be stripped when that + // slot falls in the same month, because it is the day the owner actually + // settled; otherwise the 1st, the same convention the startDate expansion + // uses. Requiring the same month matters: taking a day from another month + // would MOVE the purchase, and moving money is what this step must not do. // // NOT behaviour-preserving, and it must not pretend to be: an anchored // cost's projected dates deliberately change, which is the point of the @@ -227,7 +241,14 @@ const MIGRATIONS: Array<(data: any) => any> = [ }), purchases: (Array.isArray(data.purchases) ? data.purchases : []).map((p: any) => { if (!p?.source || !anchoredIds.has(p.source.recurringId)) return p; - return { ...p, source: { recurringId: p.source.recurringId } }; + const slot = p.source.occurrenceDate; + const monthOnly = typeof p.date === "string" && p.date.length === 7; + const date = !monthOnly + ? p.date + : typeof slot === "string" && slot.length === 10 && slot.slice(0, 7) === p.date + ? slot + : `${p.date}-01`; + return { ...p, date, source: { recurringId: p.source.recurringId } }; }), }; },