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()
})
})
})
+127
View File
@@ -0,0 +1,127 @@
/**
* Batch Document Matching
*
* Orchestrates matching multiple inbox items to transactions in a single sweep.
* Fetches all unbooked transactions once, then runs per-item matching with
* greedy assignment to prevent double-matching.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import type { InvoiceInboxItem, Transaction } from '@/types'
import { matchDocumentToTransactions, type DocumentMatchResult } from './document-matcher'
export interface BatchMatchResult {
matched: number
total: number
matches: Array<{ inboxItemId: string; result: DocumentMatchResult }>
}
/**
* Run a matching sweep for all ready unmatched inbox items.
*
* 1. Fetches all ready/processing inbox items without a matched_transaction_id
* 2. Fetches all unbooked expense transactions
* 3. Runs matching per item, greedily assigning (highest confidence first)
* 4. Persists matches back to inbox items
*/
export async function runDocumentMatchingSweep(
supabase: SupabaseClient,
userId: string,
inboxItemIds?: string[]
): Promise<BatchMatchResult> {
// 1. Fetch unmatched inbox items
let query = supabase
.from('invoice_inbox_items')
.select('*')
.eq('user_id', userId)
.is('matched_transaction_id', null)
.in('status', ['ready', 'processing'])
if (inboxItemIds && inboxItemIds.length > 0) {
query = query.in('id', inboxItemIds)
}
const { data: inboxItems, error: itemsError } = await query
if (itemsError || !inboxItems || inboxItems.length === 0) {
console.log(`[batch-match] No unmatched inbox items found`)
return { matched: 0, total: 0, matches: [] }
}
console.log(`[batch-match] Starting sweep: ${inboxItems.length} unmatched inbox items`)
// 2. Fetch all unbooked expense transactions (broad window: last 90 days)
const ninetyDaysAgo = new Date()
ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90)
const { data: transactions, error: txError } = await supabase
.from('transactions')
.select('*')
.eq('user_id', userId)
.is('journal_entry_id', null)
.is('is_business', null)
.lt('amount', 0)
.gte('date', ninetyDaysAgo.toISOString().split('T')[0])
.order('date', { ascending: false })
if (txError || !transactions || transactions.length === 0) {
console.log(`[batch-match] No candidate transactions found (last 90 days)`)
return { matched: 0, total: inboxItems.length, matches: [] }
}
console.log(`[batch-match] ${transactions.length} candidate transactions (last 90 days)`)
// 3. Run matching for each item and collect results
const pendingMatches: Array<{
inboxItemId: string
result: DocumentMatchResult
}> = []
for (const item of inboxItems as InvoiceInboxItem[]) {
const result = await matchDocumentToTransactions(
supabase,
userId,
item,
transactions as Transaction[]
)
if (result) {
pendingMatches.push({ inboxItemId: item.id, result })
}
}
// 4. Greedy assignment: sort by confidence desc, assign each transaction at most once
pendingMatches.sort((a, b) => b.result.confidence - a.result.confidence)
const assignedTransactionIds = new Set<string>()
const finalMatches: typeof pendingMatches = []
for (const match of pendingMatches) {
if (assignedTransactionIds.has(match.result.transactionId)) {
console.log(`[batch-match] Skipped item=${match.inboxItemId} → tx=${match.result.transactionId} (already assigned to higher-confidence match)`)
continue // Transaction already assigned to a higher-confidence match
}
assignedTransactionIds.add(match.result.transactionId)
finalMatches.push(match)
}
console.log(`[batch-match] Sweep complete: ${finalMatches.length}/${inboxItems.length} items matched, ${pendingMatches.length - finalMatches.length} skipped (greedy dedup)`)
// 5. Persist matches
for (const { inboxItemId, result } of finalMatches) {
await supabase
.from('invoice_inbox_items')
.update({
matched_transaction_id: result.transactionId,
match_confidence: result.confidence,
match_method: result.method,
})
.eq('id', inboxItemId)
.eq('user_id', userId)
}
return {
matched: finalMatches.length,
total: inboxItems.length,
matches: finalMatches,
}
}
+164
View File
@@ -0,0 +1,164 @@
/**
* Document Classifier using Claude Haiku Vision API
*
* SERVER-ONLY: This module uses the Anthropic SDK and must only be imported
* in server components or API routes.
*
* Classifies documents as supplier invoices, receipts, government letters,
* or unknown. Also detects EU reverse charge for supplier invoices.
*/
import 'server-only'
import Anthropic from '@anthropic-ai/sdk'
import type { DocumentClassificationType } from '@/types'
const anthropic = new Anthropic()
const MAX_RETRIES = 3
const RETRY_DELAY_MS = 1000
type ImageMediaType = 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif'
export interface DocumentClassification {
type: DocumentClassificationType
confidence: number
reasoning: string
isReverseCharge?: boolean
}
/**
* Classify a document using Claude Haiku Vision.
* Determines if it's a supplier invoice, receipt, government letter, or unknown.
*/
export async function classifyDocument(
base64: string,
mimeType: string
): Promise<DocumentClassification> {
const systemPrompt = `Du är expert på att klassificera svenska affärsdokument.
Din uppgift är att avgöra vilken typ av dokument som visas.
DOKUMENTTYPER:
- supplier_invoice: Leverantörsfaktura (har fakturanummer, bankgiro/plusgiro, förfallodatum, leverantörsuppgifter)
- receipt: Kvitto (butiks-/restaurangkvitto, kort betalningsbevis med artikelrader)
- government_letter: Myndighetspost (från Skatteverket, Bolagsverket, Försäkringskassan, kommun, etc.)
- unknown: Annat dokument som inte passar ovan
FÖR LEVERANTÖRSFAKTUROR - kontrollera även:
- Är fakturan från en utländsk/EU-leverantör utan svensk moms?
- Nämner dokumentet "reverse charge", "omvänd skattskyldighet", eller "artikel 196"?
- Har leverantören ett VAT-nummer som INTE börjar med SE?
Om ja: flagga isReverseCharge = true`
const userPrompt = `Klassificera detta dokument. Returnera ENDAST ett JSON-objekt:
{
"type": "supplier_invoice" | "receipt" | "government_letter" | "unknown",
"confidence": 0.95,
"reasoning": "Kort förklaring",
"isReverseCharge": false
}
Returnera ENDAST JSON-objektet, ingen annan text.`
const isPdf = mimeType === 'application/pdf'
const isImage = mimeType.startsWith('image/')
if (!isPdf && !isImage) {
throw new Error(`Unsupported file type: ${mimeType}`)
}
let lastError: Error | null = null
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
const contentBlocks: Anthropic.MessageCreateParams['messages'][0]['content'] = isPdf
? [
{
type: 'document' as const,
source: {
type: 'base64' as const,
media_type: 'application/pdf' as const,
data: base64,
},
},
{ type: 'text' as const, text: userPrompt },
]
: [
{
type: 'image' as const,
source: {
type: 'base64' as const,
media_type: mimeType as ImageMediaType,
data: base64,
},
},
{ type: 'text' as const, text: userPrompt },
]
const message = await anthropic.messages.create({
model: 'claude-haiku-4-5-20251001',
max_tokens: 1024,
messages: [{ role: 'user', content: contentBlocks }],
system: systemPrompt,
})
const content = message.content[0]
if (content.type !== 'text') {
throw new Error('Unexpected response type from AI')
}
let jsonText = content.text.trim()
if (jsonText.startsWith('```json')) {
jsonText = jsonText.slice(7)
} else if (jsonText.startsWith('```')) {
jsonText = jsonText.slice(3)
}
if (jsonText.endsWith('```')) {
jsonText = jsonText.slice(0, -3)
}
jsonText = jsonText.trim()
const parsed = JSON.parse(jsonText)
return validateClassification(parsed)
} catch (error) {
lastError = error instanceof Error ? error : new Error('Unknown error')
if (error instanceof SyntaxError) {
throw new Error(`Failed to parse AI response: ${lastError.message}`)
}
if (attempt < MAX_RETRIES - 1) {
await sleep(RETRY_DELAY_MS * (attempt + 1))
}
}
}
throw new Error(`Document classification failed after ${MAX_RETRIES} attempts: ${lastError?.message}`)
}
const VALID_TYPES: DocumentClassificationType[] = [
'supplier_invoice',
'receipt',
'government_letter',
'unknown',
]
function validateClassification(raw: unknown): DocumentClassification {
if (!raw || typeof raw !== 'object') {
throw new Error('Invalid classification result: not an object')
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = raw as any
const type = VALID_TYPES.includes(data.type) ? data.type : 'unknown'
const confidence = typeof data.confidence === 'number' ? data.confidence : 0.5
const reasoning = typeof data.reasoning === 'string' ? data.reasoning : ''
const isReverseCharge = type === 'supplier_invoice' ? Boolean(data.isReverseCharge) : undefined
return { type, confidence, reasoning, isReverseCharge }
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
+139
View File
@@ -0,0 +1,139 @@
/**
* Core Receipt Matcher — pure matching utility functions extracted from the
* receipt-ocr extension so they can be reused by the document matching engine.
*
* These are pure functions with no Supabase or extension dependencies.
*/
// Matching configuration (re-exported for consumers)
export const DATE_TOLERANCE_DAYS = 3
export const AMOUNT_TOLERANCE_PERCENT = 0.05
export const MIN_MATCH_CONFIDENCE = 0.4
/**
* Normalize a merchant name for comparison.
* Removes special characters, Swedish company suffixes, and extra whitespace.
*/
export function normalizeMerchantName(name: string): string {
return name
.toLowerCase()
.replace(/[^\w\såäöé]/g, '') // Remove special chars except Swedish letters
.replace(/\b(ab|hb|kb|ek|för|stiftelse)\b/g, '') // Remove company suffixes
.replace(/\s+/g, ' ')
.trim()
}
/**
* Calculate Levenshtein (edit) distance between two strings.
*/
export function levenshteinDistance(str1: string, str2: string): number {
const m = str1.length
const n = str2.length
const dp: number[][] = Array(m + 1)
.fill(null)
.map(() => Array(n + 1).fill(0))
for (let i = 0; i <= m; i++) dp[i][0] = i
for (let j = 0; j <= n; j++) dp[0][j] = j
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
const cost = str1[i - 1] === str2[j - 1] ? 0 : 1
dp[i][j] = Math.min(
dp[i - 1][j] + 1, // deletion
dp[i][j - 1] + 1, // insertion
dp[i - 1][j - 1] + cost // substitution
)
}
}
return dp[m][n]
}
/**
* Calculate merchant name similarity using Levenshtein distance and word overlap.
* Returns a value between 0 (no match) and 1 (exact match).
*/
export function calculateMerchantSimilarity(name1: string, name2: string): number {
if (!name1 || !name2) return 0
const n1 = normalizeMerchantName(name1)
const n2 = normalizeMerchantName(name2)
// Exact match
if (n1 === n2) return 1
// One contains the other
if (n1.includes(n2) || n2.includes(n1)) return 0.9
// Word overlap
const words1 = n1.split(/\s+/)
const words2 = n2.split(/\s+/)
const commonWords = words1.filter((w) => words2.includes(w))
if (commonWords.length > 0) {
const overlapScore = commonWords.length / Math.max(words1.length, words2.length)
if (overlapScore >= 0.5) return 0.7 + overlapScore * 0.2
}
// Levenshtein similarity
const distance = levenshteinDistance(n1, n2)
const maxLength = Math.max(n1.length, n2.length)
return 1 - distance / maxLength
}
/**
* Calculate a weighted match confidence score from date, amount, and merchant signals.
* Weights: amount 40%, merchant 35%, date 25%.
*
* When merchant similarity is 0, the merchant weight is excluded from the
* total weight so the confidence is normalized across the active signals only.
*/
export function calculateMatchConfidence(
dateVariance: number,
amountVariance: number,
merchantSimilarity: number,
dateTolerance: number = DATE_TOLERANCE_DAYS,
amountTolerance: number = AMOUNT_TOLERANCE_PERCENT
): { confidence: number; matchReasons: string[] } {
const matchReasons: string[] = []
let totalWeight = 0
let weightedScore = 0
// Date score (weight: 25%)
const dateScore = Math.max(0, 1 - dateVariance / dateTolerance)
if (dateScore >= 0.8) {
matchReasons.push(dateVariance === 0 ? 'Exakt datum' : `Datum ±${Math.round(dateVariance)} dagar`)
}
weightedScore += dateScore * 0.25
totalWeight += 0.25
// Amount score (weight: 40%)
const amountScore = Math.max(0, 1 - amountVariance / amountTolerance)
if (amountVariance < 0.01) {
matchReasons.push('Exakt belopp')
} else if (amountVariance < amountTolerance) {
matchReasons.push(`Belopp ±${Math.round(amountVariance * 100)}%`)
}
weightedScore += amountScore * 0.4
totalWeight += 0.4
// Merchant score (weight: 35%) — only counted when there's data
if (merchantSimilarity > 0) {
if (merchantSimilarity >= 0.9) {
matchReasons.push('Handlare matchar')
} else if (merchantSimilarity >= 0.6) {
matchReasons.push('Trolig handlarmatch')
}
weightedScore += merchantSimilarity * 0.35
totalWeight += 0.35
}
const confidence = totalWeight > 0 ? weightedScore / totalWeight : 0
return {
confidence: Math.round(confidence * 100) / 100,
matchReasons,
}
}
+322
View File
@@ -0,0 +1,322 @@
/**
* Document-to-Transaction Matcher
*
* Pure matching logic that works from extracted data already stored on inbox items.
* Zero AI or extension dependencies — works entirely from structured data.
*
* Matching passes by document type:
*
* Supplier invoices:
* 1. Payment reference exact match → 0.98
* 2. Exact amount + bankgiro → 0.92
* 3. Exact amount + date ±5 days → 0.85
* 4. Fuzzy amount + supplier name → 0.70
*
* Receipts:
* Weighted scoring (amount 40%, date 25%, merchant 35%), min confidence 0.60
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import type { InvoiceInboxItem, Transaction, InvoiceExtractionResult, ReceiptExtractionResult } from '@/types'
import {
calculateMerchantSimilarity,
calculateMatchConfidence,
} from './core-receipt-matcher'
export type DocumentMatchMethod =
| 'payment_reference'
| 'amount_date'
| 'amount_merchant'
| 'receipt_match'
export interface DocumentMatchResult {
transactionId: string
confidence: number
method: DocumentMatchMethod
matchReasons: string[]
}
/**
* Match a single inbox item to the best candidate transaction.
*
* If `candidateTransactions` is not provided, fetches unbooked expense
* transactions within ±7 days of the document date.
*/
export async function matchDocumentToTransactions(
supabase: SupabaseClient,
userId: string,
inboxItem: InvoiceInboxItem,
candidateTransactions?: Transaction[]
): Promise<DocumentMatchResult | null> {
const tag = `[document-matcher] item=${inboxItem.id} type=${inboxItem.document_type}`
// Only match supplier invoices and receipts
if (inboxItem.document_type === 'government_letter' || inboxItem.document_type === 'unknown') {
console.log(`${tag} — skipped (unsupported document type)`)
return null
}
if (!inboxItem.extracted_data) {
console.log(`${tag} — skipped (no extracted_data)`)
return null
}
const transactions = candidateTransactions ?? (await fetchCandidateTransactions(supabase, userId, inboxItem))
console.log(`${tag} — ${transactions.length} candidate transactions`)
if (transactions.length === 0) {
console.log(`${tag} — no candidates, aborting`)
return null
}
let result: DocumentMatchResult | null = null
if (inboxItem.document_type === 'supplier_invoice') {
result = matchSupplierInvoiceDocument(inboxItem, transactions)
} else if (inboxItem.document_type === 'receipt') {
result = matchReceiptDocument(inboxItem, transactions)
}
if (result) {
console.log(`${tag} — MATCHED tx=${result.transactionId} confidence=${result.confidence} method=${result.method} reasons=[${result.matchReasons.join(', ')}]`)
} else {
console.log(`${tag} — no match found`)
}
return result
}
/**
* Fetch unbooked expense transactions within ±7 days of the document date.
*/
async function fetchCandidateTransactions(
supabase: SupabaseClient,
userId: string,
inboxItem: InvoiceInboxItem
): Promise<Transaction[]> {
const docDate = getDocumentDate(inboxItem)
if (!docDate) return []
const startDate = new Date(docDate)
startDate.setDate(startDate.getDate() - 7)
const endDate = new Date(docDate)
endDate.setDate(endDate.getDate() + 7)
const { data, error } = await supabase
.from('transactions')
.select('*')
.eq('user_id', userId)
.is('journal_entry_id', null)
.is('is_business', null)
.lt('amount', 0)
.gte('date', startDate.toISOString().split('T')[0])
.lte('date', endDate.toISOString().split('T')[0])
.order('date', { ascending: false })
if (error || !data) return []
return data as Transaction[]
}
/**
* Extract the most relevant date from an inbox item's extracted data.
*/
function getDocumentDate(inboxItem: InvoiceInboxItem): string | null {
const data = inboxItem.extracted_data as Record<string, unknown> | null
if (!data) return null
if (inboxItem.document_type === 'supplier_invoice') {
const extraction = data as unknown as InvoiceExtractionResult
return extraction.invoice?.dueDate ?? extraction.invoice?.invoiceDate ?? null
}
if (inboxItem.document_type === 'receipt') {
const extraction = data as unknown as ReceiptExtractionResult
return extraction.receipt?.date ?? null
}
return null
}
/**
* Match a supplier invoice inbox item to transactions using a 4-pass algorithm.
*/
function matchSupplierInvoiceDocument(
inboxItem: InvoiceInboxItem,
transactions: Transaction[]
): DocumentMatchResult | null {
const tag = `[document-matcher:supplier] item=${inboxItem.id}`
const extraction = inboxItem.extracted_data as unknown as InvoiceExtractionResult
if (!extraction) return null
const invoiceTotal = extraction.totals?.total
if (invoiceTotal == null || invoiceTotal === 0) {
console.log(`${tag} — no invoice total in extracted data`)
return null
}
const paymentRef = extraction.invoice?.paymentReference
const bankgiro = extraction.supplier?.bankgiro
const plusgiro = extraction.supplier?.plusgiro
const supplierName = extraction.supplier?.name
const dueDate = extraction.invoice?.dueDate ?? extraction.invoice?.invoiceDate
console.log(`${tag} — extracted: total=${invoiceTotal}, supplier=${supplierName || '?'}, dueDate=${dueDate || '?'}, paymentRef=${paymentRef || '?'}, bankgiro=${bankgiro || '?'}, templateId=${extraction.suggestedTemplateId || '?'}`)
let bestMatch: DocumentMatchResult | null = null
for (const tx of transactions) {
const txAmount = Math.abs(tx.amount)
const txDesc = (tx.description || '').toLowerCase()
const txRef = tx.reference || ''
// Pass 1: Payment reference exact match → 0.98
if (paymentRef && txRef) {
const normTxRef = txRef.replace(/\D/g, '')
const normPayRef = paymentRef.replace(/\D/g, '')
if (normTxRef && normPayRef && normTxRef === normPayRef) {
console.log(`${tag} — Pass 1 HIT: tx=${tx.id} ref=${normPayRef}`)
return {
transactionId: tx.id,
confidence: 0.98,
method: 'payment_reference',
matchReasons: ['Betalningsreferens matchar'],
}
}
}
// Pass 2: Exact amount + bankgiro/plusgiro → 0.92
const amountMatch = Math.abs(txAmount - invoiceTotal) < 0.005
if (amountMatch) {
const bgNorm = bankgiro?.replace(/\D/g, '')
const pgNorm = plusgiro?.replace(/\D/g, '')
const hasBgMatch = bgNorm && txDesc.includes(bgNorm)
const hasPgMatch = pgNorm && txDesc.includes(pgNorm)
if (hasBgMatch || hasPgMatch) {
console.log(`${tag} — Pass 2 HIT: tx=${tx.id} amount=${txAmount} bg/pg match`)
return {
transactionId: tx.id,
confidence: 0.92,
method: 'payment_reference',
matchReasons: ['Exakt belopp', hasBgMatch ? 'Bankgiro matchar' : 'Plusgiro matchar'],
}
}
}
// Pass 3: Exact amount + date ±14 days → 0.85 (close) / 0.75 (wider)
// Invoices are often paid early or a few days late, so we use a 14-day window.
if (amountMatch && dueDate) {
const txDate = new Date(tx.date)
const docDate = new Date(dueDate)
const diffDays = Math.abs((txDate.getTime() - docDate.getTime()) / (1000 * 60 * 60 * 24))
if (diffDays <= 14) {
// Higher confidence for close dates, lower for wider window
const confidence = diffDays <= 5 ? 0.85 : 0.75
console.log(`${tag} — Pass 3 HIT: tx=${tx.id} amount=${txAmount} date_diff=${diffDays.toFixed(1)}d → confidence=${confidence}`)
const candidate: DocumentMatchResult = {
transactionId: tx.id,
confidence,
method: 'amount_date',
matchReasons: ['Exakt belopp', diffDays === 0 ? 'Exakt datum' : `Datum ±${Math.round(diffDays)} dagar`],
}
if (!bestMatch || candidate.confidence > bestMatch.confidence) {
bestMatch = candidate
}
}
}
// Pass 4: Fuzzy amount (±1%) + supplier name in description → 0.70
const fuzzyAmountMatch = Math.abs(txAmount - invoiceTotal) / invoiceTotal <= 0.01
if (fuzzyAmountMatch && supplierName) {
const normalizedName = supplierName.toLowerCase().replace(/[^\w\såäöé]/g, '')
const nameWords = normalizedName.split(/\s+/).filter((w) => w.length >= 3)
const nameInDesc = nameWords.some((word) => txDesc.includes(word))
if (nameInDesc) {
console.log(`${tag} — Pass 4 HIT: tx=${tx.id} amount=${txAmount} (~${((Math.abs(txAmount - invoiceTotal) / invoiceTotal) * 100).toFixed(1)}%) name words=[${nameWords.join(',')}]`)
const candidate: DocumentMatchResult = {
transactionId: tx.id,
confidence: 0.70,
method: 'amount_merchant',
matchReasons: ['Belopp matchar (±1%)', 'Leverantörsnamn i beskrivning'],
}
if (!bestMatch || candidate.confidence > bestMatch.confidence) {
bestMatch = candidate
}
}
}
}
return bestMatch
}
/**
* Match a receipt inbox item to transactions using weighted scoring.
* Weights: amount 40%, date 25%, merchant 35%. Min confidence: 0.60.
*/
function matchReceiptDocument(
inboxItem: InvoiceInboxItem,
transactions: Transaction[]
): DocumentMatchResult | null {
const tag = `[document-matcher:receipt] item=${inboxItem.id}`
const extraction = inboxItem.extracted_data as unknown as ReceiptExtractionResult
if (!extraction) return null
const receiptTotal = extraction.totals?.total
const receiptDate = extraction.receipt?.date
const merchantName = extraction.merchant?.name
if (receiptTotal == null || receiptTotal === 0) {
console.log(`${tag} — no receipt total in extracted data`)
return null
}
console.log(`${tag} — extracted: total=${receiptTotal}, date=${receiptDate || '?'}, merchant=${merchantName || '?'}, templateId=${extraction.suggestedTemplateId || '?'}`)
let bestMatch: DocumentMatchResult | null = null
for (const tx of transactions) {
if (tx.receipt_id) continue // Skip already matched
const txAmount = Math.abs(tx.amount)
const txDate = new Date(tx.date)
// Calculate date variance
const dateVariance = receiptDate
? Math.abs((new Date(receiptDate).getTime() - txDate.getTime()) / (1000 * 60 * 60 * 24))
: 3 // Default to tolerance boundary if no date
if (dateVariance > 3) continue
// Calculate amount variance
const amountVariance = Math.abs(receiptTotal - txAmount) / receiptTotal
if (amountVariance > 0.05) continue // Skip if >5% off
// Calculate merchant similarity
const txMerchant = tx.merchant_name || tx.description || ''
const merchantSimilarity = merchantName
? calculateMerchantSimilarity(merchantName, txMerchant)
: 0
const { confidence, matchReasons } = calculateMatchConfidence(
dateVariance,
amountVariance,
merchantSimilarity
)
console.log(`${tag} — scoring tx=${tx.id} "${tx.description}": date_var=${dateVariance.toFixed(1)}d amount_var=${(amountVariance * 100).toFixed(1)}% merchant_sim=${merchantSimilarity.toFixed(2)} → confidence=${confidence}`)
if (confidence >= 0.60 && (!bestMatch || confidence > bestMatch.confidence)) {
bestMatch = {
transactionId: tx.id,
confidence,
method: 'receipt_match',
matchReasons,
}
}
}
return bestMatch
}