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:
+1
-1
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user