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:
1 parent
19087737eb
commit
3a4086807a
5 files changed
+156
-77
No files matched your search
@@ -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 && (
|
||||
|
||||
Reference in new issue
Block a user