feat: add foreign currency support, refactor bookkeeping engine, and improve invoice inbox document classification

- Add currency-utils module for SEK conversion with exchange rates
- Refactor createJournalEntry to use draft+commit flow preventing voucher number gaps (BFL 5 kap. 7§)
- Add foreign currency support to invoice entries with per-line SEK conversion
- Centralize category-to-account mapping into single source of truth
- Refactor invoice inbox to use shared document analyzer with document type classification (receipt, supplier invoice, government letter)
- Update mapping engine, supplier invoice entries, and transaction entries
- Fix report component rendering issues
- Add new validation schemas and tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-02-27 09:50:06 +01:00
co-authored by Claude Opus 4.6
parent 547fd053ec
commit 6f4573f380
33 changed files with 1365 additions and 497 deletions
+290 -158
View File
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'
import type { ApiRouteDefinition, ExtensionContext } from '@/lib/extensions/types'
import type { InvoiceExtractionResult } from './types'
import type { InvoiceInboxItem, SupplierInvoice } from '@/types'
import { analyzeInvoice } from './lib/invoice-analyzer'
import { analyzeDocument } from '@/lib/ai/document-analyzer'
import { matchSupplier } from './lib/supplier-matcher'
import { getSettings, saveSettings } from './index'
import { eventBus } from '@/lib/events/bus'
@@ -190,96 +190,33 @@ async function processInboxItem(
const supabase = await getSupabase()
try {
console.log(`[invoice-inbox] Processing item=${itemId}: starting AI extraction (${mimeType})`)
const extraction = await analyzeInvoice(base64, mimeType)
console.log(`[invoice-inbox] Processing item=${itemId}: starting AI analysis (${mimeType})`)
const result = await analyzeDocument(base64, mimeType)
console.log(`[invoice-inbox] item=${itemId} extraction complete:`, {
confidence: extraction.confidence,
suggestedTemplateId: extraction.suggestedTemplateId || null,
supplier: extraction.supplier?.name || null,
total: extraction.totals?.total || null,
invoiceDate: extraction.invoice?.invoiceDate || null,
dueDate: extraction.invoice?.dueDate || null,
paymentRef: extraction.invoice?.paymentReference || null,
})
const { classification } = result
console.log(`[invoice-inbox] item=${itemId} classified as: ${classification.type} (confidence=${classification.confidence}, reasoning=${classification.reasoning})`)
// Supplier matching
const settings = await getSettings(userId)
let matchedSupplierId: string | null = null
if (settings.autoMatchSupplierEnabled) {
const { data: suppliers } = await supabase
.from('suppliers')
.select('*')
.eq('user_id', userId)
if (suppliers && suppliers.length > 0) {
const match = matchSupplier(extraction, suppliers)
if (match && match.confidence >= settings.supplierMatchThreshold) {
matchedSupplierId = match.supplierId
console.log(`[invoice-inbox] item=${itemId} supplier matched: id=${match.supplierId} confidence=${match.confidence}`)
}
}
}
// Store extraction result with template suggestion
const updateData: Record<string, unknown> = {
status: 'ready',
extracted_data: extraction as unknown as Record<string, unknown>,
confidence: extraction.confidence,
matched_supplier_id: matchedSupplierId,
}
if (extraction.suggestedTemplateId) {
updateData.suggested_template_id = extraction.suggestedTemplateId
updateData.suggested_template_confidence = extraction.confidence
console.log(`[invoice-inbox] item=${itemId} template suggestion: ${extraction.suggestedTemplateId} (confidence=${extraction.confidence})`)
}
await supabase
.from('invoice_inbox_items')
.update(updateData)
.eq('id', itemId)
// Fetch the updated item for event emission and matching
const { data: updatedItem } = await supabase
.from('invoice_inbox_items')
.select('*')
.eq('id', itemId)
.single()
if (updatedItem) {
await eventBus.emit({
type: 'supplier_invoice.extracted',
payload: {
inboxItem: updatedItem,
confidence: extraction.confidence,
userId,
},
})
// Document-to-transaction matching
try {
const matchResult = await matchDocumentToTransactions(
supabase,
userId,
updatedItem as InvoiceInboxItem
)
if (matchResult) {
await supabase
.from('invoice_inbox_items')
.update({
matched_transaction_id: matchResult.transactionId,
match_confidence: matchResult.confidence,
match_method: matchResult.method,
})
.eq('id', itemId)
}
} catch (matchError) {
// Non-blocking: log but don't fail the item
console.error('[invoice-inbox] Transaction matching failed:', matchError)
}
// Handle based on document type
if (classification.type === 'receipt' && result.receipt) {
await processAsReceipt(supabase, itemId, userId, result.receipt)
} else if (classification.type === 'supplier_invoice' && result.invoice) {
await processAsInvoice(supabase, itemId, userId, result.invoice)
} else if (classification.type === 'government_letter' || classification.type === 'unknown') {
// Store classification but no extraction — user must review manually
await supabase
.from('invoice_inbox_items')
.update({
status: 'ready',
document_type: classification.type,
confidence: classification.confidence,
})
.eq('id', itemId)
} else {
// Classification gave a type but extraction failed — fall back to supplier_invoice extraction
console.warn(`[invoice-inbox] item=${itemId}: classified as ${classification.type} but no extraction data, falling back`)
const { extractInvoice } = await import('@/lib/ai/document-analyzer')
const fallbackExtraction = await extractInvoice(base64, mimeType)
await processAsInvoice(supabase, itemId, userId, fallbackExtraction)
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
@@ -290,6 +227,235 @@ async function processInboxItem(
}
}
async function processAsReceipt(
supabase: Awaited<ReturnType<typeof getSupabase>>,
itemId: string,
userId: string,
extraction: import('@/types').ReceiptExtractionResult
): Promise<void> {
console.log(`[invoice-inbox] item=${itemId} processing as receipt: merchant=${extraction.merchant?.name}, total=${extraction.totals?.total}`)
// Fetch the inbox item to get document_id for the receipt image_url
const { data: inboxItem } = await supabase
.from('invoice_inbox_items')
.select('document_id, document:document_attachments(storage_path)')
.eq('id', itemId)
.single()
let imageUrl: string | null = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const document = inboxItem?.document as any
if (document?.storage_path) {
const { data: urlData } = supabase.storage
.from('documents')
.getPublicUrl(document.storage_path)
imageUrl = urlData?.publicUrl || null
}
// Create receipt record
const { data: receipt, error: receiptError } = await supabase
.from('receipts')
.insert({
user_id: userId,
image_url: imageUrl,
status: 'extracted',
extraction_confidence: extraction.confidence,
merchant_name: extraction.merchant?.name || null,
merchant_org_number: extraction.merchant?.orgNumber || null,
merchant_vat_number: extraction.merchant?.vatNumber || null,
receipt_date: extraction.receipt?.date || null,
receipt_time: extraction.receipt?.time || null,
total_amount: extraction.totals?.total || null,
currency: extraction.receipt?.currency || 'SEK',
vat_amount: extraction.totals?.vatAmount || null,
is_restaurant: extraction.flags?.isRestaurant || false,
is_systembolaget: extraction.flags?.isSystembolaget || false,
is_foreign_merchant: extraction.flags?.isForeignMerchant || false,
raw_extraction: extraction,
})
.select()
.single()
if (receiptError || !receipt) {
console.error(`[invoice-inbox] item=${itemId} receipt creation failed:`, receiptError)
// Fall back: store as receipt type without linked receipt
await supabase
.from('invoice_inbox_items')
.update({
status: 'ready',
document_type: 'receipt',
extracted_data: extraction as unknown as Record<string, unknown>,
confidence: extraction.confidence,
})
.eq('id', itemId)
return
}
// Insert receipt line items
if (extraction.lineItems?.length > 0) {
const lineItemsToInsert = extraction.lineItems.map((item, index) => ({
receipt_id: receipt.id,
description: item.description || '',
quantity: item.quantity || 1,
unit_price: item.unitPrice,
line_total: item.lineTotal || 0,
vat_rate: item.vatRate,
vat_amount:
item.vatRate && item.lineTotal
? Math.round((item.lineTotal * item.vatRate) / (100 + item.vatRate) * 100) / 100
: null,
suggested_category: item.suggestedCategory || null,
sort_order: index,
}))
await supabase.from('receipt_line_items').insert(lineItemsToInsert)
}
// Update inbox item: mark as receipt with linked receipt
await supabase
.from('invoice_inbox_items')
.update({
status: 'ready',
document_type: 'receipt',
extracted_data: extraction as unknown as Record<string, unknown>,
confidence: extraction.confidence,
linked_receipt_id: receipt.id,
})
.eq('id', itemId)
// Emit receipt.extracted event (non-blocking)
try {
await eventBus.emit({
type: 'receipt.extracted',
payload: {
receipt,
documentId: inboxItem?.document_id || null,
confidence: extraction.confidence,
userId,
},
})
} catch {
// Non-blocking
}
// Try to match receipt to a transaction
try {
const { data: updatedItem } = await supabase
.from('invoice_inbox_items')
.select('*')
.eq('id', itemId)
.single()
if (updatedItem) {
const matchResult = await matchDocumentToTransactions(
supabase,
userId,
updatedItem as InvoiceInboxItem
)
if (matchResult) {
await supabase
.from('invoice_inbox_items')
.update({
matched_transaction_id: matchResult.transactionId,
match_confidence: matchResult.confidence,
match_method: matchResult.method,
})
.eq('id', itemId)
}
}
} catch (matchError) {
console.error('[invoice-inbox] Receipt transaction matching failed:', matchError)
}
}
async function processAsInvoice(
supabase: Awaited<ReturnType<typeof getSupabase>>,
itemId: string,
userId: string,
extraction: InvoiceExtractionResult
): Promise<void> {
console.log(`[invoice-inbox] item=${itemId} processing as supplier_invoice: supplier=${extraction.supplier?.name}, total=${extraction.totals?.total}`)
// Supplier matching
const settings = await getSettings(userId)
let matchedSupplierId: string | null = null
if (settings.autoMatchSupplierEnabled) {
const { data: suppliers } = await supabase
.from('suppliers')
.select('*')
.eq('user_id', userId)
if (suppliers && suppliers.length > 0) {
const match = matchSupplier(extraction, suppliers)
if (match && match.confidence >= settings.supplierMatchThreshold) {
matchedSupplierId = match.supplierId
console.log(`[invoice-inbox] item=${itemId} supplier matched: id=${match.supplierId} confidence=${match.confidence}`)
}
}
}
// Store extraction result with template suggestion
const updateData: Record<string, unknown> = {
status: 'ready',
document_type: 'supplier_invoice',
extracted_data: extraction as unknown as Record<string, unknown>,
confidence: extraction.confidence,
matched_supplier_id: matchedSupplierId,
}
if (extraction.suggestedTemplateId) {
updateData.suggested_template_id = extraction.suggestedTemplateId
updateData.suggested_template_confidence = extraction.confidence
console.log(`[invoice-inbox] item=${itemId} template suggestion: ${extraction.suggestedTemplateId} (confidence=${extraction.confidence})`)
}
await supabase
.from('invoice_inbox_items')
.update(updateData)
.eq('id', itemId)
// Fetch the updated item for event emission and matching
const { data: updatedItem } = await supabase
.from('invoice_inbox_items')
.select('*')
.eq('id', itemId)
.single()
if (updatedItem) {
await eventBus.emit({
type: 'supplier_invoice.extracted',
payload: {
inboxItem: updatedItem,
confidence: extraction.confidence,
userId,
},
})
// Document-to-transaction matching
try {
const matchResult = await matchDocumentToTransactions(
supabase,
userId,
updatedItem as InvoiceInboxItem
)
if (matchResult) {
await supabase
.from('invoice_inbox_items')
.update({
matched_transaction_id: matchResult.transactionId,
match_confidence: matchResult.confidence,
match_method: matchResult.method,
})
.eq('id', itemId)
}
} catch (matchError) {
console.error('[invoice-inbox] Transaction matching failed:', matchError)
}
}
}
// ============================================================
// GET /inbox/:id — Get single inbox item
// ============================================================
@@ -470,89 +636,55 @@ async function handleProcessInboxItem(
const arrayBuffer = await fileData.arrayBuffer()
const base64 = Buffer.from(arrayBuffer).toString('base64')
// Analyze
const extraction = await analyzeInvoice(base64, document.mime_type)
// Reset previous classification on re-process
await supabase
.from('invoice_inbox_items')
.update({
matched_transaction_id: null,
match_confidence: null,
match_method: null,
linked_receipt_id: null,
})
.eq('id', id)
// Supplier matching
const settings = await getSettings(userId)
let matchedSupplierId: string | null = null
// Analyze with classification
const result = await analyzeDocument(base64, document.mime_type)
const { classification } = result
if (settings.autoMatchSupplierEnabled) {
const { data: suppliers } = await supabase
.from('suppliers')
.select('*')
.eq('user_id', userId)
console.log(`[invoice-inbox] Re-process item=${id} classified as: ${classification.type} (confidence=${classification.confidence})`)
if (suppliers && suppliers.length > 0) {
const match = matchSupplier(extraction, suppliers)
if (match && match.confidence >= settings.supplierMatchThreshold) {
matchedSupplierId = match.supplierId
}
}
}
// Update inbox item with extraction + template suggestion
const updateData: Record<string, unknown> = {
status: 'ready',
extracted_data: extraction as unknown as Record<string, unknown>,
confidence: extraction.confidence,
matched_supplier_id: matchedSupplierId,
error_message: null,
// Reset previous match on re-process
matched_transaction_id: null,
match_confidence: null,
match_method: null,
}
if (extraction.suggestedTemplateId) {
updateData.suggested_template_id = extraction.suggestedTemplateId
updateData.suggested_template_confidence = extraction.confidence
if (classification.type === 'receipt' && result.receipt) {
await processAsReceipt(supabase, id, userId, result.receipt)
} else if (classification.type === 'supplier_invoice' && result.invoice) {
await processAsInvoice(supabase, id, userId, result.invoice)
} else if (classification.type === 'government_letter' || classification.type === 'unknown') {
await supabase
.from('invoice_inbox_items')
.update({
status: 'ready',
document_type: classification.type,
confidence: classification.confidence,
error_message: null,
})
.eq('id', id)
} else {
// Fallback: extract as invoice
const { extractInvoice } = await import('@/lib/ai/document-analyzer')
const fallbackExtraction = await extractInvoice(base64, document.mime_type)
await processAsInvoice(supabase, id, userId, fallbackExtraction)
}
// Fetch final state
const { data: updatedItem, error: updateError } = await supabase
.from('invoice_inbox_items')
.update(updateData)
.select('*')
.eq('id', id)
.select()
.single()
if (updateError) {
return NextResponse.json({ error: updateError.message }, { status: 500 })
}
if (updatedItem) {
await eventBus.emit({
type: 'supplier_invoice.extracted',
payload: {
inboxItem: updatedItem,
confidence: extraction.confidence,
userId,
},
})
// Document-to-transaction matching (non-blocking)
try {
const matchResult = await matchDocumentToTransactions(
supabase,
userId,
updatedItem as InvoiceInboxItem
)
if (matchResult) {
await supabase
.from('invoice_inbox_items')
.update({
matched_transaction_id: matchResult.transactionId,
match_confidence: matchResult.confidence,
match_method: matchResult.method,
})
.eq('id', id)
}
} catch (matchError) {
console.error('[invoice-inbox] Transaction matching failed:', matchError)
}
}
return NextResponse.json({ data: updatedItem })
} catch (error) {
const message = error instanceof Error ? error.message : 'Processing failed'
@@ -14,6 +14,12 @@
"dataPattern": "manual",
"hasOwnData": true,
"description": "Ta emot alla dokument via e-post — fakturor, kvitton och myndighetspost",
"longDescription": "Skicka alla affärsdokument till en dedikerad e-postadress. AI klassificerar automatiskt dokumenttyp (faktura, kvitto, myndighetspost), extraherar data och matchar mot transaktioner. En inkorg för alla dokument."
"longDescription": "Skicka alla affärsdokument till en dedikerad e-postadress. AI klassificerar automatiskt dokumenttyp (faktura, kvitto, myndighetspost), extraherar data och matchar mot transaktioner. En inkorg för alla dokument.",
"quickAction": {
"label": "Dokumentinkorg",
"description": "Granska inkommande dokument",
"icon": "Inbox",
"href": "/e/general/invoice-inbox"
}
}
}