From 1ae3f9720bbcee73228ae804221d3b17f73d0ccc Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 30 Sep 2026 16:42:46 +0200 Subject: [PATCH] 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 --- src/store/actions.test.ts | 45 +++++++++++++++++++++++++++++++++++++++ src/store/actions.ts | 13 ++++++----- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/src/store/actions.test.ts b/src/store/actions.test.ts index 38aa78c..edf5927 100644 --- a/src/store/actions.test.ts +++ b/src/store/actions.test.ts @@ -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]!; diff --git a/src/store/actions.ts b/src/store/actions.ts index 319dc10..8b25437 100755 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -234,12 +234,15 @@ export function updatePurchase( ): void { const purchase = requirePurchase(draft, purchaseId); const resolved: Partial> = { ...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); }