-- One account, one identity — without destroying how the number reads. -- -- `account_number` carries the same fragmentation `bank_name` had, from the -- same cause: Gemini copies whatever the PDF prints, and the formatting varies -- per document. -- -- ANZ '4085-56264' 4 statements ANZ ACCESS ADVANTAGE -- ANZ '408556264' 1 statement ACCESS ADVANTAGE <- same account -- -- That matters because `uq_statement_identity` is -- (bank_name, account_number, billing_end_date). Re-importing one period under -- the other spelling evades the duplicate check entirely. -- -- (It is NOT what caused the documented 31-row / $42,040.68 ANZ duplication. -- Statements 107/142/143 overlap on *different* end dates, which that index -- cannot catch at any spelling. Different problem, same table.) -- -- WHY A GENERATED COLUMN RATHER THAN REWRITING THE VALUE: the raw number is the -- readable one and some of it is structure, not noise. Up stores -- '633-123 / 176540052' — a BSB and an account number — and flattening that to -- '633123176540052' would lose a distinction a human reads at a glance, to fix -- a machine problem. So the raw text stays exactly as extracted and the -- comparison key is derived beside it. Same reason the app derives trip -- participation instead of storing it: two records of one fact drift. -- -- Separators only. Case and the Amex mask ('XXXX-XXXXXX-01000') are preserved, -- because 'X' is real information about which digits were redacted, and -- case-folding an alphanumeric account id could merge two genuinely different -- ones. -- -- Verified collision-free before rebuilding the index: no two statements share -- (bank_name, stripped account_number, billing_end_date). -- -- Idempotent: safe to re-run. ALTER TABLE statements ADD COLUMN IF NOT EXISTS account_number_key TEXT GENERATED ALWAYS AS ( NULLIF(regexp_replace(coalesce(account_number, ''), '[^0-9A-Za-z]', '', 'g'), '') ) STORED; COMMENT ON COLUMN statements.account_number_key IS 'Derived from account_number, separators stripped. The identity used by ' 'uq_statement_identity so one account under two formats is one account. ' 'Never write to it — it is GENERATED. Display account_number instead.'; -- Rebuild the identity index on the derived key. Dropped and recreated rather -- than added alongside: leaving the old one in place would keep admitting the -- duplicate it exists to stop. DROP INDEX IF EXISTS uq_statement_identity; CREATE UNIQUE INDEX uq_statement_identity ON statements (bank_name, account_number_key, billing_end_date) WHERE bank_name IS NOT NULL AND billing_end_date IS NOT NULL; -- The fragmentation detector, now able to see both halves: one account under -- two bank names, or one bank under two spellings of one number. CREATE OR REPLACE VIEW statement_identity_drift AS SELECT account_number_key, array_agg(DISTINCT bank_name) AS bank_names, array_agg(DISTINCT account_number) AS account_numbers, count(*) AS statements FROM statements WHERE account_number_key IS NOT NULL GROUP BY account_number_key HAVING count(DISTINCT bank_name) > 1 OR count(DISTINCT account_number) > 1; COMMENT ON VIEW statement_identity_drift IS 'Non-empty means an account is fragmented: normalize_bank_name() cannot know ' 'that two names for a bank it has never seen are one institution, and this is ' 'how that always shows up. Check it after loading a new bank.';