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:
co-authored by
Claude Fable 5
Jakob Wennberg
Jakob Wennberg
parent
3d1ed15b6d
commit
70845edf69
@@ -828,3 +828,6 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-07] QuickReviewDialog's 'none'-collapse closed with a deviation test, not a blanket map: 'none' as the SEEDED default (exempt categories) stays off the wire so untouched bank-fee bookings remain byte-identical, while 'none' as a user deviation goes as explicit 'exempt' (books no VAT line, records the classification the momsdeklaration should see, lands income on 3004). The batch dialog's blanket 'none'→'exempt' map from #1443 is correct THERE because its select has no seeded-per-category default. CategoryExpandedDialog carries the same collapse but has zero references (dead code): left untouched, deletion is hygiene for a separate sweep.
|
||||
[2026-08-08] Ruta 41 unblocked with a static ACCOUNT_RUTA mapping of 3231/3232/3233 (incl. 3232/3233, in the SKV 4700 mapping though not in our shipped BAS 2026 catalog) instead of starting the per-account VAT-treatment picker: the ElvaSmultron/Spiris support case needed omvänd-moms sales in the declaration now, and RC sales deliberately stay out of the ruta 05-08 vs 10-12 pairing checks because the buyer accounts for the VAT. The picker (any custom konto to any ruta, incl. purchase-side 4056-style Visma accounts to ruta 20) is #1457.
|
||||
[2026-08-08] Community registry source of truth moved into this repo (registry/ with entries + authors, validator wired into core-build): the site button "Lagg till en egen" pointed at erp-mafia/accounted while the registry MDX lived in the private website repo, so an external contributor had no path to PR (found by the first would-be contributor). The website now syncs FROM registry/ instead of owning the content. Entry bodies are gated to plain Markdown because the site renders them through MDX, which would execute JSX and import/export statements inside the website build.
|
||||
[2026-07-28] Transaction method (structured payment rail): the trailing channel phrase ("Överföring via internet", "Kortköp/uttag") is stripped from transactions.description at INGEST and by a one-shot BACKFILL, not merely hidden at render: description is the mutable working title, original_description keeps the full bank string, and every dedup surface (external_id: date+öre only; content bridge: prefix-containment over original_description ?? description, and a trailing strip leaves a prefix) is provably unaffected. transaction_method is text + CHECK (repo convention, no PG enums) beside verbatim bank_transaction_code / proprietary_bank_transaction_code evidence columns per data_quality_master Appendix B Layer-A; the dead `enrichment` jsonb was NOT reused (the Gokind lesson: opaque blobs with no readers die). mapping-engine now also matches original_description so user rules written against the full bank text keep firing on stripped rows.
|
||||
[2026-07-29] Transaction-method backfill scope: classification and title-stripping are FEED-ROW concepts (import_source present, not manual/mcp), enforced identically at ingest and in the 20260808090100 backfill, plus an adjective guard so "Egen insättning"/"Eget uttag"/"Intern överföring" keep their full titles even on feed rows (the phrase IS the meaning after a possessive/scope adjective). Chosen over vocabulary tweaks because the failure mode for unknown bank phrasings must be "row unchanged", and user-authored titles must never be rewritten by a channel vocabulary. A read-only prod dry-run script exists for coverage measurement but prod reads were left to the founder (permission-gated).
|
||||
[2026-08-08] Compliance-bot finding on the transaction_method backfill (booked rows' titles rewritten without a rattelse trail) triaged as satisfied-by-design, not a blocker: BFL 5 kap 5 attaches to bokforingsposter, and the backfill touches no journal table; the verifikat description is snapshotted into journal_entries at commit and SIE #VER export reads journal_entries only (both verified in code, no report reads transactions.description lazily); the bank original is preserved byte-identical in original_description by the same UPDATE (enforced since 80ef1ee0, and prod has 0/25,566 feed rows lacking it). The stricter TRANSACTION_TITLE_LOCKED gate on booked rows blocks arbitrary user free-text renames, a different mutation class from a deterministic trailing-vocabulary strip that skips user-edited titles and keeps the original adjacent. Period-lock triggers sit on the journal tables and fiscal periods, not on transactions; the pg-upgrade CI run applied the backfill against seeded booked rows with all enforcement triggers active.
|
||||
|
||||
@@ -103,6 +103,7 @@ export default function TransactionInboxCard({
|
||||
onToggleSelect,
|
||||
}: TransactionInboxCardProps) {
|
||||
const t = useTranslations('tx_inbox_card')
|
||||
const tMethod = useTranslations('tx_method')
|
||||
// Attaching underlag is a write: hide the affordance from viewers so they
|
||||
// don't dead-end on a 403 (mirrors the gate in TransactionHistoryList).
|
||||
const { canWrite } = useCanWrite()
|
||||
@@ -220,10 +221,10 @@ export default function TransactionInboxCard({
|
||||
})
|
||||
|
||||
// The foldout carries row detail only (actions live on the row: pill + ⋯).
|
||||
// Rows with nothing to show don't expand at all; once bank-tx metadata
|
||||
// classification lands (see the transactions-metadata issue) every imported
|
||||
// row will have foldout content again.
|
||||
// Rows with nothing to show don't expand at all; classified imported rows
|
||||
// always have at least the payment-method line.
|
||||
const hasFoldoutContent =
|
||||
Boolean(transaction.transaction_method) ||
|
||||
(transaction.currency !== 'SEK' && transaction.amount_sek != null) ||
|
||||
Boolean(transaction.title_edited_at && originalName) ||
|
||||
Boolean(skvCounterpartDate) ||
|
||||
@@ -249,6 +250,10 @@ export default function TransactionInboxCard({
|
||||
onKeyDown={
|
||||
canExpand
|
||||
? (e) => {
|
||||
// Only when the row itself is focused: Enter/Space on a nested
|
||||
// control (Bokför, ⋯, checkbox) bubbles here, and preventDefault
|
||||
// would cancel the button's keyboard activation.
|
||||
if (e.target !== e.currentTarget) return
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onToggleExpand(transaction.id)
|
||||
@@ -457,10 +462,18 @@ export default function TransactionInboxCard({
|
||||
<td colSpan={5} className="border-b border-border p-0">
|
||||
<RowFoldout>
|
||||
<div className="pb-6 pt-1">
|
||||
{(transaction.currency !== 'SEK' && transaction.amount_sek != null) ||
|
||||
{transaction.transaction_method ||
|
||||
(transaction.currency !== 'SEK' && transaction.amount_sek != null) ||
|
||||
transaction.title_edited_at ||
|
||||
skvCounterpartDate ? (
|
||||
<div className="space-y-1 py-1 text-xs text-muted-foreground">
|
||||
{transaction.transaction_method && (
|
||||
<p>
|
||||
{t('method_line', {
|
||||
method: tMethod(transaction.transaction_method),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{transaction.currency !== 'SEK' && transaction.amount_sek != null && (
|
||||
<p className="tabular-nums">
|
||||
{formatCurrency(transaction.amount, transaction.currency)}
|
||||
|
||||
@@ -184,6 +184,10 @@ export async function syncAccountTransactions(
|
||||
import_source: 'enable_banking',
|
||||
counterparty_iban: looksLikeIban ? cpAccount!.replace(/\s+/g, '') : null,
|
||||
counterparty_account: !looksLikeIban ? cpAccount : null,
|
||||
// Verbatim transaction-type codes: the ingest boundary classifies them
|
||||
// into transaction_method and persists them as evidence columns.
|
||||
bank_transaction_code: tx.bank_transaction_code || null,
|
||||
proprietary_bank_transaction_code: tx.proprietary_bank_transaction_code || null,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -194,6 +194,7 @@ describe('mapBalanceTransaction', () => {
|
||||
external_id: 'stripe_acct_1_txn_charge_1',
|
||||
import_source: STRIPE_IMPORT_SOURCE,
|
||||
description: 'Stripe-betalning Anna Andersson',
|
||||
transaction_method: 'card',
|
||||
})
|
||||
expect(rows[1]).toMatchObject({
|
||||
date: CREATED_DATE,
|
||||
@@ -201,6 +202,7 @@ describe('mapBalanceTransaction', () => {
|
||||
currency: 'SEK',
|
||||
external_id: 'stripe_acct_1_txn_charge_1_fee',
|
||||
description: 'Stripe-avgift (Stripe-betalning Anna Andersson)',
|
||||
transaction_method: 'fee',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -234,6 +236,7 @@ describe('mapBalanceTransaction', () => {
|
||||
amount: -200,
|
||||
description: 'Stripe-återbetalning',
|
||||
external_id: 'stripe_acct_1_txn_refund_1',
|
||||
transaction_method: 'card',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -244,6 +247,7 @@ describe('mapBalanceTransaction', () => {
|
||||
amount: -485.5,
|
||||
description: 'Stripe-utbetalning po_1',
|
||||
external_id: 'stripe_acct_1_txn_payout_1',
|
||||
transaction_method: 'transfer',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -260,12 +264,31 @@ describe('mapBalanceTransaction', () => {
|
||||
})
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[0].description).toBe('Stripe-tvist')
|
||||
expect(rows[0].transaction_method).toBe('adjustment')
|
||||
expect(rows[1]).toMatchObject({
|
||||
amount: -15,
|
||||
external_id: 'stripe_acct_1_txn_adj_1_fee',
|
||||
transaction_method: 'fee',
|
||||
})
|
||||
})
|
||||
|
||||
it("maps the SDK-unmodeled 'tax' type to a fee", () => {
|
||||
// Live Stripe sends type 'tax' for automatic-tax deductions even though
|
||||
// this SDK's BalanceTransaction union does not model it.
|
||||
const rows = mapBalanceTransaction('acct_1', {
|
||||
id: 'txn_tax_1',
|
||||
type: 'tax' as BalanceTxnLike['type'],
|
||||
amount: -1_374,
|
||||
fee: 0,
|
||||
currency: 'sek',
|
||||
created: CREATED,
|
||||
description: 'Automatic Taxes (2026-07-26)',
|
||||
})
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0].description).toBe('Stripe: Automatic Taxes (2026-07-26)')
|
||||
expect(rows[0].transaction_method).toBe('fee')
|
||||
})
|
||||
|
||||
it('dates rows on created, not available_on semantics', () => {
|
||||
// created is the only date input: a mapped row for a txn created on the
|
||||
// 10th must land on the 10th even though Stripe settles days later.
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ingestTransactions } from '@/lib/transactions/ingest'
|
||||
import { ensureManualCashAccount } from '@/lib/cash-accounts/service'
|
||||
import { syncMappedAccounts } from '@/lib/import/account-sync'
|
||||
import { createLogger, type Logger } from '@/lib/logger'
|
||||
import type { RawTransaction } from '@/types'
|
||||
import type { RawTransaction, TransactionMethod } from '@/types'
|
||||
import { connectedAccountOptions, isRevokedConnectionError } from './connect'
|
||||
import type { StripeConnection } from '../types'
|
||||
|
||||
@@ -156,6 +156,37 @@ function describeBalanceTxn(txn: BalanceTxnLike): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Payment rail per balance-transaction type: Stripe's `type` is a structured
|
||||
* discriminator, so the feed sets transaction_method explicitly instead of
|
||||
* letting the ingest boundary guess from the description string. Charges and
|
||||
* their refunds travel the card rail; payouts are transfers to the bank
|
||||
* account; Stripe's own billing/tax deductions are fees; disputes surface as
|
||||
* adjustments. Unknown types return null (unclassified).
|
||||
*/
|
||||
function methodForBalanceTxn(txn: BalanceTxnLike): TransactionMethod | null {
|
||||
// Widened to string: live Stripe sends types the SDK union doesn't model
|
||||
// (e.g. 'tax' for automatic-tax deductions).
|
||||
switch (txn.type as string) {
|
||||
case 'charge':
|
||||
case 'payment':
|
||||
case 'refund':
|
||||
case 'payment_refund':
|
||||
case 'payment_failure_refund':
|
||||
return 'card'
|
||||
case 'payout':
|
||||
return 'transfer'
|
||||
case 'stripe_fee':
|
||||
case 'stripe_fx_fee':
|
||||
case 'tax':
|
||||
return 'fee'
|
||||
case 'adjustment':
|
||||
return 'adjustment'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map one balance transaction to its feed row(s): a main row for the gross
|
||||
* amount and, when Stripe deducted a fee, a separate negative fee row. Dates
|
||||
@@ -179,6 +210,7 @@ export function mapBalanceTransaction(
|
||||
currency,
|
||||
external_id: stripeExternalId(stripeAccountId, txn.id),
|
||||
import_source: STRIPE_IMPORT_SOURCE,
|
||||
transaction_method: methodForBalanceTxn(txn),
|
||||
},
|
||||
]
|
||||
if (txn.fee) {
|
||||
@@ -189,6 +221,7 @@ export function mapBalanceTransaction(
|
||||
currency,
|
||||
external_id: stripeFeeExternalId(stripeAccountId, txn.id),
|
||||
import_source: STRIPE_IMPORT_SOURCE,
|
||||
transaction_method: 'fee',
|
||||
})
|
||||
}
|
||||
return rows
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
+18
-1
@@ -2555,7 +2555,24 @@
|
||||
"edit_title_original_hint": "Original bank name: {name}.",
|
||||
"edit_title_restore": "Restore",
|
||||
"edit_title_cancel": "Cancel",
|
||||
"edit_title_save": "Save"
|
||||
"edit_title_save": "Save",
|
||||
"method_line": "Payment method: {method}"
|
||||
},
|
||||
"tx_method": {
|
||||
"card": "Card purchase",
|
||||
"transfer": "Transfer",
|
||||
"bankgiro": "Bankgiro payment",
|
||||
"plusgiro": "Plusgiro payment",
|
||||
"swish": "Swish",
|
||||
"autogiro": "Direct debit",
|
||||
"e_invoice": "E-invoice",
|
||||
"international": "International payment",
|
||||
"deposit": "Deposit",
|
||||
"withdrawal": "Withdrawal",
|
||||
"salary": "Salary",
|
||||
"fee": "Fee",
|
||||
"interest": "Interest",
|
||||
"adjustment": "Adjustment"
|
||||
},
|
||||
"tx_quick_review": {
|
||||
"open_attached_failed": "Could not open the receipt",
|
||||
|
||||
+18
-1
@@ -2555,7 +2555,24 @@
|
||||
"edit_title_original_hint": "Bankens originalnamn: {name}.",
|
||||
"edit_title_restore": "Återställ",
|
||||
"edit_title_cancel": "Avbryt",
|
||||
"edit_title_save": "Spara"
|
||||
"edit_title_save": "Spara",
|
||||
"method_line": "Betalsätt: {method}"
|
||||
},
|
||||
"tx_method": {
|
||||
"card": "Kortköp",
|
||||
"transfer": "Överföring",
|
||||
"bankgiro": "Bankgirobetalning",
|
||||
"plusgiro": "Plusgirobetalning",
|
||||
"swish": "Swish",
|
||||
"autogiro": "Autogiro",
|
||||
"e_invoice": "E-faktura",
|
||||
"international": "Utlandsbetalning",
|
||||
"deposit": "Insättning",
|
||||
"withdrawal": "Uttag",
|
||||
"salary": "Lön",
|
||||
"fee": "Avgift",
|
||||
"interest": "Ränta",
|
||||
"adjustment": "Justering"
|
||||
},
|
||||
"tx_quick_review": {
|
||||
"open_attached_failed": "Kunde inte öppna underlaget",
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
-- Structured transaction-method metadata on bank/feed transactions.
|
||||
--
|
||||
-- Until now the payment channel lived only inside the description string
|
||||
-- ("Vercel Jul Överföring via internet", "ANTHROPIC* CLAUDE SUB SAN FRANCISCO
|
||||
-- Kortköp/uttag"): the PSD2 remittance array is joined into one string at
|
||||
-- conversion, and the ISO 20022 transaction-type codes Enable Banking sends
|
||||
-- (bank_transaction_code / proprietary_bank_transaction_code) were mapped in
|
||||
-- TS and dropped at INSERT (dev_docs/data_quality_master.md, Appendix B,
|
||||
-- "Layer-A capture"). This migration promotes the channel to queryable columns
|
||||
-- so downstream logic can branch on it (a card purchase implies a physical
|
||||
-- receipt exists; an e-invoice or Bankgiro payment implies a supplier invoice)
|
||||
-- and the UI can show a clean title with the method as structured detail.
|
||||
--
|
||||
-- transaction_method is a closed vocabulary describing HOW the money moved
|
||||
-- (the payment rail), not what it was for. NULL = not classifiable from the
|
||||
-- source data. The raw code columns preserve the provider evidence verbatim
|
||||
-- so classification can be re-derived or refined later without re-fetching.
|
||||
|
||||
-- ===== 1. Columns =====
|
||||
|
||||
ALTER TABLE public.transactions
|
||||
ADD COLUMN IF NOT EXISTS transaction_method text NULL,
|
||||
ADD COLUMN IF NOT EXISTS bank_transaction_code text NULL,
|
||||
ADD COLUMN IF NOT EXISTS proprietary_bank_transaction_code text NULL;
|
||||
|
||||
-- ===== 2. Closed vocabulary =====
|
||||
|
||||
ALTER TABLE public.transactions
|
||||
DROP CONSTRAINT IF EXISTS transactions_transaction_method_check;
|
||||
ALTER TABLE public.transactions
|
||||
ADD CONSTRAINT transactions_transaction_method_check
|
||||
CHECK (transaction_method IS NULL OR transaction_method IN (
|
||||
'card',
|
||||
'transfer',
|
||||
'bankgiro',
|
||||
'plusgiro',
|
||||
'swish',
|
||||
'autogiro',
|
||||
'e_invoice',
|
||||
'international',
|
||||
'deposit',
|
||||
'withdrawal',
|
||||
'salary',
|
||||
'fee',
|
||||
'interest',
|
||||
'adjustment'
|
||||
));
|
||||
|
||||
-- ===== 3. Column documentation =====
|
||||
|
||||
COMMENT ON COLUMN public.transactions.transaction_method IS
|
||||
'Payment rail the transaction moved on (card, transfer, bankgiro, swish, ...). Classified at ingest from the source''s structured type codes, the Swedish channel phrase in the bank description, or the MCC; NULL when unclassifiable. Mirrored by the TransactionMethod union in types/index.ts and classifyTransactionMethod() in lib/transactions/transaction-method.ts.';
|
||||
COMMENT ON COLUMN public.transactions.bank_transaction_code IS
|
||||
'ISO 20022 bank transaction code from PSD2 (e.g. PMNT-CCRD-POSD), verbatim from Enable Banking. Evidence for transaction_method; previously dropped at insert.';
|
||||
COMMENT ON COLUMN public.transactions.proprietary_bank_transaction_code IS
|
||||
'ASPSP-proprietary transaction code from PSD2, verbatim. Evidence for transaction_method; format varies per bank.';
|
||||
|
||||
-- ===== 4. Reload PostgREST schema cache =====
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,203 @@
|
||||
-- Backfill transaction_method for existing FEED rows and strip the trailing
|
||||
-- channel phrase from unedited feed titles.
|
||||
--
|
||||
-- One-shot, point-in-time backfill: it deliberately duplicates the trailing-
|
||||
-- phrase vocabulary of classifyTransactionMethod() (lib/transactions/
|
||||
-- transaction-method.ts) in SQL. This is NOT a live mirror that must stay in
|
||||
-- sync (contrast normalize_counterparty_key): rows ingested after this
|
||||
-- migration are classified in TS at the ingest boundary.
|
||||
--
|
||||
-- SCOPE: bank/feed rows only: a live bank_connection_id, OR an
|
||||
-- import_source that is present and not manual/mcp (the isImportedTransaction
|
||||
-- predicate from lib/transactions/origin.ts; bank_connection_id covers the
|
||||
-- oldest PSD2 rows that predate the import_source column). User-created rows
|
||||
-- (manual UI adds have import_source NULL or 'manual', MCP/agent rows 'mcp')
|
||||
-- carry user-authored titles like "Egen insättning": classifying or rewriting
|
||||
-- those from a channel vocabulary would corrupt meaning, so they are excluded
|
||||
-- from every statement below. The same invariant holds at ingest.
|
||||
--
|
||||
-- Classification reads coalesce(original_description, description): the
|
||||
-- immutable bank original when present (title edits never touch it), the
|
||||
-- working title for legacy rows predating the column. Historical rows carry
|
||||
-- no ISO codes (they were dropped at insert before this feature), so text is
|
||||
-- the only available signal; MCC and the Stripe feed's own description
|
||||
-- prefixes fill the gaps.
|
||||
--
|
||||
-- Title stripping only touches rows the user has NOT renamed
|
||||
-- (title_edited_at IS NULL), never empties a title (a description that IS
|
||||
-- just the phrase, e.g. a bare "Insättning", is kept), and never strips when
|
||||
-- the remaining title would end in a possessive/scope adjective (the
|
||||
-- adjective guard: "Egen insättning" must not become "Egen"; the method
|
||||
-- classification still applies). Stripping a TRAILING phrase leaves a prefix
|
||||
-- of the original string, so the content-dedup bridge (descriptionsBridge:
|
||||
-- symmetric prefix containment against original_description ?? description)
|
||||
-- still bridges re-imports. Booked rows are included: transactions.description
|
||||
-- is staging/display data, not räkenskapsinformation (see 20260605120000);
|
||||
-- the verifikat text lives on the journal entry.
|
||||
|
||||
-- ===== 1. Classify from the trailing channel phrase (feed rows only) =====
|
||||
-- Most-specific vocabularies first; the generic bare "överföring" runs last.
|
||||
-- Postgres POSIX regexes prefer the longest alternation match, so
|
||||
-- "överföring via internet" always beats "överföring" within one pattern.
|
||||
|
||||
UPDATE public.transactions
|
||||
SET transaction_method = 'card'
|
||||
WHERE transaction_method IS NULL
|
||||
AND (bank_connection_id IS NOT NULL OR (import_source IS NOT NULL AND import_source NOT IN ('manual', 'mcp')))
|
||||
AND coalesce(original_description, description) ~* '(^|[[:space:]])(kortköp/uttag|kortköp|kortbetalning|webbköp)[[:space:]]*$';
|
||||
|
||||
UPDATE public.transactions
|
||||
SET transaction_method = 'bankgiro'
|
||||
WHERE transaction_method IS NULL
|
||||
AND (bank_connection_id IS NOT NULL OR (import_source IS NOT NULL AND import_source NOT IN ('manual', 'mcp')))
|
||||
AND coalesce(original_description, description) ~* '(^|[[:space:]])(bg-bet\. via internet|bg-bet via internet|bg-betalning|bg betalning|bgmax|bg-inb|bankgiro|bg-bet\.?)[[:space:]]*$';
|
||||
|
||||
UPDATE public.transactions
|
||||
SET transaction_method = 'plusgiro'
|
||||
WHERE transaction_method IS NULL
|
||||
AND (bank_connection_id IS NOT NULL OR (import_source IS NOT NULL AND import_source NOT IN ('manual', 'mcp')))
|
||||
AND coalesce(original_description, description) ~* '(^|[[:space:]])(pg-betalning|pg betalning|plusgiro)[[:space:]]*$';
|
||||
|
||||
UPDATE public.transactions
|
||||
SET transaction_method = 'international'
|
||||
WHERE transaction_method IS NULL
|
||||
AND (bank_connection_id IS NOT NULL OR (import_source IS NOT NULL AND import_source NOT IN ('manual', 'mcp')))
|
||||
AND coalesce(original_description, description) ~* '(^|[[:space:]])(europabetalning|utlandsbetalning)[[:space:]]*$';
|
||||
|
||||
UPDATE public.transactions
|
||||
SET transaction_method = 'salary'
|
||||
WHERE transaction_method IS NULL
|
||||
AND (bank_connection_id IS NOT NULL OR (import_source IS NOT NULL AND import_source NOT IN ('manual', 'mcp')))
|
||||
AND coalesce(original_description, description) ~* '(^|[[:space:]])(löneinsättning|lönebetalning|löneutbetalning|lön)[[:space:]]*$';
|
||||
|
||||
UPDATE public.transactions
|
||||
SET transaction_method = 'e_invoice'
|
||||
WHERE transaction_method IS NULL
|
||||
AND (bank_connection_id IS NOT NULL OR (import_source IS NOT NULL AND import_source NOT IN ('manual', 'mcp')))
|
||||
AND coalesce(original_description, description) ~* '(^|[[:space:]])(e-faktura|efaktura)[[:space:]]*$';
|
||||
|
||||
UPDATE public.transactions
|
||||
SET transaction_method = 'swish'
|
||||
WHERE transaction_method IS NULL
|
||||
AND (bank_connection_id IS NOT NULL OR (import_source IS NOT NULL AND import_source NOT IN ('manual', 'mcp')))
|
||||
AND (
|
||||
coalesce(original_description, description) ~* '(^|[[:space:]])(swish-betalning|swish betalning|swish)[[:space:]]*$'
|
||||
OR coalesce(original_description, description) ~* '^swish (till|från)([[:space:]]|$)'
|
||||
);
|
||||
|
||||
UPDATE public.transactions
|
||||
SET transaction_method = 'autogiro'
|
||||
WHERE transaction_method IS NULL
|
||||
AND (bank_connection_id IS NOT NULL OR (import_source IS NOT NULL AND import_source NOT IN ('manual', 'mcp')))
|
||||
AND coalesce(original_description, description) ~* '(^|[[:space:]])(autogirobetalning|autogiro)[[:space:]]*$';
|
||||
|
||||
UPDATE public.transactions
|
||||
SET transaction_method = 'fee'
|
||||
WHERE transaction_method IS NULL
|
||||
AND (bank_connection_id IS NOT NULL OR (import_source IS NOT NULL AND import_source NOT IN ('manual', 'mcp')))
|
||||
AND coalesce(original_description, description) ~* '(^|[[:space:]])(pris betalning|prisbetalning|avgift)[[:space:]]*$';
|
||||
|
||||
UPDATE public.transactions
|
||||
SET transaction_method = 'interest'
|
||||
WHERE transaction_method IS NULL
|
||||
AND (bank_connection_id IS NOT NULL OR (import_source IS NOT NULL AND import_source NOT IN ('manual', 'mcp')))
|
||||
AND coalesce(original_description, description) ~* '(^|[[:space:]])(insättningsränta|ränta)[[:space:]]*$';
|
||||
|
||||
UPDATE public.transactions
|
||||
SET transaction_method = 'deposit'
|
||||
WHERE transaction_method IS NULL
|
||||
AND (bank_connection_id IS NOT NULL OR (import_source IS NOT NULL AND import_source NOT IN ('manual', 'mcp')))
|
||||
AND coalesce(original_description, description) ~* '(^|[[:space:]])(kontantinsättning|insättning)[[:space:]]*$';
|
||||
|
||||
UPDATE public.transactions
|
||||
SET transaction_method = 'withdrawal'
|
||||
WHERE transaction_method IS NULL
|
||||
AND (bank_connection_id IS NOT NULL OR (import_source IS NOT NULL AND import_source NOT IN ('manual', 'mcp')))
|
||||
AND coalesce(original_description, description) ~* '(^|[[:space:]])(bankomatuttag|kontantuttag|uttag)[[:space:]]*$';
|
||||
|
||||
UPDATE public.transactions
|
||||
SET transaction_method = 'transfer'
|
||||
WHERE transaction_method IS NULL
|
||||
AND (bank_connection_id IS NOT NULL OR (import_source IS NOT NULL AND import_source NOT IN ('manual', 'mcp')))
|
||||
AND coalesce(original_description, description) ~* '(^|[[:space:]])(överföring via internet|överföring via mobil|överföring via app|överföring inom banken|överföring inom bank|överföring mellan konton|direktöverföring|direktbetalning|internetbetalning|mobilbetalning|överföring)[[:space:]]*$';
|
||||
|
||||
-- ===== 2. MCC fallback (card rail; 6011 = ATM cash disbursement) =====
|
||||
|
||||
UPDATE public.transactions
|
||||
SET transaction_method = CASE WHEN mcc_code = 6011 THEN 'withdrawal' ELSE 'card' END
|
||||
WHERE transaction_method IS NULL
|
||||
AND (bank_connection_id IS NOT NULL OR (import_source IS NOT NULL AND import_source NOT IN ('manual', 'mcp')))
|
||||
AND mcc_code IS NOT NULL;
|
||||
|
||||
-- ===== 3. Stripe feed rows: the sync's own description prefixes are a =====
|
||||
-- ===== reliable type discriminator (describeBalanceTxn is deterministic) ====
|
||||
|
||||
UPDATE public.transactions
|
||||
SET transaction_method = 'fee'
|
||||
WHERE transaction_method IS NULL
|
||||
AND import_source = 'stripe'
|
||||
AND (
|
||||
coalesce(original_description, description) LIKE 'Stripe-avgift%'
|
||||
OR coalesce(original_description, description) LIKE 'Stripe: Billing%'
|
||||
OR coalesce(original_description, description) LIKE 'Stripe: Automatic Taxes%'
|
||||
);
|
||||
|
||||
UPDATE public.transactions
|
||||
SET transaction_method = 'card'
|
||||
WHERE transaction_method IS NULL
|
||||
AND import_source = 'stripe'
|
||||
AND (
|
||||
coalesce(original_description, description) LIKE 'Stripe-betalning%'
|
||||
OR coalesce(original_description, description) LIKE 'Stripe-återbetalning%'
|
||||
);
|
||||
|
||||
UPDATE public.transactions
|
||||
SET transaction_method = 'transfer'
|
||||
WHERE transaction_method IS NULL
|
||||
AND import_source = 'stripe'
|
||||
AND coalesce(original_description, description) LIKE 'Stripe-utbetalning%';
|
||||
|
||||
UPDATE public.transactions
|
||||
SET transaction_method = 'adjustment'
|
||||
WHERE transaction_method IS NULL
|
||||
AND import_source = 'stripe'
|
||||
AND (
|
||||
coalesce(original_description, description) LIKE 'Stripe-justering%'
|
||||
OR coalesce(original_description, description) LIKE 'Stripe-tvist%'
|
||||
);
|
||||
|
||||
-- ===== 4. Strip the trailing channel phrase from unedited FEED titles =====
|
||||
-- The union of every trailing vocabulary above. Guarded so a title is never
|
||||
-- emptied, never rewritten to itself, never rewritten on a user-created row,
|
||||
-- and never left ending in a possessive/scope adjective ("Egen insättning"
|
||||
-- keeps its full title; the method column still says deposit).
|
||||
-- original_description keeps the full bank string, so the rewrite is exactly
|
||||
-- reversible and "restore original" still works. That invariant already holds
|
||||
-- on any DB that replayed 20260605120000 (its backfill filled every NULL, and
|
||||
-- ingest writes the column on every insert since), but the strip below
|
||||
-- ENFORCES it rather than assuming it: a row that somehow reached this point
|
||||
-- with original_description NULL gets its pre-strip description preserved in
|
||||
-- the same statement, so the full bank string can never be lost.
|
||||
|
||||
WITH pat AS (
|
||||
SELECT '(^|[[:space:]])(kortköp/uttag|kortköp|kortbetalning|webbköp|bg-bet\. via internet|bg-bet via internet|bg-betalning|bg betalning|bgmax|bg-inb|bankgiro|bg-bet\.?|pg-betalning|pg betalning|plusgiro|europabetalning|utlandsbetalning|löneinsättning|lönebetalning|löneutbetalning|lön|e-faktura|efaktura|swish-betalning|swish betalning|swish|autogirobetalning|autogiro|pris betalning|prisbetalning|avgift|insättningsränta|ränta|kontantinsättning|insättning|bankomatuttag|kontantuttag|uttag|överföring via internet|överföring via mobil|överföring via app|överföring inom banken|överföring inom bank|överföring mellan konton|direktöverföring|direktbetalning|internetbetalning|mobilbetalning|överföring)[[:space:]]*$'::text AS p
|
||||
),
|
||||
stripped AS (
|
||||
SELECT t.id,
|
||||
btrim(regexp_replace(t.description, pat.p, '', 'i')) AS new_desc
|
||||
FROM public.transactions t, pat
|
||||
WHERE t.title_edited_at IS NULL
|
||||
AND (t.bank_connection_id IS NOT NULL OR (t.import_source IS NOT NULL AND t.import_source NOT IN ('manual', 'mcp')))
|
||||
AND t.description ~* pat.p
|
||||
)
|
||||
UPDATE public.transactions t
|
||||
SET description = s.new_desc,
|
||||
original_description = coalesce(t.original_description, t.description)
|
||||
FROM stripped s
|
||||
WHERE t.id = s.id
|
||||
AND s.new_desc <> ''
|
||||
AND s.new_desc <> t.description
|
||||
-- Adjective guard: the last remaining word must not be a possessive/scope
|
||||
-- adjective whose meaning depended on the stripped noun.
|
||||
AND lower(regexp_replace(s.new_desc, '^.*[[:space:]]', '')) NOT IN
|
||||
('egen', 'eget', 'egna', 'privat', 'privata', 'intern', 'interna', 'extern', 'externa');
|
||||
@@ -196,6 +196,9 @@ export function makeTransaction(overrides: Partial<Transaction> = {}): Transacti
|
||||
journal_entry_id: null,
|
||||
mcc_code: null,
|
||||
merchant_name: 'ICA Maxi',
|
||||
transaction_method: null,
|
||||
bank_transaction_code: null,
|
||||
proprietary_bank_transaction_code: null,
|
||||
reconciliation_method: null,
|
||||
is_ignored: false,
|
||||
receipt_id: null,
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { TransactionMethod } from '@/types'
|
||||
import { seedCompany, insertTransaction } from '@/tests/pg/fixtures'
|
||||
import { getPool } from '@/tests/pg/setup'
|
||||
|
||||
/**
|
||||
* pg-real coverage for transactions.transaction_method
|
||||
* (20260808090000_transactions_transaction_method.sql + the paired backfill
|
||||
* 20260808090100_..._backfill.sql).
|
||||
*
|
||||
* Locks in:
|
||||
* - The closed-vocabulary CHECK constraint.
|
||||
* - The backfill's trailing-phrase classification (real production strings),
|
||||
* its precedence (word-boundary: "Löneinsättning" is salary, never
|
||||
* deposit), the MCC and Stripe-prefix fallbacks, and the NULL result for
|
||||
* unclassifiable rows.
|
||||
* - FEED-ROW SCOPE: user-created rows (import_source NULL/manual/mcp) are
|
||||
* never classified and never rewritten ("Egen insättning" stays intact).
|
||||
* - Title stripping: unedited titles lose the trailing channel phrase but
|
||||
* are never emptied; user-edited titles are untouched even when the
|
||||
* original carries a phrase (classification still fires off the original);
|
||||
* the adjective guard keeps "Egen insättning" whole even on feed rows.
|
||||
* - Idempotence: a second run changes nothing.
|
||||
*/
|
||||
|
||||
// Run the real backfill migration SQL so the test exercises exactly what
|
||||
// ships, not a re-implementation.
|
||||
const BACKFILL_SQL = readFileSync(
|
||||
join(
|
||||
process.cwd(),
|
||||
'supabase/migrations/20260808090100_transactions_transaction_method_backfill.sql',
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
async function runBackfill(): Promise<void> {
|
||||
await getPool().query(BACKFILL_SQL)
|
||||
}
|
||||
|
||||
async function insertFeedRow(params: {
|
||||
companyId: string
|
||||
userId: string
|
||||
description: string
|
||||
originalDescription?: string | null
|
||||
titleEditedAt?: string | null
|
||||
/** Defaults to a bank feed; pass null/'manual'/'mcp' for user-created rows. */
|
||||
importSource?: string | null
|
||||
mccCode?: number | null
|
||||
}): Promise<string> {
|
||||
const id = await insertTransaction({
|
||||
companyId: params.companyId,
|
||||
userId: params.userId,
|
||||
description: params.description,
|
||||
})
|
||||
await getPool().query(
|
||||
`UPDATE public.transactions
|
||||
SET original_description = $2,
|
||||
title_edited_at = $3::timestamptz,
|
||||
import_source = $4,
|
||||
mcc_code = $5
|
||||
WHERE id = $1`,
|
||||
[
|
||||
id,
|
||||
params.originalDescription === undefined ? params.description : params.originalDescription,
|
||||
params.titleEditedAt ?? null,
|
||||
params.importSource === undefined ? 'enable_banking' : params.importSource,
|
||||
params.mccCode ?? null,
|
||||
],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
async function getRow(
|
||||
txId: string,
|
||||
): Promise<{ description: string; transaction_method: TransactionMethod | null }> {
|
||||
const { rows } = await getPool().query(
|
||||
`SELECT description, transaction_method FROM public.transactions WHERE id = $1`,
|
||||
[txId],
|
||||
)
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
describe('transactions.transaction_method: CHECK constraint', () => {
|
||||
it('accepts vocabulary values and NULL, rejects anything else', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const tx = await insertTransaction({ companyId, userId })
|
||||
|
||||
await getPool().query(
|
||||
`UPDATE public.transactions SET transaction_method = 'card' WHERE id = $1`,
|
||||
[tx],
|
||||
)
|
||||
await getPool().query(
|
||||
`UPDATE public.transactions SET transaction_method = NULL WHERE id = $1`,
|
||||
[tx],
|
||||
)
|
||||
await expect(
|
||||
getPool().query(
|
||||
`UPDATE public.transactions SET transaction_method = 'bankid' WHERE id = $1`,
|
||||
[tx],
|
||||
),
|
||||
).rejects.toMatchObject({ code: '23514' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('transactions.transaction_method: backfill classification + title strip', () => {
|
||||
it('classifies the real production strings and strips the trailing phrase', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const cases: Array<{
|
||||
description: string
|
||||
method: TransactionMethod
|
||||
stripped: string
|
||||
}> = [
|
||||
{
|
||||
description: 'Vercel Jul Överföring via internet',
|
||||
method: 'transfer',
|
||||
stripped: 'Vercel Jul',
|
||||
},
|
||||
{
|
||||
description: 'ANTHROPIC* CLAUDE SUB SAN FRANCISCO Kortköp/uttag',
|
||||
method: 'card',
|
||||
stripped: 'ANTHROPIC* CLAUDE SUB SAN FRANCISCO',
|
||||
},
|
||||
{
|
||||
description: 'Inbetalning skat BG 0000050501055 Bg-bet. via internet',
|
||||
method: 'bankgiro',
|
||||
stripped: 'Inbetalning skat BG 0000050501055',
|
||||
},
|
||||
{ description: '1260624917587 Europabetalning', method: 'international', stripped: '1260624917587' },
|
||||
{ description: '1260624917587 Pris betalning', method: 'fee', stripped: '1260624917587' },
|
||||
{ description: 'SWED2607270AUEOU Insättning', method: 'deposit', stripped: 'SWED2607270AUEOU' },
|
||||
{ description: 'Lön Juli Emil Överföring via internet', method: 'transfer', stripped: 'Lön Juli Emil' },
|
||||
{ description: 'ACME AB Löneinsättning', method: 'salary', stripped: 'ACME AB' },
|
||||
{ description: 'Swish till Erik Andersson', method: 'swish', stripped: 'Swish till Erik Andersson' },
|
||||
]
|
||||
const ids = []
|
||||
for (const c of cases) {
|
||||
ids.push(await insertFeedRow({ companyId, userId, description: c.description }))
|
||||
}
|
||||
|
||||
await runBackfill()
|
||||
|
||||
for (let i = 0; i < cases.length; i++) {
|
||||
const row = await getRow(ids[i])
|
||||
expect(row.transaction_method).toBe(cases[i].method)
|
||||
expect(row.description).toBe(cases[i].stripped)
|
||||
}
|
||||
})
|
||||
|
||||
it('never touches user-created rows (import_source NULL, manual, mcp)', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const cases = [null, 'manual', 'mcp']
|
||||
const ids: string[] = []
|
||||
for (const src of cases) {
|
||||
// Titles that WOULD classify+strip if they came from a bank feed.
|
||||
ids.push(
|
||||
await insertFeedRow({ companyId, userId, description: 'Egen insättning', importSource: src }),
|
||||
)
|
||||
}
|
||||
|
||||
await runBackfill()
|
||||
|
||||
for (const id of ids) {
|
||||
const row = await getRow(id)
|
||||
expect(row.transaction_method).toBeNull()
|
||||
expect(row.description).toBe('Egen insättning')
|
||||
}
|
||||
})
|
||||
|
||||
it('adjective guard: feed rows classify but keep "Egen insättning"-style titles', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const deposit = await insertFeedRow({ companyId, userId, description: 'Egen insättning' })
|
||||
const withdrawal = await insertFeedRow({ companyId, userId, description: 'Eget uttag' })
|
||||
const transfer = await insertFeedRow({ companyId, userId, description: 'Intern överföring' })
|
||||
|
||||
await runBackfill()
|
||||
|
||||
expect(await getRow(deposit)).toEqual({
|
||||
description: 'Egen insättning',
|
||||
transaction_method: 'deposit',
|
||||
})
|
||||
expect(await getRow(withdrawal)).toEqual({
|
||||
description: 'Eget uttag',
|
||||
transaction_method: 'withdrawal',
|
||||
})
|
||||
expect(await getRow(transfer)).toEqual({
|
||||
description: 'Intern överföring',
|
||||
transaction_method: 'transfer',
|
||||
})
|
||||
})
|
||||
|
||||
it('never empties a title that IS the phrase', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const tx = await insertFeedRow({ companyId, userId, description: 'Insättning' })
|
||||
|
||||
await runBackfill()
|
||||
|
||||
const row = await getRow(tx)
|
||||
expect(row.transaction_method).toBe('deposit')
|
||||
expect(row.description).toBe('Insättning')
|
||||
})
|
||||
|
||||
it('classifies an edited row from the original but leaves the edited title alone', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const tx = await insertFeedRow({
|
||||
companyId,
|
||||
userId,
|
||||
description: 'Serverhyra juli Överföring via internet',
|
||||
originalDescription: 'Hetzner GmbH Överföring via internet',
|
||||
titleEditedAt: '2026-07-01T10:00:00Z',
|
||||
})
|
||||
|
||||
await runBackfill()
|
||||
|
||||
const row = await getRow(tx)
|
||||
expect(row.transaction_method).toBe('transfer')
|
||||
// User-edited titles are never rewritten, even when they end in a phrase.
|
||||
expect(row.description).toBe('Serverhyra juli Överföring via internet')
|
||||
})
|
||||
|
||||
it('falls back to the legacy description when original_description is NULL', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const tx = await insertFeedRow({
|
||||
companyId,
|
||||
userId,
|
||||
description: 'TELIA AB Autogiro',
|
||||
originalDescription: null,
|
||||
})
|
||||
|
||||
await runBackfill()
|
||||
|
||||
const row = await getRow(tx)
|
||||
expect(row.transaction_method).toBe('autogiro')
|
||||
expect(row.description).toBe('TELIA AB')
|
||||
|
||||
// The strip must preserve the only copy of the full bank string: a legacy
|
||||
// NULL original_description is filled from the pre-strip description in
|
||||
// the same statement, never lost.
|
||||
const { rows } = await getPool().query(
|
||||
`SELECT original_description FROM public.transactions WHERE id = $1`,
|
||||
[tx],
|
||||
)
|
||||
expect(rows[0].original_description).toBe('TELIA AB Autogiro')
|
||||
})
|
||||
|
||||
it('uses MCC presence as the card-rail fallback (6011 = withdrawal)', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const card = await insertFeedRow({
|
||||
companyId,
|
||||
userId,
|
||||
description: 'COOP KONSUM STOCKHOLM',
|
||||
mccCode: 5411,
|
||||
})
|
||||
const atm = await insertFeedRow({
|
||||
companyId,
|
||||
userId,
|
||||
description: 'BANKOMAT VASAGATAN',
|
||||
mccCode: 6011,
|
||||
})
|
||||
|
||||
await runBackfill()
|
||||
|
||||
expect((await getRow(card)).transaction_method).toBe('card')
|
||||
expect((await getRow(atm)).transaction_method).toBe('withdrawal')
|
||||
// No phrase → the title is untouched.
|
||||
expect((await getRow(card)).description).toBe('COOP KONSUM STOCKHOLM')
|
||||
})
|
||||
|
||||
it('classifies Stripe feed rows from their deterministic prefixes', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const mk = (description: string) =>
|
||||
insertFeedRow({ companyId, userId, description, importSource: 'stripe' })
|
||||
const fee = await mk('Stripe-avgift (Stripe-betalning Carl)')
|
||||
const usage = await mk('Stripe: Billing - Usage Fee (2026-07-26)')
|
||||
const charge = await mk('Stripe-betalning Fredrik Schöön')
|
||||
const payout = await mk('Stripe-utbetalning po_1')
|
||||
|
||||
await runBackfill()
|
||||
|
||||
expect((await getRow(fee)).transaction_method).toBe('fee')
|
||||
expect((await getRow(usage)).transaction_method).toBe('fee')
|
||||
expect((await getRow(charge)).transaction_method).toBe('card')
|
||||
expect((await getRow(payout)).transaction_method).toBe('transfer')
|
||||
})
|
||||
|
||||
it('leaves unclassifiable rows NULL and untouched, and is idempotent', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const plain = await insertFeedRow({ companyId, userId, description: 'Okänd transaktion' })
|
||||
const phrased = await insertFeedRow({
|
||||
companyId,
|
||||
userId,
|
||||
description: 'Vercel Jul Överföring via internet',
|
||||
})
|
||||
// Pre-classified rows must not be reclassified.
|
||||
const preset = await insertFeedRow({ companyId, userId, description: 'X Kortköp' })
|
||||
await getPool().query(
|
||||
`UPDATE public.transactions SET transaction_method = 'swish' WHERE id = $1`,
|
||||
[preset],
|
||||
)
|
||||
|
||||
await runBackfill()
|
||||
const first = [await getRow(plain), await getRow(phrased), await getRow(preset)]
|
||||
await runBackfill()
|
||||
const second = [await getRow(plain), await getRow(phrased), await getRow(preset)]
|
||||
|
||||
expect(first[0]).toEqual({ description: 'Okänd transaktion', transaction_method: null })
|
||||
expect(first[1]).toEqual({ description: 'Vercel Jul', transaction_method: 'transfer' })
|
||||
expect(first[2].transaction_method).toBe('swish')
|
||||
expect(second).toEqual(first)
|
||||
})
|
||||
})
|
||||
@@ -525,6 +525,31 @@ export type ImportSource =
|
||||
| 'camt053'
|
||||
| 'manual'
|
||||
|
||||
/**
|
||||
* Closed vocabulary for HOW money moved (the payment rail), classified at
|
||||
* ingest by classifyTransactionMethod() (lib/transactions/transaction-method.ts).
|
||||
* Mirrored by the transactions_transaction_method_check DB constraint
|
||||
* (migration 20260808090000): keep the three in sync when adding a value.
|
||||
*/
|
||||
export const TRANSACTION_METHODS = [
|
||||
'card',
|
||||
'transfer',
|
||||
'bankgiro',
|
||||
'plusgiro',
|
||||
'swish',
|
||||
'autogiro',
|
||||
'e_invoice',
|
||||
'international',
|
||||
'deposit',
|
||||
'withdrawal',
|
||||
'salary',
|
||||
'fee',
|
||||
'interest',
|
||||
'adjustment',
|
||||
] as const
|
||||
|
||||
export type TransactionMethod = (typeof TRANSACTION_METHODS)[number]
|
||||
|
||||
// Transaction
|
||||
export interface Transaction {
|
||||
id: string
|
||||
@@ -580,6 +605,14 @@ export interface Transaction {
|
||||
mcc_code: number | null
|
||||
merchant_name: string | null
|
||||
|
||||
// Payment rail classified at ingest (or by the 20260808090100 backfill);
|
||||
// null = unclassifiable from the source data.
|
||||
transaction_method: TransactionMethod | null
|
||||
// Raw PSD2 transaction-type codes, verbatim provider evidence for the
|
||||
// classification (previously dropped at insert). Null for non-PSD2 sources.
|
||||
bank_transaction_code: string | null
|
||||
proprietary_bank_transaction_code: string | null
|
||||
|
||||
// Receipt link
|
||||
receipt_id: string | null
|
||||
|
||||
@@ -3756,6 +3789,16 @@ export interface RawTransaction {
|
||||
* doesn't accidentally collide BG numbers with IBAN strings.
|
||||
*/
|
||||
counterparty_account?: string | null
|
||||
/**
|
||||
* Payment rail the source already knows structurally (e.g. the Stripe feed's
|
||||
* balance-transaction type). Beats every ingest-side heuristic; leave unset
|
||||
* to let classifyTransactionMethod() derive it from codes/description/MCC.
|
||||
*/
|
||||
transaction_method?: TransactionMethod | null
|
||||
/** ISO 20022 bank transaction code from PSD2, verbatim (e.g. PMNT-CCRD-POSD). */
|
||||
bank_transaction_code?: string | null
|
||||
/** ASPSP-proprietary transaction code from PSD2, verbatim. */
|
||||
proprietary_bank_transaction_code?: string | null
|
||||
}
|
||||
|
||||
/** Options for the transaction ingestion pipeline */
|
||||
|
||||
Reference in New Issue
Block a user