fix(store): 7 -> 8 expands a month-only charge date on an anchored cost
The step stripped `occurrenceDate` from an anchored cost's charges and left `Purchase.date` alone. Under schema 7 those dates are legitimately month-only: 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 without a day. An anchored cost has no slots, so after the upgrade that date IS the anchor the series steps from — a realistic schema-7 dataset therefore migrated cleanly and then threw out of every money route on first load. The owner has a phone bill; this is their data, 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 already uses. A slot in a DIFFERENT month is not borrowed from — that would move the purchase, and moving money is what this step must not do. Extending an unshipped step rather than appending a new one: 7 -> 8 exists only on this branch, so no stored dataset has been through it. The regression test builds a schema-7 dataset the app itself would have written and folds the result, rather than only inspecting the shape. A second test pins the agreement the two halves of this fix have to keep: `parseDatasetJson` migrates before it validates, so everything this step produces must satisfy the import boundary the next commit tightens. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
d542a6bdab
commit
bdb2b9d80f
2 files changed
+176
-3
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
+24
-3
@@ -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 } };
|
||||
}),
|
||||
};
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user