feat(store): migrate to the anchored model, schema 8

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.
This commit is contained in:
Lucas Winther
2026-09-05 22:32:12 +02:00
parent c49224d4f7
commit 6d63aafab4
3 changed files with 108 additions and 1 deletions
+1 -1
View File
@@ -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;
+69
View File
@@ -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);
});
});
+38
View File
@@ -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 {