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:
co-authored by
Claude Fable 5.1
parent
fdcb7d937e
commit
477b59453f
@@ -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'],
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user