477b59453f
* fix(enable-banking): flatten Enable Banking's bank_transaction_code object to a string
Enable Banking serializes bank_transaction_code as {description, code,
sub_code}; three places declared it a string. The direct path passed the
object through, so PostgREST wrote its JSON text into
transactions.bank_transaction_code for every Enable Banking row since
2026-08-09 (6,356 rows, 78 companies) and the label/method derivation never
matched. The Connect service forwarded the same object and the wire contract
rejected it, so every connector-canary sync failed from 2026-09-03 (Capstone
support case 2026-09-07, "banksynken mot Nordea").
One rule, one place: normalizeBankTransactionCode in the connect-contract
file (code, code/sub_code, else description, else null), applied by
convertTransaction here and by Connect's normalizeBookedTransaction in the
mirrored contract. The wire schema stays z.string().nullable();
CONTRACT_VERSION bumps to 2026-09-08. A repair migration rewrites the stored
JSON text with the same rule and touches nothing else.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnbRMhwvUigizrPar5hW47
* fix(enable-banking): skip reset-source rows in the repair and read "Kortköp/uttag" as card
Skeptic findings on 4a30bb3f3:
- The repair migration would have aborted on prod: 110 of the 6,356 rows
belong to a migration-reset source company, whose transactions are
immutable by trigger (transactions_block_migration_reset_source_mutation).
Same failure as 20260903170000. Those rows are now excluded; nothing reads
the column back for an archived company.
- With the code description reaching the keyword tables as a string,
"Kortköp/uttag" (SEB/Swedbank wording for an ordinary card purchase)
matched UTTAG before KORT in both CODE_KEYWORD_METHODS and KEYWORD_LABELS,
so 256 card rows a month would have shown "Betalsätt: Uttag". Card now
precedes withdrawal in both tables (and in the Connect mirror), matching
what TRAILING_PHRASES already says about the same phrase.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnbRMhwvUigizrPar5hW47
* chore(migrations): annotate the repair as pg-test skip and state why no rattelse log is owed
coverage-gate flagged the migration because it creates a function; the only
function is a pg_temp helper dropped in the same statement batch, and a
one-shot UPDATE cannot be re-exercised after apply, so the annotation is the
honest disposition. The header also answers the Swedish compliance review:
the column is a write-once ingest projection with no reader, the underlag is
the archived raw PSD2 page (untouched), and the verifikat lives in
journal_entries, which the statement never reads or writes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnbRMhwvUigizrPar5hW47
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
91 lines
3.9 KiB
PL/PgSQL
91 lines
3.9 KiB
PL/PgSQL
-- One-shot, re-runnable repair of transactions.bank_transaction_code and
|
|
-- transactions.proprietary_bank_transaction_code for Enable Banking rows.
|
|
--
|
|
-- Enable Banking serializes bank_transaction_code as an object
|
|
-- ({"description": ..., "code": ..., "sub_code": ...}); the direct sync path
|
|
-- typed it as a string and passed it through, so PostgREST wrote the object's
|
|
-- JSON text into the text column for every Enable Banking row since
|
|
-- 2026-08-09 (6,356 rows across 78 companies on prod at 2026-09-07). The
|
|
-- Connect service hit the same type lie the loud way (the wire contract
|
|
-- rejected the object) and that is the 2026-09-03 canary outage.
|
|
--
|
|
-- The converter now flattens with the contract's normalizeBankTransactionCode
|
|
-- rule; this migration applies the same rule to the rows already written:
|
|
-- code present -> code, or code/sub_code when a sub-code exists
|
|
-- code absent -> description
|
|
-- neither -> NULL
|
|
-- Only rows whose value is a JSON object are touched (the regex keeps plain
|
|
-- strings out of the cast), only Enable Banking rows, and the statement is a
|
|
-- pure text rewrite of an evidence column: no journal entries, no matching,
|
|
-- no categorization, no transaction_method re-derivation. Guarded so a value
|
|
-- that is not valid JSON is left as it is rather than failing the migration.
|
|
--
|
|
-- Rows of a company that is a migration-reset source (company_migration_resets)
|
|
-- are immutable by trigger (transactions_block_migration_reset_source_mutation,
|
|
-- 20260818084050) and are skipped: 20260903170000 failed on prod for exactly
|
|
-- that reason. Those rows (110 on prod, one archived company) keep the JSON
|
|
-- text; nothing reads the column back, so nothing is lost.
|
|
--
|
|
-- Why no per-row rattelse log (BFL 5 kap 5 §, 5 kap 11 §): this column is not
|
|
-- the bokforingspost and not the underlag. The underlag is the raw PSD2 page,
|
|
-- archived verbatim by uploadDocument on every sync (räkenskapsinformation,
|
|
-- BFL 7 kap) and untouched here; the verifikat's content lives in
|
|
-- journal_entries / journal_entry_lines, which this statement never reads or
|
|
-- writes. The column is a write-once ingest projection with a single consumer
|
|
-- (classifyTransactionMethod at insert time) and no reader in UI, API, MCP,
|
|
-- reports or SIE, and the new text is a deterministic function of the old
|
|
-- text and the archived page. The dated migration file plus the DECISIONS.md
|
|
-- entry are the systemdokumentation (BFNAR 2013:2 kap 9) for the change.
|
|
--
|
|
-- pg-test: skip (one-shot data repair: the only function is a pg_temp helper
|
|
-- dropped in the same migration; no trigger, RPC, policy or constraint is
|
|
-- created or changed, and the UPDATE cannot be re-exercised after apply)
|
|
|
|
CREATE OR REPLACE FUNCTION pg_temp.flatten_eb_transaction_code(p_raw text)
|
|
RETURNS text
|
|
LANGUAGE plpgsql
|
|
IMMUTABLE
|
|
AS $$
|
|
DECLARE
|
|
v_obj jsonb;
|
|
v_code text;
|
|
v_sub text;
|
|
v_desc text;
|
|
BEGIN
|
|
IF p_raw IS NULL OR p_raw !~ '^\{.*\}$' THEN
|
|
RETURN p_raw;
|
|
END IF;
|
|
BEGIN
|
|
v_obj := p_raw::jsonb;
|
|
EXCEPTION WHEN others THEN
|
|
RETURN p_raw;
|
|
END;
|
|
IF jsonb_typeof(v_obj) <> 'object' THEN
|
|
RETURN p_raw;
|
|
END IF;
|
|
v_code := NULLIF(btrim(v_obj->>'code'), '');
|
|
v_sub := NULLIF(btrim(v_obj->>'sub_code'), '');
|
|
v_desc := NULLIF(btrim(v_obj->>'description'), '');
|
|
IF v_code IS NOT NULL THEN
|
|
RETURN CASE WHEN v_sub IS NOT NULL THEN v_code || '/' || v_sub ELSE v_code END;
|
|
END IF;
|
|
RETURN v_desc;
|
|
END;
|
|
$$;
|
|
|
|
UPDATE public.transactions
|
|
SET
|
|
bank_transaction_code = pg_temp.flatten_eb_transaction_code(bank_transaction_code),
|
|
proprietary_bank_transaction_code = pg_temp.flatten_eb_transaction_code(proprietary_bank_transaction_code)
|
|
WHERE import_source = 'enable_banking'
|
|
AND (
|
|
bank_transaction_code ~ '^\{.*\}$'
|
|
OR proprietary_bank_transaction_code ~ '^\{.*\}$'
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM public.company_migration_resets r
|
|
WHERE r.source_company_id = transactions.company_id
|
|
);
|
|
|
|
DROP FUNCTION pg_temp.flatten_eb_transaction_code(text);
|