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:
co-authored by
Claude Opus 5.5
parent
7f6cf4692a
commit
1ae3f9720b
@@ -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]!;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user