diff --git a/src/store/migrations.test.ts b/src/store/migrations.test.ts index 0201f44..84e5061 100644 --- a/src/store/migrations.test.ts +++ b/src/store/migrations.test.ts @@ -22,3 +22,95 @@ describe("migrate", () => { expect(() => migrate({ posts: [] })).toThrow(/schemaVersion/); }); }); + +import { allocationFor } from "../domain/allocation.ts"; + +/** A v1 dataset, in the shape that shipped before rules were versioned. */ +function v1Dataset() { + return { + settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1 }, + fxRates: [], + posts: [ + { + id: "food", + name: "Food", + order: 0, + archived: false, + currency: "DKK", + standingRule: { kind: "percentOfIncome", percent: 20 }, + }, + { + id: "games", + name: "Video Games", + order: 1, + archived: false, + currency: "DKK", + standingRule: { kind: "fixed", amount: { amount: 400, currency: "DKK" } }, + }, + ], + months: [ + { id: "2026-01", income: { amount: 10000, currency: "DKK" }, ruleOverrides: {} }, + { + id: "2026-02", + income: { amount: 12000, currency: "DKK" }, + ruleOverrides: { games: { kind: "fixed", amount: { amount: 900, currency: "DKK" } } }, + }, + ], + purchases: [], + }; +} + +describe("v1 -> v2: rules become a dated series", () => { + test("each standingRule becomes one version starting at the fold start", () => { + const migrated = migrate(v1Dataset()); + expect(migrated.settings.schemaVersion).toBe(2); + expect(migrated.posts[0]!.rules).toEqual([ + { from: "2026-01", rule: { kind: "percentOfIncome", percent: 20 } }, + ]); + expect(migrated.posts[1]!.rules).toEqual([ + { from: "2026-01", rule: { kind: "fixed", amount: { amount: 400, currency: "DKK" } } }, + ]); + }); + + test("the old field is gone afterwards", () => { + const migrated = migrate(v1Dataset()) as unknown as Record; + expect("standingRule" in (migrated.posts as any[])[0]).toBe(false); + }); + + test("per-month overrides are left untouched", () => { + const migrated = migrate(v1Dataset()); + expect(migrated.months[1]!.ruleOverrides.games).toEqual({ + kind: "fixed", + amount: { amount: 900, currency: "DKK" }, + }); + }); + + // The property that matters: this is a shape change, not a recalculation. + // A shape-only test would pass while every balance quietly moved. + test("NO allocation changes: every post in every month resolves identically", () => { + const before = v1Dataset(); + const migrated = migrate(v1Dataset()); + + // Resolve the v1 way by hand, since allocationFor now speaks v2 only. + const v1Allocation = (postId: string, monthId: string) => { + const post = before.posts.find((p) => p.id === postId)!; + const month = before.months.find((m) => m.id === monthId); + const rule = (month?.ruleOverrides as any)?.[postId] ?? post.standingRule; + const income = before.months.find((m) => m.id === monthId)?.income.amount ?? 0; + return rule.kind === "fixed" + ? rule.amount.amount + : Number(((income * rule.percent) / 100).toFixed(2)); + }; + + for (const postId of ["food", "games"]) { + for (const monthId of ["2026-01", "2026-02"]) { + expect(allocationFor(migrated, postId, monthId)).toBe(v1Allocation(postId, monthId)); + } + } + }); + + test("a v1 post with no posts at all migrates without throwing", () => { + const empty = { ...v1Dataset(), posts: [] }; + expect(migrate(empty).posts).toEqual([]); + }); +}); diff --git a/src/store/migrations.ts b/src/store/migrations.ts index 4791e1c..348c416 100644 --- a/src/store/migrations.ts +++ b/src/store/migrations.ts @@ -16,7 +16,27 @@ export class UnsupportedSchemaError extends Error { * because users' stored data has already been through it. */ const MIGRATIONS: Array<(data: any) => any> = [ - // index 0: there is no version 0 in the wild yet. + // index 0: there is no version 0 in the wild. + undefined as unknown as (data: any) => any, + + // 1 -> 2: a post's single `standingRule` becomes a one-entry dated series, + // effective from the fold start. Behaviour-preserving by construction: the + // standing rule already applied from the fold start onward, so every month + // resolves to exactly the allocation it did before. + (data: any) => ({ + ...data, + settings: { ...data.settings, schemaVersion: 2 }, + posts: (data.posts ?? []).map((post: any) => { + const { standingRule, ...rest } = post; + return { + ...rest, + rules: + standingRule === undefined + ? [] + : [{ from: data.settings.foldStartMonth, rule: standingRule }], + }; + }), + }), ]; export function migrate(raw: unknown): Dataset {