fix(recurring): refuse a confirmation date that stops the series

A confirmation is an ordinary Purchase, and the ordinary purchase dialog can
edit its date. Under lastCharge anchoring that date is what the walk measures
the next occurrence from, so moving it back more than a cycle made the walk
step backwards and throw out of foldBalances -- every month route, the year
view and the summary down at once, from an edit that never mentioned recurring
costs.

updatePurchase is the write that can invalidate an already-valid confirmation,
so it now asks wouldAdvancePast -- the same helper ExpectedBand asks before
offering a row -- and refuses the edit instead. The walk's throw is unchanged:
a step that lands backwards really is bad data, and this stops it being
created. A purchase with no source is not checked at all, and a source naming
a cost that no longer exists falls through.

PurchaseDialog was closing on save regardless of the outcome, with its
useMutate error rendered nowhere, so a refusal looked like a save that did
nothing. mutate now reports whether the write landed, and the dialog stays
open and shows the reason when it did not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5 committed 2026-09-04 23:55:38 +02:00
1 parent 92e6b763f2
commit 0c9df88a45
4 files changed
+188 -21

No files matched your search

+28 -17
View File
@@ -43,7 +43,7 @@ interface Props {
export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
const dataset = useDataset();
const { mutate } = useMutate();
const { mutate, error, clearError } = useMutate();
const activePosts = dataset.posts
.filter((p) => !p.archived)
.sort((a, b) => a.order - b.order);
@@ -74,23 +74,25 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
setBulk(emptyBulkDraft(monthId, activePosts[0]?.id ?? ""));
}
// 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.
function save() {
if (errors.length > 0) return;
if (bulkMode) {
// One mutate for the whole batch: a single write, one queue entry, and no
// way to half-commit a shopping trip.
const created = toPurchases(bulk);
mutate((data) => {
for (const p of created) addPurchase(data, p);
});
} else {
mutate((data) => {
if (purchase) updatePurchase(data, purchase.id, toPurchase(draft));
else addPurchase(data, toPurchase(draft));
});
}
setOpen(false);
if (!purchase) reset();
// One mutate for the whole batch: a single write, one queue entry, and no
// way to half-commit a shopping trip.
const created = bulkMode ? toPurchases(bulk) : null;
void mutate((data) => {
if (created) for (const p of created) addPurchase(data, p);
else if (purchase) updatePurchase(data, purchase.id, toPurchase(draft));
else addPurchase(data, toPurchase(draft));
}).then((saved) => {
if (!saved) return;
setOpen(false);
if (!purchase) reset();
});
}
return (
@@ -98,7 +100,10 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
open={open}
onOpenChange={(next) => {
setOpen(next);
if (next) reset();
if (next) {
reset();
clearError();
}
}}
>
<DialogTrigger asChild>{trigger}</DialogTrigger>
@@ -321,6 +326,12 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
))}
</ul>
)}
{error && (
<p role="alert" className="text-sm text-destructive">
Could not save: {error}
</p>
)}
</div>
<DialogFooter>