fix(validation): both boundaries reject a month-only date on an anchored charge
Every guard this model shipped with checked the COST's `startDate` granularity. Nobody checked the CHARGE's. An anchored (`lastCharge`) cost has no slots, so `anchoredOccurrences` takes the latest charge's own `Purchase.date` as its anchor and steps forward from it — and `Purchase.date`'s documented shape allows "YYYY-MM". Both write paths accepted that and the next fold took every money route down, with no way to recover from the UI: the anchoring select is disabled on a charged cost, there is no delete action, and the walk's precheck sits before the `endedFrom` break, so ending the cost does not help either. It was reachable by default, not by accident: `emptyDraft` seeds `date: monthId`, so a new purchase starts month-only, and the dialog offered every anchored cost regardless. `updatePurchase` accepted the same shape when "use the month only" truncated the date of an existing anchored charge. `requireSourcePairing` now takes the RESOLVED date, so an update touching only the date is checked against the source already stored. Both boundaries also route a day-shaped value through the domain's own parser, the way each already does for `startDate`: "2026-02-30" is day-granular and calendar-impossible, and `PURCHASE_DATE` lets it through, so it would otherwise import clean and throw from the same frame. Both files now ask `startDateNeedsDay`/`chargeDateNeedsDay` rather than restating the rule, and `parseDatasetJson`'s doc no longer claims more than it checks. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
bdb2b9d80f
commit
19087737eb
@@ -8,6 +8,7 @@ import {
|
||||
} from "./json.ts";
|
||||
import { createSeedDataset } from "../domain/seed.ts";
|
||||
import { monthView, yearView } from "../domain/views.ts";
|
||||
import { occurrencesByMonth } from "../domain/occurrences.ts";
|
||||
import type { Dataset } from "../domain/types.ts";
|
||||
|
||||
function populated(): Dataset {
|
||||
@@ -663,3 +664,77 @@ describe("startDate granularity under the anchored model", () => {
|
||||
expect(() => parseDatasetJson(withCost("lastCharge", "everyNMonths", "2026-09-31"))).toThrow(/impossible/i);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The other half of the same rule, and the half nobody checked: an anchored
|
||||
* cost has no slots, so `anchoredOccurrences` steps forward from its latest
|
||||
* CHARGE's own `Purchase.date`. `PURCHASE_DATE` allows "YYYY-MM" — right for
|
||||
* an ordinary purchase, wrong for this one — so a month-only anchored charge
|
||||
* imported clean and threw out of the very first fold, from a frame naming no
|
||||
* cost. That falsified this file's own claim to validate far enough that the
|
||||
* fold cannot throw, and made restoring a backup reproduce the crash.
|
||||
*/
|
||||
describe("an anchored cost's CHARGE dates need a day at the import boundary", () => {
|
||||
function withCharge(anchoring: string, kind: string, purchaseDate: string): string {
|
||||
const data: any = createSeedDataset("2026-09");
|
||||
data.posts[0].id = "housing";
|
||||
data.recurring = [{
|
||||
id: "r1", name: "Bill", order: 0, archived: false,
|
||||
amount: { amount: 300, currency: "DKK" },
|
||||
// Before the charge, so the charge's own date is the anchor the walk
|
||||
// uses rather than being masked by `max(latest, startDate)`.
|
||||
startDate: anchoring === "lastCharge" ? "2026-08-04" : "2026-08",
|
||||
recurrence: kind === "everyNMonths" ? { kind, n: 1 } : { kind, n: 30 },
|
||||
anchoring, splitMode: "percent",
|
||||
splits: [{ postId: "housing", value: 100, absorbsRemainder: true }],
|
||||
}];
|
||||
data.purchases = [{
|
||||
id: "p1", date: purchaseDate, description: "Bill",
|
||||
total: { amount: 300, currency: "DKK" }, splitMode: "percent",
|
||||
splits: [{ postId: "housing", value: 100, absorbsRemainder: true }],
|
||||
schedule: null,
|
||||
source: anchoring === "lastCharge"
|
||||
? { recurringId: "r1" }
|
||||
: { recurringId: "r1", occurrenceDate: "2026-09" },
|
||||
}];
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
|
||||
test("a month-only date on an anchored monthly charge is refused, naming the cost", () => {
|
||||
expect(() => parseDatasetJson(withCharge("lastCharge", "everyNMonths", "2026-09"))).toThrow(
|
||||
/Bill.*day-granular/,
|
||||
);
|
||||
});
|
||||
|
||||
test("a month-only date on an anchored DAY-granular charge is refused too", () => {
|
||||
expect(() => parseDatasetJson(withCharge("lastCharge", "everyNDays", "2026-09"))).toThrow(
|
||||
/Bill.*day-granular/,
|
||||
);
|
||||
});
|
||||
|
||||
test("a day-granular date on an anchored charge is accepted, and folds", () => {
|
||||
const dataset = parseDatasetJson(withCharge("lastCharge", "everyNMonths", "2026-09-20"));
|
||||
expect(() => occurrencesByMonth(dataset, "2026-12")).not.toThrow();
|
||||
});
|
||||
|
||||
test("a day-SHAPED but calendar-impossible date on an anchored charge is refused", () => {
|
||||
expect(() => parseDatasetJson(withCharge("lastCharge", "everyNMonths", "2026-02-30"))).toThrow(
|
||||
/impossible/i,
|
||||
);
|
||||
});
|
||||
|
||||
test("a month-only date on a CALENDAR cost's charge is still accepted", () => {
|
||||
// It settles the slot in `occurrenceDate`; its own date is never stepped
|
||||
// from, so there is nothing here to require a day.
|
||||
const dataset = parseDatasetJson(withCharge("calendar", "everyNMonths", "2026-09"));
|
||||
expect(() => occurrencesByMonth(dataset, "2026-12")).not.toThrow();
|
||||
});
|
||||
|
||||
test("every dataset this boundary accepts can be folded", () => {
|
||||
// The claim in `parseDatasetJson`'s own doc, asserted rather than stated.
|
||||
for (const date of ["2026-09-01", "2026-09-20", "2026-09-30"]) {
|
||||
const dataset = parseDatasetJson(withCharge("lastCharge", "everyNMonths", date));
|
||||
expect(() => occurrencesByMonth(dataset, "2027-06")).not.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user