diff --git a/prisma/migrations/0026_precutover_credits_orders/migration.sql b/prisma/migrations/0026_precutover_credits_orders/migration.sql new file mode 100644 index 0000000..c058256 --- /dev/null +++ b/prisma/migrations/0026_precutover_credits_orders/migration.sql @@ -0,0 +1,31 @@ +-- Let credits-funded orders exist before the cutover. +-- +-- `chk_ingested_orders_after_cutover` (migration 0018) refused any row with +-- payment_method = 'credits' dated before 2026-01-09. It was written as a +-- database-level guard for invariant I1, whose stated reason was splits: +-- before the cutover, shared expenses lived in SplitMyExpenses, and +-- re-importing them would double-charge Sonu against carryover transaction +-- 2348. +-- +-- That reason no longer holds. Since finance-app 788219b, ACTIVE_OBLIGATION is +-- `ts.settled = false AND t.transaction_date >= '2026-01-09'`, so a split on a +-- pre-cutover transaction cannot assert a debt at all. The guard now blocks +-- something it was never aimed at: the orders themselves, which are ordinary +-- historical spend. 275 of them — $9,799.96 of meals and rides across +-- 2020-2025 — were invisible because the receipt was the only record and the +-- money came from a gift-card balance rather than a card. +-- +-- What is NOT resolved, and is accepted deliberately (user, 2026-07-28): some +-- of those orders were funded by ShopBack gift cards that are themselves +-- recorded as expenses, so that portion is counted twice. The exposure is +-- bounded at $3,411.16 (14 loads) and is probably smaller, because the +-- descriptors name no brand — "ShopBack Gift Cards SQ" is a batch code, and +-- the card could be Amazon, Airbnb or Shell as easily as DoorDash. Six are +-- categorised `gifts` and may be real presents rather than self-funding. +-- Reclassifying them on a guess would corrupt correct data to fix a +-- double-count that cannot be demonstrated, so they are left alone; only the +-- ShopBack purchase emails can settle it, joined on total paid. +-- +-- The split guard is untouched: this changes what may exist, not what may be +-- owed. +ALTER TABLE transactions DROP CONSTRAINT IF EXISTS chk_ingested_orders_after_cutover; diff --git a/src/__tests__/integration/order-ingestion.test.ts b/src/__tests__/integration/order-ingestion.test.ts index 321b149..3507d7a 100644 --- a/src/__tests__/integration/order-ingestion.test.ts +++ b/src/__tests__/integration/order-ingestion.test.ts @@ -182,16 +182,38 @@ describe("Order ingestion — invariants", () => { expect(Number(n!.c)).toBe(1); }); - it("I1: a credits order before the cutover is refused", async () => { + it("I1 retired: a credits order before the cutover is recorded, not refused", async () => { + // I1 used to store nothing at all for these — no transaction and no + // metadata — so the receipt was discarded. Its reason was splits (shared + // expenses lived in SplitMyExpenses before 2026-01-09), and that expired + // when ACTIVE_OBLIGATION gained its date bound: a pre-cutover split can no + // longer assert a debt, so a pre-cutover order cannot move a balance + // however it is recorded. All it was still doing was hiding history. const p = parseOrderHTML(html("dd-01"), meta({ receivedAt: "2025-11-15T12:00:00Z" })); const res = await processOrderIngestion(p); - expect(res.skipped).toBe("pre_cutover"); - expect(res.transactionId).toBeNull(); - await expect( - queryRaw( - `INSERT INTO transactions (transaction_date, amount, payment_method) VALUES ('2025-11-15', 20.00, 'credits')` - ) - ).rejects.toThrow(); + expect(res.skipped).toBeUndefined(); + expect(res.transactionId).not.toBeNull(); + expect(res.flags).toContain("pre_cutover_credits_order"); + + const txn = await queryRow<{ transaction_date: string; payment_method: string; amount: string }>( + `SELECT transaction_date::text, payment_method, amount::text + FROM transactions WHERE id = $1`, + [res.transactionId] + ); + expect(txn!.transaction_date).toBe("2025-11-15"); + expect(txn!.payment_method).toBe("credits"); + }); + + it("a pre-cutover order still creates no split, so no balance moves", async () => { + // The whole safety argument for retiring I1. Ingestion writes no splits at + // any date; if that ever changes, this fails before a balance does. + const p = parseOrderHTML(html("dd-01"), meta({ receivedAt: "2025-11-15T12:00:00Z", messageId: "pre-cut-2" })); + const res = await processOrderIngestion(p); + const n = await queryRow<{ c: string }>( + `SELECT count(*)::text c FROM transaction_splits WHERE transaction_id = $1`, + [res.transactionId] + ); + expect(Number(n!.c)).toBe(0); }); it("I11: a [Family] order records provenance and creates no transaction", async () => { diff --git a/src/lib/order-ingestion.ts b/src/lib/order-ingestion.ts index 2fd59c1..e60e4ab 100644 --- a/src/lib/order-ingestion.ts +++ b/src/lib/order-ingestion.ts @@ -171,9 +171,29 @@ export async function processOrderIngestion( creditsAmount = order.payment.credits_amount; } - // ---- I1: cutover -------------------------------------------------------- + // ---- I1: cutover (retired 2026-07-28) ----------------------------------- + // + // This used to return early for any credits order dated before the cutover, + // storing NOTHING — no transaction and no metadata, so the receipt was + // discarded entirely. + // + // Its reason was splits, not spend: before 2026-01-09 shared expenses lived + // in SplitMyExpenses, and re-importing them would double-charge against + // carryover transaction 2348. That reason expired with 788219b, where + // ACTIVE_OBLIGATION became `settled = false AND transaction_date >= + // '2026-01-09'` — a pre-cutover split can no longer assert a debt, so a + // pre-cutover order cannot move a balance however it is recorded. + // + // What it was left doing was hiding ordinary history: 275 orders, $9,799.96 + // of meals and rides across 2020-2025, invisible purely because they were + // paid from a gift-card balance instead of a card. Ingestion still writes no + // splits at all, so nothing here touches what anyone owes. + // + // The known imprecision is on the funding side and is accepted rather than + // guessed at — see migration 0026 for why the ShopBack loads are left as + // they are. if (creditsAmount !== null && day < CUTOVER_DATE) { - return { transactionId: null, metadataId: null, flags, skipped: "pre_cutover" }; + flags.push("pre_cutover_credits_order"); } // ---- I6 / I5 ------------------------------------------------------------