Files
accounted/lib/bookkeeping/__tests__/counterparty-templates.test.ts
T
Jakob WennbergandClaude Opus 4.8 e5f1d7a916 fix(reports): count reversed entries in bank/supplier/AR reconciliation (#734)
A reversed (storno-corrected) journal entry must be summed together with
its storno + correction, exactly as the trial balance and balance sheet
already do (.in('status', ['posted','reversed'])). The reconciliation
paths used posted-only and manufactured phantom differences.

- bank-reconciliation getReconciliationStatus: gl_1930_balance now counts
  posted+reversed (so it equals the balansräkning for the account). Dropped
  the correction_adjustment subtraction and the reversed-linked-tx drop — a
  corrected/amount-corrected bank receipt now reconciles. The prior model
  broke once correctEntry began re-pointing the bank transaction to the live
  correction (the two changes were mutually inconsistent and produced a
  difference equal to the corrected amount).
- supplier-reconciliation (2440) and ar-reconciliation (1510/1513): same
  posted -> posted+reversed fix. Removes the false "Ej avstämd" gap a fully
  paid, fully corrected company shows — the books net to 0 over posted+reversed
  while a posted-only query double-counted the payment legs.

Also in this change:
- MCP gnubok_get_reconciliation_status: add account_number param (was hardcoded
  to 1930; the lib already supported per-account reconciliation).
- counterparty-templates normalizeCounterpartyName: strip trailing month /
  personal-initials tokens so "ngrok JW" / "Ngrok Mars" learn as one merchant.
- pending-operation reject 409 (route + MCP tool): clarify a resolved op was
  approved explicitly (no auto-commit path exists) instead of a bare
  "already committed".
- create_voucher: normalize description with String().trim() for consistency
  with line_description.
- migration 20260625120000: backfill stripped diacritics on ~830 companies'
  seeded chart-of-accounts names (Foretagskonto -> Företagskonto, etc.); the
  seed function was fixed for new companies in 20260516130000 but never
  backfilled. UPDATE-only and idempotent. Already applied to production.

Tests: 899 passing across the touched suites; lint + typecheck clean.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 20:46:41 +02:00

750 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 type { TemplateUpsertParams } from '../counterparty-templates'
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
})
})
// ── 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: '' })
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()
})
})
// ── 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('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: '' })
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)
})
})
})