fix(store): an explicit source: undefined clears a purchase's tag before validation

updatePurchase resolved the pair it validates with `??`, which cannot tell
"no source key" from "source: undefined, clear the association". Untagging an
anchored charge while dropping it to a month-only date was refused, citing the
cost being removed. Resolve by key presence instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-09-30 16:42:46 +02:00
co-authored by Claude Opus 5.5
parent 7f6cf4692a
commit 1ae3f9720b
2 changed files with 53 additions and 5 deletions
+45
View File
@@ -1157,6 +1157,51 @@ describe("a charge for an anchored cost needs a day-granular date", () => {
expect(() => occurrencesByMonth(d, "2026-12")).not.toThrow();
});
/**
* `updatePurchase` resolves the pair it validates, and `??` cannot tell "no
* source key in this update" from "source: undefined, clear the
* association" — which is exactly what `PurchaseDialog.save()` sends when
* the owner picks "None", deliberately, because `Object.assign` would
* otherwise leave the stored association untouched.
*
* Pre-wave that conflation was harmless: the stored source was always
* self-consistent, so re-validating it changed nothing. Adding the date to
* the check is what made it bite — the store began refusing a write on the
* strength of a pair that will not exist once the write lands, citing a cost
* the owner is in the middle of removing. The flow is the obvious one: a
* tagged purchase, "use the month only", read the dialog's line saying the
* date must be exact, decide to drop the tag instead, and Save is refused.
*/
describe("an explicit source: undefined CLEARS the association, and is validated as such", () => {
test("untagging and dropping to a month-only date in one write is accepted", () => {
const { d, post, cost } = seeded({ kind: "everyNMonths", n: 1 });
const purchase = actions.addPurchase(d, charge(post.id, cost.id, "2026-09-20"));
expect(() =>
actions.updatePurchase(d, purchase.id, { source: undefined, date: "2026-09" }),
).not.toThrow();
expect(d.purchases[0]!.source).toBeUndefined();
expect(d.purchases[0]!.date).toBe("2026-09");
expect(() => occurrencesByMonth(d, "2026-12")).not.toThrow();
});
test("untagging alone is still accepted", () => {
const { d, post, cost } = seeded({ kind: "everyNMonths", n: 1 });
const purchase = actions.addPurchase(d, charge(post.id, cost.id, "2026-09-20"));
expect(() => actions.updatePurchase(d, purchase.id, { source: undefined })).not.toThrow();
expect(d.purchases[0]!.source).toBeUndefined();
});
test("an update that omits `source` entirely still re-validates the STORED one", () => {
// The other half of the same distinction: omitting the key must NOT be
// read as clearing the tag, or an unrelated edit could carry a stored
// anchored charge to a month-only date unchecked.
const { d, post, cost } = seeded({ kind: "everyNMonths", n: 1 });
const purchase = actions.addPurchase(d, charge(post.id, cost.id, "2026-09-20"));
expect(() => actions.updatePurchase(d, purchase.id, { date: "2026-09" })).toThrow(/Phone/);
expect(d.purchases[0]!.source).toEqual({ recurringId: cost.id });
});
});
test("a month-only charge date on a CALENDAR cost is still fine — it settles a slot, it is not an anchor", () => {
const d = createSeedDataset("2026-09");
const post = d.posts[0]!;
+8 -5
View File
@@ -234,12 +234,15 @@ export function updatePurchase(
): void {
const purchase = requirePurchase(draft, purchaseId);
const resolved: Partial<Omit<Purchase, "id">> = { ...changes };
// The RESOLVED pair, same `??` style `updateRecurringCost` uses for
// anchoring/recurrence above: the incoming `source` if this update sets
// one, else the one already stored — so touching an unrelated field still
// The RESOLVED pair: the incoming `source` if this update carries the key,
// else the one already stored — so touching an unrelated field still
// re-validates whatever source is already there, and a `source` this
// update DOES set is checked against the cost it now names.
requireSourcePairing(draft, changes.source ?? purchase.source, changes.date ?? purchase.date);
// update DOES set is checked against the cost it now names. Key presence,
// not `??`: `source: undefined` CLEARS the association (what
// `PurchaseDialog` sends for "None"), and `??` would validate the stored
// tag the write is removing.
const source = "source" in changes ? changes.source : purchase.source;
requireSourcePairing(draft, source, changes.date ?? purchase.date);
if (changes.total) {
resolved.total = roundMoneyValue(draft, changes.total);
}