fix(enable-banking): flatten Enable Banking's bank_transaction_code object to a string (#2398)

* 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>
This commit is contained in:
Mattsson
2026-09-08 00:37:23 +02:00
committed by GitHub
parent fdcb7d937e
commit 477b59453f
10 changed files with 290 additions and 11 deletions
+1
View File
@@ -1654,5 +1654,6 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-07] MCP: gnubok_list_rot_rut_payout_requests ships search-only (READ, bridged by gnubok_call_tool); gnubok_settle_rot_rut_payout stays in the default catalog as a WRITE and the tools/list ceiling moved 60K to 60.5K (measured 60 428) rather than demoting a read without usage data. The dashboard match route and the MCP commit executor share one pre-flight + settle in lib/invoices/rot-rut-match-transaction.ts. No MCP reclaim tool yet: the reclaim is a dashboard action until an agent asks for it.
[2026-09-07] Klarmarkera (markPeriodClosedExternally) now asks whether the period has any lines on result accounts (BAS class 3-8) instead of whether it has any non-imported verifikat at all. The old test was a proxy for the thing the guard protects (3xxx-8xxx transferred by a bokslutsverifikat, BFL 5-6 kap) and it shut out the migrated first year whose only native voucher re-keys the opening balance (1930/2081 aktiekapital) after a failed SIE import; with the next year's IB already imported the normal year-end refused too (NEXT_PERIOD_HAS_IB), so the year had no closing path at all (EHAL AB support case 2026-09-07). Existence check via id-only entry fetch plus per-chunk head counts with early exit, no line fetch, no journal_entries!inner embed. Same session: the loaded årsredovisning view got the FyPicker in its header, because the no-period branch auto-jumps to the remembered scope before its own picker is ever seen, which left users with no way to reach another year's årsredovisning.
[2026-09-07] Klarmarkera's new balance-sheet-only leg additionally requires the NEXT period to already carry IB (opening_balance_entry_id set). Without that, a natively bookkept year with only balance-sheet vouchers (dormant AB with just the aktiekapital deposit) could be klarmarkerad and its balances would never reach the next year, since klarmarkera skips the IB posting the normal year-end does. With the IB in place the normal year-end refuses (NEXT_PERIOD_HAS_IB) and klarmarkera is the only path; without it the normal year-end works and is the right path. Raised by the swedish-compliance bot on PR #2393 (its suggested alternative, a dedicated IB-correction tag on entries, was rejected: it adds a concept for the user to know about where the ledger already answers the question). Same round: the result-account range is bounded to class 3-8 (lt '9'), class 9 interna poster are not transferred by a bokslut.
[2026-09-08] Enable Banking's bank_transaction_code object ({description, code, sub_code}) is flattened to a string by one helper in the connect-contract file (normalizeBankTransactionCode: code, code/sub_code, else description), applied by both producers (Connect's normalizeBookedTransaction, the ledger's convertTransaction), and the wire schema stays z.string().nullable(). Rejected: widening the contract to string | object and normalizing only in the ledger's sync.ts. It keeps the wire type dishonest, leaves Connect's own label derivation reading an object, and still needs the direct-path fix, because that path had been writing the object's JSON text into transactions.bank_transaction_code for 78 companies since 2026-08-09 while Connect failed the same type lie loudly on every canary sync from 2026-09-03 (Capstone support case, 2026-09-07). The helper lives in the contract despite its "shape, never behaviour" rule because the two repos already mirror that file byte for byte; a copy per producer is the drift that caused the outage. Repair migration rewrites the stored JSON text with the same rule and nothing else (no transaction_method re-derivation).
[2026-09-07] PR #2397 skeptic + review pass: the reclaim is refused while any of its invoices sits in a later live begäran (avslag → new file is Skatteverket's retry; booking the refused share onto the customer meanwhile would clear 1513 twice), a reclaimed invoice is blocked from a new begäran (DEDUCTION_RECLAIMED) until the reclaim voucher is reversed, and a storno of the reclaim voucher syncs the invoices and the begäran back (lib/invoices/rot-rut-reclaim-reversal.ts, hooked into reverseEntry next to the payment sync). Per-invoice reopen goes through one idempotent RPC (apply_rot_rut_reclaim_invoice, item marker + invoice row in one transaction) so a failure after the voucher is resumable instead of stuck at ALREADY_DONE. Crediting an invoice with a reclaimed share is refused (reverse the reclaim first): the credit note reverses the issue-time 1510/1513 split. Declined: CONCURRENTLY for the partial unique index (Supabase migrations run in one transaction; same shape as the shipped rot_rut_payout index in 20260904021000, partial predicate on a source_type that few rows match).
[2026-09-08] PR #2397 review cycle 3: the reclaim RPCs own the accounting values. apply_rot_rut_reclaim_invoice takes only the refused share and validates it against the locked item, request and invoice (never above the item's requested amount, the beslut's refused total, or the 1513 headroom), then derives remaining_amount and status from the same formula as the INSERT guard (rot_rut_customer_outstanding); revert_rot_rut_reclaim_invoice mirrors it for a reversed reclaim voucher and the request link is cleared only after every leg succeeded. Reason: a SECURITY INVOKER function that accepted caller-supplied remaining/status was an unchecked accounting write for any writer-role member (CWE-862). The 20260907160300 signature is dropped in 20260907160400 rather than edited: the preview branch had already applied it.
@@ -736,6 +736,39 @@ describe('convertTransaction', () => {
expect(out.proprietary_bank_transaction_code).toBe('XB')
})
// Enable Banking's real payload: bank_transaction_code is an object. Until
// 2026-09 the object went straight through, so the ledger column received
// its JSON text ('{"description":"Card purchase",...}') and the label
// derivation never matched anything.
it('flattens the object-shaped bank_transaction_code to a string', () => {
const tx = makeTx({
bank_transaction_code: { description: 'Card purchase', code: 'PMNT', sub_code: 'CCRD' },
})
const out = convertTransaction(tx, 'SEK')
expect(out.bank_transaction_code).toBe('PMNT/CCRD')
expect(out.description).toBe('Kortköp')
})
it('keeps the description when the ASPSP sends no ISO code (the Swedish norm) and labels from it', () => {
const tx = makeTx({
bank_transaction_code: { description: 'Card purchase', code: null, sub_code: null },
})
const out = convertTransaction(tx, 'SEK')
expect(out.bank_transaction_code).toBe('Card purchase')
expect(out.description).toBe('Kortköp')
})
it('drops an empty object instead of storing its JSON', () => {
const tx = makeTx({
bank_transaction_code: { description: '', code: null, sub_code: null },
proprietary_bank_transaction_code: { description: null, code: null, sub_code: null },
})
const out = convertTransaction(tx, 'SEK')
expect(out.bank_transaction_code).toBeUndefined()
expect(out.proprietary_bank_transaction_code).toBeUndefined()
expect(out.description).toBe('Okänd transaktion')
})
// Enable Banking has no `bban` key on AccountIdentification: a Swedish
// BBAN arrives as other.identification with scheme_name BBAN. The earlier
// `.bban` read was always undefined, so domestic counterparties were lost.
@@ -35,6 +35,16 @@ describe('deriveTransactionLabel', () => {
).toBe('Uttag')
})
it('reads the combined Swedish channel wording "Kortköp/uttag" as a card purchase, not a withdrawal', () => {
// Enable Banking's flattened code description for ordinary card purchases
// at SEB/Swedbank. The card rule must win over the UTTAG keyword.
expect(deriveTransactionLabel({ bankTransactionCode: 'Kortköp/uttag' })).toBe('Kortköp')
expect(deriveTransactionLabel({ bankTransactionCode: 'Card purchase' })).toBe('Kortköp')
// A bare withdrawal wording still labels as Uttag.
expect(deriveTransactionLabel({ bankTransactionCode: 'ATM WITHDRAWAL' })).toBe('Uttag')
expect(deriveTransactionLabel({ bankTransactionCode: 'Uttag' })).toBe('Uttag')
})
it('uses the bare PMNT domain + direction as a last generic resort', () => {
expect(deriveTransactionLabel({ bankTransactionCode: 'PMNT', isCredit: true })).toBe('Inbetalning')
expect(deriveTransactionLabel({ bankTransactionCode: 'PMNT', isCredit: false })).toBe('Betalning')
@@ -16,6 +16,7 @@ import { getAuthorizationHeader } from './jwt'
import { deriveTransactionLabel } from './transaction-label'
import { FALLBACK_DESCRIPTION } from '@/lib/transactions/external-id'
import { bankConnectorMode, CONNECTOR_COMPANY_HEADER } from '@/lib/connect/instance/upstreams'
import { normalizeBankTransactionCode } from '@accounted/connect-contract'
import { dateFromDaysBefore, historyWindowDays } from './history-window'
// Prefer _PRODUCTION variant; sandbox uses api.tilisy.com, production uses api.enablebanking.com
@@ -193,8 +194,20 @@ export interface Transaction {
}
remittance_information?: string[]
merchant_category_code?: string
bank_transaction_code?: string
proprietary_bank_transaction_code?: string
/**
* Enable Banking sends this as an object ({ description, code, sub_code });
* older sandbox fixtures and some ASPSPs send a bare string. Never read it
* raw: convertTransaction flattens it with the shared contract rule.
*/
bank_transaction_code?: string | EnableBankingTransactionCode
proprietary_bank_transaction_code?: string | EnableBankingTransactionCode
}
/** The structured transaction code as Enable Banking actually serializes it. */
export interface EnableBankingTransactionCode {
description?: string | null
code?: string | null
sub_code?: string | null
}
export interface TransactionsResponse {
@@ -1347,6 +1360,14 @@ export function convertTransaction(tx: Transaction, accountCurrency: string): Ba
const creditorName = tx.creditor?.name || tx.creditor_name
const debtorName = tx.debtor?.name || tx.debtor_name
// Enable Banking's codes arrive as { description, code, sub_code } objects.
// Flatten them ONCE here, with the rule the Connect service applies on its
// side (normalizeBankTransactionCode), so the label derivation below sees a
// string and the ledger column receives text, not the object's JSON.
const bankTransactionCode = normalizeBankTransactionCode(tx.bank_transaction_code) ?? undefined
const proprietaryBankTransactionCode =
normalizeBankTransactionCode(tx.proprietary_bank_transaction_code) ?? undefined
return {
id: tx.entry_reference || tx.transaction_id || `${tx.booking_date}_${rawAmount}`,
date: tx.value_date || tx.booking_date || new Date().toISOString().split('T')[0],
@@ -1361,8 +1382,8 @@ export function convertTransaction(tx: Transaction, accountCurrency: string): Ba
description: tx.remittance_information?.filter(r => r.trim()).join(' ') ||
(isCredit ? debtorName : creditorName) ||
deriveTransactionLabel({
bankTransactionCode: tx.bank_transaction_code,
proprietaryBankTransactionCode: tx.proprietary_bank_transaction_code,
bankTransactionCode,
proprietaryBankTransactionCode,
mcc: tx.merchant_category_code,
isCredit,
}) ||
@@ -1372,8 +1393,8 @@ export function convertTransaction(tx: Transaction, accountCurrency: string): Ba
? pickAccountIdentifier(tx.debtor_account, tx.debtor_account_additional_identification)
: pickAccountIdentifier(tx.creditor_account, tx.creditor_account_additional_identification),
merchant_category_code: tx.merchant_category_code,
bank_transaction_code: tx.bank_transaction_code,
proprietary_bank_transaction_code: tx.proprietary_bank_transaction_code,
bank_transaction_code: bankTransactionCode,
proprietary_bank_transaction_code: proprietaryBankTransactionCode,
}
}
@@ -58,8 +58,12 @@ const MCC_LABELS: Record<string, string> = {
const KEYWORD_LABELS: Array<[RegExp, string]> = [
[/INTRST|INTEREST|RÄNTA|RANTA/i, 'Ränta'],
[/\bFEE\b|CHRG|CHARGE|AVGIFT/i, 'Avgift'],
[/ATM|CASH.?WDL|WITHDRAW|UTTAG/i, 'Uttag'],
// Card before withdrawal: the flattened Enable Banking description for an
// ordinary card purchase is often the combined channel wording
// "Kortköp/uttag" (SEB, Swedbank); same order as CODE_KEYWORD_METHODS in
// lib/transactions/transaction-method.ts and the Connect mirror.
[/\bCARD\b|KORT|\bPOS\b/i, 'Kortköp'],
[/ATM|CASH.?WDL|WITHDRAW|UTTAG/i, 'Uttag'],
[/SALA|SALARY|\bLÖN\b|\bLON\b/i, 'Lön'],
]
@@ -148,6 +148,21 @@ describe('classifyTransactionMethod', () => {
expect(r.method).toBe('swish')
})
it('reads the flattened Enable Banking description "Kortköp/uttag" as card, not withdrawal', () => {
// SEB/Swedbank send this combined channel wording as the code description
// for ordinary card purchases; the row title carries only the merchant.
expect(
classifyTransactionMethod({ description: 'AIMO PARK', bankTransactionCode: 'Kortköp/uttag' }).method
).toBe('card')
expect(
classifyTransactionMethod({ description: 'SPOTIFY', bankTransactionCode: 'Card purchase' }).method
).toBe('card')
// A bare withdrawal wording still classifies as withdrawal.
expect(
classifyTransactionMethod({ description: 'BANKOMAT 123', bankTransactionCode: 'ATM WITHDRAWAL' }).method
).toBe('withdrawal')
})
// ── MCC and explicit methods ─────────────────────────────────────────────
it('uses MCC presence as the card-rail fallback (6011 = ATM withdrawal)', () => {
+5 -1
View File
@@ -172,8 +172,12 @@ const CODE_KEYWORD_METHODS: ReadonlyArray<readonly [RegExp, TransactionMethod]>
[/AUTOGIRO/i, 'autogiro'],
[/INTRST|INTEREST|RÄNTA|RANTA/i, 'interest'],
[/\bFEE\b|CHRG|CHARGE|AVGIFT/i, 'fee'],
[/ATM|CASH.?WDL|WITHDRAW|UTTAG/i, 'withdrawal'],
// Card before withdrawal: Swedish banks send the combined channel wording
// "Kortköp/uttag" as the code description for ordinary card purchases (SEB,
// Swedbank), and TRAILING_PHRASES already reads that phrase as card. A bare
// ATM/UTTAG code without a card marker still lands on withdrawal.
[/\bCARD\b|KORT|\bPOS\b/i, 'card'],
[/ATM|CASH.?WDL|WITHDRAW|UTTAG/i, 'withdrawal'],
[/SALA|SALARY|\bLÖN\b|\bLON\b/i, 'salary'],
]
@@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest'
import { normalizeBankTransactionCode, normalizedBankTransactionSchema } from '../index'
describe('normalizeBankTransactionCode', () => {
it('flattens an Enable Banking object to code/sub_code when the ISO code is present', () => {
expect(
normalizeBankTransactionCode({ description: 'Card purchase', code: 'PMNT', sub_code: 'CCRD' }),
).toBe('PMNT/CCRD')
expect(normalizeBankTransactionCode({ description: null, code: 'PMNT', sub_code: null })).toBe('PMNT')
expect(normalizeBankTransactionCode({ code: ' PMNT ', sub_code: ' ' })).toBe('PMNT')
})
it('falls back to the description when the ASPSP left code null (the Swedish norm)', () => {
expect(
normalizeBankTransactionCode({ description: 'Kortköp/uttag', code: null, sub_code: null }),
).toBe('Kortköp/uttag')
expect(normalizeBankTransactionCode({ description: 'Swish' })).toBe('Swish')
})
it('passes strings through trimmed and blanks as null', () => {
expect(normalizeBankTransactionCode('PMNT-CCRD-POSD')).toBe('PMNT-CCRD-POSD')
expect(normalizeBankTransactionCode(' XB ')).toBe('XB')
expect(normalizeBankTransactionCode(' ')).toBeNull()
expect(normalizeBankTransactionCode('')).toBeNull()
})
it('returns null for empty objects, arrays, numbers and nullish input', () => {
expect(normalizeBankTransactionCode({ description: '', code: null, sub_code: null })).toBeNull()
expect(normalizeBankTransactionCode({})).toBeNull()
expect(normalizeBankTransactionCode(['PMNT'])).toBeNull()
expect(normalizeBankTransactionCode(42)).toBeNull()
expect(normalizeBankTransactionCode(null)).toBeNull()
expect(normalizeBankTransactionCode(undefined)).toBeNull()
})
it('produces a value the wire schema accepts, which the raw object is not', () => {
const raw = { description: 'Card purchase', code: null, sub_code: null }
const base = {
booking_date: '2026-09-03',
amount: -250,
currency: 'SEK',
description: 'Card purchase',
counterparty_name: null,
counterparty_account: null,
reference: null,
merchant_category_code: null,
proprietary_bank_transaction_code: null,
}
expect(normalizedBankTransactionSchema.safeParse({ ...base, bank_transaction_code: raw }).success).toBe(false)
expect(
normalizedBankTransactionSchema.safeParse({
...base,
bank_transaction_code: normalizeBankTransactionCode(raw),
}).success,
).toBe(true)
})
})
+47 -3
View File
@@ -10,8 +10,9 @@ import { z } from 'zod'
* lookup, the migration sources.
*
* Everything here is shape, never behaviour: constants, Zod schemas and the
* TypeScript types inferred from them. Both sides validate with the same
* schemas so they cannot drift apart. The package is MIT so that anyone may
* TypeScript types inferred from them, plus the one normalizer that keeps a
* provider field on that shape (normalizeBankTransactionCode). Both sides
* validate with the same schemas so they cannot drift apart. The package is MIT so that anyone may
* implement either side of it: a self-hosted ledger talking to Accounted
* Connect, or an alternative connector service talking to the open ledger.
*
@@ -19,7 +20,7 @@ import { z } from 'zod'
* breaking change is a new operation or family name, never a changed one.
*/
export const CONTRACT_VERSION = '2026-09-03'
export const CONTRACT_VERSION = '2026-09-08'
// ---------------------------------------------------------------------------
// Keys, headers and paths
@@ -152,6 +153,49 @@ export const normalizedBankTransactionSchema = z.object({
})
export type NormalizedBankTransaction = z.infer<typeof normalizedBankTransactionSchema>
/**
* Enable Banking sends `bank_transaction_code` (and, in principle,
* `proprietary_bank_transaction_code`) as an object
* `{ description, code, sub_code }`, not as the string both sides declared.
* Swedish ASPSPs leave `code` null and put the only signal in `description`
* ("Card purchase", "Swish", "Kortköp/uttag"). The wire field is a string, so
* every producer (the Connect service and a ledger's direct Enable Banking
* path) flattens with this one rule before the value reaches
* `normalizedBankTransactionSchema` or a ledger column:
*
* object with `code` -> `code`, or `code/sub_code` when a sub-code exists
* object without code -> `description`
* string -> trimmed as is
* anything else -> null
*
* Shared here rather than copied per producer so the two sides cannot drift:
* the 2026-09-03 outage was exactly that drift (Connect forwarded the object,
* the ledger rejected it, and the direct path had been storing the object's
* JSON text since 2026-08-09).
*/
export function normalizeBankTransactionCode(input: unknown): string | null {
if (typeof input === 'string') {
const trimmed = input.trim()
return trimmed.length > 0 ? trimmed : null
}
if (input && typeof input === 'object' && !Array.isArray(input)) {
const record = input as Record<string, unknown>
const text = (key: string): string | null => {
const value = record[key]
if (typeof value !== 'string') return null
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : null
}
const code = text('code')
if (code) {
const subCode = text('sub_code')
return subCode ? `${code}/${subCode}` : code
}
return text('description')
}
return null
}
export const bankSyncResponseSchema = z.object({
transactions: z.array(normalizedBankTransactionSchema),
/** Raw provider pages, verbatim, for the installation's archive. */
@@ -0,0 +1,90 @@
-- 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);