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:
Lucas Winther
2026-09-06 18:32:05 +02:00
co-authored by Claude Opus 5
parent bdb2b9d80f
commit 19087737eb
4 changed files with 296 additions and 28 deletions
+75
View File
@@ -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();
}
});
});
+50 -11
View File
@@ -2,6 +2,7 @@ import type { Currency, CurrencyDef, Dataset, MonthId } from "../domain/types.ts
import { migrate } from "../store/migrations.ts";
import { monthOf } from "../domain/months.ts";
import { toDayOrdinal } from "../domain/days.ts";
import { chargeDateNeedsDay, startDateNeedsDay } from "../domain/granularity.ts";
export class ImportValidationError extends Error {
constructor(message: string) {
@@ -39,10 +40,13 @@ const MONTH_ID = /^\d{4}-(0[1-9]|1[0-2])$/;
const PURCHASE_DATE = /^\d{4}-(0[1-9]|1[0-2])(-(0[1-9]|[12]\d|3[01]))?$/;
/**
* The day-granular subset of `PURCHASE_DATE`: "YYYY-MM-DD" only, no month-only
* form. `everyNDays` and `everyNWeeks` hand `startDate` straight to `addDays`
* (`src/domain/occurrences.ts`), which throws on a month-only date — so those
* kinds need this narrower shape, checked here rather than three months later
* inside a fold.
* form. WHICH values need this narrower shape is `startDateNeedsDay` /
* `chargeDateNeedsDay` in `src/domain/granularity.ts`, not a rule this file
* restates: `everyNDays` and `everyNWeeks` hand `startDate` straight to
* `addDays`, `everyNMonths` anchored to `lastCharge` hands it to
* `addMonthsToDate`, and an anchored cost's CHARGE dates are stepped from the
* same way (`src/domain/occurrences.ts`). All of those throw on a month-only
* date, so it is refused here rather than three months later inside a fold.
*/
const DAY_GRANULAR_DATE = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/;
@@ -228,11 +232,19 @@ function requireSplits(
* Validates far enough that the fold cannot throw on the imported data: every
* split points at a real post, every purchase has exactly one remainder
* absorber, every MonthId parses, every rule version resolves unambiguously
* to a number rather than to NaN or a missing rate, and every recurring
* cost's startDate is both granular enough for its recurrence kind
* (day-granular for everyNDays/everyNWeeks, since occurrencesOf hands it
* straight to addDays) and calendar-valid (no "2026-09-31"), since shape and
* range alone let an impossible date through.
* to a number rather than to NaN or a missing rate, and every date the
* projection walk will STEP FROM is both granular enough for the schedule
* that reads it and calendar-valid (no "2026-09-31"), since shape and range
* alone let an impossible date through. That last one covers a recurring
* cost's own `startDate` and, for an anchored (`lastCharge`) cost, the `date`
* of every purchase charged to it — an anchored cost has no slots, so its
* charges' own dates are the anchors, and a month-only one there used to
* import clean and take down every money route on the next fold.
*
* Note the order this runs in: `migrate` first, so what is validated is the
* upgraded dataset. A file the migration produces must therefore satisfy
* every rule below — see the 7 -> 8 step, which expands exactly the
* month-only charge dates this function refuses.
*/
export function parseDatasetJson(text: string): Dataset {
let raw: unknown;
@@ -357,8 +369,7 @@ export function parseDatasetJson(text: string): Dataset {
// always need a day-granular startDate. `endedFrom` needs no such check —
// it is only ever compared lexicographically, never fed to `addDays`.
const dayGranular = DAY_GRANULAR_DATE.test(cost.startDate);
const needsDay = cost.recurrence.kind !== "everyNMonths" || cost.anchoring === "lastCharge";
if (needsDay && !dayGranular) {
if (startDateNeedsDay(cost.recurrence.kind, cost.anchoring) && !dayGranular) {
throw new ImportValidationError(
`${label} has a "${cost.recurrence.kind}" recurrence anchored to "${cost.anchoring}" with a ` +
`month-only start date "${cost.startDate}"; it needs a day-granular startDate ("YYYY-MM-DD")`,
@@ -468,6 +479,34 @@ export function parseDatasetJson(text: string): Dataset {
);
}
}
// An anchored cost has no slots, so this purchase's OWN date is the
// anchor `anchoredOccurrences` steps forward from — under every
// recurrence kind, day-precisely. `PURCHASE_DATE` above allows
// "YYYY-MM", which is right for an ordinary purchase and wrong here: a
// month-only date would import clean and then throw out of the very
// first fold, from a frame that names no cost. The cost's own
// `startDate` has been checked this way since the anchored model
// landed; its charges were the half nobody checked.
if (chargeDateNeedsDay(cost.anchoring)) {
if (!DAY_GRANULAR_DATE.test(purchase.date)) {
throw new ImportValidationError(
`${label} is a charge for "${cost.name}", which is anchored to its last charge, so its ` +
`date is what the next charge is measured from; it needs a day-granular date ` +
`("YYYY-MM-DD"), not "${purchase.date}"`,
);
}
// Day-shaped is not day-valid — same reason `startDate` is routed
// through the domain's own parser above.
try {
toDayOrdinal(purchase.date);
} catch (error) {
throw new ImportValidationError(
`${label} is a charge for "${cost.name}" and has a calendar-impossible date ` +
`"${purchase.date}" (${(error as Error).message})`,
);
}
}
}
}