feat(recurring): datable cancellation via an "Ends" column

Gap 2 (spec §10): endedFrom is typed as any IsoDate and the spec calls it
"cancellation", but Settings only ever wrote currentMonth — backdating or
forward-dating a cancellation was import-only.

Add an "Ends" column beside "Starts" in the Recurring costs table, edited and
validated the same way (isValidEndedFrom — no granularity rule, per §10, but
still refuses a calendar-impossible date like "2026-02-30"). Empty means "not
ended" and clears the cancellation.

Coherence rule: `archived` mirrors "endedFrom is set". setRecurringCostEndedFrom
is the one place that now enforces it — endRecurringCost and
restoreRecurringCost (the one-click row buttons) delegate to it, so the column
and the buttons can never leave a bill dimmed-but-live or ended-but-undimmed.

Also closes the same calendar-impossible gap in the JSON importer, which
checked endedFrom's shape but not its calendar validity, unlike startDate.
This commit is contained in:
Lucas Winther committed 2026-09-04 08:46:57 +02:00
1 parent 9fe950bc7d
commit b276573ffe
6 files changed
+219 -12

No files matched your search

+21
View File
@@ -302,6 +302,27 @@ describe("recurring cost validation", () => {
.toThrow(/ended-from date/);
});
// Same residual-C1 gap as startDate: PURCHASE_DATE checks shape and digit
// range, not calendar validity, so "2026-02-30" passes it. §10: endedFrom
// carries no granularity rule, but it must still be a real date.
test("a calendar-impossible endedFrom is refused (day- or month-shaped)", () => {
expect(() => parseDatasetJson(withRecurring([{ ...valid, endedFrom: "2026-02-30" }])))
.toThrow(/calendar-impossible ended-from/);
expect(() => parseDatasetJson(withRecurring([{ ...valid, endedFrom: "2026-02-29" }]))) // not a leap year
.toThrow(/calendar-impossible ended-from/);
});
test("a valid endedFrom of either granularity is accepted, regardless of recurrence kind", () => {
expect(
parseDatasetJson(withRecurring([{ ...valid, endedFrom: "2026-06" }])).recurring.length,
).toBe(1);
expect(
parseDatasetJson(
withRecurring([{ ...valid, endedFrom: "2026-06-17" }]), // month-only recurrence, day-granular end
).recurring.length,
).toBe(1);
});
test("an interval below 1 is refused, not clamped", () => {
// Clamping would silently change the bill's schedule. The walk in
// occurrences.ts cannot terminate without this.