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>
253 lines
9.8 KiB
TypeScript
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 }
|
|
}
|