-- 0030 — order ↔ transaction, many-to-many (board 205, orders phase 2) -- -- Phase 1 read linkage out of `expense_metadata`, which is shaped as ONE ROW -- PER TRANSACTION: `transaction_id` is UNIQUE and `matched_transaction_id` -- partial-unique. Every multiplicity it expresses today is smuggled through a -- string key — migration 0029 keys split shipments `#f` — -- and a BNPL plan needs four rows for one order with no such trick available. -- -- The flagship case: order_ebay_14-11714-95953, a A$1,599 DJI drone paid in -- four A$399.75 Afterpay legs (txns 2318, 2333, 1652, 1664). Phase 1 shows it -- with zero linked transactions, which is correct and useless. -- -- KEYED ON entity_key, NOT entities.id. finance-app does not model the spine -- and must not carry an FK across an ownership boundary that a spine -- re-extraction can decompile. The real cascade risk is NOT transaction -- deletion (that is handled below) but spine RE-KEYING: supersede_stale() -- decompiles an entity when a re-extraction yields a different entity_key, and -- a TEXT key with no FK silently orphans. Bridge-sourced links are rebuildable; -- `manual` ones are curation and are not. Hence the orphan view at the end. CREATE TABLE IF NOT EXISTS order_transaction_links ( id SERIAL PRIMARY KEY, entity_key TEXT NOT NULL, transaction_id INTEGER NOT NULL REFERENCES transactions(id) ON DELETE CASCADE, leg_kind TEXT NOT NULL CHECK (leg_kind IN ('charge','shipment','instalment','refund','fee')), -- HUMAN position only: "2 of 4". Never an id. -- -- The first draft stored a fact id here. Live shipment keys already carry -- fact ids 22664 and 22711, max(extracted_facts.id) is 23,781 today, and -- SMALLINT tops out at 32,767 — that ceiling arrives on ordinary corpus -- growth and the insert dies with `smallint out of range`. Fact ids go in -- source_fact_id, which is BIGINT. leg_index SMALLINT, leg_count SMALLINT, source_fact_id BIGINT, -- The LEG's own amount, signed: positive is money out, negative is a credit -- coming back. Storing the order total on every leg would make four rows sum -- to four times the purchase. amount NUMERIC(12,2) NOT NULL, currency TEXT NOT NULL DEFAULT 'AUD', source TEXT NOT NULL CHECK (source IN ('order-bridge','instalment-matcher','manual')), confidence TEXT NOT NULL DEFAULT 'exact' CHECK (confidence IN ('exact','derived','manual')), evidence JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- Every index needs IF NOT EXISTS, not just the table: a run that creates the -- table and then fails during the backfill would otherwise abort on re-run -- with "relation already exists" and need manual surgery. -- -- uq_otl_transaction is load-bearing. Without it two matchers can each claim -- txn 2318 and the order reads as paid twice. It is deliberately stricter than -- reality for one case — a single card charge covering two orders — which then -- surfaces as a NAMED REJECTED INSERT rather than as silent duplication. CREATE UNIQUE INDEX IF NOT EXISTS uq_otl_transaction ON order_transaction_links (transaction_id); -- WHAT IDENTIFIES A LEG DEPENDS ON ITS KIND, and the first version of this -- index got it wrong in the most on-brand way available. It read -- (entity_key, leg_kind, COALESCE(leg_index, 0)), so two shipment legs of the -- same order — both with a NULL leg_index — collapsed to the same key and -- ON CONFLICT DO NOTHING dropped one SILENTLY. Caught only because the -- backfill reported 62 rows against 63 candidates. -- -- The row it ate was order_amazon_249-4859367-0690246's $130.00 second -- shipment (txn 3171) — the same order named in migration 0029's comment as -- the reason split shipments need distinct keys at all, because Amazon mails -- and CHARGES per shipment. A uniqueness rule that cannot hold two shipments -- is the exact defect 0029 was written to fix. -- -- So: an instalment leg is identified by its INDEX (1 of 4), a shipment leg by -- its FACT (each shipment is its own extracted fact), and a whole-order charge -- by neither — one per order, which the zeros express. DROP INDEX IF EXISTS uq_otl_leg; CREATE UNIQUE INDEX IF NOT EXISTS uq_otl_leg ON order_transaction_links (entity_key, leg_kind, COALESCE(leg_index, 0), COALESCE(source_fact_id, 0)); CREATE INDEX IF NOT EXISTS idx_otl_entity ON order_transaction_links (entity_key); -- Backfill the existing bridge rows so the table is not empty on day one and -- the readers can switch over in the same change. `#f` splits into a -- shipment leg carrying its fact id; everything else is a whole-order charge. INSERT INTO order_transaction_links (entity_key, transaction_id, leg_kind, leg_index, source_fact_id, amount, currency, source, confidence, evidence) SELECT split_part(em.source_message_id, '#', 1) AS entity_key, COALESCE(em.matched_transaction_id, em.transaction_id) AS transaction_id, CASE WHEN em.source_message_id LIKE '%#f%' THEN 'shipment' ELSE 'charge' END, NULL::smallint, NULLIF(substring(em.source_message_id FROM '#f([0-9]+)$'), '')::bigint, COALESCE(em.amount, 0), COALESCE(em.currency, 'AUD'), 'order-bridge', 'exact', jsonb_build_object('backfilled_from', 'expense_metadata', 'source_message_id', em.source_message_id) FROM expense_metadata em WHERE em.source = 'order-bridge' AND COALESCE(em.matched_transaction_id, em.transaction_id) IS NOT NULL ON CONFLICT DO NOTHING; -- Spine re-keying orphans links silently. Bridge rows can be rebuilt by -- re-running the bridge; a `manual` orphan is lost curation and is the row -- that actually needs a human. Same idea as order_settlement_violations. CREATE OR REPLACE VIEW order_link_orphans AS SELECT l.id, l.entity_key, l.transaction_id, l.leg_kind, l.leg_index, l.amount, l.currency, l.source, l.confidence, l.created_at FROM order_transaction_links l WHERE NOT EXISTS (SELECT 1 FROM entities e WHERE e.entity_key = l.entity_key); -- Two stores that can disagree will disagree. The bridge now writes both in -- ONE transaction, so a half-written pair should be impossible — this view -- exists to prove that rather than to assume it, and it must stay empty. -- -- The plan's suggested guard (widen the bridge's `NOT EXISTS expense_metadata` -- to "neither store has it") was NOT taken: the expense_metadata INSERT has no -- ON CONFLICT, so re-attempting an order that already has a receipt row would -- duplicate it. Detecting drift is cheap; re-attempting a non-idempotent write -- to fix a state that cannot occur is not. CREATE OR REPLACE VIEW order_link_drift AS SELECT em.source_message_id, COALESCE(em.matched_transaction_id, em.transaction_id) AS transaction_id, em.amount, em.currency, em.reconciled_at FROM expense_metadata em WHERE em.source = 'order-bridge' AND COALESCE(em.matched_transaction_id, em.transaction_id) IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM order_transaction_links l WHERE l.transaction_id = COALESCE(em.matched_transaction_id, em.transaction_id) );