fix(purchase): disable recurring options a month-only date would reject

Review finding I2: choosing "+ New recurring cost..." with unit "days" (or
"months" anchored to the last charge) while the purchase's date is month-only
threw a store-level error naming a field ("startDate") the purchase dialog
never shows, with no mention of the "set exact date" toggle that fixes it.

Per the controller ruling, this stops OFFERING the action instead of
rewording the throw: newCostOptionIsSelectable (src/ui/recurringForm.ts)
mirrors the store's own needsDay rule and drives `disabled` on each unit and
anchoring <option>, with a hint pointing at "set exact date". Switching to
month-only also resets a stranded selection (e.g. "days") back to the one
pairing that always works, so a previously valid choice never sits selected
in a now-disabled state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucas Winther
2026-09-05 23:44:37 +02:00
co-authored by Claude Opus 5
parent 87f309a32c
commit 04a089eb06
3 changed files with 129 additions and 8 deletions
+40 -1
View File
@@ -1,5 +1,10 @@
import { describe, expect, test } from "bun:test";
import { offerableCosts, seedCostFromDraft, sourceFor } from "./recurringForm.ts";
import {
newCostOptionIsSelectable,
offerableCosts,
seedCostFromDraft,
sourceFor,
} from "./recurringForm.ts";
import { createSeedDataset } from "../domain/seed.ts";
import * as actions from "../store/actions.ts";
import type { Dataset } from "../domain/types.ts";
@@ -100,6 +105,40 @@ describe("sourceFor", () => {
});
});
// I2 fix: a month-only purchase date can only ever seed "everyNMonths" +
// "calendar" (rent occurs IN a month). Every other pairing hands the date
// straight to day-precise arithmetic and would throw a store-level error
// naming a field ("startDate") the purchase dialog never shows — so the
// dialog must not OFFER those options while the date is month-only, rather
// than reword the throw. This predicate is what decides which options are
// offered; it is tested directly, since a DOM test cannot exercise a
// predicate the way it can a rendered <option disabled>.
describe("newCostOptionIsSelectable", () => {
test("everyNMonths + calendar is selectable at either granularity — rent occurs IN a month", () => {
expect(newCostOptionIsSelectable("everyNMonths", "calendar", "2026-09")).toBe(true);
expect(newCostOptionIsSelectable("everyNMonths", "calendar", "2026-09-20")).toBe(true);
});
test("everyNMonths + lastCharge needs a day-granular date", () => {
expect(newCostOptionIsSelectable("everyNMonths", "lastCharge", "2026-09")).toBe(false);
expect(newCostOptionIsSelectable("everyNMonths", "lastCharge", "2026-09-20")).toBe(true);
});
test("everyNDays always needs a day-granular date, whichever anchoring", () => {
expect(newCostOptionIsSelectable("everyNDays", "calendar", "2026-09")).toBe(false);
expect(newCostOptionIsSelectable("everyNDays", "lastCharge", "2026-09")).toBe(false);
expect(newCostOptionIsSelectable("everyNDays", "calendar", "2026-09-20")).toBe(true);
expect(newCostOptionIsSelectable("everyNDays", "lastCharge", "2026-09-20")).toBe(true);
});
test("everyNWeeks always needs a day-granular date, whichever anchoring", () => {
expect(newCostOptionIsSelectable("everyNWeeks", "calendar", "2026-09")).toBe(false);
expect(newCostOptionIsSelectable("everyNWeeks", "lastCharge", "2026-09")).toBe(false);
expect(newCostOptionIsSelectable("everyNWeeks", "calendar", "2026-09-20")).toBe(true);
expect(newCostOptionIsSelectable("everyNWeeks", "lastCharge", "2026-09-20")).toBe(true);
});
});
describe("seedCostFromDraft", () => {
test("carries the purchase's amount, currency, splits and date", () => {
const draft = {