feat(transactions): structured transaction_method instead of channel-in-the-name (#1459)

* feat(transactions): structured transaction_method instead of channel-in-the-name

Swedish bank feeds embed the payment channel in the description string
("Vercel Jul Överföring via internet", "ANTHROPIC* ... Kortköp/uttag"):
the PSD2 remittance array is joined into one string and the ISO 20022
type codes were dropped at insert. This promotes the channel to data:

- transactions.transaction_method (text + CHECK closed vocabulary: card,
  transfer, bankgiro, plusgiro, swish, autogiro, e_invoice, international,
  deposit, withdrawal, salary, fee, interest, adjustment) plus verbatim
  bank_transaction_code / proprietary_bank_transaction_code evidence
  columns (data_quality_master Appendix B "Layer-A capture").
- classifyTransactionMethod() in lib/transactions/transaction-method.ts:
  explicit source method (Stripe txn.type) > trailing Swedish channel
  phrase > ISO 20022 family/subfamily > proprietary-code keywords > MCC.
  It also splits the clean display title off the description.
- Ingest stores the clean title as description and the full bank string
  as original_description; dedup is untouched (external_id is date+öre,
  the content bridge reads original_description and is prefix-based, and
  a trailing strip leaves a prefix). Enable Banking passes the codes
  through; the Stripe feed sets methods from its balance-txn types.
- Backfill migration classifies existing rows from the description text
  (+ MCC and Stripe prefixes) and strips unedited titles; user-edited
  titles are never rewritten.
- mapping-engine also matches original_description so user rules written
  against the full bank text keep firing.
- UI: the inbox row shows the clean name; clicking it now folds out
  "Betalsätt: Kortköp" etc. (sv/en), making every classified row
  expandable.

A card purchase implies a physical receipt, a Bankgiro/e-invoice payment
implies a supplier invoice: downstream automations can now branch on the
rail instead of regexing display strings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(transactions): anchor counterparty-template identity on original_description

Audit follow-up to the phrase-strip change: counterparty template lookup
AND learning derived their key from merchant_name || description. With
the working title now stripped ("SPOTIFY AB Kortköp" -> "SPOTIFY AB"),
templates learned from the full bank string would only re-match via the
occurrence-gated single-token tier, and single-token counterparties with
fewer than 3 bookings would silently stop matching.

Both sides now read merchant_name || original_description || description:
the immutable bank original is identical across eras (and across user
renames), so every stored key and alias keeps matching exactly. Same
anchoring rationale as buildMerchantHistory in category-suggestions.

Existing tests that relied on the fixture's default original_description
now state it explicitly; two new regression tests pin the era stability
(lookup via alias on the full string, learning key derivation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(transactions): review follow-ups on method classification

- methodFromCodes: two-pass subfamily-then-family scan so a SALA/XBCT
  refinement on the proprietary code beats a bare family match on the
  ISO code, matching the documented precedence; pinned by a test.
- mapping-engine: regression tests for merchant/description patterns
  that only match original_description, including the invalid-regex
  substring fallback and the no-match default.
- Stripe: regression test for the SDK-unmodeled 'tax' balance-txn type
  mapping to 'fee'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(transactions): scope method classification to feed rows + adjective guard

Multi-bank risk hardening before the backfill ships:

- Feed-row scope: classification and title stripping now require a real
  import feed (import_source present, not manual/mcp), both at the
  ingest boundary (USER_CREATED_IMPORT_SOURCES, now exported) and in
  every backfill statement. User-authored titles like "Egen insättning"
  on manual/MCP rows are never classified and never rewritten.
- Adjective guard (TS + SQL): a strip that would leave the title ending
  in a possessive/scope adjective (egen/eget/privat/intern/extern ...)
  is skipped, so "Egen insättning" stays whole even on bank-feed rows;
  the method column still classifies (deposit).
- Unknown bank phrasings remain untouched by construction: an unmatched
  phrase means no method and no rewrite, so the worst case for any bank
  whose vocabulary we have not seen is the status quo.

Pinned by new unit + pg-real cases (user-created exclusion for
NULL/manual/mcp, adjective guard, feed defaults in the pg fixture).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(migrations): re-timestamp transaction_method migrations after rebase

Main gained migrations dated 20260729-20260730 (already applied to prod)
while this branch carried 20260728 versions, which would have applied
out-of-order on merge. The files have never reached prod, so renaming to
current timestamps is safe and removes any dependence on the integration's
out-of-order handling. All code/doc references updated; the pg test reads
the backfill by its new filename.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(migrations): bump transaction_method versions past prod's max

Main's newest applied migration is 20260730090000 (future-leaning
timestamp), so the previous 202607300731xx rename still sorted before
prod's tail and risked a silent skip on merge-time apply. Versions are
now 20260730100000/20260730100100, strictly after everything applied to
prod. References updated; full migration stream replays clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(transactions): final review round: keyboard guard + bank_connection_id feed marker

- TransactionInboxCard: row-level Enter/Space handling now ignores events
  bubbling from nested controls, so keyboard activation of Bokför / the
  overflow menu is no longer cancelled by the (now much more common)
  expandable row.
- Feed predicate parity with isImportedTransaction(): a live
  bank_connection_id marks a feed row even when import_source is unset
  (the oldest PSD2 rows predate that column), in both the ingest
  classifier and every backfill statement: those legacy rows now get
  classified instead of being skipped as user-created.
- pg fixture typing uses the TransactionMethod union.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(migrations): re-timestamp transaction_method migrations past prod's 20260807 tail

Prod max applied is 20260807170000 (verified by name via list_migrations);
the 20260730-stamped pair would sort before it. References in code,
tests, and DECISIONS.md updated to the new versions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(migrations): enforce, not assume, original_description preservation in the title strip

The strip UPDATE now fills a NULL original_description from the
pre-strip description in the same statement. Prod has zero such rows
(0/25,566 feed-scope rows, verified read-only), and 20260605120000's
backfill plus ingest make the NULL case unreachable on any DB that
replayed history, but the migration should not depend on that history
to avoid losing the only copy of a bank string.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: record the compliance-review triage of the backfill's booked-row title strip

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
This commit is contained in:
Jakob Wennberg
2026-08-08 11:58:51 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg Jakob Wennberg
parent 3d1ed15b6d
commit 70845edf69
21 changed files with 1557 additions and 26 deletions
@@ -142,7 +142,7 @@ describe('counterparty-templates', () => {
describe('findCounterpartyTemplate', () => {
it('returns null for transaction without merchant name', async () => {
const { supabase } = createMockSupabase()
const tx = makeTransaction({ merchant_name: null, description: '' })
const tx = makeTransaction({ merchant_name: null, description: '', original_description: null })
const result = await findCounterpartyTemplate(supabase as never, 'user-1', tx)
expect(result).toBeNull()
})
@@ -235,6 +235,34 @@ describe('counterparty-templates', () => {
expect(result).toBeNull()
})
it('anchors identity on original_description: a template learned from the full bank string matches a stripped working title', async () => {
// Era-stability regression: the ingest boundary strips the trailing
// channel phrase off description ("SPOTIFY AB Kortköp" → "SPOTIFY AB"),
// but templates learned BEFORE that carry keys/aliases derived from the
// full string. Matching must read the immutable original, or a
// single-distinctive-token template (occurrence 1, below the
// MIN_SINGLE_TOKEN_OCCURRENCES gate) silently stops matching.
const fullString = 'SPOTIFY AB Kortköp'
const template = makeCategorizationTemplate({
counterparty_name: normalizeCounterpartyName(fullString),
confidence: 0.8,
counterparty_aliases: [fullString.toLowerCase()],
occurrence_count: 1,
})
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [template] })
const tx = makeTransaction({
merchant_name: null,
description: 'SPOTIFY AB', // stripped working title
original_description: fullString, // immutable bank original
})
const result = await findCounterpartyTemplate(supabase as never, 'user-1', tx)
expect(result).not.toBeNull()
expect(result!.matchMethod).toBe('exact_alias')
})
it('token-subset matches a template token buried in a card descriptor', async () => {
// The reported Anthropic case: a template learned from manual bookings
// ("Claude Dec" -> "claude") must match the bank's card descriptor even
@@ -250,6 +278,7 @@ describe('counterparty-templates', () => {
const tx = makeTransaction({
merchant_name: null,
description: 'ANTHROPIC* CLAUDE SUB SAN FRANCISCO',
original_description: 'ANTHROPIC* CLAUDE SUB SAN FRANCISCO',
})
const result = await findCounterpartyTemplate(supabase as never, 'user-1', tx)
@@ -272,6 +301,7 @@ describe('counterparty-templates', () => {
const tx = makeTransaction({
merchant_name: null,
description: 'ANTHROPIC*CLAUDE SUB LONDON',
original_description: 'ANTHROPIC*CLAUDE SUB LONDON',
})
const result = await findCounterpartyTemplate(supabase as never, 'user-1', tx)
@@ -291,7 +321,11 @@ describe('counterparty-templates', () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [template] })
const tx = makeTransaction({ merchant_name: null, description: 'SWISH ANDERS JOHANSSON' })
const tx = makeTransaction({
merchant_name: null,
description: 'SWISH ANDERS JOHANSSON',
original_description: 'SWISH ANDERS JOHANSSON',
})
const result = await findCounterpartyTemplate(supabase as never, 'user-1', tx)
expect(result).toBeNull()
@@ -310,6 +344,7 @@ describe('counterparty-templates', () => {
const tx = makeTransaction({
merchant_name: null,
description: 'SQ *BLUE BOTTLE COFFEE OAKLAND',
original_description: 'SQ *BLUE BOTTLE COFFEE OAKLAND',
})
const result = await findCounterpartyTemplate(supabase as never, 'user-1', tx)
@@ -353,6 +388,7 @@ describe('counterparty-templates', () => {
const tx = makeTransaction({
merchant_name: null,
description: 'ANTHROPIC* CLAUDE SUB SAN FRANCISCO',
original_description: 'ANTHROPIC* CLAUDE SUB SAN FRANCISCO',
})
const result = await findCounterpartyTemplate(supabase as never, 'user-1', tx)
@@ -475,6 +511,38 @@ describe('counterparty-templates', () => {
expect(supabase.from).toHaveBeenCalledWith('categorization_templates')
})
it('learns the key from the immutable bank original, not the stripped working title', async () => {
// Learning and lookup must derive the key from the same string
// (original_description), or the ingest-time phrase strip would fork
// template identities by era. Tailored mock: the shared queued mock
// does not capture insert payloads.
const inserted: Record<string, unknown>[] = []
const chain = {
select: () => chain,
eq: () => chain,
maybeSingle: async () => ({ data: null, error: null }),
insert: async (payload: Record<string, unknown>) => {
inserted.push(payload)
return { error: null }
},
}
const supabase = { from: () => chain }
const tx = makeTransaction({
merchant_name: null,
description: 'SPOTIFY AB',
original_description: 'SPOTIFY AB Kortköp',
date: '2024-06-15',
})
await upsertCounterpartyTemplate(
supabase as never, 'user-1', tx, mappingResult, 'auto_learned'
)
expect(inserted).toHaveLength(1)
expect(inserted[0].counterparty_name).toBe(normalizeCounterpartyName('SPOTIFY AB Kortköp'))
expect(inserted[0].counterparty_aliases).toEqual(['spotify ab kortköp'])
})
it('does not throw on insert error', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const tx = makeTransaction({ merchant_name: 'New Company AB' })
@@ -530,7 +598,7 @@ describe('counterparty-templates', () => {
it('skips upsert for transactions without merchant name', async () => {
const { supabase } = createQueuedMockSupabase()
const tx = makeTransaction({ merchant_name: null, description: '' })
const tx = makeTransaction({ merchant_name: null, description: '', original_description: null })
await upsertCounterpartyTemplate(
supabase as never, 'user-1', tx, mappingResult, 'user_approved'
@@ -77,6 +77,115 @@ describe('mapping-engine', () => {
})
describe('evaluateMappingRules', () => {
// Full mapping_rules row for pattern-matching tests; override per case.
const makeRule = (overrides: Record<string, unknown>) => ({
id: 'rule-1',
user_id: null,
rule_name: 'Pattern rule',
rule_type: 'description',
priority: 100,
mcc_codes: null,
merchant_pattern: null,
description_pattern: null,
amount_min: null,
amount_max: null,
debit_account: '6540',
credit_account: '1930',
vat_treatment: null,
vat_debit_account: null,
vat_credit_account: null,
risk_level: 'LOW',
default_private: false,
requires_review: false,
confidence_score: 0.9,
capitalization_threshold: null,
capitalized_debit_account: null,
is_active: true,
source: 'system',
user_description: null,
template_id: null,
created_at: '2024-01-01',
updated_at: '2024-01-01',
...overrides,
})
it('matches a description_pattern that only appears in original_description', async () => {
// The ingest boundary strips the trailing channel phrase off the working
// title; a rule written against the bank's full text must keep firing
// via the immutable original.
const { evaluateMappingRules } = await import('../mapping-engine')
const tx = makeTransaction({
amount: -100,
merchant_name: null,
description: 'Vercel Jul',
original_description: 'Vercel Jul Överföring via internet',
})
mockResult({
data: [makeRule({ description_pattern: 'Överföring via internet' })],
error: null,
})
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
expect(result.debit_account).toBe('6540')
})
it('matches a merchant_pattern that only appears in original_description', async () => {
const { evaluateMappingRules } = await import('../mapping-engine')
const tx = makeTransaction({
amount: -100,
merchant_name: null,
description: 'SPOTIFY AB',
original_description: 'SPOTIFY AB Kortköp',
})
mockResult({
data: [makeRule({ rule_type: 'merchant_name', merchant_pattern: 'Kortköp' })],
error: null,
})
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
expect(result.debit_account).toBe('6540')
})
it('invalid-regex substring fallback also scans original_description', async () => {
const { evaluateMappingRules } = await import('../mapping-engine')
// 'fee (2026' is an invalid regex (unclosed group) and a literal
// substring of the original only.
const tx = makeTransaction({
amount: -100,
merchant_name: null,
description: 'Stripe: Billing - Usage Fee',
original_description: 'Stripe: Billing - Usage Fee (2026-07-26)',
})
mockResult({
data: [makeRule({ description_pattern: 'fee (2026' })],
error: null,
})
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
expect(result.debit_account).toBe('6540')
})
it('does not match when the pattern appears in neither description nor original', async () => {
const { evaluateMappingRules } = await import('../mapping-engine')
const tx = makeTransaction({
amount: -100,
merchant_name: null,
description: 'Vercel Jul',
original_description: 'Vercel Jul Överföring via internet',
})
mockResult({
data: [makeRule({ description_pattern: 'Kortköp' })],
error: null,
})
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
expect(result.debit_account).toBe('6991') // default expense fallback
})
it('returns default result when no rules match (expense)', async () => {
const { evaluateMappingRules } = await import('../mapping-engine')
+12 -2
View File
@@ -339,7 +339,13 @@ export async function findCounterpartyTemplatesBatch(
}
for (const tx of transactions) {
const rawName = tx.merchant_name || tx.description
// Identity anchors on the immutable bank original, not the working title:
// the ingest boundary strips the trailing channel phrase off description
// ("SPOTIFY AB Kortköp" → "SPOTIFY AB") and users can rename it, but
// templates were learned from the full bank string, so matching the
// original keeps every era's keys and aliases aligned (same rationale as
// buildMerchantHistory in lib/transactions/category-suggestions.ts).
const rawName = tx.merchant_name || tx.original_description || tx.description
if (!rawName) continue
const normalized = normalizeCounterpartyName(rawName)
@@ -906,7 +912,11 @@ export async function upsertCounterpartyTemplate(
// flip the template's accounts and poison future matches.
if (mappingResult.direction_mismatch) return
const rawName = transaction.merchant_name || transaction.description
// Learn from the immutable bank original (see findCounterpartyTemplatesBatch):
// learning and lookup MUST derive the key from the same string, or the
// ingest-time phrase strip would fork template identities by era.
const rawName =
transaction.merchant_name || transaction.original_description || transaction.description
if (!rawName) return
const normalized = normalizeCounterpartyName(rawName)
+24 -6
View File
@@ -209,31 +209,49 @@ function matchesRule(rule: MappingRule, transaction: Transaction): boolean {
}
}
// Merchant name pattern matching (case-insensitive)
// Merchant name pattern matching (case-insensitive). The description leg
// also tests original_description: since the ingest boundary started
// stripping the trailing channel phrase off the working title
// (classifyTransactionMethod), a rule written against the bank's full text
// ("Överföring via internet") only matches the immutable original.
if (rule.merchant_pattern) {
const merchantName = transaction.merchant_name || transaction.description || ''
const originalName = transaction.original_description || ''
try {
const regex = new RegExp(rule.merchant_pattern, 'i')
if (!regex.test(merchantName)) {
if (!regex.test(merchantName) && !(originalName && regex.test(originalName))) {
return false
}
} catch {
// Invalid regex, try simple includes
if (!merchantName.toLowerCase().includes(rule.merchant_pattern.toLowerCase())) {
const needle = rule.merchant_pattern.toLowerCase()
if (
!merchantName.toLowerCase().includes(needle) &&
!originalName.toLowerCase().includes(needle)
) {
return false
}
}
}
// Description pattern matching
// Description pattern matching: the working title OR the full bank original
// (see the merchant_pattern note above).
if (rule.description_pattern) {
const originalDescription = transaction.original_description || ''
try {
const regex = new RegExp(rule.description_pattern, 'i')
if (!regex.test(transaction.description)) {
if (
!regex.test(transaction.description) &&
!(originalDescription && regex.test(originalDescription))
) {
return false
}
} catch {
if (!transaction.description.toLowerCase().includes(rule.description_pattern.toLowerCase())) {
const needle = rule.description_pattern.toLowerCase()
if (
!transaction.description.toLowerCase().includes(needle) &&
!originalDescription.toLowerCase().includes(needle)
) {
return false
}
}
+102
View File
@@ -219,6 +219,108 @@ describe('ingestTransactions', () => {
expect((txInserts[0] as { cash_account_id?: string | null }).cash_account_id).toBeNull()
})
// -----------------------------------------------------------------------
// 1d. Transaction-method classification at the insert boundary
// -----------------------------------------------------------------------
it('classifies transaction_method, strips the channel phrase from the title, and persists the raw codes', async () => {
const { supabase, enqueue, inserts } = createQueueMockSupabase()
const raw = makeRaw({
description: 'Vercel Jul Överföring via internet',
bank_transaction_code: 'PMNT/ICDT',
proprietary_bank_transaction_code: 'Överföring',
})
const inserted = makeTransaction({ id: 'tx-1', external_id: raw.external_id })
enqueue({ data: [], error: null }) // booked map
enqueue({ data: [], error: null }) // unbooked map
enqueue({ data: [], error: null }) // supplier invoices
enqueue({ data: [], error: null }) // external_id dedup
enqueue({ data: inserted, error: null }) // insert
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
expect(result.imported).toBe(1)
const payload = (inserts['transactions'] ?? [])[0] as Record<string, unknown>
// Working title is the clean prefix; the immutable original keeps the
// full bank string (dedup-bridge + restore-original source).
expect(payload.description).toBe('Vercel Jul')
expect(payload.original_description).toBe('Vercel Jul Överföring via internet')
// The phrase beats the generic ISO family (ICDT = credit transfer).
expect(payload.transaction_method).toBe('transfer')
expect(payload.bank_transaction_code).toBe('PMNT/ICDT')
expect(payload.proprietary_bank_transaction_code).toBe('Överföring')
})
it('treats a bank_connection_id row as a feed even without import_source', async () => {
// The oldest PSD2 rows predate the import_source column; a live bank
// connection is the unambiguous feed marker (isImportedTransaction).
const { supabase, enqueue, inserts } = createQueueMockSupabase()
const raw = makeRaw({
description: 'Vercel Jul Överföring via internet',
import_source: undefined,
bank_connection_id: 'bc-1',
})
const inserted = makeTransaction({ id: 'tx-1', external_id: raw.external_id })
enqueue({ data: [], error: null }) // booked map
enqueue({ data: [], error: null }) // unbooked map
enqueue({ data: [], error: null }) // supplier invoices
enqueue({ data: [], error: null }) // external_id dedup
enqueue({ data: inserted, error: null }) // insert
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
const feedPayload = (inserts['transactions'] ?? [])[0] as Record<string, unknown>
expect(feedPayload.transaction_method).toBe('transfer')
expect(feedPayload.description).toBe('Vercel Jul')
expect(feedPayload.original_description).toBe('Vercel Jul Överföring via internet')
})
it('never classifies or strips user-created sources (manual/mcp)', async () => {
const { supabase, enqueue, inserts } = createQueueMockSupabase()
// A user-authored title that WOULD classify+strip if it came from a feed.
const raw = makeRaw({ description: 'Egen insättning', import_source: 'manual' })
const inserted = makeTransaction({ id: 'tx-1', external_id: raw.external_id })
enqueue({ data: [], error: null }) // booked map
enqueue({ data: [], error: null }) // unbooked map
enqueue({ data: [], error: null }) // supplier invoices
enqueue({ data: [], error: null }) // external_id dedup
enqueue({ data: inserted, error: null }) // insert
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
const payload = (inserts['transactions'] ?? [])[0] as Record<string, unknown>
expect(payload.description).toBe('Egen insättning')
expect(payload.original_description).toBe('Egen insättning')
expect(payload.transaction_method).toBeNull()
})
it('leaves the title untouched and method null when nothing classifies', async () => {
const { supabase, enqueue, inserts } = createQueueMockSupabase()
const raw = makeRaw({ description: 'Test transaction' })
const inserted = makeTransaction({ id: 'tx-1', external_id: raw.external_id })
enqueue({ data: [], error: null }) // booked map
enqueue({ data: [], error: null }) // unbooked map
enqueue({ data: [], error: null }) // supplier invoices
enqueue({ data: [], error: null }) // external_id dedup
enqueue({ data: inserted, error: null }) // insert
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
const payload = (inserts['transactions'] ?? [])[0] as Record<string, unknown>
expect(payload.description).toBe('Test transaction')
expect(payload.original_description).toBe('Test transaction')
expect(payload.transaction_method).toBeNull()
expect(payload.bank_transaction_code).toBeNull()
expect(payload.proprietary_bank_transaction_code).toBeNull()
})
// -----------------------------------------------------------------------
// 2. Detects duplicates
// -----------------------------------------------------------------------
@@ -0,0 +1,213 @@
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')
})
// ── 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)
}
})
})
+38 -7
View File
@@ -8,6 +8,7 @@ import { fetchExchangeRate } from '@/lib/currency/riksbanken'
import { logMatchEvent } from '@/lib/invoices/match-log'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { contentBucketKey, descriptionsBridge, normalizeImportedDescription, shiftIsoDate } from '@/lib/transactions/external-id'
import { classifyTransactionMethod } from '@/lib/transactions/transaction-method'
import { isImportedTransaction } from '@/lib/transactions/origin'
import { createLogger } from '@/lib/logger'
import type { Transaction, RawTransaction, IngestResult, IngestOptions, SupplierInvoice, Currency, ExchangeRate } from '@/types'
@@ -532,10 +533,34 @@ export async function ingestTransactions(
// Normalize the source title once. Guarantees a non-empty, Swedish-first
// label for every import path (PSD2 sync + all bank-file CSV/CAMT parsers
// funnel into raw.description): catching both empty/whitespace titles and
// the legacy English 'Unknown' sentinel. This normalized value is stored as
// both description and original_description below; it's what the user sees
// and edits, and what the content-dedup key is built from.
// the legacy English 'Unknown' sentinel. This normalized FULL value is what
// the content-dedup key is built from and what original_description stores;
// the row's working title (description column) is the classifier's
// displayTitle: the same string with the trailing channel phrase
// ("Överföring via internet", "Kortköp/uttag", ...) stripped. A stripped
// title is a PREFIX of the full string, so the prefix-containment dedup
// bridge is unaffected.
const description = normalizeImportedDescription(raw.description)
// Classification is a FEED-row concept: a user-created row (manual UI,
// MCP, or a source-less caller without a bank connection) carries a
// user-authored title, not bank channel vocabulary; classifying or
// stripping it would corrupt meaning ("Egen insättning" is a title, not a
// deposit label). Same predicate as isImportedTransaction(): a live
// bank_connection_id marks a feed row even when import_source is unset.
// Mirrors the scope of the 20260808090100 backfill.
const isUserCreatedSource = !isImportedTransaction({
bank_connection_id: raw.bank_connection_id ?? null,
import_source: raw.import_source ?? null,
})
const { method: transactionMethod, displayTitle } = isUserCreatedSource
? { method: null, displayTitle: description }
: classifyTransactionMethod({
description,
bankTransactionCode: raw.bank_transaction_code ?? null,
proprietaryBankTransactionCode: raw.proprietary_bank_transaction_code ?? null,
mccCode: raw.mcc_code ?? null,
explicitMethod: raw.transaction_method ?? null,
})
// 1. Check for duplicates via external_id (batch pre-fetched)
if (existingExternalIds.has(raw.external_id)) {
@@ -863,11 +888,17 @@ export async function ingestTransactions(
cash_account_id: cashAccountId,
external_id: raw.external_id,
date: raw.date,
description: description,
// Immutable bank/PSD2 original: captured once, never overwritten by a
// title edit. Equals description at insert; they diverge only if the
// user later edits the title.
// Working title: the source description with the trailing channel
// phrase stripped (classifyTransactionMethod). Falls back to the full
// string when no phrase matched or stripping would empty it.
description: displayTitle,
// Immutable bank/PSD2 original: the FULL source string, captured once,
// never overwritten by a title edit or the phrase strip. Dedup-bridge
// source and the "restore original" value.
original_description: description,
transaction_method: transactionMethod,
bank_transaction_code: raw.bank_transaction_code || null,
proprietary_bank_transaction_code: raw.proprietary_bank_transaction_code || null,
amount: raw.amount,
currency: raw.currency,
amount_sek: amountSek,
+3 -1
View File
@@ -42,8 +42,10 @@ export type TransactionOrigin = {
* `import_source` values produced by in-app creation flows. A `null` source
* (with no bank connection) is also user-created: that's the plain manual-add
* path. Anything NOT in this set is considered an external import feed.
* Exported for the ingest boundary: transaction_method classification and
* title stripping are feed-row concepts and must skip user-created sources.
*/
const USER_CREATED_IMPORT_SOURCES: ReadonlySet<string> = new Set(['manual', 'mcp'])
export const USER_CREATED_IMPORT_SOURCES: ReadonlySet<string> = new Set(['manual', 'mcp'])
/**
* True when the transaction was fetched via bank sync or uploaded via a
+248
View File
@@ -0,0 +1,248 @@
/**
* 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'],
[/ATM|CASH.?WDL|WITHDRAW|UTTAG/i, 'withdrawal'],
[/\bCARD\b|KORT|\bPOS\b/i, 'card'],
[/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 }
}