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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+50
-11
@@ -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})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1055,6 +1055,129 @@ describe("addPurchase/updatePurchase enforce the source/occurrenceDate pairing",
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Every guard this model shipped with checked the COST's `startDate`
|
||||
* granularity. Nobody checked the CHARGE's own date — and an anchored cost has
|
||||
* no slots, so its latest charge's `Purchase.date` is exactly what
|
||||
* `anchoredOccurrences` steps forward from. A month-only one was accepted by
|
||||
* both write paths and then threw out of the very next fold, taking every
|
||||
* money route down: `emptyDraft` seeds `date: monthId`, so month-only was the
|
||||
* DEFAULT for a new purchase, and no UI route could undo it afterwards.
|
||||
*
|
||||
* Each test folds afterwards rather than only asserting the throw: the point
|
||||
* is not that a message appears, it is that no dataset the store accepts can
|
||||
* take the fold down.
|
||||
*/
|
||||
describe("a charge for an anchored cost needs a day-granular date", () => {
|
||||
function seeded(recurrence: Parameters<typeof actions.addRecurringCost>[1]["recurrence"]) {
|
||||
const d = createSeedDataset("2026-09");
|
||||
const post = d.posts[0]!;
|
||||
// Starts BEFORE the charge, so the charge's own date is what the walk
|
||||
// anchors on — `max(latest, startDate)` would otherwise hide the bug
|
||||
// behind a day-granular startDate.
|
||||
const cost = actions.addRecurringCost(d, {
|
||||
name: "Phone", archived: false,
|
||||
amount: { amount: 300, currency: "DKK" },
|
||||
startDate: "2026-08-04",
|
||||
recurrence,
|
||||
anchoring: "lastCharge", splitMode: "percent",
|
||||
splits: [{ postId: post.id, value: 100, absorbsRemainder: true }],
|
||||
});
|
||||
return { d, post, cost };
|
||||
}
|
||||
|
||||
function charge(postId: string, recurringId: string, date: string) {
|
||||
return {
|
||||
date,
|
||||
description: "Phone",
|
||||
total: { amount: 300, currency: "DKK" },
|
||||
splitMode: "percent" as const,
|
||||
splits: [{ postId, value: 100, absorbsRemainder: true }],
|
||||
schedule: null,
|
||||
source: { recurringId },
|
||||
};
|
||||
}
|
||||
|
||||
test("addPurchase refuses a month-only date on a monthly anchored cost", () => {
|
||||
const { d, post, cost } = seeded({ kind: "everyNMonths", n: 1 });
|
||||
expect(() => actions.addPurchase(d, charge(post.id, cost.id, "2026-09"))).toThrow(
|
||||
/Phone.*day-granular|day \("YYYY-MM-DD"\)/,
|
||||
);
|
||||
expect(d.purchases).toHaveLength(0);
|
||||
expect(() => occurrencesByMonth(d, "2026-12")).not.toThrow();
|
||||
});
|
||||
|
||||
test("addPurchase refuses a month-only date on a DAY-granular anchored cost too", () => {
|
||||
// The precheck in `anchoredOccurrences` used to cover `everyNMonths`
|
||||
// only, so this shape fell through to a bare "Not a day-granular IsoDate"
|
||||
// out of `addDays`, naming no cost at all.
|
||||
const { d, post, cost } = seeded({ kind: "everyNDays", n: 30 });
|
||||
expect(() => actions.addPurchase(d, charge(post.id, cost.id, "2026-09"))).toThrow(/Phone/);
|
||||
expect(() => occurrencesByMonth(d, "2026-12")).not.toThrow();
|
||||
});
|
||||
|
||||
test("addPurchase refuses a month-only date on a WEEKLY anchored cost too", () => {
|
||||
const { d, post, cost } = seeded({ kind: "everyNWeeks", n: 2, weekday: 4 });
|
||||
expect(() => actions.addPurchase(d, charge(post.id, cost.id, "2026-09"))).toThrow(/Phone/);
|
||||
expect(() => occurrencesByMonth(d, "2026-12")).not.toThrow();
|
||||
});
|
||||
|
||||
test("addPurchase accepts a day-granular date on an anchored cost", () => {
|
||||
const { d, post, cost } = seeded({ kind: "everyNMonths", n: 1 });
|
||||
expect(() => actions.addPurchase(d, charge(post.id, cost.id, "2026-09-20"))).not.toThrow();
|
||||
expect(() => occurrencesByMonth(d, "2026-12")).not.toThrow();
|
||||
});
|
||||
|
||||
test("addPurchase refuses a day-SHAPED but impossible date on an anchored cost", () => {
|
||||
const { d, post, cost } = seeded({ kind: "everyNMonths", n: 1 });
|
||||
expect(() => actions.addPurchase(d, charge(post.id, cost.id, "2026-09-31"))).toThrow(/Phone/);
|
||||
});
|
||||
|
||||
test("updatePurchase refuses truncating a stored anchored charge's date to the month", () => {
|
||||
// The purchase dialog's "use the month only" toggle resets the NEW-cost
|
||||
// pairing but leaves an already-selected anchored cost selected, so this
|
||||
// is the shape that reached `updatePurchase` from the UI.
|
||||
const { d, post, cost } = seeded({ kind: "everyNMonths", n: 1 });
|
||||
const purchase = actions.addPurchase(d, charge(post.id, cost.id, "2026-09-20"));
|
||||
expect(() => actions.updatePurchase(d, purchase.id, { date: "2026-09" })).toThrow(/Phone/);
|
||||
expect(d.purchases[0]!.date).toBe("2026-09-20");
|
||||
expect(() => occurrencesByMonth(d, "2026-12")).not.toThrow();
|
||||
});
|
||||
|
||||
test("updatePurchase refuses tagging an existing month-only purchase to an anchored cost", () => {
|
||||
const { d, post, cost } = seeded({ kind: "everyNMonths", n: 1 });
|
||||
const purchase = actions.addPurchase(d, {
|
||||
...charge(post.id, cost.id, "2026-09"),
|
||||
source: undefined,
|
||||
});
|
||||
expect(() =>
|
||||
actions.updatePurchase(d, purchase.id, { source: { recurringId: cost.id } }),
|
||||
).toThrow(/Phone/);
|
||||
expect(d.purchases[0]!.source).toBeUndefined();
|
||||
expect(() => occurrencesByMonth(d, "2026-12")).not.toThrow();
|
||||
});
|
||||
|
||||
test("a month-only charge date on a CALENDAR cost is still fine — it settles a slot, it is not an anchor", () => {
|
||||
const d = createSeedDataset("2026-09");
|
||||
const post = d.posts[0]!;
|
||||
const rent = actions.addRecurringCost(d, {
|
||||
name: "Rent", archived: false,
|
||||
amount: { amount: 8000, currency: "DKK" },
|
||||
startDate: "2026-09",
|
||||
recurrence: { kind: "everyNMonths", n: 1 },
|
||||
anchoring: "calendar", splitMode: "percent",
|
||||
splits: [{ postId: post.id, value: 100, absorbsRemainder: true }],
|
||||
});
|
||||
expect(() =>
|
||||
actions.addPurchase(d, {
|
||||
...charge(post.id, rent.id, "2026-09"),
|
||||
source: { recurringId: rent.id, occurrenceDate: "2026-09" },
|
||||
}),
|
||||
).not.toThrow();
|
||||
expect(() => occurrencesByMonth(d, "2026-12")).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// REVIEW ROUND 1: pre-existing asymmetries with json.ts, confirmed clean
|
||||
// against the baseline — neither `requireRecurrence` nor `addRecurringCost`/
|
||||
// `updateRecurringCost` validated `recurrence.kind`/`anchoring` against their
|
||||
|
||||
+48
-17
@@ -1,6 +1,11 @@
|
||||
import { newId } from "../domain/seed.ts";
|
||||
import { compareMonths, monthOf } from "../domain/months.ts";
|
||||
import { toDayOrdinal } from "../domain/days.ts";
|
||||
import {
|
||||
chargeDateNeedsDay,
|
||||
isDayGranularDate,
|
||||
startDateNeedsDay,
|
||||
} from "../domain/granularity.ts";
|
||||
import { roundMoney } from "../domain/money.ts";
|
||||
import { currencyUsage, normalizeCurrencyCode } from "../domain/currencies.ts";
|
||||
import type {
|
||||
@@ -204,7 +209,7 @@ export function addPurchase(
|
||||
draft: Dataset,
|
||||
purchase: Omit<Purchase, "id">,
|
||||
): Purchase {
|
||||
requireSourcePairing(draft, purchase.source);
|
||||
requireSourcePairing(draft, purchase.source, purchase.date);
|
||||
const created: Purchase = {
|
||||
...purchase,
|
||||
id: newId(),
|
||||
@@ -234,7 +239,7 @@ export function updatePurchase(
|
||||
// one, else the one already stored — so touching an unrelated field still
|
||||
// re-validates whatever source is already there, and a `source` this
|
||||
// update DOES set is checked against the cost it now names.
|
||||
requireSourcePairing(draft, changes.source ?? purchase.source);
|
||||
requireSourcePairing(draft, changes.source ?? purchase.source, changes.date ?? purchase.date);
|
||||
if (changes.total) {
|
||||
resolved.total = roundMoneyValue(draft, changes.total);
|
||||
}
|
||||
@@ -398,8 +403,20 @@ function requireRecurringCost(draft: Dataset, id: RecurringCostId): RecurringCos
|
||||
* FORBIDS one (an anchored cost has no grid, so a slot id would describe a
|
||||
* schedule this app will not compute). `undefined` (no source at all) is
|
||||
* always fine and skips this entirely.
|
||||
*
|
||||
* `date` is the purchase's OWN date — the resolved one, so an update that
|
||||
* touches only the date is checked against the source already stored. An
|
||||
* anchored cost's next due date is measured from it (`chargeDateNeedsDay`), so
|
||||
* a month-only date there is not a display preference but a schedule the
|
||||
* anchored walk cannot compute: it would reach `addDays`/`addMonthsToDate`
|
||||
* from the fold and take every money route down. Refuse it here, where the
|
||||
* owner is still looking at the purchase.
|
||||
*/
|
||||
function requireSourcePairing(draft: Dataset, source: Purchase["source"]): void {
|
||||
function requireSourcePairing(
|
||||
draft: Dataset,
|
||||
source: Purchase["source"],
|
||||
date: IsoDate,
|
||||
): void {
|
||||
if (source === undefined) return;
|
||||
const cost = requireRecurringCost(draft, source.recurringId);
|
||||
if (cost.anchoring === "lastCharge") {
|
||||
@@ -415,6 +432,26 @@ function requireSourcePairing(draft: Dataset, source: Purchase["source"]): void
|
||||
`date (slot) it settles`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!chargeDateNeedsDay(cost.anchoring)) return;
|
||||
if (!isDayGranularDate(date)) {
|
||||
throw new Error(
|
||||
`"${cost.name}" is anchored to its last charge, so this purchase's own date is what the next ` +
|
||||
`charge is measured from and needs a day ("YYYY-MM-DD"), not "${date}"`,
|
||||
);
|
||||
}
|
||||
// Day-SHAPED is not day-VALID: "2026-09-31" would pass the check above and
|
||||
// then throw out of the fold, exactly the way an impossible `startDate`
|
||||
// would without `requireStartDateGranularity`'s parse below. Same domain
|
||||
// parser, same translation into this file's error style.
|
||||
try {
|
||||
toDayOrdinal(date);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`"${cost.name}" is anchored to its last charge, so this purchase needs a real date, not ` +
|
||||
`"${date}" (${(error as Error).message})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -466,18 +503,13 @@ function requireRecurrence(recurrence: Recurrence): Recurrence {
|
||||
return recurrence;
|
||||
}
|
||||
|
||||
const DAY_GRANULAR_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/**
|
||||
* `everyNMonths` walks the cursor through `monthOf` under `calendar`
|
||||
* anchoring, so a month-only `startDate` is fine there — rent occurs IN a
|
||||
* month. Under `lastCharge` anchoring the SAME kind walks day-precisely
|
||||
* (`stepFrom`'s `addMonthsToDate` branch), because the next due date is
|
||||
* computed from a real charge date, so it needs a real day to carry forward.
|
||||
* `everyNDays` and `everyNWeeks` hand `startDate` straight to `addDays`
|
||||
* regardless of anchoring, which throws on a month-only date — so those two
|
||||
* kinds always require a day-granular `startDate` here, at the boundary,
|
||||
* rather than three months later when a fold walks off the end of it.
|
||||
* Which pairings need a day is `startDateNeedsDay` in
|
||||
* `domain/granularity.ts`, stated once and called from here, from the import
|
||||
* boundary and from both controls that offer these choices — the rule used to
|
||||
* be written out separately in each of those four places. Enforced HERE, at
|
||||
* the boundary, rather than three months later when a fold walks off the end
|
||||
* of a date it cannot step from.
|
||||
*
|
||||
* Shape alone is not enough: "2026-09-31" is day-SHAPED but calendar-
|
||||
* impossible (September has 30 days), and would otherwise reach `addDays` (or
|
||||
@@ -491,9 +523,8 @@ function requireStartDateGranularity(
|
||||
startDate: IsoDate,
|
||||
anchoring: Anchoring,
|
||||
): void {
|
||||
const dayGranular = DAY_GRANULAR_DATE.test(startDate);
|
||||
const needsDay = recurrence.kind !== "everyNMonths" || anchoring === "lastCharge";
|
||||
if (needsDay && !dayGranular) {
|
||||
const dayGranular = isDayGranularDate(startDate);
|
||||
if (startDateNeedsDay(recurrence.kind, anchoring) && !dayGranular) {
|
||||
throw new Error(
|
||||
`A "${recurrence.kind}" recurrence anchored to ${anchoring} needs a day-granular startDate ("YYYY-MM-DD"), not "${startDate}"`,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user