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:
co-authored by
Claude Opus 4.6
parent
6956a757f3
commit
39e407644d
@@ -46,10 +46,19 @@ export interface CategorizationContext {
|
||||
recentHistory: { description: string; category: string }[]
|
||||
}
|
||||
|
||||
export interface DocumentEnrichment {
|
||||
type: 'receipt' | 'supplier_invoice'
|
||||
merchantName?: string
|
||||
lineItems?: Array<{ description: string; amount: number; category?: string; accountSuggestion?: string }>
|
||||
vatBreakdown?: Array<{ rate: number; amount: number }>
|
||||
isReverseCharge?: boolean
|
||||
}
|
||||
|
||||
export interface EnrichedCategorizationContext extends CategorizationContext {
|
||||
candidateTemplates: BookingTemplate[]
|
||||
userAccountUsage: AccountUsageEntry[]
|
||||
merchantHistory: MerchantHistoryEntry[]
|
||||
documentData?: DocumentEnrichment
|
||||
}
|
||||
|
||||
export interface CategorizationSuggestion {
|
||||
@@ -223,6 +232,11 @@ export class AnthropicCategorizationProvider implements CategorizationProvider {
|
||||
.join('\n')}`
|
||||
: ''
|
||||
|
||||
// Build document enrichment context (from linked receipt or supplier invoice)
|
||||
const documentContext = enriched?.documentData
|
||||
? buildDocumentContext(enriched.documentData)
|
||||
: ''
|
||||
|
||||
const systemPrompt = `Du är expert på svensk bokföring och kategorisering av banktransaktioner enligt BAS-kontoplanen.
|
||||
Din uppgift är att kategorisera varje transaktion till rätt mall-ID (templateId) och BAS-konto.
|
||||
|
||||
@@ -243,7 +257,7 @@ MOMSHANTERING:
|
||||
- Intäkter: Normalt 25% moms (utgående moms, MP1)
|
||||
|
||||
${NON_DEDUCTIBLE_RULES}
|
||||
|
||||
${documentContext}
|
||||
REGLER:
|
||||
1. Negativa belopp = utgifter, positiva = intäkter
|
||||
2. VIKTIGT: Dessa transaktioner kommer från företagets bankkonto/kort. Anta ALLTID att de är affärsrelaterade. Klassificera ALDRIG som "private" — det beslutet tar användaren själv.
|
||||
@@ -382,6 +396,42 @@ ${transactionList}`
|
||||
}
|
||||
}
|
||||
|
||||
function buildDocumentContext(doc: DocumentEnrichment): string {
|
||||
const parts: string[] = []
|
||||
|
||||
const typeLabel = doc.type === 'receipt' ? 'KVITTO' : 'LEVERANTÖRSFAKTURA'
|
||||
parts.push(`LÄNKAT DOKUMENT (${typeLabel}):`)
|
||||
|
||||
if (doc.merchantName) {
|
||||
parts.push(`Handlare/leverantör: ${doc.merchantName}`)
|
||||
}
|
||||
|
||||
if (doc.lineItems && doc.lineItems.length > 0) {
|
||||
parts.push('Rader:')
|
||||
for (const item of doc.lineItems) {
|
||||
let line = `- ${item.description}: ${item.amount} kr`
|
||||
if (item.accountSuggestion) line += ` (föreslaget konto: ${item.accountSuggestion})`
|
||||
if (item.category) line += ` [${item.category}]`
|
||||
parts.push(line)
|
||||
}
|
||||
}
|
||||
|
||||
if (doc.vatBreakdown && doc.vatBreakdown.length > 0) {
|
||||
parts.push('Momsfördelning:')
|
||||
for (const vat of doc.vatBreakdown) {
|
||||
parts.push(`- ${vat.rate}%: ${vat.amount} kr`)
|
||||
}
|
||||
}
|
||||
|
||||
if (doc.isReverseCharge) {
|
||||
parts.push(`VIKTIGT: Omvänd skattskyldighet (reverse charge). Använd dubbelkontering:
|
||||
- Debitera 2645 (beräknad ingående moms) OCH kreditera 2614 (utgående moms, omvänd skattskyldighet)
|
||||
- Mallen "purchase_eu_service_reverse_charge" ska användas om tillgänglig`)
|
||||
}
|
||||
|
||||
return parts.join('\n') + '\n'
|
||||
}
|
||||
|
||||
function isEnrichedContext(
|
||||
ctx: CategorizationContext | EnrichedCategorizationContext
|
||||
): ctx is EnrichedCategorizationContext {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import 'server-only'
|
||||
import Anthropic from '@anthropic-ai/sdk'
|
||||
import type { InvoiceExtractionResult, ExtractedInvoiceLineItem, VatBreakdownItem } from '../types'
|
||||
import { buildTemplatePromptSection } from '@/lib/bookkeeping/template-prompt'
|
||||
|
||||
const anthropic = new Anthropic()
|
||||
|
||||
@@ -40,6 +41,8 @@ VIKTIGT:
|
||||
- Belopp ska vara numeriska värden utan valutasymboler
|
||||
- Ange konfidenstal (0.0-1.0) för hela extraheringen`
|
||||
|
||||
const templateSection = buildTemplatePromptSection()
|
||||
|
||||
const userPrompt = `Analysera denna leverantörsfaktura och extrahera strukturerad data.
|
||||
|
||||
Returnera ett JSON-objekt med följande struktur:
|
||||
@@ -67,7 +70,8 @@ Returnera ett JSON-objekt med följande struktur:
|
||||
"unitPrice": 100.00,
|
||||
"lineTotal": 100.00,
|
||||
"vatRate": 25,
|
||||
"accountSuggestion": "BAS-kontonummer som 5410 eller null"
|
||||
"accountSuggestion": "BAS-kontonummer som 5410 eller null",
|
||||
"suggestedTemplateId": "mall-id eller null"
|
||||
}
|
||||
],
|
||||
"totals": {
|
||||
@@ -82,10 +86,13 @@ Returnera ett JSON-objekt med följande struktur:
|
||||
"amount": 25.00
|
||||
}
|
||||
],
|
||||
"confidence": 0.95
|
||||
"confidence": 0.95,
|
||||
"suggestedTemplateId": "mall-id för hela fakturan eller null"
|
||||
}
|
||||
|
||||
KONTOKATEGORIER (BAS):
|
||||
${templateSection}
|
||||
|
||||
KONTOKATEGORIER (BAS, backup om ingen mall matchar):
|
||||
- 4000-4999: Varuinköp, material
|
||||
- 5010: Lokalhyra
|
||||
- 5410: Förbrukningsinventarier
|
||||
@@ -212,6 +219,7 @@ function validateAndEnhanceResult(raw: unknown): InvoiceExtractionResult {
|
||||
},
|
||||
vatBreakdown: validateVatBreakdown(data.vatBreakdown),
|
||||
confidence: validateNumber(data.confidence) || 0.5,
|
||||
suggestedTemplateId: validateString(data.suggestedTemplateId) || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,6 +236,7 @@ function validateLineItems(data: any): ExtractedInvoiceLineItem[] {
|
||||
lineTotal: validateNumber(item.lineTotal) || 0,
|
||||
vatRate: validateNumber(item.vatRate),
|
||||
accountSuggestion: validateAccountNumber(item.accountSuggestion as string | undefined),
|
||||
suggestedTemplateId: validateString(item.suggestedTemplateId as string | undefined) || undefined,
|
||||
}))
|
||||
.filter((item: ExtractedInvoiceLineItem) => item.lineTotal > 0 || item.description.length > 0)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
|
||||
// Mock server-only
|
||||
vi.mock('server-only', () => ({}))
|
||||
|
||||
// Mock receipt analyzer
|
||||
vi.mock('../receipt-analyzer', () => ({
|
||||
analyzeReceipt: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock receipt categorizer
|
||||
vi.mock('../receipt-categorizer', () => ({
|
||||
processLineItems: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock receipt matcher
|
||||
vi.mock('../receipt-matcher', () => ({
|
||||
autoMatchReceipts: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock event bus
|
||||
vi.mock('@/lib/events/bus', () => ({
|
||||
eventBus: { emit: vi.fn(), clear: vi.fn() },
|
||||
}))
|
||||
|
||||
import { processReceiptFromDocument } from '../receipt-pipeline'
|
||||
import { analyzeReceipt } from '../receipt-analyzer'
|
||||
import { processLineItems } from '../receipt-categorizer'
|
||||
import { autoMatchReceipts } from '../receipt-matcher'
|
||||
|
||||
function createMockSupabase() {
|
||||
const mockResult = { data: null, error: null }
|
||||
|
||||
const chain = {
|
||||
insert: vi.fn().mockReturnThis(),
|
||||
update: vi.fn().mockReturnThis(),
|
||||
select: vi.fn().mockReturnThis(),
|
||||
single: vi.fn().mockImplementation(() => Promise.resolve(mockResult)),
|
||||
eq: vi.fn().mockReturnThis(),
|
||||
is: vi.fn().mockReturnThis(),
|
||||
lt: vi.fn().mockReturnThis(),
|
||||
gte: vi.fn().mockReturnThis(),
|
||||
lte: vi.fn().mockReturnThis(),
|
||||
}
|
||||
|
||||
const supabase = {
|
||||
from: vi.fn().mockReturnValue(chain),
|
||||
}
|
||||
|
||||
return { supabase, chain, setResult: (data: unknown, error: unknown = null) => {
|
||||
mockResult.data = data as null
|
||||
mockResult.error = error as null
|
||||
} }
|
||||
}
|
||||
|
||||
const mockExtraction = {
|
||||
merchant: {
|
||||
name: 'ICA Maxi',
|
||||
orgNumber: '556123-4567',
|
||||
vatNumber: null,
|
||||
isForeign: false,
|
||||
},
|
||||
receipt: {
|
||||
date: '2024-06-15',
|
||||
time: '14:30',
|
||||
currency: 'SEK',
|
||||
},
|
||||
lineItems: [
|
||||
{ description: 'Mjölk', quantity: 1, unitPrice: 15, lineTotal: 15, vatRate: 12, suggestedCategory: 'other' },
|
||||
],
|
||||
totals: { subtotal: 13.39, vatAmount: 1.61, total: 15 },
|
||||
flags: { isRestaurant: false, isSystembolaget: false, isForeignMerchant: false },
|
||||
confidence: 0.92,
|
||||
}
|
||||
|
||||
describe('processReceiptFromDocument', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
vi.mocked(analyzeReceipt).mockResolvedValue(mockExtraction)
|
||||
vi.mocked(processLineItems).mockReturnValue([
|
||||
{ ...mockExtraction.lineItems[0], category: 'expense_other' as const, basAccount: '6991', confidence: 0.8 },
|
||||
])
|
||||
vi.mocked(autoMatchReceipts).mockReturnValue([])
|
||||
})
|
||||
|
||||
it('creates receipt record with extracted data', async () => {
|
||||
const { supabase, chain, setResult } = createMockSupabase()
|
||||
|
||||
const receipt = {
|
||||
id: 'receipt-1',
|
||||
user_id: 'user-1',
|
||||
status: 'extracted',
|
||||
merchant_name: 'ICA Maxi',
|
||||
total_amount: 15,
|
||||
receipt_date: '2024-06-15',
|
||||
}
|
||||
|
||||
// First from() = receipts insert
|
||||
setResult(receipt)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await processReceiptFromDocument(supabase as any, 'user-1', 'base64data', 'image/jpeg', {
|
||||
documentId: 'doc-1',
|
||||
source: 'email',
|
||||
emailFrom: 'sender@example.com',
|
||||
storageUrl: 'https://storage.example.com/file.jpg',
|
||||
})
|
||||
|
||||
expect(result.receipt.id).toBe('receipt-1')
|
||||
expect(analyzeReceipt).toHaveBeenCalledWith('base64data', 'image/jpeg')
|
||||
expect(processLineItems).toHaveBeenCalledWith(mockExtraction.lineItems)
|
||||
|
||||
// Verify receipt insert includes source and email_from
|
||||
expect(chain.insert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: 'email',
|
||||
email_from: 'sender@example.com',
|
||||
document_id: 'doc-1',
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('emits receipt.extracted event', async () => {
|
||||
const { supabase, setResult } = createMockSupabase()
|
||||
const receipt = { id: 'receipt-1', user_id: 'user-1' }
|
||||
setResult(receipt)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await processReceiptFromDocument(supabase as any, 'user-1', 'base64data', 'image/jpeg', {
|
||||
documentId: 'doc-1',
|
||||
source: 'upload',
|
||||
storageUrl: 'https://storage.example.com/file.jpg',
|
||||
})
|
||||
|
||||
expect(eventBus.emit).toHaveBeenCalledWith({
|
||||
type: 'receipt.extracted',
|
||||
payload: expect.objectContaining({
|
||||
receipt,
|
||||
documentId: 'doc-1',
|
||||
confidence: 0.92,
|
||||
userId: 'user-1',
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('throws when receipt insert fails', async () => {
|
||||
const { supabase, setResult } = createMockSupabase()
|
||||
setResult(null, { message: 'insert failed' })
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await expect(processReceiptFromDocument(supabase as any, 'user-1', 'base64data', 'image/jpeg', {
|
||||
documentId: null,
|
||||
source: 'upload',
|
||||
storageUrl: 'https://storage.example.com/file.jpg',
|
||||
})).rejects.toThrow('Failed to create receipt')
|
||||
})
|
||||
|
||||
it('sets email_from to null when not provided', async () => {
|
||||
const { supabase, chain, setResult } = createMockSupabase()
|
||||
setResult({ id: 'receipt-1', user_id: 'user-1' })
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await processReceiptFromDocument(supabase as any, 'user-1', 'base64data', 'image/jpeg', {
|
||||
documentId: null,
|
||||
source: 'upload',
|
||||
storageUrl: 'https://storage.example.com/file.jpg',
|
||||
})
|
||||
|
||||
expect(chain.insert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: 'upload',
|
||||
email_from: null,
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -14,6 +14,7 @@ import { ChatAnthropic } from '@langchain/anthropic'
|
||||
import { HumanMessage, SystemMessage } from '@langchain/core/messages'
|
||||
import { z } from 'zod'
|
||||
import type { ReceiptExtractionResult, ExtractedLineItem } from '@/types'
|
||||
import { buildTemplatePromptSection } from '@/lib/bookkeeping/template-prompt'
|
||||
import {
|
||||
SYSTEMBOLAGET_PATTERNS,
|
||||
RESTAURANT_PATTERNS,
|
||||
@@ -54,6 +55,8 @@ VIKTIGT:
|
||||
- Belopp ska vara numeriska värden utan valutasymboler
|
||||
- Ange konfidenstal (0.0-1.0) för hela extraheringen baserat på bildkvalitet`
|
||||
|
||||
const templateSection = buildTemplatePromptSection()
|
||||
|
||||
const userPrompt = `Analysera detta kvitto och extrahera strukturerad data.
|
||||
|
||||
Returnera ett JSON-objekt med följande struktur:
|
||||
@@ -77,7 +80,8 @@ Returnera ett JSON-objekt med följande struktur:
|
||||
"unitPrice": 100.00,
|
||||
"lineTotal": 100.00,
|
||||
"vatRate": 25,
|
||||
"suggestedCategory": "equipment|software|travel|office|marketing|professional_services|education|other"
|
||||
"suggestedCategory": "equipment|software|travel|office|marketing|professional_services|education|other",
|
||||
"suggestedTemplateId": "mall-id eller null"
|
||||
}
|
||||
],
|
||||
"totals": {
|
||||
@@ -90,10 +94,13 @@ Returnera ett JSON-objekt med följande struktur:
|
||||
"isSystembolaget": false,
|
||||
"isForeignMerchant": false
|
||||
},
|
||||
"confidence": 0.95
|
||||
"confidence": 0.95,
|
||||
"suggestedTemplateId": "mall-id för hela kvittot eller null"
|
||||
}
|
||||
|
||||
KATEGORIER för suggestedCategory:
|
||||
${templateSection}
|
||||
|
||||
KATEGORIER för suggestedCategory (backup om ingen mall matchar):
|
||||
- equipment: Datorer, telefoner, kameror, teknikprylar
|
||||
- software: Program, appar, molntjänster, prenumerationer
|
||||
- travel: Flyg, tåg, hotell, taxi
|
||||
@@ -224,6 +231,7 @@ function validateAndEnhanceResult(raw: unknown): ReceiptExtractionResult {
|
||||
isForeignMerchant: isForeign,
|
||||
},
|
||||
confidence: validateNumber(data.confidence) || 0.5,
|
||||
suggestedTemplateId: validateString(data.suggestedTemplateId) || undefined,
|
||||
}
|
||||
|
||||
return result
|
||||
@@ -245,6 +253,7 @@ function validateLineItems(data: any): ExtractedLineItem[] {
|
||||
lineTotal: validateNumber(item.lineTotal) || 0,
|
||||
vatRate: validateNumber(item.vatRate),
|
||||
suggestedCategory: validateCategory(item.suggestedCategory),
|
||||
suggestedTemplateId: validateString(item.suggestedTemplateId) || undefined,
|
||||
confidence: validateNumber(item.confidence) || undefined,
|
||||
}))
|
||||
.filter((item) => item.lineTotal > 0 || item.description.length > 0)
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import type { TransactionCategory, ReceiptLineItem, ExtractedLineItem } from '@/types'
|
||||
import { getTemplateById } from '@/lib/bookkeeping/booking-templates'
|
||||
|
||||
// Category mappings from suggested category to TransactionCategory
|
||||
const CATEGORY_MAPPING: Record<string, TransactionCategory> = {
|
||||
@@ -108,6 +109,16 @@ export function mapSuggestedCategory(suggestedCategory: string | null): Transact
|
||||
return CATEGORY_MAPPING[suggestedCategory] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a booking template ID to a TransactionCategory.
|
||||
* Falls back to the template's `fallback_category` field.
|
||||
*/
|
||||
export function mapTemplateIdToCategory(templateId: string | null | undefined): TransactionCategory | null {
|
||||
if (!templateId) return null
|
||||
const template = getTemplateById(templateId)
|
||||
return template?.fallback_category ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get BAS account for a category
|
||||
*/
|
||||
@@ -148,9 +159,15 @@ export function processLineItems(
|
||||
lineItems: ExtractedLineItem[]
|
||||
): Array<ExtractedLineItem & { category: TransactionCategory | null; basAccount: string | null }> {
|
||||
return lineItems.map((item) => {
|
||||
// First try the AI-suggested category
|
||||
let category = mapSuggestedCategory(item.suggestedCategory)
|
||||
let confidence = item.confidence || 0.8
|
||||
// First try the AI-suggested template ID
|
||||
let category = mapTemplateIdToCategory(item.suggestedTemplateId)
|
||||
let confidence = category ? (item.confidence || 0.85) : 0
|
||||
|
||||
// Then try the AI-suggested category
|
||||
if (!category) {
|
||||
category = mapSuggestedCategory(item.suggestedCategory)
|
||||
confidence = item.confidence || 0.8
|
||||
}
|
||||
|
||||
// If no AI suggestion, try pattern matching
|
||||
if (!category) {
|
||||
|
||||
@@ -3,14 +3,24 @@
|
||||
*
|
||||
* Uses date variance, amount tolerance, and merchant name similarity
|
||||
* to find potential transaction matches for receipts.
|
||||
*
|
||||
* Core matching utilities (levenshtein, merchant similarity) are imported
|
||||
* from @/lib/documents/core-receipt-matcher and re-exported for backward compat.
|
||||
*/
|
||||
|
||||
import type { Transaction, Receipt, ReceiptMatchCandidate } from '@/types'
|
||||
import {
|
||||
calculateMerchantSimilarity,
|
||||
calculateMatchConfidence,
|
||||
levenshteinDistance,
|
||||
normalizeMerchantName,
|
||||
DATE_TOLERANCE_DAYS,
|
||||
AMOUNT_TOLERANCE_PERCENT,
|
||||
MIN_MATCH_CONFIDENCE,
|
||||
} from '@/lib/documents/core-receipt-matcher'
|
||||
|
||||
// Matching configuration
|
||||
const DATE_TOLERANCE_DAYS = 3 // Allow ±3 days variance
|
||||
const AMOUNT_TOLERANCE_PERCENT = 0.05 // Allow ±5% variance (for currency conversion)
|
||||
const MIN_MATCH_CONFIDENCE = 0.4 // Minimum confidence to consider a match
|
||||
// Re-export core functions for backward compatibility
|
||||
export { calculateMerchantSimilarity, levenshteinDistance, normalizeMerchantName }
|
||||
|
||||
/**
|
||||
* Find potential transaction matches for a receipt
|
||||
@@ -59,7 +69,7 @@ export function findTransactionMatches(
|
||||
const merchantSimilarity = calculateMerchantSimilarity(merchantName, transactionMerchant)
|
||||
|
||||
// Calculate overall confidence
|
||||
const { confidence, matchReasons } = calculateMatchConfidence(
|
||||
const { confidence, matchReasons } = calculateReceiptMatchConfidence(
|
||||
dateVariance,
|
||||
amountVariance,
|
||||
merchantSimilarity,
|
||||
@@ -83,140 +93,28 @@ export function findTransactionMatches(
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate merchant name similarity using Levenshtein distance
|
||||
* Wrapper around core calculateMatchConfidence that adds receipt-specific
|
||||
* MCC bonus logic for backward compatibility.
|
||||
*/
|
||||
function calculateMerchantSimilarity(name1: string, name2: string): number {
|
||||
if (!name1 || !name2) return 0
|
||||
|
||||
// Normalize names
|
||||
const n1 = normalizeMerchantName(name1)
|
||||
const n2 = normalizeMerchantName(name2)
|
||||
|
||||
// Check for exact match
|
||||
if (n1 === n2) return 1
|
||||
|
||||
// Check if one contains the other
|
||||
if (n1.includes(n2) || n2.includes(n1)) return 0.9
|
||||
|
||||
// Check for 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
|
||||
}
|
||||
|
||||
// Calculate Levenshtein similarity
|
||||
const distance = levenshteinDistance(n1, n2)
|
||||
const maxLength = Math.max(n1.length, n2.length)
|
||||
const similarity = 1 - distance / maxLength
|
||||
|
||||
return similarity
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize merchant name for comparison
|
||||
*/
|
||||
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 distance between two strings
|
||||
*/
|
||||
function levenshteinDistance(str1: string, str2: string): number {
|
||||
const m = str1.length
|
||||
const n = str2.length
|
||||
|
||||
// Create matrix
|
||||
const dp: number[][] = Array(m + 1)
|
||||
.fill(null)
|
||||
.map(() => Array(n + 1).fill(0))
|
||||
|
||||
// Initialize first row and column
|
||||
for (let i = 0; i <= m; i++) dp[i][0] = i
|
||||
for (let j = 0; j <= n; j++) dp[0][j] = j
|
||||
|
||||
// Fill the matrix
|
||||
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 overall match confidence and reasons
|
||||
*/
|
||||
function calculateMatchConfidence(
|
||||
function calculateReceiptMatchConfidence(
|
||||
dateVariance: number,
|
||||
amountVariance: number,
|
||||
merchantSimilarity: number,
|
||||
receipt: Receipt,
|
||||
transaction: Transaction
|
||||
): { confidence: number; matchReasons: string[] } {
|
||||
const matchReasons: string[] = []
|
||||
let totalWeight = 0
|
||||
let weightedScore = 0
|
||||
const result = calculateMatchConfidence(dateVariance, amountVariance, merchantSimilarity)
|
||||
|
||||
// Date score (weight: 25%)
|
||||
const dateScore = Math.max(0, 1 - dateVariance / DATE_TOLERANCE_DAYS)
|
||||
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 / AMOUNT_TOLERANCE_PERCENT)
|
||||
if (amountVariance < 0.01) {
|
||||
matchReasons.push('Exakt belopp')
|
||||
} else if (amountVariance < AMOUNT_TOLERANCE_PERCENT) {
|
||||
matchReasons.push(`Belopp ±${Math.round(amountVariance * 100)}%`)
|
||||
}
|
||||
weightedScore += amountScore * 0.4
|
||||
totalWeight += 0.4
|
||||
|
||||
// Merchant score (weight: 35%)
|
||||
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
|
||||
}
|
||||
|
||||
// Bonus for MCC match (if transaction has MCC and receipt is flagged)
|
||||
// Bonus for MCC match (receipt-specific: restaurant MCC codes)
|
||||
if (receipt.is_restaurant && transaction.mcc_code) {
|
||||
const restaurantMCCs = [5812, 5813, 5814]
|
||||
if (restaurantMCCs.includes(transaction.mcc_code)) {
|
||||
matchReasons.push('Restaurang MCC matchar')
|
||||
weightedScore += 0.1
|
||||
result.matchReasons.push('Restaurang MCC matchar')
|
||||
result.confidence = Math.round((result.confidence + 0.1) * 100) / 100
|
||||
}
|
||||
}
|
||||
|
||||
const confidence = totalWeight > 0 ? weightedScore / totalWeight : 0
|
||||
|
||||
return {
|
||||
confidence: Math.round(confidence * 100) / 100,
|
||||
matchReasons,
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Receipt Pipeline — shared, reusable receipt processing function.
|
||||
*
|
||||
* SERVER-ONLY: uses receipt-analyzer (Anthropic SDK).
|
||||
*
|
||||
* Extracts receipt data, categorizes line items, inserts records,
|
||||
* and attempts auto-matching against bank transactions.
|
||||
*/
|
||||
|
||||
import 'server-only'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { Receipt, ReceiptMatchCandidate } from '@/types'
|
||||
import { analyzeReceipt } from './receipt-analyzer'
|
||||
import { processLineItems } from './receipt-categorizer'
|
||||
import { autoMatchReceipts } from './receipt-matcher'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
|
||||
export interface ReceiptPipelineOptions {
|
||||
documentId: string | null
|
||||
source: 'upload' | 'camera' | 'email'
|
||||
emailFrom?: string
|
||||
storageUrl: string
|
||||
}
|
||||
|
||||
export interface ProcessedReceipt {
|
||||
receipt: Receipt
|
||||
lineItems: unknown[]
|
||||
matchedTransaction?: { transactionId: string; confidence: number }
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a document through the receipt pipeline:
|
||||
* 1. Analyze with Claude Vision
|
||||
* 2. Categorize line items
|
||||
* 3. Insert receipt + line items
|
||||
* 4. Auto-match against unmatched transactions
|
||||
* 5. Emit receipt.extracted event
|
||||
*/
|
||||
export async function processReceiptFromDocument(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
base64: string,
|
||||
mimeType: string,
|
||||
opts: ReceiptPipelineOptions
|
||||
): Promise<ProcessedReceipt> {
|
||||
// 1. Analyze receipt with Claude Vision
|
||||
const validImageType = mimeType as 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif'
|
||||
const extraction = await analyzeReceipt(base64, validImageType)
|
||||
|
||||
// 2. Categorize line items
|
||||
const processedLineItems = processLineItems(extraction.lineItems)
|
||||
|
||||
// 3. Insert receipt record
|
||||
const { data: receipt, error: insertError } = await supabase
|
||||
.from('receipts')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
image_url: opts.storageUrl,
|
||||
status: 'extracted',
|
||||
extraction_confidence: extraction.confidence,
|
||||
merchant_name: extraction.merchant.name,
|
||||
merchant_org_number: extraction.merchant.orgNumber,
|
||||
merchant_vat_number: extraction.merchant.vatNumber,
|
||||
receipt_date: extraction.receipt.date,
|
||||
receipt_time: extraction.receipt.time,
|
||||
total_amount: extraction.totals.total,
|
||||
currency: extraction.receipt.currency,
|
||||
vat_amount: extraction.totals.vatAmount,
|
||||
is_restaurant: extraction.flags.isRestaurant,
|
||||
is_systembolaget: extraction.flags.isSystembolaget,
|
||||
is_foreign_merchant: extraction.flags.isForeignMerchant,
|
||||
raw_extraction: extraction,
|
||||
document_id: opts.documentId,
|
||||
source: opts.source,
|
||||
email_from: opts.emailFrom ?? null,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (insertError || !receipt) {
|
||||
throw new Error(`Failed to create receipt: ${insertError?.message}`)
|
||||
}
|
||||
|
||||
// 4. Insert line items
|
||||
if (processedLineItems.length > 0) {
|
||||
const lineItemsToInsert = processedLineItems.map((item, index) => ({
|
||||
receipt_id: receipt.id,
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unit_price: item.unitPrice,
|
||||
line_total: item.lineTotal,
|
||||
vat_rate: item.vatRate,
|
||||
vat_amount:
|
||||
item.vatRate && item.lineTotal
|
||||
? Math.round((item.lineTotal * item.vatRate) / (100 + item.vatRate) * 100) / 100
|
||||
: null,
|
||||
extraction_confidence: item.confidence,
|
||||
suggested_category: item.suggestedCategory,
|
||||
category: item.category,
|
||||
bas_account: item.basAccount,
|
||||
sort_order: index,
|
||||
}))
|
||||
|
||||
await supabase.from('receipt_line_items').insert(lineItemsToInsert)
|
||||
}
|
||||
|
||||
// 5. Auto-match against unmatched expense transactions (±7 days from receipt date)
|
||||
let matchedTransaction: ProcessedReceipt['matchedTransaction'] | undefined
|
||||
|
||||
if (extraction.receipt.date && extraction.totals.total) {
|
||||
const receiptDate = new Date(extraction.receipt.date)
|
||||
const dateFrom = new Date(receiptDate)
|
||||
dateFrom.setDate(dateFrom.getDate() - 7)
|
||||
const dateTo = new Date(receiptDate)
|
||||
dateTo.setDate(dateTo.getDate() + 7)
|
||||
|
||||
const { data: transactions } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.is('receipt_id', null)
|
||||
.lt('amount', 0)
|
||||
.gte('date', dateFrom.toISOString().split('T')[0])
|
||||
.lte('date', dateTo.toISOString().split('T')[0])
|
||||
|
||||
if (transactions && transactions.length > 0) {
|
||||
const matches = autoMatchReceipts([receipt], transactions, 0.8)
|
||||
if (matches.length > 0) {
|
||||
const best = matches[0]
|
||||
matchedTransaction = {
|
||||
transactionId: best.match.transaction.id,
|
||||
confidence: best.match.confidence,
|
||||
}
|
||||
|
||||
// Link receipt to transaction
|
||||
await supabase
|
||||
.from('receipts')
|
||||
.update({
|
||||
matched_transaction_id: best.match.transaction.id,
|
||||
match_confidence: best.match.confidence,
|
||||
})
|
||||
.eq('id', receipt.id)
|
||||
|
||||
await supabase
|
||||
.from('transactions')
|
||||
.update({ receipt_id: receipt.id })
|
||||
.eq('id', best.match.transaction.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Emit event
|
||||
await eventBus.emit({
|
||||
type: 'receipt.extracted',
|
||||
payload: {
|
||||
receipt,
|
||||
documentId: opts.documentId,
|
||||
confidence: extraction.confidence,
|
||||
userId,
|
||||
},
|
||||
})
|
||||
|
||||
return { receipt, lineItems: processedLineItems, matchedTransaction }
|
||||
}
|
||||
Reference in New Issue
Block a user