Files
Mattsson 477b59453f 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>
2026-09-08 00:37:23 +02:00

253 lines
9.8 KiB
TypeScript

/**
* Classify HOW a bank/feed transaction moved (its payment rail) into the
* closed `TransactionMethod` vocabulary, and derive the clean display title.
*
* Swedish bank feeds embed the channel in the description text itself: the
* PSD2 remittance array arrives as ["Vercel Jul", "Överföring via internet"]
* and is joined into one string at conversion, so "Kortköp/uttag",
* "Bg-bet. via internet", "Europabetalning" etc. ride along as a trailing
* phrase. This module is the single place that vocabulary lives: it both
* classifies the phrase into a structured method AND strips it off the
* working title, so the list shows "Vercel Jul" and the method becomes a
* queryable field (a card purchase implies a physical receipt exists; a
* Bankgiro/e-invoice payment implies a supplier invoice).
*
* Precedence (most trustworthy first):
* 1. An explicit method from the source (the Stripe feed knows its own
* balance-transaction types).
* 2. The trailing Swedish channel phrase in the description: bank-authored
* and more specific than the ISO family (the ISO code for a Bankgiro
* payment is just "issued credit transfer").
* 3. ISO 20022 bank_transaction_code domain/family (+ subfamily override).
* 4. Keyword scan over the raw (often proprietary) code strings.
* 5. MCC presence: only card transactions carry an MCC (6011 = ATM).
*
* Title stripping is independent of which source won: whenever a trailing
* phrase is found it is removed, but the title is never emptied (a
* description that IS the phrase, e.g. a bare "Insättning", is kept).
* Stripping a trailing phrase leaves a PREFIX of the original string, which
* the content-dedup bridge (descriptionsBridge, prefix containment) is built
* to survive; the full bank string is preserved in original_description.
*
* Pure and side-effect free. The SQL backfill in migration 20260808090100
* duplicates the phrase vocabulary as a one-shot snapshot; it does NOT need
* to stay in sync (post-migration rows are classified here at ingest).
*/
import type { TransactionMethod } from '@/types'
export interface TransactionMethodInput {
/** Normalized full source description (the ingest boundary's original). */
description: string
/** ISO 20022 bank transaction code, e.g. "PMNT-CCRD-POSD" or "PMNT/ICDT". */
bankTransactionCode?: string | null
/** ASPSP-proprietary code (free-form, varies per bank). */
proprietaryBankTransactionCode?: string | null
/** Merchant category code: present only on card-rail transactions. */
mccCode?: number | null
/** Method the source already knows structurally (e.g. Stripe txn.type). */
explicitMethod?: TransactionMethod | null
}
export interface ClassifiedTransactionMethod {
method: TransactionMethod | null
/**
* Description with the trailing channel phrase stripped: what the user sees
* as the row title. Equals `description` when no phrase matched or when
* stripping would empty the title.
*/
displayTitle: string
}
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
/**
* Trailing channel phrases, checked in order: first match wins, so longer /
* more specific variants must precede their substrings ("överföring via
* internet" before "överföring", "kortköp/uttag" before "kortköp"). The bare
* "lön" sits last: it is the riskiest token and must never shadow a more
* specific phrase.
*/
const TRAILING_PHRASES: ReadonlyArray<readonly [string, TransactionMethod]> = [
['kortköp/uttag', 'card'],
['kortköp', 'card'],
['kortbetalning', 'card'],
['webbköp', 'card'],
['bg-bet. via internet', 'bankgiro'],
['bg-bet via internet', 'bankgiro'],
['bg-betalning', 'bankgiro'],
['bg betalning', 'bankgiro'],
['bgmax', 'bankgiro'],
['bg-inb', 'bankgiro'],
['bankgiro', 'bankgiro'],
['bg-bet.', 'bankgiro'],
['bg-bet', 'bankgiro'],
['pg-betalning', 'plusgiro'],
['pg betalning', 'plusgiro'],
['plusgiro', 'plusgiro'],
['europabetalning', 'international'],
['utlandsbetalning', 'international'],
['löneinsättning', 'salary'],
['lönebetalning', 'salary'],
['löneutbetalning', 'salary'],
['e-faktura', 'e_invoice'],
['efaktura', 'e_invoice'],
['swish-betalning', 'swish'],
['swish betalning', 'swish'],
['swish', 'swish'],
['autogirobetalning', 'autogiro'],
['autogiro', 'autogiro'],
['pris betalning', 'fee'],
['prisbetalning', 'fee'],
['avgift', 'fee'],
['insättningsränta', 'interest'],
['ränta', 'interest'],
['kontantinsättning', 'deposit'],
['insättning', 'deposit'],
['bankomatuttag', 'withdrawal'],
['kontantuttag', 'withdrawal'],
['uttag', 'withdrawal'],
['överföring via internet', 'transfer'],
['överföring via mobil', 'transfer'],
['överföring via app', 'transfer'],
['överföring inom banken', 'transfer'],
['överföring inom bank', 'transfer'],
['överföring mellan konton', 'transfer'],
['direktöverföring', 'transfer'],
['direktbetalning', 'transfer'],
['internetbetalning', 'transfer'],
['mobilbetalning', 'transfer'],
['överföring', 'transfer'],
['lön', 'salary'],
]
const TRAILING_PHRASE_RULES = TRAILING_PHRASES.map(([phrase, method]) => ({
method,
// Anchored at the end, preceded by start-of-string or whitespace so a
// phrase inside a word never matches ("Löneinsättning" is salary, not
// deposit; "Bankavgift" is untouched by the bare "avgift" rule).
regex: new RegExp(`(?:^|\\s)${escapeRegExp(phrase)}\\s*$`, 'i'),
}))
/** Leading patterns: classification signal only, never stripped. */
const LEADING_RULES: ReadonlyArray<readonly [RegExp, TransactionMethod]> = [
[/^swish (?:till|från)(?:\s|$)/i, 'swish'],
]
/**
* Possessive/scope adjectives whose meaning depends on the noun after them:
* "Egen insättning" must not become "Egen" (the phrase IS the meaning there).
* When the stripped title would END in one of these, the strip is skipped;
* the method classification still applies. Mirrored by the adjective guard in
* the 20260808090100 backfill.
*/
const ADJECTIVE_GUARD = new Set([
'egen', 'eget', 'egna', 'privat', 'privata', 'intern', 'interna', 'extern', 'externa',
])
/** ISO 20022 External Bank Transaction Codes, keyed by DOMAIN/FAMILY. */
const ISO_FAMILY_METHODS: Record<string, TransactionMethod> = {
'PMNT/RCDT': 'transfer', // ReceivedCreditTransfers
'PMNT/ICDT': 'transfer', // IssuedCreditTransfers
'PMNT/CCRD': 'card', // CustomerCardTransactions
'PMNT/MCRD': 'card', // MerchantCardTransactions
'PMNT/RDDT': 'autogiro', // ReceivedDirectDebits
'PMNT/IDDT': 'autogiro', // IssuedDirectDebits
'PMNT/CWDL': 'withdrawal', // CashWithdrawal
'PMNT/CAJT': 'adjustment', // CashAdjustments
}
/** Subfamily refinements that beat the family default. */
const ISO_SUBFAMILY_METHODS: Record<string, TransactionMethod> = {
SALA: 'salary', // SalaryPayment
XBCT: 'international', // CrossBorderCreditTransfer
ESCT: 'international', // SEPACreditTransfer
}
/** Keyword → method over raw code strings (covers proprietary formats). */
const CODE_KEYWORD_METHODS: ReadonlyArray<readonly [RegExp, TransactionMethod]> = [
[/SWISH/i, 'swish'],
[/AUTOGIRO/i, 'autogiro'],
[/INTRST|INTEREST|RÄNTA|RANTA/i, 'interest'],
[/\bFEE\b|CHRG|CHARGE|AVGIFT/i, 'fee'],
// 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'],
]
function methodFromCodes(codes: string[]): TransactionMethod | null {
// Two passes so a subfamily refinement on EITHER code beats a family match
// on the other: with one combined pass, a family hit on the ISO code would
// short-circuit before the proprietary code's subfamily is inspected.
const partsList = codes.map((raw) => raw.toUpperCase().split(/[/\-_.\s]+/).filter(Boolean))
for (const parts of partsList) {
if (parts.length >= 3 && ISO_SUBFAMILY_METHODS[parts[2]]) {
return ISO_SUBFAMILY_METHODS[parts[2]]
}
}
for (const parts of partsList) {
if (parts.length >= 2) {
const family = ISO_FAMILY_METHODS[`${parts[0]}/${parts[1]}`]
if (family) return family
}
}
for (const raw of codes) {
for (const [re, method] of CODE_KEYWORD_METHODS) {
if (re.test(raw)) return method
}
}
return null
}
export function classifyTransactionMethod(
input: TransactionMethodInput
): ClassifiedTransactionMethod {
const description = (input.description ?? '').trim()
// Trailing phrase: sole source of the clean title, and the strongest
// text-level method signal.
let phraseMethod: TransactionMethod | null = null
let displayTitle = description
for (const rule of TRAILING_PHRASE_RULES) {
if (rule.regex.test(description)) {
phraseMethod = rule.method
const stripped = description.replace(rule.regex, '').trim()
const lastWord = stripped.toLowerCase().split(/\s+/).filter(Boolean).at(-1)
if (stripped.length > 0 && (!lastWord || !ADJECTIVE_GUARD.has(lastWord))) {
displayTitle = stripped
}
break
}
}
let leadingMethod: TransactionMethod | null = null
for (const [re, method] of LEADING_RULES) {
if (re.test(description)) {
leadingMethod = method
break
}
}
const codes = [input.bankTransactionCode, input.proprietaryBankTransactionCode].filter(
(c): c is string => typeof c === 'string' && c.trim().length > 0
)
const mccMethod: TransactionMethod | null =
input.mccCode != null ? (input.mccCode === 6011 ? 'withdrawal' : 'card') : null
const method =
input.explicitMethod ??
phraseMethod ??
leadingMethod ??
methodFromCodes(codes) ??
mccMethod
return { method, displayTitle }
}