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:
Lucas WintherandClaude Opus 5 committed 2026-09-06 18:31:52 +02:00
1 parent d542a6bdab
commit bdb2b9d80f
2 files changed
+176 -3

No files matched your search

+152
View File
@@ -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
View File
@@ -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 } };
}),
};
},