-- A transaction imported twice cannot simply be deleted. -- -- Every child of `transactions` is ON DELETE CASCADE — splits, tags, overrides, -- expense_metadata, order_reviews. Deleting a row said to be "the duplicate" -- therefore destroys whatever curation happens to sit on it, silently and -- unrecoverably. The curation is not reliably on the surviving side either: of -- the 31 known duplicate pairs, one carries splits and six carry overrides, and -- which member holds them is an accident of import order. -- -- So a duplicate is superseded, never removed. The row stays, keeps its -- children, and points at the row that replaces it. Reversing a mistake is then -- one UPDATE rather than a restore from backup. -- -- This is the statement-vs-statement case. `reconciled_with_id` already covers -- manual-vs-statement, and deliberately cannot be reused: the predicate that -- hides a reconciled row is scoped to `statement_id IS NULL`, because a -- statement line pointing at something else is the survivor, not the duplicate. -- Both of these rows are statement lines. ALTER TABLE transactions ADD COLUMN IF NOT EXISTS superseded_by_id integer REFERENCES transactions(id) ON DELETE SET NULL; COMMENT ON COLUMN transactions.superseded_by_id IS 'This row was imported twice; the named row is the one that counts. Excluded from every figure, kept for its children and its audit trail. NULL = live.'; CREATE INDEX IF NOT EXISTS idx_transactions_superseded ON transactions (superseded_by_id) WHERE superseded_by_id IS NOT NULL; -- A row cannot supersede itself, and a survivor cannot itself be superseded -- (that would hide both members of the pair and lose the amount entirely). ALTER TABLE transactions DROP CONSTRAINT IF EXISTS transactions_no_self_supersede; ALTER TABLE transactions ADD CONSTRAINT transactions_no_self_supersede CHECK (superseded_by_id IS NULL OR superseded_by_id <> id);