From 6d63aafab4621debeb09b3bea78ce3ddbfe51e64 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Sat, 5 Sep 2026 22:32:12 +0200 Subject: [PATCH] feat(store): migrate to the anchored model, schema 8 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strips occurrenceDate from an anchored cost's charges, because that path has no slots and a leftover slot id is a lie a future reader would trust. Expands a month-only startDate on an anchored monthly cost to the 1st, because day precision now needs a real day. calendar costs are not touched at all. Deliberately NOT behaviour-preserving — an anchored cost's projected dates change, which is the point. Every recorded figure is preserved: no Money is touched, no purchase added or removed. --- src/domain/seed.ts | 2 +- src/store/migrations.test.ts | 69 ++++++++++++++++++++++++++++++++++++ src/store/migrations.ts | 38 ++++++++++++++++++++ 3 files changed, 108 insertions(+), 1 deletion(-) diff --git a/src/domain/seed.ts b/src/domain/seed.ts index 77b99f7..3b4d258 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 = 7; +export const SCHEMA_VERSION = 8; const SEED_POST_NAMES = ["Video Games", "Food", "Events and Social"] as const; diff --git a/src/store/migrations.test.ts b/src/store/migrations.test.ts index d1f27ce..8dbce6d 100644 --- a/src/store/migrations.test.ts +++ b/src/store/migrations.test.ts @@ -485,3 +485,72 @@ describe("6 -> 7: recurring costs join the dataset", () => { } }); }); + +describe("7 -> 8: the anchored model", () => { + function v7(recurring: any[], purchases: any[]): any { + const base = createSeedDataset("2026-09"); + return { + ...base, + settings: { ...base.settings, schemaVersion: 7 }, + recurring, + purchases, + }; + } + const anchored = { + id: "phone", name: "Phone", order: 0, archived: false, + amount: { amount: 300, currency: "DKK" }, + startDate: "2026-09", recurrence: { kind: "everyNMonths", n: 1 }, + anchoring: "lastCharge", splitMode: "percent", + splits: [{ postId: "p", value: 100, absorbsRemainder: true }], + }; + const grid = { ...anchored, id: "rent", name: "Rent", anchoring: "calendar" }; + + function purchase(recurringId: string, occurrenceDate: string) { + return { + id: `pay-${recurringId}`, date: "2026-09-20", description: "x", + total: { amount: 300, currency: "DKK" }, splitMode: "percent", + splits: [{ postId: "p", value: 100, absorbsRemainder: true }], + schedule: null, source: { recurringId, occurrenceDate }, + }; + } + + test("strips occurrenceDate from an ANCHORED cost's charges", () => { + const out = migrate(v7([anchored], [purchase("phone", "2026-09")])); + expect(out.purchases[0]!.source).toEqual({ recurringId: "phone" }); + expect(out.settings.schemaVersion).toBe(SCHEMA_VERSION); + }); + + test("LEAVES occurrenceDate on a calendar cost's charges", () => { + const out = migrate(v7([grid], [purchase("rent", "2026-09")])); + expect(out.purchases[0]!.source).toEqual({ recurringId: "rent", occurrenceDate: "2026-09" }); + }); + + test("expands a month-only startDate on an anchored MONTHLY cost", () => { + // Day precision now needs a real day; the 1st is the honest reading of + // "this cost started in September". + expect(migrate(v7([anchored], [])).recurring[0]!.startDate).toBe("2026-09-01"); + }); + + test("leaves a calendar monthly cost's month-only startDate alone", () => { + expect(migrate(v7([grid], [])).recurring[0]!.startDate).toBe("2026-09"); + }); + + test("leaves an already day-granular startDate alone", () => { + const dated = { ...anchored, startDate: "2026-09-20" }; + expect(migrate(v7([dated], [])).recurring[0]!.startDate).toBe("2026-09-20"); + }); + + test("an ordinary purchase with no source is untouched", () => { + const plain = { ...purchase("phone", "2026-09"), source: undefined }; + delete (plain as any).source; + const out = migrate(v7([anchored], [plain])); + expect(out.purchases[0]!.source).toBeUndefined(); + }); + + test("NO recorded figure changes", () => { + const out = migrate(v7([anchored], [purchase("phone", "2026-09")])); + expect(out.purchases[0]!.total).toEqual({ amount: 300, currency: "DKK" }); + expect(out.recurring[0]!.amount).toEqual({ amount: 300, currency: "DKK" }); + expect(out.purchases.length).toBe(1); + }); +}); diff --git a/src/store/migrations.ts b/src/store/migrations.ts index de9ba06..1bfe29c 100644 --- a/src/store/migrations.ts +++ b/src/store/migrations.ts @@ -193,6 +193,44 @@ const MIGRATIONS: Array<(data: any) => any> = [ settings: { ...data.settings, schemaVersion: 7 }, recurring: Array.isArray(data.recurring) ? data.recurring : [], }), + + // 7 -> 8: recurring costs split into two projection models. + // + // A `lastCharge` cost no longer has a grid of slots — its next due date is + // one cycle after the latest actual charge — so `occurrenceDate` on its + // 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. + // + // NOT behaviour-preserving, and it must not pretend to be: an anchored + // cost's projected dates deliberately change, which is the point of the + // change. What IS preserved is every recorded figure — no Money is touched, + // no purchase is added or removed. + // + // The two literals below are inline strings, not imported constants: this + // step must not read a live default. + (data: any) => { + const recurring = Array.isArray(data.recurring) ? data.recurring : []; + const anchoredIds = new Set( + recurring.filter((c: any) => c?.anchoring === "lastCharge").map((c: any) => c?.id), + ); + return { + ...data, + settings: { ...data.settings, schemaVersion: 8 }, + recurring: recurring.map((cost: any) => { + if (cost?.anchoring !== "lastCharge") return cost; + if (cost?.recurrence?.kind !== "everyNMonths") return cost; + if (typeof cost.startDate !== "string" || cost.startDate.length !== 7) return cost; + return { ...cost, startDate: `${cost.startDate}-01` }; + }), + 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 } }; + }), + }; + }, ]; export function migrate(raw: unknown): Dataset {