fix(purchase): do not offer a tag the write will refuse

`offerableCosts` offered every non-archived anchored cost whatever the date, so
the default new-purchase flow — month-only, since `emptyDraft` seeds
`date: monthId` — offered the owner a choice that now fails at the boundary.
An anchored cost needs a day, the same way `newCostOptionIsSelectable` already
gates the new-cost pairings. A calendar cost is unaffected: its charge names
the slot it settles and its own date is never stepped from.

An anchored cost stays SELECTED when the date drops to month-only, because a
selection is never taken away out from under the owner — so the dialog says
what is wrong there rather than letting Save be the first place it comes up.

Two more in the same file and its neighbour:

- A recurring cost that has vanished since the dialog opened (deleted from
  Settings in another tab) made the purchase save with no association and no
  message, leaving the bill still showing as expected. It is refused now.
- The comment above `save()` described `requireConfirmationAdvances`, deleted
  in this branch. A write can still be refused; only the stated reason was
  stale.

`normalizedStartDateFor` and `isValidStartDate` both asked their own copy of
the granularity rule, and the first had `anchoring` optional — which the unit
handler omitted, silently answering "calendar" for a cost anchored to its last
charge. Both call the domain predicate now, with anchoring required.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5 committed 2026-09-06 18:32:17 +02:00
1 parent 19087737eb
commit 3a4086807a
5 files changed
+156 -77

No files matched your search

+32 -6
View File
@@ -116,11 +116,14 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
setNewAnchoring("calendar");
}
// A write can be REFUSED, not merely fail on a full disk: editing the date
// of a purchase that confirms a recurring occurrence is rejected when the
// new date would stop the series advancing. Closing the dialog regardless
// would show the user a save that silently did nothing, so the dialog stays
// open and shows `error` instead. `mutate` never rejects.
// A write can be REFUSED, not merely fail on a full disk. Tagging a
// purchase to a cost anchored to its last charge is rejected when the
// purchase's date carries no day, because that date is what the next charge
// is measured from; a cost that has vanished since the dialog opened is
// rejected below; and any of the store's other pairing rules can refuse
// too. Closing the dialog regardless would show the user a save that
// silently did nothing, so the dialog stays open and shows `error` instead.
// `mutate` never rejects.
function save() {
if (errors.length > 0) return;
// One mutate for the whole batch: a single write, one queue entry, and no
@@ -142,7 +145,19 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
source = sourceFor(data, seeded, draft.date);
} else if (recurringSelection !== "none") {
const cost = data.recurring.find((c) => c.id === recurringSelection);
if (cost) source = sourceFor(data, cost, draft.date);
// Throw rather than save untagged. The cost can genuinely be gone —
// deleted from Settings in another tab while this dialog sat open —
// and dropping the association silently would save a purchase the
// owner believes is a charge for a bill, leaving that bill still
// showing as expected. `useMutate` catches this and the dialog stays
// open with the message.
if (!cost) {
throw new Error(
`The recurring cost this purchase was tagged to no longer exists. Choose another one ` +
`(or "None") and save again.`,
);
}
source = sourceFor(data, cost, draft.date);
}
if (purchase) {
// `source` is passed explicitly, even when undefined: updatePurchase
@@ -401,6 +416,17 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
measured from the last charge, that also restarts its cycle
from today.
</p>
{/* An anchored cost stays SELECTED when the date drops to
month-only (the selection is never taken away out from under
the owner), but the store will refuse the save: that date is
what the bill's next charge is measured from. Say so here
rather than letting Save be the first place it comes up. */}
{selected?.anchoring === "lastCharge" && draft.date.length <= 7 && (
<p className="text-xs text-destructive">
“{selected.name}” is measured from its last charge, so this
purchase needs an exact date — use “set exact date” above.
</p>
)}
</div>
{recurringSelection === NEW_COST && (