From 3144cf31760fced00f3e36ea7fcede7a20ee34d5 Mon Sep 17 00:00:00 2001 From: siddharthd Date: Tue, 28 Jul 2026 22:20:03 +1000 Subject: [PATCH] feat(orders): record credits orders from before the cutover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I1 refused any credits-funded order dated before 2026-01-09, storing nothing at all — no transaction and no metadata, so the receipt was discarded rather than kept as history. Its reason was splits, not spend: before the cutover 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 — and ingestion writes no splits at any date, which now has a test of its own. What the guard was still doing was hiding ordinary history: 275 orders, $9,799.96 of meals and rides across 2020-2025, invisible only because the money came from a gift-card balance instead of a card. The funding side stays as it is, deliberately. Some of those orders were paid from ShopBack gift cards that are themselves booked as expenses, so that portion is counted twice. The exposure is bounded at $3,411.16 over 14 loads and is probably smaller: 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 — and six are categorised `gifts`, which 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 shown. Only the ShopBack purchase emails can settle it, joined on total paid. --- .../migration.sql | 31 +++++++++++++++ .../integration/order-ingestion.test.ts | 38 +++++++++++++++---- src/lib/order-ingestion.ts | 24 +++++++++++- 3 files changed, 83 insertions(+), 10 deletions(-) create mode 100644 prisma/migrations/0026_precutover_credits_orders/migration.sql 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 ------------------------------------------------------------