Files
MattssonandClaude Fable 5.1 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

229 lines
8.9 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import { classifyTransactionMethod } from '../transaction-method'
import { TRANSACTION_METHODS } from '@/types'
describe('classifyTransactionMethod', () => {
// ── Trailing channel phrases (real Swedbank-style PSD2 strings) ──────────
it('classifies and strips "Överföring via internet"', () => {
const r = classifyTransactionMethod({ description: 'Vercel Jul Överföring via internet' })
expect(r.method).toBe('transfer')
expect(r.displayTitle).toBe('Vercel Jul')
})
it('classifies and strips "Kortköp/uttag"', () => {
const r = classifyTransactionMethod({
description: 'ANTHROPIC* CLAUDE SUB SAN FRANCISCO Kortköp/uttag',
})
expect(r.method).toBe('card')
expect(r.displayTitle).toBe('ANTHROPIC* CLAUDE SUB SAN FRANCISCO')
})
it('classifies and strips "Bg-bet. via internet"', () => {
const r = classifyTransactionMethod({
description: 'Inbetalning skat BG 0000050501055 Bg-bet. via internet',
})
expect(r.method).toBe('bankgiro')
expect(r.displayTitle).toBe('Inbetalning skat BG 0000050501055')
})
it('classifies and strips "Europabetalning" and its fee sibling "Pris betalning"', () => {
const eu = classifyTransactionMethod({ description: '1260624917587 Europabetalning' })
expect(eu.method).toBe('international')
expect(eu.displayTitle).toBe('1260624917587')
const fee = classifyTransactionMethod({ description: '1260624917587 Pris betalning' })
expect(fee.method).toBe('fee')
expect(fee.displayTitle).toBe('1260624917587')
})
it('classifies and strips "Insättning"', () => {
const r = classifyTransactionMethod({ description: 'SWED2607270AUEOU Insättning' })
expect(r.method).toBe('deposit')
expect(r.displayTitle).toBe('SWED2607270AUEOU')
})
it('keeps a title that IS the phrase instead of emptying it', () => {
const r = classifyTransactionMethod({ description: 'Insättning' })
expect(r.method).toBe('deposit')
expect(r.displayTitle).toBe('Insättning')
})
it('prefers the longer phrase over its substring', () => {
// "överföring via internet" must win over the bare "överföring";
// "kortköp/uttag" must win over "uttag" (which never matches: no
// preceding whitespace).
const r = classifyTransactionMethod({ description: 'Lön Juli Emil Överföring via internet' })
expect(r.method).toBe('transfer')
expect(r.displayTitle).toBe('Lön Juli Emil')
})
it('does not match a phrase inside a word', () => {
// "Löneinsättning" ends in "insättning" but is salary, not deposit.
const salary = classifyTransactionMethod({ description: 'ACME AB Löneinsättning' })
expect(salary.method).toBe('salary')
expect(salary.displayTitle).toBe('ACME AB')
// "Bankavgift" (single word) is untouched by the bare "avgift" rule.
const bankfee = classifyTransactionMethod({ description: 'Bankavgift' })
expect(bankfee.method).toBeNull()
expect(bankfee.displayTitle).toBe('Bankavgift')
})
it('classifies leading "Swish till/från" without stripping the counterparty', () => {
const r = classifyTransactionMethod({ description: 'Swish till Erik Andersson' })
expect(r.method).toBe('swish')
expect(r.displayTitle).toBe('Swish till Erik Andersson')
})
it('adjective guard: classifies but never strips "Egen insättning" / "Eget uttag"', () => {
// The phrase IS the meaning when preceded by a possessive/scope adjective:
// stripping would leave a nonsense title ("Egen").
const deposit = classifyTransactionMethod({ description: 'Egen insättning' })
expect(deposit.method).toBe('deposit')
expect(deposit.displayTitle).toBe('Egen insättning')
const withdrawal = classifyTransactionMethod({ description: 'Eget uttag' })
expect(withdrawal.method).toBe('withdrawal')
expect(withdrawal.displayTitle).toBe('Eget uttag')
const transfer = classifyTransactionMethod({ description: 'Intern överföring' })
expect(transfer.method).toBe('transfer')
expect(transfer.displayTitle).toBe('Intern överföring')
})
// ── ISO 20022 / proprietary codes ────────────────────────────────────────
it('classifies from the ISO 20022 family when no phrase matches', () => {
const r = classifyTransactionMethod({
description: 'COOP KONSUM STOCKHOLM',
bankTransactionCode: 'PMNT-CCRD-POSD',
})
expect(r.method).toBe('card')
expect(r.displayTitle).toBe('COOP KONSUM STOCKHOLM')
})
it('lets the ISO subfamily refine the family default', () => {
expect(
classifyTransactionMethod({
description: 'Payroll run',
bankTransactionCode: 'PMNT/ICDT/SALA',
}).method
).toBe('salary')
expect(
classifyTransactionMethod({
description: 'Payment abroad',
bankTransactionCode: 'PMNT/ICDT/XBCT',
}).method
).toBe('international')
})
it('lets a subfamily on the second code beat a family match on the first', () => {
// The ISO code carries only DOMAIN/FAMILY while the proprietary code
// carries the full triple: the SALA refinement must still win.
const r = classifyTransactionMethod({
description: 'Payroll run',
bankTransactionCode: 'PMNT/ICDT',
proprietaryBankTransactionCode: 'PMNT-ICDT-SALA',
})
expect(r.method).toBe('salary')
})
it('prefers the bank-authored phrase over the generic ISO family', () => {
// A Bankgiro payment travels as an issued credit transfer (ICDT): the
// phrase is the more specific truth.
const r = classifyTransactionMethod({
description: 'TELIA AB Bg-betalning',
bankTransactionCode: 'PMNT/ICDT',
})
expect(r.method).toBe('bankgiro')
expect(r.displayTitle).toBe('TELIA AB')
})
it('falls back to a keyword scan over proprietary codes', () => {
const r = classifyTransactionMethod({
description: 'Some row',
proprietaryBankTransactionCode: 'SWISH_PAYMENT',
})
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)', () => {
expect(
classifyTransactionMethod({ description: 'SPOTIFY AB', mccCode: 5815 }).method
).toBe('card')
expect(
classifyTransactionMethod({ description: 'BANKOMAT 123', mccCode: 6011 }).method
).toBe('withdrawal')
})
it('lets an explicit source method beat every heuristic', () => {
const r = classifyTransactionMethod({
description: 'Stripe-utbetalning po_123 Kortköp',
explicitMethod: 'transfer',
})
expect(r.method).toBe('transfer')
// The phrase strip still applies to the title.
expect(r.displayTitle).toBe('Stripe-utbetalning po_123')
})
it('returns null method and the untouched title when nothing matches', () => {
const r = classifyTransactionMethod({ description: 'Okänd transaktion' })
expect(r.method).toBeNull()
expect(r.displayTitle).toBe('Okänd transaktion')
})
it('every classified method is part of the closed vocabulary', () => {
const samples = [
'X Överföring via internet',
'X Kortköp',
'X Bg-betalning',
'X Plusgiro',
'X Swish',
'X Autogiro',
'X E-faktura',
'X Europabetalning',
'X Insättning',
'X Uttag',
'X Lönebetalning',
'X Avgift',
'X Ränta',
]
for (const description of samples) {
const { method } = classifyTransactionMethod({ description })
expect(method).not.toBeNull()
expect(TRANSACTION_METHODS).toContain(method)
}
})
it('stripping yields a prefix of the original (dedup-bridge invariant)', () => {
const cases = [
'Vercel Jul Överföring via internet',
'TIC BG 0000005786439 Bg-bet. via internet',
'UTBETALNING Insättning',
'ANTHROPIC* CLAUDE SUB SAN FRANCISCO Kortköp/uttag',
]
for (const description of cases) {
const { displayTitle } = classifyTransactionMethod({ description })
expect(description.toLowerCase().startsWith(displayTitle.toLowerCase())).toBe(true)
}
})
})