feat: unified document inbox, full BAS 2026, and document-transaction matching

- Expand BAS reference from ~180 to ~1,276 accounts (full BAS Kontoplan 2026)
  with K2 exclusion flags, per-class data files, and computed SRU codes
- Evolve invoice inbox into unified document inbox handling invoices, receipts,
  and government letters with AI-powered classification (Claude Haiku Vision)
- Add multi-pass document-to-transaction matching engine with greedy assignment
  for both supplier invoices (reference/amount/date/name) and receipts
  (weighted amount/merchant/date scoring)
- Add supplier invoice matching in transaction ingest pipeline
- Inject booking template suggestions into AI extraction prompts
- Surface matched documents in swipe categorization UI with one-tap booking
- Auto-activate missing BAS accounts during SIE import against full reference
- Add K2 filter toggle in Chart of Accounts manager
- Add receipt confirmation route with BFNAR representation fields
- Add database migrations for K2 support and document matching columns
- Remove obsolete extension migration scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-02-25 16:59:02 +01:00
co-authored by Claude Opus 4.6
parent 6956a757f3
commit 39e407644d
68 changed files with 19234 additions and 2412 deletions
+238
View File
@@ -0,0 +1,238 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock server-only
vi.mock('server-only', () => ({}))
// Mock Anthropic SDK - vi.hoisted ensures the variable is available before vi.mock hoisting
const { mockCreate } = vi.hoisted(() => {
const mockCreate = vi.fn()
return { mockCreate }
})
vi.mock('@anthropic-ai/sdk', () => {
return {
default: class MockAnthropic {
messages = { create: mockCreate }
},
}
})
import { classifyDocument } from '../classifier'
function makeResponse(json: Record<string, unknown>) {
return {
content: [{ type: 'text', text: JSON.stringify(json) }],
}
}
describe('classifyDocument', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('classifies a supplier invoice', async () => {
mockCreate.mockResolvedValueOnce(
makeResponse({
type: 'supplier_invoice',
confidence: 0.95,
reasoning: 'Contains invoice number, bankgiro, and supplier details',
isReverseCharge: false,
})
)
const result = await classifyDocument('base64data', 'application/pdf')
expect(result.type).toBe('supplier_invoice')
expect(result.confidence).toBe(0.95)
expect(result.isReverseCharge).toBe(false)
})
it('classifies a receipt', async () => {
mockCreate.mockResolvedValueOnce(
makeResponse({
type: 'receipt',
confidence: 0.92,
reasoning: 'Store receipt with line items and total',
})
)
const result = await classifyDocument('base64data', 'image/jpeg')
expect(result.type).toBe('receipt')
expect(result.confidence).toBe(0.92)
expect(result.isReverseCharge).toBeUndefined()
})
it('classifies a government letter', async () => {
mockCreate.mockResolvedValueOnce(
makeResponse({
type: 'government_letter',
confidence: 0.88,
reasoning: 'Letter from Skatteverket',
})
)
const result = await classifyDocument('base64data', 'application/pdf')
expect(result.type).toBe('government_letter')
expect(result.confidence).toBe(0.88)
expect(result.isReverseCharge).toBeUndefined()
})
it('classifies unknown documents', async () => {
mockCreate.mockResolvedValueOnce(
makeResponse({
type: 'unknown',
confidence: 0.5,
reasoning: 'Cannot determine document type',
})
)
const result = await classifyDocument('base64data', 'image/png')
expect(result.type).toBe('unknown')
expect(result.confidence).toBe(0.5)
})
it('detects reverse charge on EU invoices', async () => {
mockCreate.mockResolvedValueOnce(
makeResponse({
type: 'supplier_invoice',
confidence: 0.93,
reasoning: 'EU invoice with reverse charge',
isReverseCharge: true,
})
)
const result = await classifyDocument('base64data', 'application/pdf')
expect(result.type).toBe('supplier_invoice')
expect(result.isReverseCharge).toBe(true)
})
it('retries on API error', async () => {
mockCreate
.mockRejectedValueOnce(new Error('API timeout'))
.mockResolvedValueOnce(
makeResponse({
type: 'receipt',
confidence: 0.9,
reasoning: 'Receipt',
})
)
const result = await classifyDocument('base64data', 'image/jpeg')
expect(result.type).toBe('receipt')
expect(mockCreate).toHaveBeenCalledTimes(2)
})
it('throws on JSON parse error without retry', async () => {
mockCreate.mockResolvedValueOnce({
content: [{ type: 'text', text: 'not valid json' }],
})
await expect(classifyDocument('base64data', 'image/jpeg')).rejects.toThrow(
'Failed to parse AI response'
)
expect(mockCreate).toHaveBeenCalledTimes(1)
})
it('throws on unsupported MIME type', async () => {
await expect(classifyDocument('base64data', 'text/plain')).rejects.toThrow(
'Unsupported file type: text/plain'
)
expect(mockCreate).not.toHaveBeenCalled()
})
it('falls back to unknown for invalid type values', async () => {
mockCreate.mockResolvedValueOnce(
makeResponse({
type: 'invalid_type',
confidence: 0.8,
reasoning: 'Test',
})
)
const result = await classifyDocument('base64data', 'image/jpeg')
expect(result.type).toBe('unknown')
})
it('handles PDF content blocks correctly', async () => {
mockCreate.mockResolvedValueOnce(
makeResponse({
type: 'supplier_invoice',
confidence: 0.95,
reasoning: 'PDF invoice',
isReverseCharge: false,
})
)
await classifyDocument('base64data', 'application/pdf')
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
messages: [
{
role: 'user',
content: expect.arrayContaining([
expect.objectContaining({ type: 'document' }),
]),
},
],
})
)
})
it('handles image content blocks correctly', async () => {
mockCreate.mockResolvedValueOnce(
makeResponse({
type: 'receipt',
confidence: 0.9,
reasoning: 'Image receipt',
})
)
await classifyDocument('base64data', 'image/png')
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
messages: [
{
role: 'user',
content: expect.arrayContaining([
expect.objectContaining({ type: 'image' }),
]),
},
],
})
)
})
it('strips markdown code blocks from response', async () => {
mockCreate.mockResolvedValueOnce({
content: [
{
type: 'text',
text: '```json\n{"type":"receipt","confidence":0.9,"reasoning":"Test"}\n```',
},
],
})
const result = await classifyDocument('base64data', 'image/jpeg')
expect(result.type).toBe('receipt')
})
it('throws after max retries', async () => {
mockCreate
.mockRejectedValueOnce(new Error('API error 1'))
.mockRejectedValueOnce(new Error('API error 2'))
.mockRejectedValueOnce(new Error('API error 3'))
await expect(classifyDocument('base64data', 'image/jpeg')).rejects.toThrow(
'Document classification failed after 3 attempts'
)
expect(mockCreate).toHaveBeenCalledTimes(3)
})
})
@@ -0,0 +1,107 @@
import { describe, it, expect } from 'vitest'
import {
levenshteinDistance,
normalizeMerchantName,
calculateMerchantSimilarity,
calculateMatchConfidence,
} from '../core-receipt-matcher'
describe('levenshteinDistance', () => {
it('returns 0 for identical strings', () => {
expect(levenshteinDistance('abc', 'abc')).toBe(0)
})
it('returns length of other string for empty string', () => {
expect(levenshteinDistance('', 'abc')).toBe(3)
expect(levenshteinDistance('abc', '')).toBe(3)
})
it('calculates correct edit distance', () => {
expect(levenshteinDistance('kitten', 'sitting')).toBe(3)
expect(levenshteinDistance('saturday', 'sunday')).toBe(3)
})
})
describe('normalizeMerchantName', () => {
it('lowercases and trims', () => {
expect(normalizeMerchantName(' ICA MAXI ')).toBe('ica maxi')
})
it('removes Swedish company suffixes', () => {
expect(normalizeMerchantName('Telia AB')).toBe('telia')
})
it('removes special characters but keeps Swedish letters', () => {
expect(normalizeMerchantName('Café Överkås!')).toBe('café överkås')
})
it('collapses whitespace', () => {
expect(normalizeMerchantName('ica maxi stockholm')).toBe('ica maxi stockholm')
})
})
describe('calculateMerchantSimilarity', () => {
it('returns 1 for exact match', () => {
expect(calculateMerchantSimilarity('ICA Maxi', 'ICA Maxi')).toBe(1)
})
it('returns 1 for match after normalization', () => {
expect(calculateMerchantSimilarity('Telia AB', 'telia')).toBe(1)
})
it('returns 0.9 when one contains the other', () => {
expect(calculateMerchantSimilarity('ICA', 'ICA MAXI STOCKHOLM')).toBe(0.9)
})
it('returns 0 for empty strings', () => {
expect(calculateMerchantSimilarity('', 'abc')).toBe(0)
expect(calculateMerchantSimilarity('abc', '')).toBe(0)
})
it('returns score between 0 and 1 for partial matches', () => {
const score = calculateMerchantSimilarity('ICA Maxi', 'Coop Forum')
expect(score).toBeGreaterThanOrEqual(0)
expect(score).toBeLessThanOrEqual(1)
})
it('gives high score for word overlap', () => {
const score = calculateMerchantSimilarity('ICA Maxi Stockholm', 'ICA Maxi Solna')
expect(score).toBeGreaterThan(0.7)
})
})
describe('calculateMatchConfidence', () => {
it('gives high confidence for exact date + amount + merchant', () => {
const { confidence, matchReasons } = calculateMatchConfidence(0, 0, 1.0)
expect(confidence).toBeGreaterThan(0.9)
expect(matchReasons).toContain('Exakt datum')
expect(matchReasons).toContain('Exakt belopp')
expect(matchReasons).toContain('Handlare matchar')
})
it('gives lower confidence when date is off', () => {
const exact = calculateMatchConfidence(0, 0, 1.0)
const dateOff = calculateMatchConfidence(2, 0, 1.0)
expect(dateOff.confidence).toBeLessThan(exact.confidence)
})
it('gives lower confidence when amount is off', () => {
const exact = calculateMatchConfidence(0, 0, 1.0)
const amountOff = calculateMatchConfidence(0, 0.03, 1.0)
expect(amountOff.confidence).toBeLessThan(exact.confidence)
})
it('gives lower confidence with no merchant similarity when other signals are imperfect', () => {
// With imperfect date/amount, missing merchant signal lowers overall confidence
const withMerchant = calculateMatchConfidence(1, 0.02, 0.8)
const noMerchant = calculateMatchConfidence(1, 0.02, 0)
expect(noMerchant.confidence).toBeLessThan(withMerchant.confidence)
})
it('respects custom tolerances', () => {
// With wider tolerance, same variance should give higher score
const narrow = calculateMatchConfidence(2, 0.03, 0.5, 3, 0.05)
const wide = calculateMatchConfidence(2, 0.03, 0.5, 7, 0.10)
expect(wide.confidence).toBeGreaterThan(narrow.confidence)
})
})
@@ -0,0 +1,352 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { matchDocumentToTransactions } from '../document-matcher'
import { makeInvoiceInboxItem, makeTransaction } from '@/tests/helpers'
import type { InvoiceExtractionResult, ReceiptExtractionResult, Transaction } from '@/types'
describe('matchDocumentToTransactions', () => {
const mockSupabase = {} as never // Not used when candidateTransactions is provided
beforeEach(() => {
vi.clearAllMocks()
})
describe('supplier_invoice matching', () => {
const baseExtraction: InvoiceExtractionResult = {
supplier: {
name: 'Telia AB',
orgNumber: '556103-4249',
vatNumber: 'SE556103424901',
address: 'Stockholm',
bankgiro: '5820-5093',
plusgiro: null,
},
invoice: {
invoiceNumber: 'INV-2024-001',
invoiceDate: '2024-06-10',
dueDate: '2024-06-20',
paymentReference: '73401284756',
currency: 'SEK',
},
lineItems: [],
totals: { subtotal: 800, vatAmount: 200, total: 1000 },
vatBreakdown: [],
confidence: 0.95,
}
it('returns null for government_letter type', async () => {
const item = makeInvoiceInboxItem({
document_type: 'government_letter',
extracted_data: baseExtraction as unknown as Record<string, unknown>,
})
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [])
expect(result).toBeNull()
})
it('returns null when no extracted_data', async () => {
const item = makeInvoiceInboxItem({ extracted_data: null })
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [])
expect(result).toBeNull()
})
it('returns null when no candidate transactions', async () => {
const item = makeInvoiceInboxItem({
status: 'ready',
extracted_data: baseExtraction as unknown as Record<string, unknown>,
})
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [])
expect(result).toBeNull()
})
it('pass 1: matches by payment reference with 0.98 confidence', async () => {
const item = makeInvoiceInboxItem({
status: 'ready',
extracted_data: baseExtraction as unknown as Record<string, unknown>,
})
const tx = makeTransaction({
amount: -1000,
reference: '73401284756',
date: '2024-06-20',
})
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
expect(result).not.toBeNull()
expect(result!.confidence).toBe(0.98)
expect(result!.method).toBe('payment_reference')
expect(result!.transactionId).toBe(tx.id)
})
it('pass 1: matches with whitespace/dash-normalized references', async () => {
const item = makeInvoiceInboxItem({
status: 'ready',
extracted_data: baseExtraction as unknown as Record<string, unknown>,
})
const tx = makeTransaction({
amount: -1000,
reference: '734 012 847 56',
date: '2024-06-20',
})
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
expect(result).not.toBeNull()
expect(result!.confidence).toBe(0.98)
expect(result!.method).toBe('payment_reference')
})
it('pass 2: matches by exact amount + bankgiro with 0.92 confidence', async () => {
const extractionNoRef = {
...baseExtraction,
invoice: { ...baseExtraction.invoice, paymentReference: null },
}
const item = makeInvoiceInboxItem({
status: 'ready',
extracted_data: extractionNoRef as unknown as Record<string, unknown>,
})
const tx = makeTransaction({
amount: -1000,
reference: null,
description: 'BETALNING 58205093',
date: '2024-06-20',
})
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
expect(result).not.toBeNull()
expect(result!.confidence).toBe(0.92)
expect(result!.method).toBe('payment_reference')
})
it('pass 3: matches by exact amount + date proximity with 0.85 confidence', async () => {
const extractionNoBg = {
...baseExtraction,
invoice: { ...baseExtraction.invoice, paymentReference: null },
supplier: { ...baseExtraction.supplier, bankgiro: null, plusgiro: null },
}
const item = makeInvoiceInboxItem({
status: 'ready',
extracted_data: extractionNoBg as unknown as Record<string, unknown>,
})
const tx = makeTransaction({
amount: -1000,
reference: null,
description: 'PAYMENT',
date: '2024-06-22',
merchant_name: null,
})
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
expect(result).not.toBeNull()
expect(result!.confidence).toBe(0.85)
expect(result!.method).toBe('amount_date')
})
it('pass 3: matches with lower confidence at 6–14 days', async () => {
const extractionNoBg = {
...baseExtraction,
invoice: { ...baseExtraction.invoice, paymentReference: null },
supplier: { ...baseExtraction.supplier, bankgiro: null, plusgiro: null },
}
const item = makeInvoiceInboxItem({
status: 'ready',
extracted_data: extractionNoBg as unknown as Record<string, unknown>,
})
const tx = makeTransaction({
amount: -1000,
reference: null,
description: 'PAYMENT',
date: '2024-06-28', // 8 days after due date
merchant_name: null,
})
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
expect(result).not.toBeNull()
expect(result!.confidence).toBe(0.75)
expect(result!.method).toBe('amount_date')
})
it('pass 3: does not match if date is >14 days away', async () => {
const extractionNoBg = {
...baseExtraction,
invoice: { ...baseExtraction.invoice, paymentReference: null },
supplier: { ...baseExtraction.supplier, bankgiro: null, plusgiro: null },
}
const item = makeInvoiceInboxItem({
status: 'ready',
extracted_data: extractionNoBg as unknown as Record<string, unknown>,
})
const tx = makeTransaction({
amount: -1000,
reference: null,
description: 'PAYMENT',
date: '2024-07-06', // 16 days after due date
merchant_name: null,
})
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
expect(result).toBeNull()
})
it('pass 4: matches by fuzzy amount + supplier name with 0.70 confidence', async () => {
const extractionMinimal = {
...baseExtraction,
invoice: {
...baseExtraction.invoice,
paymentReference: null,
dueDate: null,
invoiceDate: null,
},
supplier: {
...baseExtraction.supplier,
bankgiro: null,
plusgiro: null,
name: 'Telia Sverige',
},
}
const item = makeInvoiceInboxItem({
status: 'ready',
extracted_data: extractionMinimal as unknown as Record<string, unknown>,
})
const tx = makeTransaction({
amount: -1000,
reference: null,
description: 'telia faktura april',
date: '2024-06-15',
merchant_name: null,
})
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
expect(result).not.toBeNull()
expect(result!.confidence).toBe(0.70)
expect(result!.method).toBe('amount_merchant')
})
it('prefers higher confidence matches', async () => {
const item = makeInvoiceInboxItem({
status: 'ready',
extracted_data: baseExtraction as unknown as Record<string, unknown>,
})
const txWithRef = makeTransaction({
amount: -1000,
reference: '73401284756',
date: '2024-06-20',
})
const txWithAmount = makeTransaction({
amount: -1000,
reference: null,
description: 'BETALNING',
date: '2024-06-20',
})
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [txWithAmount, txWithRef])
expect(result!.confidence).toBe(0.98)
expect(result!.transactionId).toBe(txWithRef.id)
})
})
describe('receipt matching', () => {
const receiptExtraction: ReceiptExtractionResult = {
merchant: {
name: 'ICA Maxi',
orgNumber: null,
vatNumber: null,
isForeign: false,
},
receipt: {
date: '2024-06-15',
time: '14:30',
currency: 'SEK',
},
lineItems: [],
totals: { subtotal: 239.2, vatAmount: 59.8, total: 299 },
flags: {
isRestaurant: false,
isSystembolaget: false,
isForeignMerchant: false,
},
confidence: 0.92,
}
it('matches receipt to transaction with high confidence', async () => {
const item = makeInvoiceInboxItem({
document_type: 'receipt',
status: 'ready',
extracted_data: receiptExtraction as unknown as Record<string, unknown>,
})
const tx = makeTransaction({
amount: -299,
date: '2024-06-15',
merchant_name: 'ICA Maxi',
description: 'ICA MAXI STOCKHOLM',
})
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
expect(result).not.toBeNull()
expect(result!.method).toBe('receipt_match')
expect(result!.confidence).toBeGreaterThanOrEqual(0.60)
})
it('returns null when amount is too different', async () => {
const item = makeInvoiceInboxItem({
document_type: 'receipt',
status: 'ready',
extracted_data: receiptExtraction as unknown as Record<string, unknown>,
})
const tx = makeTransaction({
amount: -500,
date: '2024-06-15',
merchant_name: 'ICA Maxi',
})
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
expect(result).toBeNull()
})
it('returns null when date is too far away', async () => {
const item = makeInvoiceInboxItem({
document_type: 'receipt',
status: 'ready',
extracted_data: receiptExtraction as unknown as Record<string, unknown>,
})
const tx = makeTransaction({
amount: -299,
date: '2024-06-25', // 10 days after
merchant_name: 'ICA Maxi',
})
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
expect(result).toBeNull()
})
it('skips transactions with existing receipt_id', async () => {
const item = makeInvoiceInboxItem({
document_type: 'receipt',
status: 'ready',
extracted_data: receiptExtraction as unknown as Record<string, unknown>,
})
const tx = makeTransaction({
amount: -299,
date: '2024-06-15',
merchant_name: 'ICA Maxi',
receipt_id: 'existing-receipt',
})
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
expect(result).toBeNull()
})
it('returns null when total is 0 or null', async () => {
const zeroExtraction = {
...receiptExtraction,
totals: { ...receiptExtraction.totals, total: 0 },
}
const item = makeInvoiceInboxItem({
document_type: 'receipt',
status: 'ready',
extracted_data: zeroExtraction as unknown as Record<string, unknown>,
})
const tx = makeTransaction({ amount: -299, date: '2024-06-15' })
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
expect(result).toBeNull()
})
})
})