* 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>
1803 lines
68 KiB
TypeScript
1803 lines
68 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import {
|
|
createMockSupabase,
|
|
createQueuedMockSupabase,
|
|
makeTransaction,
|
|
makeCategorizationTemplate,
|
|
makeSIEVoucher,
|
|
} from '@/tests/helpers'
|
|
import {
|
|
normalizeCounterpartyName,
|
|
calculateConfidence,
|
|
findCounterpartyTemplate,
|
|
buildMappingResultFromCounterpartyTemplate,
|
|
upsertCounterpartyTemplate,
|
|
resolveSource,
|
|
insertOrUpdateTemplate,
|
|
populateTemplatesFromSieVouchers,
|
|
} from '../counterparty-templates'
|
|
import { buildTransactionEntryLines } from '../transaction-entries'
|
|
import { roundOre } from '@/lib/money'
|
|
import type { TemplateUpsertParams } from '../counterparty-templates'
|
|
import type { LinePatternEntry } from '@/types'
|
|
import type { SIETransactionLine } from '@/lib/import/types'
|
|
|
|
describe('counterparty-templates', () => {
|
|
// ── Normalization ──────────────────────────────────────────
|
|
|
|
describe('normalizeCounterpartyName', () => {
|
|
it('strips KORTKÖP prefix', () => {
|
|
expect(normalizeCounterpartyName('KORTKÖP ICA MAXI')).toBe('ica maxi')
|
|
})
|
|
|
|
it('strips SWISH prefix', () => {
|
|
expect(normalizeCounterpartyName('SWISH ANDERS JOHANSSON')).toBe('anders johansson')
|
|
})
|
|
|
|
it('strips BANKGIRO prefix', () => {
|
|
expect(normalizeCounterpartyName('BANKGIRO TELIA SVERIGE AB')).toBe('telia sverige')
|
|
})
|
|
|
|
it('strips AUTOGIRO prefix', () => {
|
|
expect(normalizeCounterpartyName('AUTOGIRO FOLKSAM')).toBe('folksam')
|
|
})
|
|
|
|
it('strips trailing dates (YYYYMMDD)', () => {
|
|
expect(normalizeCounterpartyName('ICA MAXI 20240615')).toBe('ica maxi')
|
|
})
|
|
|
|
it('strips trailing dates (YYYY-MM-DD)', () => {
|
|
expect(normalizeCounterpartyName('TELIA 2024-06-15')).toBe('telia')
|
|
})
|
|
|
|
it('strips inline dates', () => {
|
|
expect(normalizeCounterpartyName('SPOTIFY 20240615 PREMIUM')).toBe('spotify premium')
|
|
})
|
|
|
|
it('strips invoice references', () => {
|
|
expect(normalizeCounterpartyName('LEVERANTÖR F2024001')).toBe('leverantör')
|
|
})
|
|
|
|
it('strips trailing digit sequences (4+)', () => {
|
|
expect(normalizeCounterpartyName('CLAS OHLSON 12345')).toBe('clas ohlson')
|
|
})
|
|
|
|
it('strips Swedish company suffixes (AB, HB, KB)', () => {
|
|
expect(normalizeCounterpartyName('SPOTIFY AB')).toBe('spotify')
|
|
})
|
|
|
|
it('handles combined prefixes and dates', () => {
|
|
expect(normalizeCounterpartyName('KORTKÖP TELIA SVERIGE AB 20240615')).toBe('telia sverige')
|
|
})
|
|
|
|
it('preserves meaningful content', () => {
|
|
expect(normalizeCounterpartyName('HEMKÖP LINNÉ')).toBe('hemköp linné')
|
|
})
|
|
|
|
it('collapses trailing personal initials and month labels to one merchant', () => {
|
|
// Regression: three identical ngrok bookings ("ngrok JW", "Ngrok Mars",
|
|
// "ngrok JW") splintered into separate counterparty names, so matching
|
|
// never learned. They must all normalize to the same canonical merchant.
|
|
expect(normalizeCounterpartyName('ngrok JW')).toBe('ngrok')
|
|
expect(normalizeCounterpartyName('Ngrok Mars')).toBe('ngrok')
|
|
expect(normalizeCounterpartyName('SPOTIFY januari')).toBe('spotify')
|
|
expect(normalizeCounterpartyName('ICA MAXI AK')).toBe('ica maxi')
|
|
})
|
|
|
|
it('does not strip multi-letter trailing words or 3+ letter brands', () => {
|
|
// Conservative guard: only 1-2 char all-caps initials and month tokens go.
|
|
expect(normalizeCounterpartyName('SWISH ANDERS JOHANSSON')).toBe('anders johansson')
|
|
expect(normalizeCounterpartyName('NORDEA SEB')).toBe('nordea seb') // SEB is 3 chars: kept
|
|
expect(normalizeCounterpartyName('KLARNA')).toBe('klarna') // single token kept
|
|
})
|
|
|
|
it('reduces card-network descriptors to the merchant segment', () => {
|
|
// The Anthropic splinter bug: the per-charge tail after '*' (product,
|
|
// ref, city) changes between charges, so keeping it makes every
|
|
// recurring foreign SaaS subscription a new counterparty each month.
|
|
expect(normalizeCounterpartyName('ANTHROPIC* CLAUDE SUB SAN FRANCISCO')).toBe('anthropic')
|
|
expect(normalizeCounterpartyName('ANTHROPIC*CLAUDE SUB +14155551234')).toBe('anthropic')
|
|
expect(normalizeCounterpartyName('AMZN MKTP SE*A12B34CD5')).toBe('amzn mktp')
|
|
})
|
|
|
|
it('keeps the merchant AFTER the star for processor descriptors', () => {
|
|
expect(normalizeCounterpartyName('PAYPAL *SPOTIFY')).toBe('spotify')
|
|
expect(normalizeCounterpartyName('SQ *BLUE BOTTLE COFFEE')).toBe('blue bottle coffee')
|
|
expect(normalizeCounterpartyName('KLARNA*BOOZT FASHION')).toBe('boozt fashion')
|
|
})
|
|
})
|
|
|
|
// ── Confidence ─────────────────────────────────────────────
|
|
|
|
describe('calculateConfidence', () => {
|
|
it('returns ~0.45 for occurrence_count = 1', () => {
|
|
const c = calculateConfidence(1)
|
|
expect(c).toBeCloseTo(0.45, 1)
|
|
})
|
|
|
|
it('grows logarithmically', () => {
|
|
const c1 = calculateConfidence(1)
|
|
const c5 = calculateConfidence(5)
|
|
const c10 = calculateConfidence(10)
|
|
expect(c5).toBeGreaterThan(c1)
|
|
expect(c10).toBeGreaterThan(c5)
|
|
// Growth should slow down
|
|
expect(c10 - c5).toBeLessThan(c5 - c1)
|
|
})
|
|
|
|
it('caps at 0.95', () => {
|
|
expect(calculateConfidence(100)).toBe(0.95)
|
|
expect(calculateConfidence(1000)).toBe(0.95)
|
|
})
|
|
|
|
it('never exceeds 0.95', () => {
|
|
for (let i = 1; i <= 50; i++) {
|
|
expect(calculateConfidence(i)).toBeLessThanOrEqual(0.95)
|
|
}
|
|
})
|
|
})
|
|
|
|
// ── Lookup ─────────────────────────────────────────────────
|
|
|
|
describe('findCounterpartyTemplate', () => {
|
|
it('returns null for transaction without merchant name', async () => {
|
|
const { supabase } = createMockSupabase()
|
|
const tx = makeTransaction({ merchant_name: null, description: '', original_description: null })
|
|
const result = await findCounterpartyTemplate(supabase as never, 'user-1', tx)
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
it('returns exact alias match with full confidence', async () => {
|
|
const template = makeCategorizationTemplate({
|
|
confidence: 0.8,
|
|
counterparty_aliases: ['telia sverige ab'],
|
|
})
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
|
|
// Batch query returns all templates
|
|
enqueue({ data: [template] })
|
|
|
|
const tx = makeTransaction({ merchant_name: 'Telia Sverige AB' })
|
|
const result = await findCounterpartyTemplate(supabase as never, 'user-1', tx)
|
|
|
|
expect(result).not.toBeNull()
|
|
expect(result!.matchMethod).toBe('exact_alias')
|
|
expect(result!.confidence).toBe(0.8)
|
|
})
|
|
|
|
it('falls through to exact normalized when alias misses', async () => {
|
|
const template = makeCategorizationTemplate({
|
|
counterparty_name: 'telia',
|
|
confidence: 0.8,
|
|
counterparty_aliases: ['telia ab'],
|
|
})
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
|
|
// Batch query returns all templates (alias won't match 'Telia')
|
|
enqueue({ data: [template] })
|
|
|
|
const tx = makeTransaction({ merchant_name: 'Telia' })
|
|
const result = await findCounterpartyTemplate(supabase as never, 'user-1', tx)
|
|
|
|
expect(result).not.toBeNull()
|
|
expect(result!.matchMethod).toBe('exact_normalized')
|
|
expect(result!.confidence).toBeCloseTo(0.76, 1) // 0.8 * 0.95
|
|
})
|
|
|
|
it('falls through to fuzzy match within Levenshtein threshold', async () => {
|
|
const template = makeCategorizationTemplate({
|
|
counterparty_name: 'telia',
|
|
confidence: 0.8,
|
|
counterparty_aliases: ['telia ab'],
|
|
})
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
|
|
// Batch query returns all templates
|
|
enqueue({ data: [template] })
|
|
|
|
// "teliq" has Levenshtein distance 1 from "telia"
|
|
const tx = makeTransaction({ merchant_name: 'Teliq' })
|
|
const result = await findCounterpartyTemplate(supabase as never, 'user-1', tx)
|
|
|
|
expect(result).not.toBeNull()
|
|
expect(result!.matchMethod).toBe('fuzzy')
|
|
expect(result!.confidence).toBeLessThan(0.8)
|
|
expect(result!.confidence).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('returns null when fuzzy match exceeds threshold', async () => {
|
|
const template = makeCategorizationTemplate({
|
|
counterparty_name: 'telia',
|
|
confidence: 0.8,
|
|
counterparty_aliases: ['telia ab'],
|
|
})
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
|
|
// Batch query returns all templates
|
|
enqueue({ data: [template] })
|
|
|
|
// "xxxxx" has Levenshtein distance 5 from "telia"
|
|
const tx = makeTransaction({ merchant_name: 'XXXXX' })
|
|
const result = await findCounterpartyTemplate(supabase as never, 'user-1', tx)
|
|
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
it('returns null when no templates exist', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
|
|
// Batch query returns empty
|
|
enqueue({ data: [] })
|
|
|
|
const tx = makeTransaction({ merchant_name: 'Unknown Company' })
|
|
const result = await findCounterpartyTemplate(supabase as never, 'user-1', tx)
|
|
|
|
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
|
|
// though the strings differ by whole words, not typos.
|
|
const template = makeCategorizationTemplate({
|
|
counterparty_name: 'claude',
|
|
confidence: 0.8,
|
|
counterparty_aliases: ['claude dec'],
|
|
})
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: [template] })
|
|
|
|
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)
|
|
|
|
expect(result).not.toBeNull()
|
|
expect(result!.matchMethod).toBe('token_subset')
|
|
expect(result!.confidence).toBeCloseTo(0.68, 2) // 0.8 * 0.85
|
|
})
|
|
|
|
it('token-subset matches a card-core descriptor against a legacy splintered template', async () => {
|
|
// Templates learned before card-core normalization stored the full
|
|
// descriptor; the new normalized core must still find them.
|
|
const template = makeCategorizationTemplate({
|
|
counterparty_name: 'anthropic claude sub san francisco',
|
|
confidence: 0.7,
|
|
counterparty_aliases: [],
|
|
})
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: [template] })
|
|
|
|
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)
|
|
|
|
expect(result).not.toBeNull()
|
|
expect(result!.matchMethod).toBe('token_subset')
|
|
})
|
|
|
|
it('suppresses single-token matches without booking history behind them', async () => {
|
|
// A template named after a common first name (one prior booking) must
|
|
// not vacuum up an unrelated SWISH transfer that shares the name.
|
|
const template = makeCategorizationTemplate({
|
|
counterparty_name: 'anders',
|
|
confidence: 0.5,
|
|
occurrence_count: 1,
|
|
counterparty_aliases: [],
|
|
})
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: [template] })
|
|
|
|
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()
|
|
})
|
|
|
|
it('multi-token agreement matches without an occurrence floor', async () => {
|
|
const template = makeCategorizationTemplate({
|
|
counterparty_name: 'blue bottle',
|
|
confidence: 0.5,
|
|
occurrence_count: 1,
|
|
counterparty_aliases: [],
|
|
})
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: [template] })
|
|
|
|
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)
|
|
|
|
expect(result).not.toBeNull()
|
|
expect(result!.matchMethod).toBe('token_subset')
|
|
})
|
|
|
|
it('never token-matches on generic tokens alone', async () => {
|
|
// A template whose name is all generic/geo words must not vacuum up
|
|
// every descriptor mentioning them.
|
|
const template = makeCategorizationTemplate({
|
|
counterparty_name: 'sverige',
|
|
confidence: 0.9,
|
|
counterparty_aliases: [],
|
|
})
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: [template] })
|
|
|
|
const tx = makeTransaction({ merchant_name: 'IKEA SVERIGE STOCKHOLM' })
|
|
const result = await findCounterpartyTemplate(supabase as never, 'user-1', tx)
|
|
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
it('prefers exact normalized over token subset', async () => {
|
|
const exact = makeCategorizationTemplate({
|
|
id: 'tmpl-exact',
|
|
counterparty_name: 'anthropic',
|
|
confidence: 0.8,
|
|
counterparty_aliases: [],
|
|
})
|
|
const tokenish = makeCategorizationTemplate({
|
|
id: 'tmpl-token',
|
|
counterparty_name: 'claude',
|
|
confidence: 0.8,
|
|
counterparty_aliases: [],
|
|
})
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: [tokenish, exact] })
|
|
|
|
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)
|
|
|
|
expect(result).not.toBeNull()
|
|
expect(result!.matchMethod).toBe('exact_normalized')
|
|
expect(result!.template.id).toBe('tmpl-exact')
|
|
})
|
|
})
|
|
|
|
// ── Build MappingResult ────────────────────────────────────
|
|
|
|
describe('buildMappingResultFromCounterpartyTemplate', () => {
|
|
it('builds correct MappingResult for expense with VAT', () => {
|
|
const template = makeCategorizationTemplate({
|
|
debit_account: '6200',
|
|
credit_account: '1930',
|
|
vat_treatment: 'standard_25',
|
|
occurrence_count: 10,
|
|
})
|
|
const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.85 }
|
|
const tx = makeTransaction({ amount: -1250 })
|
|
|
|
const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'enskild_firma')
|
|
|
|
expect(result.debit_account).toBe('6200')
|
|
expect(result.credit_account).toBe('1930')
|
|
expect(result.confidence).toBe(0.85)
|
|
expect(result.vat_lines.length).toBe(1)
|
|
expect(result.vat_lines[0].account_number).toBe('2641')
|
|
expect(result.vat_lines[0].debit_amount).toBeGreaterThan(0)
|
|
expect(result.rule).toBeNull()
|
|
expect(result.description).toContain('telia')
|
|
expect(result.description).toContain('10 ggr')
|
|
})
|
|
|
|
it('builds correct MappingResult for expense without VAT', () => {
|
|
const template = makeCategorizationTemplate({
|
|
debit_account: '6570',
|
|
credit_account: '1930',
|
|
vat_treatment: null,
|
|
})
|
|
const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.9 }
|
|
const tx = makeTransaction({ amount: -50 })
|
|
|
|
const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'enskild_firma')
|
|
|
|
expect(result.debit_account).toBe('6570')
|
|
expect(result.vat_lines).toHaveLength(0)
|
|
})
|
|
|
|
it('builds correct MappingResult for reverse charge', () => {
|
|
const template = makeCategorizationTemplate({
|
|
debit_account: '6540',
|
|
credit_account: '1930',
|
|
vat_treatment: 'reverse_charge',
|
|
})
|
|
const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.8 }
|
|
const tx = makeTransaction({ amount: -5000 })
|
|
|
|
const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'aktiebolag')
|
|
|
|
expect(result.vat_lines.length).toBe(2)
|
|
expect(result.vat_lines.some(l => l.account_number === '2645')).toBe(true)
|
|
})
|
|
|
|
it('does not generate VAT lines for income transactions', () => {
|
|
const template = makeCategorizationTemplate({
|
|
debit_account: '1930',
|
|
credit_account: '3001',
|
|
vat_treatment: 'standard_25',
|
|
})
|
|
const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.8 }
|
|
const tx = makeTransaction({ amount: 10000 })
|
|
|
|
const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'enskild_firma')
|
|
|
|
expect(result.vat_lines).toHaveLength(0)
|
|
})
|
|
|
|
it('detects private accounts', () => {
|
|
const template = makeCategorizationTemplate({
|
|
debit_account: '2013',
|
|
credit_account: '1930',
|
|
})
|
|
const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.8 }
|
|
const tx = makeTransaction({ amount: -500 })
|
|
|
|
const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'enskild_firma')
|
|
|
|
expect(result.default_private).toBe(true)
|
|
})
|
|
})
|
|
|
|
// ── Upsert ─────────────────────────────────────────────────
|
|
|
|
describe('upsertCounterpartyTemplate', () => {
|
|
const mappingResult = {
|
|
rule: null,
|
|
debit_account: '5410',
|
|
credit_account: '1930',
|
|
risk_level: 'NONE' as const,
|
|
confidence: 0.9,
|
|
requires_review: false,
|
|
default_private: false,
|
|
vat_lines: [],
|
|
description: 'Test',
|
|
}
|
|
|
|
it('inserts new template for unknown counterparty', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
const tx = makeTransaction({ merchant_name: 'New Company AB', date: '2024-06-15' })
|
|
|
|
enqueue({ data: null }) // No existing template
|
|
enqueue({ data: null }) // Insert succeeds
|
|
|
|
await upsertCounterpartyTemplate(
|
|
supabase as never, 'user-1', tx, mappingResult, 'user_approved'
|
|
)
|
|
|
|
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' })
|
|
|
|
enqueue({ data: null }) // No existing
|
|
enqueue({ error: { message: 'constraint violation' } })
|
|
|
|
// Should not throw
|
|
await upsertCounterpartyTemplate(
|
|
supabase as never, 'user-1', tx, mappingResult, 'user_approved'
|
|
)
|
|
})
|
|
|
|
it('updates existing template on re-approval', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
const existing = makeCategorizationTemplate({
|
|
debit_account: '5410',
|
|
credit_account: '1930',
|
|
occurrence_count: 3,
|
|
counterparty_aliases: ['ica maxi'],
|
|
})
|
|
const tx = makeTransaction({ merchant_name: 'ICA Maxi', date: '2024-07-01' })
|
|
|
|
enqueue({ data: existing }) // Existing found
|
|
enqueue({ data: null }) // Update succeeds
|
|
|
|
await upsertCounterpartyTemplate(
|
|
supabase as never, 'user-1', tx, mappingResult, 'user_approved'
|
|
)
|
|
|
|
expect(supabase.from).toHaveBeenCalledWith('categorization_templates')
|
|
})
|
|
|
|
it('resets occurrence_count on correction', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
const existing = makeCategorizationTemplate({
|
|
debit_account: '6200', // Different from mappingResult's 5410
|
|
credit_account: '1930',
|
|
occurrence_count: 10,
|
|
confidence: 0.85,
|
|
})
|
|
const tx = makeTransaction({ merchant_name: 'Telia Sverige AB', date: '2024-07-01' })
|
|
|
|
enqueue({ data: existing }) // Existing found (different accounts = correction)
|
|
enqueue({ data: null }) // Update succeeds
|
|
|
|
await upsertCounterpartyTemplate(
|
|
supabase as never, 'user-1', tx, mappingResult, 'user_approved'
|
|
)
|
|
|
|
expect(supabase.from).toHaveBeenCalledWith('categorization_templates')
|
|
})
|
|
|
|
it('skips upsert for transactions without merchant name', async () => {
|
|
const { supabase } = createQueuedMockSupabase()
|
|
const tx = makeTransaction({ merchant_name: null, description: '', original_description: null })
|
|
|
|
await upsertCounterpartyTemplate(
|
|
supabase as never, 'user-1', tx, mappingResult, 'user_approved'
|
|
)
|
|
|
|
expect(supabase.from).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
// ── Source Priority ──────────────────────────────────────────
|
|
|
|
describe('resolveSource', () => {
|
|
it('sie_import does not downgrade user_approved', () => {
|
|
expect(resolveSource('user_approved', 'sie_import')).toBe('user_approved')
|
|
})
|
|
|
|
it('user_approved upgrades auto_learned', () => {
|
|
expect(resolveSource('auto_learned', 'user_approved')).toBe('user_approved')
|
|
})
|
|
|
|
it('sie_import upgrades auto_learned', () => {
|
|
expect(resolveSource('auto_learned', 'sie_import')).toBe('sie_import')
|
|
})
|
|
|
|
it('auto_learned does not upgrade sie_import', () => {
|
|
expect(resolveSource('sie_import', 'auto_learned')).toBe('sie_import')
|
|
})
|
|
|
|
it('same source returns same source', () => {
|
|
expect(resolveSource('user_approved', 'user_approved')).toBe('user_approved')
|
|
expect(resolveSource('sie_import', 'sie_import')).toBe('sie_import')
|
|
})
|
|
})
|
|
|
|
// ── insertOrUpdateTemplate ───────────────────────────────────
|
|
|
|
describe('insertOrUpdateTemplate', () => {
|
|
const baseParams: TemplateUpsertParams = {
|
|
counterpartyName: 'telia',
|
|
aliases: ['telia sverige ab'],
|
|
debitAccount: '6200',
|
|
creditAccount: '1930',
|
|
vatTreatment: 'standard_25',
|
|
vatAccount: '2641',
|
|
category: null,
|
|
occurrenceCount: 1,
|
|
confidence: 0.45,
|
|
lastSeenDate: '2024-06-15',
|
|
source: 'sie_import',
|
|
}
|
|
|
|
it('inserts new template when existingTemplate is null', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: null }) // insert
|
|
|
|
await insertOrUpdateTemplate(supabase as never, 'user-1', baseParams, null)
|
|
|
|
expect(supabase.from).toHaveBeenCalledWith('categorization_templates')
|
|
})
|
|
|
|
it('uses pre-fetched template without DB lookup', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
const existing = makeCategorizationTemplate({
|
|
debit_account: '6200',
|
|
credit_account: '1930',
|
|
occurrence_count: 5,
|
|
source: 'auto_learned',
|
|
})
|
|
|
|
enqueue({ data: null }) // update
|
|
|
|
await insertOrUpdateTemplate(supabase as never, 'user-1', baseParams, existing)
|
|
|
|
// Should NOT have queried for existing: only the update call
|
|
// The from() call count indicates no select was made before update
|
|
expect(supabase.from).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('accumulates occurrence count on re-approval', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
const existing = makeCategorizationTemplate({
|
|
debit_account: '6200',
|
|
credit_account: '1930',
|
|
occurrence_count: 5,
|
|
})
|
|
|
|
enqueue({ data: null }) // update
|
|
|
|
const params = { ...baseParams, occurrenceCount: 10 }
|
|
await insertOrUpdateTemplate(supabase as never, 'user-1', params, existing)
|
|
|
|
// Verify supabase.from was called (update happened)
|
|
expect(supabase.from).toHaveBeenCalledWith('categorization_templates')
|
|
})
|
|
|
|
it('respects source priority: sie_import does not overwrite user_approved', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
const existing = makeCategorizationTemplate({
|
|
debit_account: '5410', // Different accounts = correction
|
|
credit_account: '1930',
|
|
source: 'user_approved',
|
|
})
|
|
|
|
enqueue({ data: null }) // update
|
|
|
|
await insertOrUpdateTemplate(supabase as never, 'user-1', baseParams, existing)
|
|
|
|
// The source should remain user_approved (not downgraded to sie_import)
|
|
expect(supabase.from).toHaveBeenCalledWith('categorization_templates')
|
|
})
|
|
|
|
it('does DB lookup when existingTemplate is undefined', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: null }) // select returns null
|
|
enqueue({ data: null }) // insert
|
|
|
|
await insertOrUpdateTemplate(supabase as never, 'user-1', baseParams)
|
|
|
|
// Two calls: select + insert
|
|
expect(supabase.from).toHaveBeenCalledTimes(2)
|
|
})
|
|
})
|
|
|
|
// ── populateTemplatesFromSieVouchers ─────────────────────────
|
|
|
|
describe('populateTemplatesFromSieVouchers', () => {
|
|
it('returns 0 for empty vouchers', async () => {
|
|
const { supabase } = createMockSupabase()
|
|
const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', [])
|
|
expect(result).toBe(0)
|
|
})
|
|
|
|
it('groups vouchers by normalized description', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
const vouchers = [
|
|
makeSIEVoucher({ description: 'Telia Sverige AB', number: 1 }),
|
|
makeSIEVoucher({ description: 'TELIA SVERIGE', number: 2 }),
|
|
makeSIEVoucher({ description: 'telia sverige ab', number: 3 }),
|
|
]
|
|
|
|
enqueue({ data: [] }) // pre-fetch existing templates
|
|
enqueue({ data: null }) // insert
|
|
|
|
const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', vouchers)
|
|
expect(result).toBe(1)
|
|
})
|
|
|
|
it('skips groups with fewer than 2 occurrences', async () => {
|
|
const { supabase } = createQueuedMockSupabase()
|
|
const vouchers = [
|
|
makeSIEVoucher({ description: 'Telia', number: 1 }),
|
|
]
|
|
|
|
const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', vouchers)
|
|
expect(result).toBe(0)
|
|
})
|
|
|
|
it('accepts groups with sufficient dominance (75% > 60%)', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
const vouchers = [
|
|
makeSIEVoucher({ description: 'Telia', number: 1, lines: [{ account: '1930', amount: -1000 }, { account: '6212', amount: 1000 }] }),
|
|
makeSIEVoucher({ description: 'Telia', number: 2, lines: [{ account: '1930', amount: -1000 }, { account: '6212', amount: 1000 }] }),
|
|
makeSIEVoucher({ description: 'Telia', number: 3, lines: [{ account: '1930', amount: -1000 }, { account: '6212', amount: 1000 }] }),
|
|
makeSIEVoucher({ description: 'Telia', number: 4, lines: [{ account: '1930', amount: -1000 }, { account: '6230', amount: 1000 }] }),
|
|
]
|
|
|
|
enqueue({ data: [] }) // pre-fetch
|
|
enqueue({ data: null }) // insert
|
|
|
|
const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', vouchers)
|
|
expect(result).toBe(1)
|
|
})
|
|
|
|
it('rejects groups with insufficient dominance (50% < 60%)', async () => {
|
|
const { supabase } = createQueuedMockSupabase()
|
|
const vouchers = [
|
|
makeSIEVoucher({ description: 'Telia', number: 1, lines: [{ account: '1930', amount: -1000 }, { account: '6212', amount: 1000 }] }),
|
|
makeSIEVoucher({ description: 'Telia', number: 2, lines: [{ account: '1930', amount: -1000 }, { account: '6212', amount: 1000 }] }),
|
|
makeSIEVoucher({ description: 'Telia', number: 3, lines: [{ account: '1930', amount: -1000 }, { account: '6230', amount: 1000 }] }),
|
|
makeSIEVoucher({ description: 'Telia', number: 4, lines: [{ account: '1930', amount: -1000 }, { account: '6230', amount: 1000 }] }),
|
|
]
|
|
|
|
const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', vouchers)
|
|
expect(result).toBe(0)
|
|
})
|
|
|
|
it('skips descriptions in skip set', async () => {
|
|
const { supabase } = createQueuedMockSupabase()
|
|
const vouchers = [
|
|
makeSIEVoucher({ description: 'Lön', number: 1 }),
|
|
makeSIEVoucher({ description: 'Lön', number: 2 }),
|
|
makeSIEVoucher({ description: 'Lön', number: 3 }),
|
|
]
|
|
|
|
const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', vouchers)
|
|
expect(result).toBe(0)
|
|
})
|
|
|
|
it('skips split bookings with more than 5 business accounts', async () => {
|
|
const { supabase } = createQueuedMockSupabase()
|
|
const vouchers = Array.from({ length: 3 }, (_, i) =>
|
|
makeSIEVoucher({
|
|
description: 'Complex booking',
|
|
number: i + 1,
|
|
lines: [
|
|
{ account: '1930', amount: -6000 },
|
|
{ account: '6200', amount: 1000 },
|
|
{ account: '6210', amount: 1000 },
|
|
{ account: '6220', amount: 1000 },
|
|
{ account: '6230', amount: 1000 },
|
|
{ account: '6240', amount: 1000 },
|
|
{ account: '6250', amount: 1000 },
|
|
],
|
|
})
|
|
)
|
|
|
|
const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', vouchers)
|
|
expect(result).toBe(0)
|
|
})
|
|
|
|
it('extracts VAT treatment from 2641 line', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
const vouchers = Array.from({ length: 3 }, (_, i) =>
|
|
makeSIEVoucher({
|
|
description: 'Kontorsmaterial AB',
|
|
number: i + 1,
|
|
lines: [
|
|
{ account: '1930', amount: -1250 },
|
|
{ account: '6100', amount: 1000 },
|
|
{ account: '2641', amount: 250 },
|
|
],
|
|
})
|
|
)
|
|
|
|
enqueue({ data: [] }) // pre-fetch
|
|
enqueue({ data: null }) // insert
|
|
|
|
const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', vouchers)
|
|
expect(result).toBe(1)
|
|
})
|
|
|
|
it('filters out old vouchers beyond recency window', async () => {
|
|
const { supabase } = createQueuedMockSupabase()
|
|
const recentDate = new Date(2024, 5, 15)
|
|
const oldDate = new Date(2021, 0, 1) // >24 months before recent
|
|
|
|
const vouchers = [
|
|
makeSIEVoucher({ description: 'Old Company', date: oldDate, number: 1 }),
|
|
makeSIEVoucher({ description: 'Old Company', date: oldDate, number: 2 }),
|
|
makeSIEVoucher({ description: 'Old Company', date: oldDate, number: 3 }),
|
|
makeSIEVoucher({ description: 'Recent Company', date: recentDate, number: 4 }),
|
|
]
|
|
|
|
// Only 1 recent voucher for "Recent Company" → below min count → 0 templates
|
|
const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', vouchers)
|
|
expect(result).toBe(0)
|
|
})
|
|
|
|
it('computes SIE confidence formula correctly', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
// 40 occurrences, 38 dominant (95% dominance)
|
|
const vouchers: ReturnType<typeof makeSIEVoucher>[] = []
|
|
for (let i = 0; i < 38; i++) {
|
|
vouchers.push(makeSIEVoucher({
|
|
description: 'Telia',
|
|
number: i + 1,
|
|
lines: [{ account: '1930', amount: -1000 }, { account: '6212', amount: 1000 }],
|
|
}))
|
|
}
|
|
for (let i = 0; i < 2; i++) {
|
|
vouchers.push(makeSIEVoucher({
|
|
description: 'Telia',
|
|
number: 39 + i,
|
|
lines: [{ account: '1930', amount: -1000 }, { account: '6230', amount: 1000 }],
|
|
}))
|
|
}
|
|
|
|
enqueue({ data: [] }) // pre-fetch
|
|
enqueue({ data: null }) // insert
|
|
|
|
const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', vouchers)
|
|
expect(result).toBe(1)
|
|
// dominance = 38/40 = 0.95, confidence = 0.95 * (1 - 1/38) ≈ 0.925 → rounded to 0.92
|
|
})
|
|
|
|
it('recognizes 1510 (receivables) as settlement account', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
const vouchers = Array.from({ length: 3 }, (_, i) =>
|
|
makeSIEVoucher({
|
|
description: 'Kund AB',
|
|
number: i + 1,
|
|
lines: [
|
|
{ account: '1510', amount: 10000 },
|
|
{ account: '3001', amount: -10000 },
|
|
],
|
|
})
|
|
)
|
|
|
|
enqueue({ data: [] }) // pre-fetch
|
|
enqueue({ data: null }) // insert
|
|
|
|
const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', vouchers)
|
|
expect(result).toBe(1)
|
|
})
|
|
|
|
it('recognizes 2890 (credit card) as settlement account', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
const vouchers = Array.from({ length: 3 }, (_, i) =>
|
|
makeSIEVoucher({
|
|
description: 'Företaget AB',
|
|
number: i + 1,
|
|
lines: [
|
|
{ account: '2890', amount: -1000 },
|
|
{ account: '6200', amount: 1000 },
|
|
],
|
|
})
|
|
)
|
|
|
|
enqueue({ data: [] }) // pre-fetch
|
|
enqueue({ data: null }) // insert
|
|
|
|
const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', vouchers)
|
|
expect(result).toBe(1)
|
|
})
|
|
|
|
it('end-to-end: multiple counterparties, filters correctly', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
const vouchers = [
|
|
// Telia: 5 vouchers, all same pattern → should produce template
|
|
...Array.from({ length: 5 }, (_, i) =>
|
|
makeSIEVoucher({
|
|
description: 'Telia',
|
|
number: i + 1,
|
|
lines: [{ account: '1930', amount: -500 }, { account: '6212', amount: 500 }],
|
|
})
|
|
),
|
|
// ICA: 4 vouchers, all same pattern → should produce template
|
|
...Array.from({ length: 4 }, (_, i) =>
|
|
makeSIEVoucher({
|
|
description: 'ICA Maxi',
|
|
number: 10 + i,
|
|
lines: [{ account: '1930', amount: -200 }, { account: '4010', amount: 200 }],
|
|
})
|
|
),
|
|
// Rare: 1 voucher → below minimum, no template
|
|
makeSIEVoucher({
|
|
description: 'Rare Supplier',
|
|
number: 20,
|
|
lines: [{ account: '1930', amount: -100 }, { account: '6590', amount: 100 }],
|
|
}),
|
|
]
|
|
|
|
enqueue({ data: [] }) // pre-fetch
|
|
enqueue({ data: null }) // insert 1
|
|
enqueue({ data: null }) // insert 2
|
|
|
|
const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', vouchers)
|
|
expect(result).toBe(2)
|
|
})
|
|
})
|
|
})
|
|
|
|
// ── dimensions propagation (PR7) ─────────────────────────────
|
|
|
|
describe('dimensions propagation (PR7)', () => {
|
|
describe('buildMappingResultFromCounterpartyTemplate: multi-line pattern', () => {
|
|
it('materialized business lines carry the pattern bag; VAT and tax lines stay untagged', () => {
|
|
const template = makeCategorizationTemplate({
|
|
debit_account: '5410',
|
|
credit_account: '1930',
|
|
line_pattern: [
|
|
{ account: '2641', type: 'vat', side: 'debit', vat_rate: 0.25 },
|
|
{ account: '5410', type: 'business', side: 'debit', ratio: 0.8, dimensions: { '1': 'KS01' } },
|
|
// Even a (bogus) bag on a tax entry must not materialize: only
|
|
// business lines are tagged.
|
|
{ account: '2710', type: 'tax', side: 'debit', ratio: 0.2, dimensions: { '1': 'KS01' } },
|
|
],
|
|
})
|
|
const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.9 }
|
|
const tx = makeTransaction({ amount: -1250 })
|
|
|
|
const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'enskild_firma')
|
|
|
|
expect(result.all_lines_complete).toBe(true)
|
|
const business = result.vat_lines.find((l) => l.account_number === '5410')
|
|
expect(business?.dimensions).toEqual({ '1': 'KS01' })
|
|
expect(result.vat_lines.find((l) => l.account_number === '2641')?.dimensions).toBeUndefined()
|
|
expect(result.vat_lines.find((l) => l.account_number === '2710')?.dimensions).toBeUndefined()
|
|
})
|
|
|
|
it('a pattern business line without a bag materializes untagged', () => {
|
|
const template = makeCategorizationTemplate({
|
|
debit_account: '5410',
|
|
credit_account: '1930',
|
|
line_pattern: [
|
|
{ account: '5410', type: 'business', side: 'debit', ratio: 1 },
|
|
],
|
|
})
|
|
const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.9 }
|
|
const tx = makeTransaction({ amount: -1000 })
|
|
|
|
const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'enskild_firma')
|
|
|
|
expect(result.vat_lines.find((l) => l.account_number === '5410')?.dimensions).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('populateTemplatesFromSieVouchers: line-pattern dimension learning', () => {
|
|
/**
|
|
* Queue-based supabase mock that also records `.insert()` payloads per
|
|
* table, so assertions can inspect the stored line_pattern. Follows the
|
|
* capture pattern in lib/pending-operations/__tests__/create-invoice-executor.test.ts.
|
|
*/
|
|
function createCapturingSupabase(results: Array<{ data?: unknown; error?: unknown }>) {
|
|
const queue = [...results]
|
|
const inserts: Record<string, unknown[]> = {}
|
|
|
|
const from = vi.fn((_table: string) => {
|
|
const raw = queue.shift() ?? { data: null, error: null }
|
|
const result = { data: raw.data ?? null, error: raw.error ?? null }
|
|
const chain: object = new Proxy(
|
|
{},
|
|
{
|
|
get(_target, prop) {
|
|
if (prop === 'then') {
|
|
return (resolve: (v: unknown) => void) => resolve(result)
|
|
}
|
|
if (prop === 'insert') {
|
|
return (payload: unknown) => {
|
|
;(inserts[_table] ??= []).push(payload)
|
|
return chain
|
|
}
|
|
}
|
|
return () => chain
|
|
},
|
|
},
|
|
)
|
|
return chain
|
|
})
|
|
|
|
return { supabase: { from }, inserts }
|
|
}
|
|
|
|
function capturedLinePattern(inserts: Record<string, unknown[]>): LinePatternEntry[] {
|
|
const rows = inserts['categorization_templates']
|
|
expect(rows).toHaveLength(1)
|
|
const row = rows[0] as { line_pattern: LinePatternEntry[] | null }
|
|
expect(row.line_pattern).not.toBeNull()
|
|
return row.line_pattern!
|
|
}
|
|
|
|
it('preserves a business line bag only when EVERY voucher occurrence has the identical bag', async () => {
|
|
// Two business accounts → not "simple", so the pattern is stored as
|
|
// line_pattern JSONB. All three vouchers tag both accounts identically.
|
|
const vouchers = Array.from({ length: 3 }, (_, i) =>
|
|
makeSIEVoucher({
|
|
description: 'Projektbygget AB',
|
|
number: i + 1,
|
|
lines: [
|
|
{ account: '1930', amount: -1100 },
|
|
{ account: '6212', amount: 600, dimensions: { '1': 'KS01' } },
|
|
{ account: '5410', amount: 500, dimensions: { '6': 'P001' } },
|
|
],
|
|
})
|
|
)
|
|
|
|
const { supabase, inserts } = createCapturingSupabase([
|
|
{ data: [] }, // pre-fetch existing templates
|
|
{ data: null }, // insert
|
|
])
|
|
|
|
const count = await populateTemplatesFromSieVouchers(supabase as never, 'company-1', vouchers)
|
|
expect(count).toBe(1)
|
|
|
|
const pattern = capturedLinePattern(inserts)
|
|
expect(pattern.find((e) => e.account === '6212')?.dimensions).toEqual({ '1': 'KS01' })
|
|
expect(pattern.find((e) => e.account === '5410')?.dimensions).toEqual({ '6': 'P001' })
|
|
})
|
|
|
|
it('drops the bag when a voucher disagrees, or leaves the line untagged', async () => {
|
|
// 6212 conflicts across vouchers (KS01 vs KS02); 5410 is untagged in one
|
|
// voucher. Both must lose the bag: a template must never invent a tag
|
|
// history does not consistently support.
|
|
const linesFor = (v: number): SIETransactionLine[] => [
|
|
{ account: '1930', amount: -1100 },
|
|
{ account: '6212', amount: 600, dimensions: v === 2 ? { '1': 'KS02' } : { '1': 'KS01' } },
|
|
v === 2
|
|
? { account: '5410', amount: 500 }
|
|
: { account: '5410', amount: 500, dimensions: { '6': 'P001' } },
|
|
]
|
|
const vouchers = [1, 2, 3].map((v) =>
|
|
makeSIEVoucher({ description: 'Projektbygget AB', number: v, lines: linesFor(v) })
|
|
)
|
|
|
|
const { supabase, inserts } = createCapturingSupabase([
|
|
{ data: [] }, // pre-fetch existing templates
|
|
{ data: null }, // insert
|
|
])
|
|
|
|
const count = await populateTemplatesFromSieVouchers(supabase as never, 'company-1', vouchers)
|
|
expect(count).toBe(1)
|
|
|
|
const pattern = capturedLinePattern(inserts)
|
|
expect(pattern.find((e) => e.account === '6212')?.dimensions).toBeUndefined()
|
|
expect(pattern.find((e) => e.account === '5410')?.dimensions).toBeUndefined()
|
|
})
|
|
})
|
|
})
|
|
|
|
// ── learning-loop repair (issue #865) ────────────────────────
|
|
|
|
describe('learning-loop repair (issue #865)', () => {
|
|
/** Queue-based mock that records insert payloads per table (see PR7 block). */
|
|
function createCapturingSupabase(results: Array<{ data?: unknown; error?: unknown }>) {
|
|
const queue = [...results]
|
|
const inserts: Record<string, unknown[]> = {}
|
|
|
|
const from = vi.fn((_table: string) => {
|
|
const raw = queue.shift() ?? { data: null, error: null }
|
|
const result = { data: raw.data ?? null, error: raw.error ?? null }
|
|
const chain: object = new Proxy(
|
|
{},
|
|
{
|
|
get(_target, prop) {
|
|
if (prop === 'then') {
|
|
return (resolve: (v: unknown) => void) => resolve(result)
|
|
}
|
|
if (prop === 'insert') {
|
|
return (payload: unknown) => {
|
|
;(inserts[_table] ??= []).push(payload)
|
|
return chain
|
|
}
|
|
}
|
|
return () => chain
|
|
},
|
|
},
|
|
)
|
|
return chain
|
|
})
|
|
|
|
return { supabase: { from }, inserts }
|
|
}
|
|
|
|
function capturedTemplateRow(inserts: Record<string, unknown[]>) {
|
|
const rows = inserts['categorization_templates']
|
|
expect(rows).toHaveLength(1)
|
|
return rows[0] as {
|
|
vat_treatment: string | null
|
|
vat_account: string | null
|
|
line_pattern: LinePatternEntry[] | null
|
|
debit_account: string
|
|
credit_account: string
|
|
}
|
|
}
|
|
|
|
describe('direction mismatch (refunds/repayments)', () => {
|
|
it('mirrors an expense-learned template for an incoming refund, with reversed input VAT', () => {
|
|
const template = makeCategorizationTemplate({
|
|
debit_account: '6200',
|
|
credit_account: '1930',
|
|
vat_treatment: 'standard_25',
|
|
})
|
|
const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.85 }
|
|
const tx = makeTransaction({ amount: 1250 }) // money IN from an expense counterparty
|
|
|
|
const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'enskild_firma')
|
|
|
|
// Mirrored: settle on debit (bank), reduce the expense on credit
|
|
expect(result.debit_account).toBe('1930')
|
|
expect(result.credit_account).toBe('6200')
|
|
expect(result.requires_review).toBe(true)
|
|
expect(result.direction_mismatch).toBe(true)
|
|
// Input VAT mirrored: credit 2641 for the VAT share of 1250 = 250
|
|
expect(result.vat_lines).toHaveLength(1)
|
|
expect(result.vat_lines[0].account_number).toBe('2641')
|
|
expect(result.vat_lines[0].credit_amount).toBe(250)
|
|
expect(result.vat_lines[0].debit_amount).toBe(0)
|
|
})
|
|
|
|
it('mirrors both fiktiv-moms legs for a reverse-charge credit note, and the entry balances', () => {
|
|
const template = makeCategorizationTemplate({
|
|
counterparty_name: 'google cloud emea',
|
|
debit_account: '4535',
|
|
credit_account: '1930',
|
|
vat_treatment: 'reverse_charge',
|
|
vat_account: '2645',
|
|
})
|
|
const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.85 }
|
|
const tx = makeTransaction({ amount: 5000 }) // credit note from an RC supplier
|
|
|
|
const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'aktiebolag')
|
|
|
|
expect(result.direction_mismatch).toBe(true)
|
|
// Original purchase books debit 2645 / credit 2614; the mirror flips both
|
|
const in2645 = result.vat_lines.find((l) => l.account_number === '2645')
|
|
const out2614 = result.vat_lines.find((l) => l.account_number === '2614')
|
|
expect(in2645?.credit_amount).toBe(1250)
|
|
expect(out2614?.debit_amount).toBe(1250)
|
|
|
|
// End-to-end: the RC pair nets to zero, business keeps the gross amount,
|
|
// and the whole entry balances
|
|
const lines = buildTransactionEntryLines(tx, result)
|
|
const debits = roundOre(lines.reduce((s, l) => s + l.debit_amount, 0))
|
|
const credits = roundOre(lines.reduce((s, l) => s + l.credit_amount, 0))
|
|
expect(debits).toBe(credits)
|
|
expect(lines.find((l) => l.account_number === '4535')?.credit_amount).toBe(5000)
|
|
})
|
|
|
|
it('mirrors an income-learned template for an outgoing repayment, without VAT lines', () => {
|
|
const template = makeCategorizationTemplate({
|
|
counterparty_name: 'kund ab',
|
|
debit_account: '1930',
|
|
credit_account: '3001',
|
|
vat_treatment: null,
|
|
vat_account: null,
|
|
})
|
|
const match = { template, matchMethod: 'exact_normalized' as const, confidence: 0.8 }
|
|
const tx = makeTransaction({ amount: -5000 }) // money OUT to an income counterparty
|
|
|
|
const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'aktiebolag')
|
|
|
|
expect(result.debit_account).toBe('3001')
|
|
expect(result.credit_account).toBe('1930')
|
|
expect(result.requires_review).toBe(true)
|
|
expect(result.direction_mismatch).toBe(true)
|
|
expect(result.vat_lines).toHaveLength(0)
|
|
})
|
|
|
|
it('mirrors every side of a multi-line pattern on sign mismatch', () => {
|
|
const template = makeCategorizationTemplate({
|
|
debit_account: '5410',
|
|
credit_account: '1930',
|
|
line_pattern: [
|
|
{ account: '2641', type: 'vat', side: 'debit', vat_rate: 0.25 },
|
|
{ account: '5410', type: 'business', side: 'debit', ratio: 1 },
|
|
],
|
|
})
|
|
const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.9 }
|
|
const tx = makeTransaction({ amount: 1250 }) // refund of a multi-line expense
|
|
|
|
const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'enskild_firma')
|
|
|
|
expect(result.all_lines_complete).toBe(true)
|
|
expect(result.requires_review).toBe(true)
|
|
expect(result.direction_mismatch).toBe(true)
|
|
expect(result.debit_account).toBe('1930')
|
|
expect(result.credit_account).toBe('5410')
|
|
const vat = result.vat_lines.find((l) => l.account_number === '2641')
|
|
expect(vat?.credit_amount).toBe(250)
|
|
expect(vat?.debit_amount).toBe(0)
|
|
const business = result.vat_lines.find((l) => l.account_number === '5410')
|
|
expect(business?.credit_amount).toBe(1000)
|
|
expect(business?.debit_amount).toBe(0)
|
|
})
|
|
|
|
it('does not learn from a direction-mismatched booking', async () => {
|
|
const { supabase } = createQueuedMockSupabase()
|
|
const tx = makeTransaction({ merchant_name: 'Telia Sverige AB', amount: 1250 })
|
|
|
|
await upsertCounterpartyTemplate(
|
|
supabase as never,
|
|
'company-1',
|
|
tx,
|
|
{
|
|
rule: null,
|
|
debit_account: '1930',
|
|
credit_account: '6200',
|
|
risk_level: 'NONE',
|
|
confidence: 0.85,
|
|
requires_review: true,
|
|
direction_mismatch: true,
|
|
default_private: false,
|
|
vat_lines: [],
|
|
description: 'Motpart: telia (retur/återbetalning)',
|
|
},
|
|
'user_approved',
|
|
)
|
|
|
|
expect(supabase.from).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('refuses an opposite-direction "correction" of an existing template', async () => {
|
|
const { supabase } = createQueuedMockSupabase()
|
|
const existing = makeCategorizationTemplate({
|
|
debit_account: '6200',
|
|
credit_account: '1930',
|
|
})
|
|
|
|
const params: TemplateUpsertParams = {
|
|
counterpartyName: 'telia',
|
|
aliases: ['telia refund'],
|
|
debitAccount: '1930', // refund shape: settlement moved to debit
|
|
creditAccount: '3990',
|
|
vatTreatment: null,
|
|
vatAccount: null,
|
|
category: null,
|
|
occurrenceCount: 1,
|
|
confidence: 0.45,
|
|
lastSeenDate: '2024-07-01',
|
|
source: 'user_approved',
|
|
}
|
|
|
|
const written = await insertOrUpdateTemplate(supabase as never, 'company-1', params, existing)
|
|
|
|
expect(written).toBe(false)
|
|
expect(supabase.from).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('falls back to the pattern direction when the legacy fields cannot classify a multi-line template', async () => {
|
|
const { supabase } = createQueuedMockSupabase()
|
|
// Both legacy fields settlement-ish → legacy direction 'unknown';
|
|
// the pattern's business sides say expense.
|
|
const existing = makeCategorizationTemplate({
|
|
debit_account: '2440',
|
|
credit_account: '1930',
|
|
line_pattern: [
|
|
{ account: '2641', type: 'vat', side: 'debit', vat_rate: 0.25 },
|
|
{ account: '5410', type: 'business', side: 'debit', ratio: 1 },
|
|
],
|
|
})
|
|
|
|
const params: TemplateUpsertParams = {
|
|
counterpartyName: 'telia',
|
|
aliases: [],
|
|
debitAccount: '1930', // income-shaped: opposite of the learned pattern
|
|
creditAccount: '3001',
|
|
vatTreatment: null,
|
|
vatAccount: null,
|
|
category: null,
|
|
occurrenceCount: 1,
|
|
confidence: 0.45,
|
|
lastSeenDate: '2024-07-01',
|
|
source: 'user_approved',
|
|
}
|
|
|
|
const written = await insertOrUpdateTemplate(supabase as never, 'company-1', params, existing)
|
|
|
|
expect(written).toBe(false)
|
|
expect(supabase.from).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('still applies a same-direction account correction', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
const existing = makeCategorizationTemplate({
|
|
debit_account: '6200',
|
|
credit_account: '1930',
|
|
})
|
|
enqueue({ data: null }) // update
|
|
|
|
const params: TemplateUpsertParams = {
|
|
counterpartyName: 'telia',
|
|
aliases: [],
|
|
debitAccount: '6230', // still expense-shaped
|
|
creditAccount: '1930',
|
|
vatTreatment: 'standard_25',
|
|
vatAccount: '2641',
|
|
category: null,
|
|
occurrenceCount: 1,
|
|
confidence: 0.45,
|
|
lastSeenDate: '2024-07-01',
|
|
source: 'user_approved',
|
|
}
|
|
|
|
const written = await insertOrUpdateTemplate(supabase as never, 'company-1', params, existing)
|
|
|
|
expect(written).toBe(true)
|
|
expect(supabase.from).toHaveBeenCalledWith('categorization_templates')
|
|
})
|
|
})
|
|
|
|
describe('reduced_12 templates across the livsmedel transition (2026-04-01)', () => {
|
|
const staleTemplate = () => makeCategorizationTemplate({
|
|
debit_account: '4010',
|
|
credit_account: '1930',
|
|
vat_treatment: 'reduced_12',
|
|
last_seen_date: '2026-03-10', // learned before the 12→6 livsmedel change
|
|
})
|
|
|
|
it('review-gates a stale 12% template applied after the transition', () => {
|
|
const match = { template: staleTemplate(), matchMethod: 'exact_alias' as const, confidence: 0.85 }
|
|
const tx = makeTransaction({ amount: -560, date: '2026-05-02' })
|
|
|
|
const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'enskild_firma')
|
|
|
|
expect(result.requires_review).toBe(true)
|
|
expect(result.direction_mismatch).toBeUndefined()
|
|
})
|
|
|
|
it('does not gate a 12% template re-confirmed after the transition', () => {
|
|
const template = makeCategorizationTemplate({
|
|
debit_account: '5831', // e.g. restaurant/hotel: legitimately still 12%
|
|
credit_account: '1930',
|
|
vat_treatment: 'reduced_12',
|
|
last_seen_date: '2026-04-20',
|
|
})
|
|
const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.85 }
|
|
const tx = makeTransaction({ amount: -560, date: '2026-05-02' })
|
|
|
|
const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'enskild_firma')
|
|
|
|
expect(result.requires_review).toBe(false)
|
|
})
|
|
|
|
it('does not gate pre-transition transaction dates or other rates', () => {
|
|
const match = { template: staleTemplate(), matchMethod: 'exact_alias' as const, confidence: 0.85 }
|
|
const backdated = makeTransaction({ amount: -560, date: '2026-03-20' })
|
|
expect(
|
|
buildMappingResultFromCounterpartyTemplate(match, backdated, 'enskild_firma').requires_review
|
|
).toBe(false)
|
|
|
|
const t25 = makeCategorizationTemplate({
|
|
vat_treatment: 'standard_25',
|
|
last_seen_date: '2026-03-10',
|
|
})
|
|
const match25 = { template: t25, matchMethod: 'exact_alias' as const, confidence: 0.85 }
|
|
const tx = makeTransaction({ amount: -560, date: '2026-05-02' })
|
|
expect(
|
|
buildMappingResultFromCounterpartyTemplate(match25, tx, 'enskild_firma').requires_review
|
|
).toBe(false)
|
|
})
|
|
|
|
it('review-gates a stale multi-line pattern carrying a 12% VAT entry', () => {
|
|
const template = makeCategorizationTemplate({
|
|
debit_account: '4010',
|
|
credit_account: '1930',
|
|
vat_treatment: null,
|
|
last_seen_date: '2026-02-01',
|
|
line_pattern: [
|
|
{ account: '2641', type: 'vat', side: 'debit', vat_rate: 0.12 },
|
|
{ account: '4010', type: 'business', side: 'debit', ratio: 1 },
|
|
],
|
|
})
|
|
const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.9 }
|
|
const tx = makeTransaction({ amount: -1120, date: '2026-05-02' })
|
|
|
|
const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'enskild_firma')
|
|
|
|
expect(result.requires_review).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('SEK resolution for foreign-currency transactions', () => {
|
|
it('computes legacy VAT lines from the SEK amount, not the foreign amount', () => {
|
|
const template = makeCategorizationTemplate({
|
|
debit_account: '6200',
|
|
credit_account: '1930',
|
|
vat_treatment: 'standard_25',
|
|
})
|
|
const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.85 }
|
|
const tx = makeTransaction({
|
|
amount: -100,
|
|
currency: 'EUR',
|
|
amount_sek: -1100,
|
|
exchange_rate: 11,
|
|
})
|
|
|
|
const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'enskild_firma')
|
|
|
|
// VAT share of 1100 SEK at 25% = 220, not 20 (which the EUR amount gives)
|
|
expect(result.vat_lines[0].debit_amount).toBe(220)
|
|
})
|
|
|
|
it('computes multi-line pattern amounts from the SEK amount so the entry balances', () => {
|
|
const template = makeCategorizationTemplate({
|
|
debit_account: '5410',
|
|
credit_account: '1930',
|
|
line_pattern: [
|
|
{ account: '2641', type: 'vat', side: 'debit', vat_rate: 0.25 },
|
|
{ account: '5410', type: 'business', side: 'debit', ratio: 1 },
|
|
],
|
|
})
|
|
const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.9 }
|
|
const tx = makeTransaction({
|
|
amount: -100,
|
|
currency: 'EUR',
|
|
amount_sek: -1100,
|
|
exchange_rate: 11,
|
|
})
|
|
|
|
const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'enskild_firma')
|
|
|
|
const vat = result.vat_lines.find((l) => l.account_number === '2641')
|
|
const business = result.vat_lines.find((l) => l.account_number === '5410')
|
|
expect(vat?.debit_amount).toBe(220)
|
|
expect(business?.debit_amount).toBe(880)
|
|
// Non-settlement lines sum to the SEK settlement amount: entry balances
|
|
const totalDebits = result.vat_lines.reduce((s, l) => s + l.debit_amount, 0)
|
|
expect(totalDebits).toBe(1100)
|
|
})
|
|
})
|
|
|
|
describe('SIE VAT-rate inference (2641 is rate-agnostic)', () => {
|
|
it('learns reduced_12 from voucher amounts instead of hardcoding 25%', async () => {
|
|
const vouchers = Array.from({ length: 3 }, (_, i) =>
|
|
makeSIEVoucher({
|
|
description: 'Restaurang AB',
|
|
number: i + 1,
|
|
lines: [
|
|
{ account: '1930', amount: -1120 },
|
|
{ account: '6072', amount: 1000 },
|
|
{ account: '2641', amount: 120 }, // 12% of the 1000 net
|
|
],
|
|
})
|
|
)
|
|
|
|
const { supabase, inserts } = createCapturingSupabase([
|
|
{ data: [] }, // pre-fetch existing templates
|
|
{ data: null }, // insert
|
|
])
|
|
|
|
const count = await populateTemplatesFromSieVouchers(supabase as never, 'company-1', vouchers)
|
|
expect(count).toBe(1)
|
|
|
|
const row = capturedTemplateRow(inserts)
|
|
expect(row.vat_treatment).toBe('reduced_12')
|
|
expect(row.vat_account).toBe('2641')
|
|
})
|
|
|
|
it('drops the VAT leg when the observed rate snaps to no legal rate', async () => {
|
|
const vouchers = Array.from({ length: 3 }, (_, i) =>
|
|
makeSIEVoucher({
|
|
description: 'Utlandet Ltd',
|
|
number: i + 1,
|
|
lines: [
|
|
{ account: '1930', amount: -1190 },
|
|
{ account: '6100', amount: 1000 },
|
|
{ account: '2641', amount: 190 }, // 19%: not a Swedish rate
|
|
],
|
|
})
|
|
)
|
|
|
|
const { supabase, inserts } = createCapturingSupabase([
|
|
{ data: [] },
|
|
{ data: null },
|
|
])
|
|
|
|
const count = await populateTemplatesFromSieVouchers(supabase as never, 'company-1', vouchers)
|
|
expect(count).toBe(1)
|
|
|
|
const row = capturedTemplateRow(inserts)
|
|
expect(row.vat_treatment).toBeNull()
|
|
expect(row.vat_account).toBeNull()
|
|
})
|
|
|
|
it('excludes import output-VAT accounts (2615) from the ratio base like other fiktiv moms', async () => {
|
|
const vouchers = Array.from({ length: 3 }, (_, i) =>
|
|
makeSIEVoucher({
|
|
description: 'Import Goods Ltd',
|
|
number: i + 1,
|
|
lines: [
|
|
{ account: '1930', amount: -1000 },
|
|
{ account: '4545', amount: 1000 },
|
|
{ account: '2645', amount: 250 },
|
|
{ account: '2615', amount: -250 }, // output VAT on imports
|
|
],
|
|
})
|
|
)
|
|
|
|
const { supabase, inserts } = createCapturingSupabase([
|
|
{ data: [] },
|
|
{ data: null },
|
|
])
|
|
|
|
const count = await populateTemplatesFromSieVouchers(supabase as never, 'company-1', vouchers)
|
|
expect(count).toBe(1)
|
|
|
|
const row = capturedTemplateRow(inserts)
|
|
expect(row.vat_treatment).toBe('reverse_charge')
|
|
expect(row.debit_account).toBe('4545')
|
|
// Simple pattern: neither fiktiv leg shrank the business ratio base
|
|
expect(row.line_pattern).toBeNull()
|
|
})
|
|
|
|
it('learns reverse_charge for counterparties booked with fiktiv moms', async () => {
|
|
const vouchers = Array.from({ length: 3 }, (_, i) =>
|
|
makeSIEVoucher({
|
|
description: 'Google Cloud EMEA',
|
|
number: i + 1,
|
|
lines: [
|
|
{ account: '1930', amount: -1000 },
|
|
{ account: '4535', amount: 1000 },
|
|
{ account: '2645', amount: 250 },
|
|
{ account: '2614', amount: -250 },
|
|
],
|
|
})
|
|
)
|
|
|
|
const { supabase, inserts } = createCapturingSupabase([
|
|
{ data: [] },
|
|
{ data: null },
|
|
])
|
|
|
|
const count = await populateTemplatesFromSieVouchers(supabase as never, 'company-1', vouchers)
|
|
expect(count).toBe(1)
|
|
|
|
const row = capturedTemplateRow(inserts)
|
|
expect(row.vat_treatment).toBe('reverse_charge')
|
|
expect(row.vat_account).toBe('2645')
|
|
expect(row.debit_account).toBe('4535')
|
|
expect(row.credit_account).toBe('1930')
|
|
// Simple pattern: RC legs are regenerated from the treatment at booking
|
|
expect(row.line_pattern).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('write-failure visibility', () => {
|
|
it('populateTemplatesFromSieVouchers counts only rows actually written', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
const vouchers = Array.from({ length: 3 }, (_, i) =>
|
|
makeSIEVoucher({
|
|
description: 'Telia',
|
|
number: i + 1,
|
|
lines: [{ account: '1930', amount: -1000 }, { account: '6212', amount: 1000 }],
|
|
})
|
|
)
|
|
|
|
enqueue({ data: [] }) // pre-fetch existing templates
|
|
enqueue({ error: { message: 'null value in column "user_id"' } }) // insert fails
|
|
|
|
const count = await populateTemplatesFromSieVouchers(supabase as never, 'company-1', vouchers)
|
|
expect(count).toBe(0)
|
|
})
|
|
|
|
it('insertOrUpdateTemplate returns false on insert error instead of swallowing it', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ error: { message: 'violates row-level security policy' } })
|
|
|
|
const written = await insertOrUpdateTemplate(supabase as never, 'company-1', {
|
|
counterpartyName: 'telia',
|
|
aliases: [],
|
|
debitAccount: '6200',
|
|
creditAccount: '1930',
|
|
vatTreatment: null,
|
|
vatAccount: null,
|
|
category: null,
|
|
occurrenceCount: 1,
|
|
confidence: 0.45,
|
|
lastSeenDate: '2024-06-15',
|
|
source: 'user_approved',
|
|
}, null)
|
|
|
|
expect(written).toBe(false)
|
|
})
|
|
})
|
|
})
|
|
|
|
// ── Runtime dimension learning (default_dimensions) ─────────────────────────
|
|
|
|
describe('counterparty template default_dimensions', () => {
|
|
/**
|
|
* The queued mock's chain proxy discards call args by design, so capture
|
|
* .insert/.update payloads with a thin wrapper around the original
|
|
* implementation.
|
|
*/
|
|
function captureWrites(supabase: ReturnType<typeof createQueuedMockSupabase>['supabase']) {
|
|
const writes: { insert: unknown[]; update: unknown[] } = { insert: [], update: [] }
|
|
const originalFrom = supabase.from.getMockImplementation()!
|
|
supabase.from.mockImplementation((table: string) => {
|
|
const chain = originalFrom(table) as object
|
|
return new Proxy(chain, {
|
|
get(target, prop, receiver) {
|
|
if (prop === 'insert' || prop === 'update') {
|
|
return (rows: unknown) => {
|
|
writes[prop].push(rows)
|
|
return (Reflect.get(target, prop, receiver) as (r: unknown) => unknown)(rows)
|
|
}
|
|
}
|
|
return Reflect.get(target, prop, receiver)
|
|
},
|
|
})
|
|
})
|
|
return writes
|
|
}
|
|
|
|
const baseMappingResult = {
|
|
rule: null,
|
|
debit_account: '5410',
|
|
credit_account: '1930',
|
|
risk_level: 'NONE' as const,
|
|
confidence: 0.9,
|
|
requires_review: false,
|
|
default_private: false,
|
|
vat_lines: [],
|
|
description: 'Test',
|
|
}
|
|
|
|
it('learns the booked bag on a fresh template insert', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
const writes = captureWrites(supabase)
|
|
const tx = makeTransaction({ merchant_name: 'Spotify AB', date: '2026-07-01' })
|
|
|
|
enqueue({ data: null }) // no existing template
|
|
enqueue({ data: null }) // insert ok
|
|
|
|
await upsertCounterpartyTemplate(
|
|
supabase as never,
|
|
'company-1',
|
|
tx,
|
|
{ ...baseMappingResult, dimensions: { '6': 'P001' } },
|
|
'user_approved',
|
|
)
|
|
|
|
expect(writes.insert).toHaveLength(1)
|
|
expect(writes.insert[0]).toMatchObject({ default_dimensions: { '6': 'P001' } })
|
|
})
|
|
|
|
it('replaces the stored bag when a re-approval carries a new one', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
const writes = captureWrites(supabase)
|
|
const existing = makeCategorizationTemplate({
|
|
debit_account: '5410',
|
|
credit_account: '1930',
|
|
occurrence_count: 3,
|
|
})
|
|
const tx = makeTransaction({ merchant_name: 'telia', date: '2026-07-01' })
|
|
|
|
enqueue({ data: existing })
|
|
enqueue({ data: null }) // update ok
|
|
|
|
await upsertCounterpartyTemplate(
|
|
supabase as never,
|
|
'company-1',
|
|
tx,
|
|
{ ...baseMappingResult, dimensions: { '6': 'P002' } },
|
|
'user_approved',
|
|
)
|
|
|
|
expect(writes.update).toHaveLength(1)
|
|
expect(writes.update[0]).toMatchObject({ default_dimensions: { '6': 'P002' } })
|
|
})
|
|
|
|
it('an untagged booking never erases the learned bag', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
const writes = captureWrites(supabase)
|
|
const existing = makeCategorizationTemplate({
|
|
debit_account: '5410',
|
|
credit_account: '1930',
|
|
occurrence_count: 3,
|
|
})
|
|
const tx = makeTransaction({ merchant_name: 'telia', date: '2026-07-01' })
|
|
|
|
enqueue({ data: existing })
|
|
enqueue({ data: null }) // update ok
|
|
|
|
await upsertCounterpartyTemplate(
|
|
supabase as never,
|
|
'company-1',
|
|
tx,
|
|
baseMappingResult, // no dimensions
|
|
'user_approved',
|
|
)
|
|
|
|
expect(writes.update).toHaveLength(1)
|
|
expect(writes.update[0]).not.toHaveProperty('default_dimensions')
|
|
})
|
|
|
|
it('legacy single-line template match applies the learned bag', () => {
|
|
const template = makeCategorizationTemplate({
|
|
debit_account: '6200',
|
|
credit_account: '1930',
|
|
vat_treatment: 'standard_25',
|
|
default_dimensions: { '1': 'KS1', '6': 'P001' },
|
|
})
|
|
const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.85 }
|
|
const tx = makeTransaction({ amount: -1250 })
|
|
|
|
const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'enskild_firma')
|
|
|
|
expect(result.dimensions).toEqual({ '1': 'KS1', '6': 'P001' })
|
|
})
|
|
|
|
it('a template without a learned bag sets no dimensions', () => {
|
|
const template = makeCategorizationTemplate({
|
|
debit_account: '6200',
|
|
credit_account: '1930',
|
|
})
|
|
const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.85 }
|
|
const tx = makeTransaction({ amount: -1250 })
|
|
|
|
const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'enskild_firma')
|
|
|
|
expect(result).not.toHaveProperty('dimensions')
|
|
})
|
|
|
|
it('a mirrored refund keeps the learned bag so the reversal reduces the same project', () => {
|
|
const template = makeCategorizationTemplate({
|
|
debit_account: '6200',
|
|
credit_account: '1930',
|
|
vat_treatment: 'standard_25',
|
|
default_dimensions: { '6': 'P001' },
|
|
})
|
|
const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.85 }
|
|
// Expense-learned template, incoming money: mirrored, review-gated.
|
|
const tx = makeTransaction({ amount: 1250 })
|
|
|
|
const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'enskild_firma')
|
|
|
|
expect(result.direction_mismatch).toBe(true)
|
|
expect(result.dimensions).toEqual({ '6': 'P001' })
|
|
})
|
|
})
|