Files
accounted/lib/bookkeeping/__tests__/counterparty-templates.test.ts
T
Jakob WennbergandClaude Opus 4.6 d9c95a7b59 feat: counterparty templates with multi-line patterns, batch matching, and settings cleanup (#118)
* feat: separate AR/AP/accounting into distinct nav groups (#92)

Split the flat "Finans" sidebar group into three visually distinct
sections — Försäljning (AR), Inköp (AP), and Redovisning — so users
coming from Fortnox immediately find customer invoicing and supplier
invoices as top-level concepts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: journal entry detail view, correction chain, and account name display

- Add journal entry detail page at /bookkeeping/[id] with full entry view
- Add correction chain API and component showing storno relationships
- Add JournalEntryStatusBadge component for entry status display
- Show debit/credit account names in template picker and review dialogs
- Expand client-side BAS account name mapping with additional accounts
- Show account codes on transaction inbox suggestion buttons

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address review feedback — N+1 query, duplicate name, nav dedup

- Batch reverse-lookup into single query per BFS iteration (was N+1)
- Differentiate account 2393 from 2893 in display names
- Extract shared loop for desktop/mobile nav group rendering

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: counterparty templates, Skatteverket extension, VAT form completeness, and UI cleanup

- Add counterparty-based categorization templates (learned from user approvals
  and auto-ingestion) with fuzzy matching in the mapping engine
- Add Skatteverket extension for direct VAT declaration submission via API
- Complete VAT declaration form with all 30 SKV 4700 boxes (ruta 08, 35-42, 50, 60-62)
- Fix ruta 49 formula to include import VAT (ruta 60+61+62)
- Simplify dashboard UI: remove redundant icons from stat cards, customer cards,
  invoice list, supplier invoices; use Badge variants consistently
- Add SkatteverketPanel component to reports page
- Add categorization_templates and skatteverket_tokens migrations
- Update tests and helpers for new types

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address PR review feedback — VAT detection, migration timestamps, dedup

- Fix detectVatTreatment to derive actual rate (12%/6%) from VAT line
  description instead of hardcoding standard_25
- Rename skatteverket_tokens migration to 20260324120001 to avoid
  duplicate timestamp with categorization_templates (fixes Supabase
  deployment failure)
- Make refreshAccessToken accept previousRefreshCount param to enforce
  refresh limit contract at the type level
- Fix rate limiter TOCTOU by claiming slot before await
- Extract formatRedovisare/formatRedovisningsperiod to shared
  lib/skatteverket/format.ts — eliminates duplication between
  mappers.ts and SkatteverketPanel.tsx

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: multi-line counterparty templates, batch matching, settings restructure

Counterparty template engine:
- Multi-line booking patterns (line_pattern JSONB) for complex entries
  with split VAT, tax accounts, and ratio-based allocation
- Batch matching (findCounterpartyTemplatesBatch) — 1 DB query for all
  transactions instead of up to 3 per transaction
- SIE voucher population (populateTemplatesFromSieVouchers) — extracts
  patterns from historical vouchers on import with dominance filtering
- Source priority system (user_approved > sie_import > auto_learned)
- Centralized counterparty: prefix helpers to prevent string fragility
- Fix: re-approval path now updates line_pattern

Transaction categorization:
- /describe route returns counterparty_match in parallel with templates/AI
- /categorize route accepts counterparty_template_id for direct booking
- /suggest-categories uses batch matching, injects counterparty suggestions
- transaction-entries supports all_lines_complete for multi-line patterns

UI:
- TemplatePicker shows "Tidigare motparter" section (no AI extension needed)
- DescribeTransactionDialog shows counterparty match card with detail
- QuickReviewDialog supports counterparty line patterns
- JournalEntryPreview renders multi-line patterns with VAT/ratio math
- Inline LinePatternEntry types replaced with shared import from @/types

Settings restructure:
- 8 tabs → 5: merged Säkerhet + Utseende + Kalender into Konto
- Renamed "Motparter" → "Mallar"
- CounterpartyTemplatesPanel: click-to-expand detail view with account
  lines, VAT, confidence, aliases, and delete

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address PR review — account_override guard, DELETE body parsing, stale test

- Block account_override when counterparty_template_id is set (prevents
  corrupting stored template via override → upsert correction path)
- Wrap DELETE request.json() in try-catch for malformed body (400 not 500)
- Clean up stale 3-query mock enqueues in test for batch-based find

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 14:54:53 +01:00

733 lines
27 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 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é')
})
})
// ── 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)
})
})
})