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:
1 parent
9fe950bc7d
commit
b276573ffe
6 files changed
+219
-12
No files matched your search
@@ -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.
|
||||
|
||||
@@ -367,6 +367,20 @@ export function parseDatasetJson(text: string): Dataset {
|
||||
);
|
||||
}
|
||||
|
||||
// `endedFrom` carries no granularity rule — checked above by not
|
||||
// requiring it to match the recurrence kind's day-granularity — but it
|
||||
// still has to be a real calendar date, the same way `startDate` does.
|
||||
if (cost.endedFrom !== undefined) {
|
||||
try {
|
||||
if (DAY_GRANULAR_DATE.test(cost.endedFrom)) toDayOrdinal(cost.endedFrom);
|
||||
else monthOf(cost.endedFrom);
|
||||
} catch (error) {
|
||||
throw new ImportValidationError(
|
||||
`${label} has a calendar-impossible ended-from date "${cost.endedFrom}" (${(error as Error).message})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!ANCHORINGS.has(cost.anchoring)) {
|
||||
throw new ImportValidationError(
|
||||
`${label} has an unknown anchoring "${String(cost.anchoring)}"`,
|
||||
|
||||
Reference in new issue
Block a user