* 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>
105 lines
4.4 KiB
TypeScript
105 lines
4.4 KiB
TypeScript
/**
|
|
* Derive a Swedish, human-readable working label for a bank transaction from
|
|
* the structured codes an ASPSP DOES send when free-text remittance and a
|
|
* counterparty name are both absent: the classic card-purchase / ATM / fee /
|
|
* interest case that otherwise falls through to a generic placeholder.
|
|
*
|
|
* Pure and side-effect free, so it is trivially unit-testable and safe to call
|
|
* inside the transaction conversion fallback chain.
|
|
*
|
|
* Precedence (most specific first):
|
|
* 1. MCC (merchant_category_code): identifies the merchant kind for a card
|
|
* purchase. Already trusted for auto-categorization
|
|
* (lib/bookkeeping/mapping-engine.ts).
|
|
* 2. ISO 20022 bank_transaction_code Domain/Family (e.g. "PMNT/CCRD").
|
|
* 3. Keyword scan over the (often proprietary, non-normalized) code strings.
|
|
* 4. Bare "PMNT" domain with no recognized family → direction-based generic.
|
|
*
|
|
* Returns null when nothing is recognized: the caller then falls through to
|
|
* its own final fallback (the ingest boundary normalizes any leftover empty /
|
|
* 'Unknown' value to 'Okänd transaktion').
|
|
*
|
|
* The mapping tables are intentionally small starters. ASPSP coverage of these
|
|
* codes varies and proprietary formats differ per bank: extend the tables
|
|
* against real archived `psd2-response_*.json` samples as they surface.
|
|
*/
|
|
|
|
export interface TransactionLabelInput {
|
|
/** ISO 20022 bank transaction code, e.g. "PMNT-CCRD-POSD" or "PMNT/RCDT". */
|
|
bankTransactionCode?: string | null
|
|
/** ASPSP-proprietary code (free-form, varies per bank). */
|
|
proprietaryBankTransactionCode?: string | null
|
|
/** Merchant category code (card transactions). */
|
|
mcc?: string | number | null
|
|
/** CRDT (money in) vs DBIT (money out): used only for the bare-domain case. */
|
|
isCredit?: boolean
|
|
}
|
|
|
|
// ISO 20022 External Bank Transaction Codes, keyed by `DOMAIN/FAMILY`.
|
|
const ISO20022_LABELS: Record<string, string> = {
|
|
'PMNT/RCDT': 'Inbetalning', // ReceivedCreditTransfers
|
|
'PMNT/ICDT': 'Betalning', // IssuedCreditTransfers
|
|
'PMNT/CCRD': 'Kortköp', // CustomerCardTransactions
|
|
'PMNT/MCRD': 'Kortköp', // MerchantCardTransactions
|
|
'PMNT/RDDT': 'Autogiro', // ReceivedDirectDebits
|
|
'PMNT/IDDT': 'Autogiro', // IssuedDirectDebits
|
|
'PMNT/CWDL': 'Uttag', // CashWithdrawal
|
|
'PMNT/CAJT': 'Justering', // CashAdjustments
|
|
}
|
|
|
|
// MCC → coarse Swedish label. Tiny starter set.
|
|
const MCC_LABELS: Record<string, string> = {
|
|
'6011': 'Uttag', // ATM / automated cash disbursements
|
|
'5411': 'Inköp dagligvaror', // Grocery stores, supermarkets
|
|
}
|
|
|
|
// Keyword → label, scanned over the raw (incl. proprietary) code strings as a
|
|
// last resort before null. Covers banks that send free-form codes, not ISO.
|
|
const KEYWORD_LABELS: Array<[RegExp, string]> = [
|
|
[/INTRST|INTEREST|RÄNTA|RANTA/i, 'Ränta'],
|
|
[/\bFEE\b|CHRG|CHARGE|AVGIFT/i, 'Avgift'],
|
|
// 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'],
|
|
]
|
|
|
|
export function deriveTransactionLabel(input: TransactionLabelInput): string | null {
|
|
// 1. MCC: most specific signal for card purchases.
|
|
const mcc = input.mcc != null ? String(input.mcc).trim() : ''
|
|
if (mcc && MCC_LABELS[mcc]) return MCC_LABELS[mcc]
|
|
|
|
const codes = [input.bankTransactionCode, input.proprietaryBankTransactionCode].filter(
|
|
(c): c is string => typeof c === 'string' && c.trim().length > 0,
|
|
)
|
|
|
|
// 2. ISO 20022 Domain/Family from the structured code.
|
|
for (const raw of codes) {
|
|
const parts = raw.toUpperCase().split(/[/\-_.\s]+/).filter(Boolean)
|
|
if (parts.length >= 2) {
|
|
const key = `${parts[0]}/${parts[1]}`
|
|
if (ISO20022_LABELS[key]) return ISO20022_LABELS[key]
|
|
}
|
|
}
|
|
|
|
// 3. Keyword scan over the raw code strings (covers proprietary formats).
|
|
for (const raw of codes) {
|
|
for (const [re, label] of KEYWORD_LABELS) {
|
|
if (re.test(raw)) return label
|
|
}
|
|
}
|
|
|
|
// 4. Bare "PMNT" domain with no recognized family → direction-based generic.
|
|
if (input.isCredit != null) {
|
|
for (const raw of codes) {
|
|
const domain = raw.toUpperCase().split(/[/\-_.\s]+/)[0]
|
|
if (domain === 'PMNT') return input.isCredit ? 'Inbetalning' : 'Betalning'
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|