{item.confidence != null && (
diff --git a/components/extensions/shared/DateRangeFilter.tsx b/components/extensions/shared/DateRangeFilter.tsx
index 1fb7e9fb..319e24bd 100644
--- a/components/extensions/shared/DateRangeFilter.tsx
+++ b/components/extensions/shared/DateRangeFilter.tsx
@@ -43,9 +43,9 @@ export default function DateRangeFilter({ onRangeChange, className }: DateRangeF
}
const periods: { key: Period; label: string }[] = [
- { key: 'month', label: 'M\u00e5nad' },
+ { key: 'month', label: 'Månad' },
{ key: 'quarter', label: 'Kvartal' },
- { key: 'year', label: '\u00c5r' },
+ { key: 'year', label: 'År' },
]
return (
diff --git a/components/extensions/shared/EmptyExtensionState.tsx b/components/extensions/shared/EmptyExtensionState.tsx
index c03c7d25..65bf757d 100644
--- a/components/extensions/shared/EmptyExtensionState.tsx
+++ b/components/extensions/shared/EmptyExtensionState.tsx
@@ -7,8 +7,8 @@ interface EmptyExtensionStateProps {
}
export default function EmptyExtensionState({
- title = 'Ingen data \u00e4nnu',
- description = 'Data kommer att visas h\u00e4r n\u00e4r det finns tillg\u00e4ngligt.',
+ title = 'Ingen data ännu',
+ description = 'Data kommer att visas här när det finns tillgängligt.',
icon,
}: EmptyExtensionStateProps) {
return (
diff --git a/extensions/general/calendar/manifest.json b/extensions/general/calendar/manifest.json
index 8bcf88f0..58c4c27e 100644
--- a/extensions/general/calendar/manifest.json
+++ b/extensions/general/calendar/manifest.json
@@ -13,7 +13,7 @@
"icon": "Calendar",
"dataPattern": "core",
"readsCoreTables": ["invoices", "deadlines", "customers"],
- "description": "Fullstandig kalendervy med manads-, vecko- och dagsvisning",
- "longDescription": "Se alla fakturadatum och deadlines i en interaktiv kalender med manads-, vecko- och dagsvy."
+ "description": "Fullständig kalendervy med månads-, vecko- och dagsvisning",
+ "longDescription": "Se alla fakturadatum och deadlines i en interaktiv kalender med månads-, vecko- och dagsvy."
}
}
diff --git a/extensions/general/invoice-inbox/lib/__tests__/invoice-analyzer.test.ts b/extensions/general/invoice-inbox/lib/__tests__/invoice-analyzer.test.ts
index d572bc77..e1a5a88a 100644
--- a/extensions/general/invoice-inbox/lib/__tests__/invoice-analyzer.test.ts
+++ b/extensions/general/invoice-inbox/lib/__tests__/invoice-analyzer.test.ts
@@ -3,19 +3,14 @@ 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 }
-})
+// Mock the core document-analyzer module
+const { mockExtractInvoice } = vi.hoisted(() => ({
+ mockExtractInvoice: vi.fn(),
+}))
-vi.mock('@anthropic-ai/sdk', () => {
- return {
- default: class MockAnthropic {
- messages = { create: mockCreate }
- },
- }
-})
+vi.mock('@/lib/ai/document-analyzer', () => ({
+ extractInvoice: mockExtractInvoice,
+}))
import { analyzeInvoice } from '../invoice-analyzer'
@@ -24,7 +19,7 @@ describe('Invoice Analyzer', () => {
vi.clearAllMocks()
})
- const validExtractionJson = JSON.stringify({
+ const validExtraction = {
supplier: {
name: 'Kontorsbolaget AB',
orgNumber: '556123-4567',
@@ -59,15 +54,14 @@ describe('Invoice Analyzer', () => {
{ rate: 25, base: 500, amount: 125 },
],
confidence: 0.92,
- })
+ }
- it('parses valid AI response for PDF', async () => {
- mockCreate.mockResolvedValue({
- content: [{ type: 'text', text: validExtractionJson }],
- })
+ it('delegates to extractInvoice from core', async () => {
+ mockExtractInvoice.mockResolvedValueOnce(validExtraction)
const result = await analyzeInvoice('base64data', 'application/pdf')
+ expect(mockExtractInvoice).toHaveBeenCalledWith('base64data', 'application/pdf')
expect(result.supplier.name).toBe('Kontorsbolaget AB')
expect(result.supplier.orgNumber).toBe('556123-4567')
expect(result.invoice.invoiceNumber).toBe('F-2024-001')
@@ -77,108 +71,40 @@ describe('Invoice Analyzer', () => {
expect(result.confidence).toBe(0.92)
})
- it('parses valid AI response for image', async () => {
- mockCreate.mockResolvedValue({
- content: [{ type: 'text', text: validExtractionJson }],
- })
+ it('works with image mime types', async () => {
+ mockExtractInvoice.mockResolvedValueOnce(validExtraction)
const result = await analyzeInvoice('base64data', 'image/jpeg')
- expect(result.supplier.name).toBe('Kontorsbolaget AB')
- expect(mockCreate).toHaveBeenCalledTimes(1)
- })
-
- it('strips markdown code blocks from response', async () => {
- mockCreate.mockResolvedValue({
- content: [{ type: 'text', text: '```json\n' + validExtractionJson + '\n```' }],
- })
-
- const result = await analyzeInvoice('base64data', 'application/pdf')
+ expect(mockExtractInvoice).toHaveBeenCalledWith('base64data', 'image/jpeg')
expect(result.supplier.name).toBe('Kontorsbolaget AB')
})
- it('validates org number format', async () => {
- mockCreate.mockResolvedValue({
- content: [{ type: 'text', text: JSON.stringify({
- supplier: { name: 'Test', orgNumber: '5561234567', vatNumber: null, address: null, bankgiro: null, plusgiro: null },
- invoice: { invoiceNumber: null, invoiceDate: null, dueDate: null, paymentReference: null, currency: 'SEK' },
- lineItems: [],
- totals: { subtotal: 0, vatAmount: 0, total: 0 },
- vatBreakdown: [],
- confidence: 0.5,
- }) }],
- })
-
- const result = await analyzeInvoice('base64data', 'application/pdf')
- expect(result.supplier.orgNumber).toBe('556123-4567') // Formatted with dash
- })
-
- it('rejects invalid VAT numbers', async () => {
- mockCreate.mockResolvedValue({
- content: [{ type: 'text', text: JSON.stringify({
- supplier: { name: 'Test', orgNumber: null, vatNumber: 'DE123', address: null, bankgiro: null, plusgiro: null },
- invoice: { invoiceNumber: null, invoiceDate: null, dueDate: null, paymentReference: null, currency: 'SEK' },
- lineItems: [],
- totals: { subtotal: 0, vatAmount: 0, total: 0 },
- vatBreakdown: [],
- confidence: 0.5,
- }) }],
- })
-
- const result = await analyzeInvoice('base64data', 'application/pdf')
- expect(result.supplier.vatNumber).toBeNull() // Not SE-prefixed
- })
-
- it('throws on JSON parse error without retrying', async () => {
- mockCreate.mockResolvedValue({
- content: [{ type: 'text', text: 'not json at all' }],
- })
-
- await expect(analyzeInvoice('base64data', 'application/pdf')).rejects.toThrow('Failed to parse AI response')
- expect(mockCreate).toHaveBeenCalledTimes(1) // No retry for parse errors
- })
-
- it('retries on API errors', async () => {
- mockCreate
- .mockRejectedValueOnce(new Error('API timeout'))
- .mockResolvedValueOnce({
- content: [{ type: 'text', text: validExtractionJson }],
- })
-
- const result = await analyzeInvoice('base64data', 'application/pdf')
- expect(result.supplier.name).toBe('Kontorsbolaget AB')
- expect(mockCreate).toHaveBeenCalledTimes(2)
- })
-
- it('throws after max retries', async () => {
- mockCreate.mockRejectedValue(new Error('API timeout'))
+ it('propagates errors from core', async () => {
+ mockExtractInvoice.mockRejectedValueOnce(new Error('Vision API call failed after 3 attempts'))
await expect(analyzeInvoice('base64data', 'application/pdf')).rejects.toThrow(
- 'Invoice analysis failed after 3 attempts'
+ 'Vision API call failed after 3 attempts'
)
- expect(mockCreate).toHaveBeenCalledTimes(3)
})
- it('rejects unsupported file types', async () => {
+ it('propagates unsupported file type errors', async () => {
+ mockExtractInvoice.mockRejectedValueOnce(new Error('Unsupported file type: text/plain'))
+
await expect(analyzeInvoice('base64data', 'text/plain')).rejects.toThrow(
'Unsupported file type'
)
})
- it('validates account number suggestions', async () => {
- mockCreate.mockResolvedValue({
- content: [{ type: 'text', text: JSON.stringify({
- supplier: { name: 'Test', orgNumber: null, vatNumber: null, address: null, bankgiro: null, plusgiro: null },
- invoice: { invoiceNumber: null, invoiceDate: null, dueDate: null, paymentReference: null, currency: 'SEK' },
- lineItems: [
- { description: 'Item', quantity: 1, unitPrice: 100, lineTotal: 100, vatRate: 25, accountSuggestion: '6100' },
- { description: 'Bad', quantity: 1, unitPrice: 50, lineTotal: 50, vatRate: 25, accountSuggestion: 'abc' },
- ],
- totals: { subtotal: 150, vatAmount: 37.5, total: 187.5 },
- vatBreakdown: [],
- confidence: 0.8,
- }) }],
- })
+ it('returns validated account suggestions', async () => {
+ const extractionWithAccounts = {
+ ...validExtraction,
+ lineItems: [
+ { description: 'Item', quantity: 1, unitPrice: 100, lineTotal: 100, vatRate: 25, accountSuggestion: '6100' },
+ { description: 'Bad', quantity: 1, unitPrice: 50, lineTotal: 50, vatRate: 25, accountSuggestion: null },
+ ],
+ }
+ mockExtractInvoice.mockResolvedValueOnce(extractionWithAccounts)
const result = await analyzeInvoice('base64data', 'application/pdf')
expect(result.lineItems[0].accountSuggestion).toBe('6100')
diff --git a/extensions/general/invoice-inbox/lib/invoice-analyzer.ts b/extensions/general/invoice-inbox/lib/invoice-analyzer.ts
index 4a17e00b..fb0d824a 100644
--- a/extensions/general/invoice-inbox/lib/invoice-analyzer.ts
+++ b/extensions/general/invoice-inbox/lib/invoice-analyzer.ts
@@ -1,312 +1,23 @@
/**
- * Invoice Analyzer using Claude Haiku Vision API
+ * Invoice Analyzer — delegates to lib/ai/document-analyzer for extraction,
+ * then applies invoice-specific validation and enhancement.
*
- * SERVER-ONLY: This module uses the Anthropic SDK and must only be imported
- * in server components or API routes.
+ * SERVER-ONLY: uses the shared vision client via document-analyzer.
*
- * Analyzes supplier invoice PDFs/images and extracts structured data
- * including supplier info, line items, VAT breakdown, and payment details.
+ * Preserved public API: analyzeInvoice().
*/
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()
-
-const MAX_RETRIES = 3
-const RETRY_DELAY_MS = 1000
-
-type ImageMediaType = 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif'
+import type { InvoiceExtractionResult } from '../types'
+import { extractInvoice } from '@/lib/ai/document-analyzer'
/**
* Analyze a supplier invoice using Claude Haiku Vision.
- * Supports both PDF (native document support) and images.
+ * Delegates extraction to the shared core.
*/
export async function analyzeInvoice(
fileBase64: string,
mimeType: string
): Promise
{
- const systemPrompt = `Du är expert på att extrahera data från svenska leverantörsfakturor.
-Din uppgift är att noggrant analysera fakturan och extrahera all relevant information.
-
-VIKTIGT:
-- Extrahera leverantörens organisationsnummer (XXXXXX-XXXX format)
-- Extrahera bankgiro och/eller plusgiro
-- Extrahera varje fakturaradspost med belopp, moms
-- Identifiera momssatser (25%, 12%, 6%, 0%)
-- Extrahera OCR-nummer eller betalningsreferens
-- Datum ska vara i ISO-format (YYYY-MM-DD)
-- 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:
-
-{
- "supplier": {
- "name": "Leverantörens namn",
- "orgNumber": "XXXXXX-XXXX eller null",
- "vatNumber": "SE... eller null",
- "address": "Fullständig adress eller null",
- "bankgiro": "XXX-XXXX eller null",
- "plusgiro": "XXXXXX-X eller null"
- },
- "invoice": {
- "invoiceNumber": "Fakturanummer",
- "invoiceDate": "YYYY-MM-DD",
- "dueDate": "YYYY-MM-DD",
- "paymentReference": "OCR-nummer eller referens eller null",
- "currency": "SEK"
- },
- "lineItems": [
- {
- "description": "Beskrivning av rad",
- "quantity": 1,
- "unitPrice": 100.00,
- "lineTotal": 100.00,
- "vatRate": 25,
- "accountSuggestion": "BAS-kontonummer som 5410 eller null",
- "suggestedTemplateId": "mall-id eller null"
- }
- ],
- "totals": {
- "subtotal": 100.00,
- "vatAmount": 25.00,
- "total": 125.00
- },
- "vatBreakdown": [
- {
- "rate": 25,
- "base": 100.00,
- "amount": 25.00
- }
- ],
- "confidence": 0.95,
- "suggestedTemplateId": "mall-id för hela fakturan eller null"
-}
-
-${templateSection}
-
-KONTOKATEGORIER (BAS, backup om ingen mall matchar):
-- 4000-4999: Varuinköp, material
-- 5010: Lokalhyra
-- 5410: Förbrukningsinventarier
-- 5420: Programvaror
-- 5800-5899: Resekostnader
-- 6100-6199: Kontorsmaterial
-- 6200-6299: Telefon, internet
-- 6310: Företagsförsäkringar
-- 6530: Redovisningstjänster
-- 6570: Bankkostnader
-
-Returnera ENDAST JSON-objektet, ingen annan text.`
-
- let lastError: Error | null = null
-
- for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
- try {
- // Build content based on mime type
- const isPdf = mimeType === 'application/pdf'
- const isImage = mimeType.startsWith('image/')
-
- if (!isPdf && !isImage) {
- throw new Error(`Unsupported file type: ${mimeType}`)
- }
-
- const contentBlocks: Anthropic.MessageCreateParams['messages'][0]['content'] = isPdf
- ? [
- {
- type: 'document' as const,
- source: {
- type: 'base64' as const,
- media_type: 'application/pdf' as const,
- data: fileBase64,
- },
- },
- { type: 'text' as const, text: userPrompt },
- ]
- : [
- {
- type: 'image' as const,
- source: {
- type: 'base64' as const,
- media_type: mimeType as ImageMediaType,
- data: fileBase64,
- },
- },
- { type: 'text' as const, text: userPrompt },
- ]
-
- const message = await anthropic.messages.create({
- model: 'claude-haiku-4-5-20251001',
- max_tokens: 4096,
- 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 validateAndEnhanceResult(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(`Invoice analysis failed after ${MAX_RETRIES} attempts: ${lastError?.message}`)
-}
-
-function validateAndEnhanceResult(raw: unknown): InvoiceExtractionResult {
- if (!raw || typeof raw !== 'object') {
- throw new Error('Invalid extraction result: not an object')
- }
-
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const data = raw as any
-
- const supplier = data.supplier || {}
- const invoice = data.invoice || {}
- const totals = data.totals || {}
-
- return {
- supplier: {
- name: validateString(supplier.name),
- orgNumber: validateOrgNumber(supplier.orgNumber),
- vatNumber: validateVatNumber(supplier.vatNumber),
- address: validateString(supplier.address),
- bankgiro: validateString(supplier.bankgiro),
- plusgiro: validateString(supplier.plusgiro),
- },
- invoice: {
- invoiceNumber: validateString(invoice.invoiceNumber),
- invoiceDate: validateDate(invoice.invoiceDate),
- dueDate: validateDate(invoice.dueDate),
- paymentReference: validateString(invoice.paymentReference),
- currency: validateString(invoice.currency) || 'SEK',
- },
- lineItems: validateLineItems(data.lineItems),
- totals: {
- subtotal: validateNumber(totals.subtotal),
- vatAmount: validateNumber(totals.vatAmount),
- total: validateNumber(totals.total),
- },
- vatBreakdown: validateVatBreakdown(data.vatBreakdown),
- confidence: validateNumber(data.confidence) || 0.5,
- suggestedTemplateId: validateString(data.suggestedTemplateId) || undefined,
- }
-}
-
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-function validateLineItems(data: any): ExtractedInvoiceLineItem[] {
- if (!Array.isArray(data)) return []
-
- return data
- .filter((item: unknown) => item && typeof item === 'object')
- .map((item: Record) => ({
- description: String(item.description || '').trim(),
- quantity: (validateNumber(item.quantity) || 1),
- unitPrice: validateNumber(item.unitPrice),
- 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)
-}
-
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-function validateVatBreakdown(data: any): VatBreakdownItem[] {
- if (!Array.isArray(data)) return []
-
- return data
- .filter((item: unknown) => item && typeof item === 'object')
- .map((item: Record) => ({
- rate: validateNumber(item.rate) || 0,
- base: validateNumber(item.base) || 0,
- amount: validateNumber(item.amount) || 0,
- }))
- .filter((item: VatBreakdownItem) => item.amount > 0 || item.base > 0)
-}
-
-function validateString(value: unknown): string | null {
- if (typeof value === 'string' && value.trim()) {
- return value.trim()
- }
- return null
-}
-
-function validateNumber(value: unknown): number | null {
- if (typeof value === 'number' && !isNaN(value)) {
- return value
- }
- if (typeof value === 'string') {
- const parsed = parseFloat(value.replace(/[^\d.-]/g, ''))
- if (!isNaN(parsed)) return parsed
- }
- return null
-}
-
-function validateDate(value: unknown): string | null {
- if (typeof value !== 'string') return null
- const date = new Date(value)
- if (isNaN(date.getTime())) return null
- return date.toISOString().split('T')[0]
-}
-
-function validateOrgNumber(value: unknown): string | null {
- if (typeof value !== 'string') return null
- const digits = value.replace(/\D/g, '')
- if (digits.length === 10) {
- return `${digits.slice(0, 6)}-${digits.slice(6)}`
- }
- return null
-}
-
-function validateVatNumber(value: unknown): string | null {
- if (typeof value !== 'string') return null
- const cleaned = value.trim().toUpperCase()
- if (cleaned.startsWith('SE') && cleaned.length >= 12) {
- return cleaned
- }
- return null
-}
-
-function validateAccountNumber(value: string | undefined): string | null {
- if (!value) return null
- const digits = value.replace(/\D/g, '')
- if (digits.length === 4 && parseInt(digits) >= 1000 && parseInt(digits) <= 9999) {
- return digits
- }
- return null
-}
-
-function sleep(ms: number): Promise {
- return new Promise((resolve) => setTimeout(resolve, ms))
+ return extractInvoice(fileBase64, mimeType)
}
diff --git a/extensions/general/receipt-ocr/api-routes.ts b/extensions/general/receipt-ocr/api-routes.ts
index 6a6eff62..059fe994 100644
--- a/extensions/general/receipt-ocr/api-routes.ts
+++ b/extensions/general/receipt-ocr/api-routes.ts
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'
import type { ApiRouteDefinition, ExtensionContext } from '@/lib/extensions/types'
import type { ConfirmReceiptInput, Receipt, ReceiptLineItem, Transaction } from '@/types'
import { analyzeReceipt } from './lib/receipt-analyzer'
-import { processLineItems } from './lib/receipt-categorizer'
+import { processLineItems, getDefaultClassification } from './lib/receipt-categorizer'
import { findTransactionMatches } from './lib/receipt-matcher'
import { getSettings, saveSettings } from './index'
@@ -144,6 +144,10 @@ async function handleUpload(
// Process and categorize line items
const processedLineItems = processLineItems(extraction.lineItems)
+ const { defaultIsBusiness } = getDefaultClassification(
+ extraction.flags.isRestaurant,
+ extraction.flags.isSystembolaget
+ )
// Update receipt with extracted data
const { error: updateError } = await supabase
@@ -184,6 +188,7 @@ async function handleUpload(
suggested_category: item.suggestedCategory,
category: item.category,
bas_account: item.basAccount,
+ is_business: defaultIsBusiness,
sort_order: index,
}))
diff --git a/extensions/general/receipt-ocr/lib/__tests__/receipt-categorizer.test.ts b/extensions/general/receipt-ocr/lib/__tests__/receipt-categorizer.test.ts
index db9f4fbf..41867f72 100644
--- a/extensions/general/receipt-ocr/lib/__tests__/receipt-categorizer.test.ts
+++ b/extensions/general/receipt-ocr/lib/__tests__/receipt-categorizer.test.ts
@@ -145,9 +145,9 @@ describe('getDefaultClassification', () => {
expect(result.warningMessage).toContain('Restaurangbesök')
})
- it('non-restaurant, non-systembolaget has no warning', () => {
+ it('normal receipt defaults to business', () => {
const result = getDefaultClassification(false, false)
- expect(result.defaultIsBusiness).toBeNull()
+ expect(result.defaultIsBusiness).toBe(true)
expect(result.requiresReview).toBe(false)
expect(result.warningMessage).toBeNull()
})
diff --git a/extensions/general/receipt-ocr/lib/__tests__/receipt-pipeline.test.ts b/extensions/general/receipt-ocr/lib/__tests__/receipt-pipeline.test.ts
index 516f1cbd..0fa1b2bf 100644
--- a/extensions/general/receipt-ocr/lib/__tests__/receipt-pipeline.test.ts
+++ b/extensions/general/receipt-ocr/lib/__tests__/receipt-pipeline.test.ts
@@ -12,6 +12,11 @@ vi.mock('../receipt-analyzer', () => ({
// Mock receipt categorizer
vi.mock('../receipt-categorizer', () => ({
processLineItems: vi.fn(),
+ getDefaultClassification: vi.fn().mockReturnValue({
+ defaultIsBusiness: true,
+ requiresReview: false,
+ warningMessage: null,
+ }),
}))
// Mock receipt matcher
diff --git a/extensions/general/receipt-ocr/lib/receipt-analyzer.ts b/extensions/general/receipt-ocr/lib/receipt-analyzer.ts
index 11d572b8..5a23e130 100644
--- a/extensions/general/receipt-ocr/lib/receipt-analyzer.ts
+++ b/extensions/general/receipt-ocr/lib/receipt-analyzer.ts
@@ -1,24 +1,21 @@
/**
- * Receipt Analyzer using Claude Haiku Vision API
+ * Receipt Analyzer — delegates to lib/ai/document-analyzer for extraction,
+ * then applies receipt-specific validation and enhancement.
*
- * SERVER-ONLY: This module uses the Anthropic SDK and must only be imported
- * in server components or API routes.
+ * SERVER-ONLY: uses the shared vision client via document-analyzer.
*
- * Analyzes receipt images and extracts line items, merchant info,
- * and special flags (restaurant, Systembolaget, foreign merchant).
+ * Preserved public API: analyzeReceipt() and estimateProductValue().
*/
import 'server-only'
-import Anthropic from '@anthropic-ai/sdk'
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 type { ReceiptExtractionResult } from '@/types'
+import { extractReceipt } from '@/lib/ai/document-analyzer'
import {
SYSTEMBOLAGET_PATTERNS,
RESTAURANT_PATTERNS,
- RESTAURANT_MCC_CODES,
} from './receipt-utils'
// Re-export client-safe functions for convenience
@@ -29,255 +26,46 @@ export {
detectRestaurant,
} from './receipt-utils'
-const anthropic = new Anthropic()
-
-// Maximum retries for API calls
+// Retry config for estimateProductValue (still uses LangChain, not vision-client)
const MAX_RETRIES = 3
const RETRY_DELAY_MS = 1000
/**
- * Analyze a receipt image using Claude Haiku Vision
+ * Analyze a receipt image using Claude Haiku Vision.
+ * Delegates extraction to the shared core, then applies receipt-specific enhancements.
*/
export async function analyzeReceipt(
imageBase64: string,
mimeType: 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif' = 'image/jpeg'
): Promise {
- const systemPrompt = `Du är expert på att extrahera data från svenska kvitton och fakturor.
-Din uppgift är att noggrant analysera kvittobilden och extrahera all relevant information.
-
-VIKTIGT:
-- Extrahera VARJE artikelrad, inte bara summan
-- Identifiera momssats per rad om möjligt (25%, 12%, 6%)
-- Flagga om detta är: restaurang, Systembolaget, eller utländsk handlare
-- Svenska organisationsnummer är i format XXXXXX-XXXX
-- Momsregistreringsnummer börjar med SE
-- Datum ska vara i ISO-format (YYYY-MM-DD)
-- 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:
-
-{
- "merchant": {
- "name": "Handlarens namn",
- "orgNumber": "XXXXXX-XXXX eller null",
- "vatNumber": "SE... eller null",
- "isForeign": false
- },
- "receipt": {
- "date": "YYYY-MM-DD",
- "time": "HH:MM eller null",
- "currency": "SEK"
- },
- "lineItems": [
- {
- "description": "Artikelbeskrivning",
- "quantity": 1,
- "unitPrice": 100.00,
- "lineTotal": 100.00,
- "vatRate": 25,
- "suggestedCategory": "equipment|software|travel|office|marketing|professional_services|education|other",
- "suggestedTemplateId": "mall-id eller null"
- }
- ],
- "totals": {
- "subtotal": 100.00,
- "vatAmount": 25.00,
- "total": 125.00
- },
- "flags": {
- "isRestaurant": false,
- "isSystembolaget": false,
- "isForeignMerchant": false
- },
- "confidence": 0.95,
- "suggestedTemplateId": "mall-id för hela kvittot eller null"
-}
-
-${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
-- office: Kontorsmaterial, möbler, hyra
-- marketing: Reklam, marknadsföring, PR
-- professional_services: Konsulter, redovisning, juridik
-- education: Kurser, böcker, utbildning
-- other: Övrigt
-
-Returnera ENDAST JSON-objektet, ingen annan text.`
-
- let lastError: Error | null = null
-
- for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
- try {
- const message = await anthropic.messages.create({
- model: 'claude-haiku-4-5-20251001',
- max_tokens: 4096,
- messages: [
- {
- role: 'user',
- content: [
- {
- type: 'image',
- source: {
- type: 'base64',
- media_type: mimeType,
- data: imageBase64,
- },
- },
- {
- type: 'text',
- text: userPrompt,
- },
- ],
- },
- ],
- system: systemPrompt,
- })
-
- // Extract the text content
- const content = message.content[0]
- if (content.type !== 'text') {
- throw new Error('Unexpected response type from AI')
- }
-
- // Parse the JSON response - strip markdown code blocks if present
- 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)
-
- // Validate and enhance the result
- return validateAndEnhanceResult(parsed)
- } catch (error) {
- lastError = error instanceof Error ? error : new Error('Unknown error')
-
- // Don't retry on JSON parse errors
- if (error instanceof SyntaxError) {
- throw new Error(`Failed to parse AI response: ${lastError.message}`)
- }
-
- // Wait before retrying
- if (attempt < MAX_RETRIES - 1) {
- await sleep(RETRY_DELAY_MS * (attempt + 1))
- }
- }
- }
-
- throw new Error(`Receipt analysis failed after ${MAX_RETRIES} attempts: ${lastError?.message}`)
+ const raw = await extractReceipt(imageBase64, mimeType)
+ return validateAndEnhanceResult(raw)
}
/**
- * Validate and enhance the extraction result
+ * Receipt-specific validation and enhancement.
+ * Applies Systembolaget/restaurant/foreign merchant detection
+ * on top of the core extraction result.
*/
-function validateAndEnhanceResult(raw: unknown): ReceiptExtractionResult {
- if (!raw || typeof raw !== 'object') {
- throw new Error('Invalid extraction result: not an object')
- }
+function validateAndEnhanceResult(result: ReceiptExtractionResult): ReceiptExtractionResult {
+ // Detect special merchants using local patterns
+ const merchantName = (result.merchant.name || '').toLowerCase()
+ const isSystembolaget = result.flags.isSystembolaget || detectSystembolagetLocal(merchantName)
+ const isRestaurant = result.flags.isRestaurant || detectRestaurantLocal(merchantName)
+ const isForeign = result.flags.isForeignMerchant || detectForeignMerchant(result.merchant, result.receipt.currency)
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const data = raw as any
-
- const merchant = data.merchant || {}
- const receipt = data.receipt || {}
- const totals = data.totals || {}
- const flags = data.flags || {}
-
- // Validate line items
- const lineItems = validateLineItems(data.lineItems)
-
- // Detect special merchants if not already flagged
- const merchantName = (merchant.name || '').toLowerCase()
- const isSystembolaget = flags.isSystembolaget || detectSystembolagetLocal(merchantName)
- const isRestaurant = flags.isRestaurant || detectRestaurantLocal(merchantName)
- const isForeign = flags.isForeignMerchant || detectForeignMerchant(merchant, receipt.currency)
-
- const result: ReceiptExtractionResult = {
+ return {
+ ...result,
merchant: {
- name: validateString(merchant.name),
- orgNumber: validateOrgNumber(merchant.orgNumber),
- vatNumber: validateVatNumber(merchant.vatNumber),
+ ...result.merchant,
isForeign,
},
- receipt: {
- date: validateDate(receipt.date),
- time: validateTime(receipt.time),
- currency: validateString(receipt.currency) || 'SEK',
- },
- lineItems,
- totals: {
- subtotal: validateNumber(totals.subtotal),
- vatAmount: validateNumber(totals.vatAmount),
- total: validateNumber(totals.total),
- },
flags: {
isRestaurant,
isSystembolaget,
isForeignMerchant: isForeign,
},
- confidence: validateNumber(data.confidence) || 0.5,
- suggestedTemplateId: validateString(data.suggestedTemplateId) || undefined,
}
-
- return result
-}
-
-/**
- * Validate line items array
- */
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-function validateLineItems(data: any): ExtractedLineItem[] {
- if (!Array.isArray(data)) return []
-
- return data
- .filter((item) => item && typeof item === 'object' && item.description)
- .map((item) => ({
- description: String(item.description).trim(),
- quantity: validateNumber(item.quantity) || 1,
- unitPrice: validateNumber(item.unitPrice),
- 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)
-}
-
-/**
- * Validate expense category suggestion
- */
-function validateCategory(value: unknown): string | null {
- const validCategories = [
- 'equipment',
- 'software',
- 'travel',
- 'office',
- 'marketing',
- 'professional_services',
- 'education',
- 'other',
- ]
-
- if (typeof value === 'string' && validCategories.includes(value)) {
- return value
- }
- return null
}
/**
@@ -297,104 +85,27 @@ function detectRestaurantLocal(merchantName: string): boolean {
/**
* Detect if merchant is foreign (non-Swedish)
*/
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-function detectForeignMerchant(merchant: any, currency?: string): boolean {
- // Check currency (non-SEK suggests foreign)
- if (currency && currency !== 'SEK') {
- return true
- }
+function detectForeignMerchant(
+ merchant: ReceiptExtractionResult['merchant'],
+ currency?: string
+): boolean {
+ if (currency && currency !== 'SEK') return true
- // Check org number format (Swedish org numbers are 10 digits)
const orgNumber = merchant.orgNumber || ''
- if (orgNumber && !isSwedishOrgNumber(orgNumber)) {
- return true
+ if (orgNumber) {
+ const digits = orgNumber.replace(/\D/g, '')
+ if (digits.length !== 10) return true
}
- // Check VAT number (Swedish starts with SE)
const vatNumber = merchant.vatNumber || ''
- if (vatNumber && !vatNumber.toUpperCase().startsWith('SE')) {
- return true
- }
+ if (vatNumber && !vatNumber.toUpperCase().startsWith('SE')) return true
return false
}
-/**
- * Check if org number is Swedish format
- */
-function isSwedishOrgNumber(value: string): boolean {
- const digits = value.replace(/\D/g, '')
- return digits.length === 10
-}
-
-// Validation helpers
-function validateString(value: unknown): string | null {
- if (typeof value === 'string' && value.trim()) {
- return value.trim()
- }
- return null
-}
-
-function validateNumber(value: unknown): number | null {
- if (typeof value === 'number' && !isNaN(value)) {
- return value
- }
- if (typeof value === 'string') {
- const parsed = parseFloat(value.replace(/[^\d.-]/g, ''))
- if (!isNaN(parsed)) {
- return parsed
- }
- }
- return null
-}
-
-function validateDate(value: unknown): string | null {
- if (typeof value !== 'string') return null
-
- const date = new Date(value)
- if (isNaN(date.getTime())) return null
-
- return date.toISOString().split('T')[0]
-}
-
-function validateTime(value: unknown): string | null {
- if (typeof value !== 'string') return null
-
- // Match HH:MM or HH:MM:SS
- const match = value.match(/^(\d{2}):(\d{2})(:\d{2})?$/)
- if (match) {
- return `${match[1]}:${match[2]}`
- }
- return null
-}
-
-function validateOrgNumber(value: unknown): string | null {
- if (typeof value !== 'string') return null
-
- const digits = value.replace(/\D/g, '')
-
- // Swedish org numbers are 10 digits
- if (digits.length === 10) {
- return `${digits.slice(0, 6)}-${digits.slice(6)}`
- }
-
- return null
-}
-
-function validateVatNumber(value: unknown): string | null {
- if (typeof value !== 'string') return null
-
- const cleaned = value.trim().toUpperCase()
- if (cleaned.startsWith('SE') && cleaned.length >= 12) {
- return cleaned
- }
-
- return null
-}
-
-function sleep(ms: number): Promise {
- return new Promise((resolve) => setTimeout(resolve, ms))
-}
+// ============================================================
+// Product Value Estimation (LangChain — separate from receipt analysis)
+// ============================================================
const ProductEstimationSchema = z.object({
estimatedValue: z.number().describe('Uppskattat marknadsvärde i SEK'),
@@ -404,8 +115,8 @@ const ProductEstimationSchema = z.object({
})
/**
- * Estimate product value from image using Claude Vision via LangChain
- * Used for gift/product registration without receipt
+ * Estimate product value from image using Claude Vision via LangChain.
+ * Used for gift/product registration without receipt.
*/
export async function estimateProductValue(
imageBase64: string,
@@ -467,3 +178,7 @@ Din uppgift är att identifiera produkten och ge en rimlig uppskattning av dess
throw new Error(`Product value estimation failed after ${MAX_RETRIES} attempts: ${lastError?.message}`)
}
+
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms))
+}
diff --git a/extensions/general/receipt-ocr/lib/receipt-categorizer.ts b/extensions/general/receipt-ocr/lib/receipt-categorizer.ts
index 8a90663e..0649bc29 100644
--- a/extensions/general/receipt-ocr/lib/receipt-categorizer.ts
+++ b/extensions/general/receipt-ocr/lib/receipt-categorizer.ts
@@ -277,7 +277,7 @@ export function getDefaultClassification(
}
return {
- defaultIsBusiness: null, // User decides per item
+ defaultIsBusiness: true, // Default to business for normal receipts
requiresReview: false,
warningMessage: null,
}
diff --git a/extensions/general/receipt-ocr/lib/receipt-pipeline.ts b/extensions/general/receipt-ocr/lib/receipt-pipeline.ts
index fc73722e..e63510b1 100644
--- a/extensions/general/receipt-ocr/lib/receipt-pipeline.ts
+++ b/extensions/general/receipt-ocr/lib/receipt-pipeline.ts
@@ -9,9 +9,9 @@
import 'server-only'
import type { SupabaseClient } from '@supabase/supabase-js'
-import type { Receipt, ReceiptMatchCandidate } from '@/types'
+import type { Receipt, ReceiptMatchCandidate, ReceiptExtractionResult } from '@/types'
import { analyzeReceipt } from './receipt-analyzer'
-import { processLineItems } from './receipt-categorizer'
+import { processLineItems, getDefaultClassification } from './receipt-categorizer'
import { autoMatchReceipts } from './receipt-matcher'
import { eventBus } from '@/lib/events/bus'
@@ -20,6 +20,7 @@ export interface ReceiptPipelineOptions {
source: 'upload' | 'camera' | 'email'
emailFrom?: string
storageUrl: string
+ preExtracted?: ReceiptExtractionResult
}
export interface ProcessedReceipt {
@@ -43,12 +44,16 @@ export async function processReceiptFromDocument(
mimeType: string,
opts: ReceiptPipelineOptions
): Promise {
- // 1. Analyze receipt with Claude Vision
+ // 1. Use pre-extracted data if available, otherwise analyze with Claude Vision
const validImageType = mimeType as 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif'
- const extraction = await analyzeReceipt(base64, validImageType)
+ const extraction = opts.preExtracted ?? await analyzeReceipt(base64, validImageType)
- // 2. Categorize line items
+ // 2. Categorize line items and apply default business classification
const processedLineItems = processLineItems(extraction.lineItems)
+ const { defaultIsBusiness } = getDefaultClassification(
+ extraction.flags.isRestaurant,
+ extraction.flags.isSystembolaget
+ )
// 3. Insert receipt record
const { data: receipt, error: insertError } = await supabase
@@ -98,6 +103,7 @@ export async function processReceiptFromDocument(
suggested_category: item.suggestedCategory,
category: item.category,
bas_account: item.basAccount,
+ is_business: defaultIsBusiness,
sort_order: index,
}))
diff --git a/extensions/general/receipt-ocr/pages/ReceiptsPage.tsx b/extensions/general/receipt-ocr/pages/ReceiptsPage.tsx
index bbf58335..e7023ba2 100644
--- a/extensions/general/receipt-ocr/pages/ReceiptsPage.tsx
+++ b/extensions/general/receipt-ocr/pages/ReceiptsPage.tsx
@@ -41,7 +41,7 @@ export default function ReceiptsPage() {
setIsLoading(true)
try {
const [receiptsRes, queueRes] = await Promise.all([
- fetch('/api/extensions/receipt-ocr'),
+ fetch('/api/extensions/ext/receipt-ocr'),
fetch('/api/extensions/ext/receipt-ocr/queue'),
])
diff --git a/lib/ai/__tests__/document-analyzer.test.ts b/lib/ai/__tests__/document-analyzer.test.ts
new file mode 100644
index 00000000..1d85bd15
--- /dev/null
+++ b/lib/ai/__tests__/document-analyzer.test.ts
@@ -0,0 +1,378 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+// Mock server-only
+vi.mock('server-only', () => ({}))
+
+// Mock vision client
+const { mockCallVision } = vi.hoisted(() => ({
+ mockCallVision: vi.fn(),
+}))
+
+vi.mock('../vision-client', () => ({
+ callVision: mockCallVision,
+}))
+
+// Mock template prompt
+vi.mock('@/lib/bookkeeping/template-prompt', () => ({
+ buildTemplatePromptSection: () => 'TEMPLATE_SECTION',
+}))
+
+import {
+ analyzeDocument,
+ extractReceipt,
+ extractInvoice,
+ classifyDocument,
+ validateExtractionConsistency,
+} from '../document-analyzer'
+
+const mockReceiptResponse = {
+ merchant: { name: 'ICA Maxi', orgNumber: '5561234567', 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,
+}
+
+const mockInvoiceResponse = {
+ supplier: { name: 'Telenor AB', orgNumber: '5561234567', vatNumber: 'SE556123456701', address: 'Stockholm', bankgiro: '123-4567', plusgiro: null },
+ invoice: { invoiceNumber: 'F-2024-001', invoiceDate: '2024-06-01', dueDate: '2024-06-30', paymentReference: '1234567890', currency: 'SEK' },
+ lineItems: [
+ { description: 'Mobilabonnemang', quantity: 1, unitPrice: 299, lineTotal: 299, vatRate: 25, accountSuggestion: '6200' },
+ ],
+ totals: { subtotal: 239.2, vatAmount: 59.8, total: 299 },
+ vatBreakdown: [{ rate: 25, base: 239.2, amount: 59.8 }],
+ confidence: 0.95,
+}
+
+describe('classifyDocument', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('classifies a supplier invoice', async () => {
+ mockCallVision.mockResolvedValueOnce({
+ type: 'supplier_invoice',
+ confidence: 0.95,
+ reasoning: 'Contains invoice number and bankgiro',
+ 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)
+ expect(mockCallVision).toHaveBeenCalledWith(
+ expect.objectContaining({ maxTokens: 1024 })
+ )
+ })
+
+ it('classifies a receipt', async () => {
+ mockCallVision.mockResolvedValueOnce({
+ type: 'receipt',
+ confidence: 0.92,
+ reasoning: 'Store receipt',
+ })
+
+ const result = await classifyDocument('base64data', 'image/jpeg')
+
+ expect(result.type).toBe('receipt')
+ expect(result.isReverseCharge).toBeUndefined()
+ })
+
+ it('falls back to unknown for invalid type', async () => {
+ mockCallVision.mockResolvedValueOnce({
+ type: 'invalid_type',
+ confidence: 0.8,
+ reasoning: 'Test',
+ })
+
+ const result = await classifyDocument('base64data', 'image/jpeg')
+
+ expect(result.type).toBe('unknown')
+ })
+})
+
+describe('extractReceipt', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('extracts receipt data', async () => {
+ mockCallVision.mockResolvedValueOnce(mockReceiptResponse)
+
+ const result = await extractReceipt('base64data', 'image/jpeg')
+
+ expect(result.merchant.name).toBe('ICA Maxi')
+ expect(result.merchant.orgNumber).toBe('556123-4567')
+ expect(result.receipt.date).toBe('2024-06-15')
+ expect(result.lineItems).toHaveLength(1)
+ expect(result.totals.total).toBe(15)
+ expect(result.confidence).toBe(0.92)
+ })
+
+ it('retries with correction prompt on consistency failure', async () => {
+ const badResponse = {
+ ...mockReceiptResponse,
+ lineItems: [
+ { description: 'Item', quantity: 1, unitPrice: 100, lineTotal: 100, vatRate: 25 },
+ ],
+ totals: { subtotal: 80, vatAmount: 20, total: 200 }, // 100 != 200 → inconsistent
+ confidence: 0.9,
+ }
+
+ const goodResponse = {
+ ...mockReceiptResponse,
+ lineItems: [
+ { description: 'Item', quantity: 1, unitPrice: 200, lineTotal: 200, vatRate: 25 },
+ ],
+ totals: { subtotal: 160, vatAmount: 40, total: 200 },
+ confidence: 0.85,
+ }
+
+ mockCallVision
+ .mockResolvedValueOnce(badResponse)
+ .mockResolvedValueOnce(goodResponse)
+
+ const result = await extractReceipt('base64data', 'image/jpeg')
+
+ expect(mockCallVision).toHaveBeenCalledTimes(2)
+ expect(result.totals.total).toBe(200)
+ expect(result.lineItems[0].lineTotal).toBe(200)
+ })
+
+ it('returns original with reduced confidence if retry also fails', async () => {
+ const badResponse = {
+ ...mockReceiptResponse,
+ lineItems: [
+ { description: 'Item', quantity: 1, unitPrice: 100, lineTotal: 100, vatRate: 25 },
+ ],
+ totals: { subtotal: 80, vatAmount: 20, total: 200 },
+ confidence: 0.9,
+ }
+
+ mockCallVision
+ .mockResolvedValueOnce(badResponse)
+ .mockRejectedValueOnce(new Error('Retry failed'))
+
+ const result = await extractReceipt('base64data', 'image/jpeg')
+
+ expect(result.confidence).toBeCloseTo(0.63, 1) // 0.9 * 0.7
+ })
+})
+
+describe('extractInvoice', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('extracts invoice data', async () => {
+ mockCallVision.mockResolvedValueOnce(mockInvoiceResponse)
+
+ const result = await extractInvoice('base64data', 'application/pdf')
+
+ expect(result.supplier.name).toBe('Telenor AB')
+ expect(result.supplier.orgNumber).toBe('556123-4567')
+ expect(result.invoice.invoiceNumber).toBe('F-2024-001')
+ expect(result.lineItems).toHaveLength(1)
+ expect(result.lineItems[0].accountSuggestion).toBe('6200')
+ expect(result.vatBreakdown).toHaveLength(1)
+ expect(result.confidence).toBe(0.95)
+ })
+})
+
+describe('analyzeDocument', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('classifies and extracts receipt in one call', async () => {
+ mockCallVision.mockResolvedValueOnce({
+ classification: { type: 'receipt', confidence: 0.93, reasoning: 'Receipt' },
+ receipt: mockReceiptResponse,
+ invoice: null,
+ })
+
+ const result = await analyzeDocument('base64data', 'image/jpeg')
+
+ expect(result.classification.type).toBe('receipt')
+ expect(result.receipt).toBeDefined()
+ expect(result.receipt!.merchant.name).toBe('ICA Maxi')
+ expect(result.invoice).toBeUndefined()
+ expect(mockCallVision).toHaveBeenCalledTimes(1) // Single call
+ })
+
+ it('classifies and extracts invoice in one call', async () => {
+ mockCallVision.mockResolvedValueOnce({
+ classification: { type: 'supplier_invoice', confidence: 0.95, reasoning: 'Invoice', isReverseCharge: false },
+ receipt: null,
+ invoice: mockInvoiceResponse,
+ })
+
+ const result = await analyzeDocument('base64data', 'application/pdf')
+
+ expect(result.classification.type).toBe('supplier_invoice')
+ expect(result.invoice).toBeDefined()
+ expect(result.invoice!.supplier.name).toBe('Telenor AB')
+ expect(result.receipt).toBeUndefined()
+ })
+
+ it('falls back to extractReceipt when type=receipt but receipt data missing', async () => {
+ mockCallVision
+ .mockResolvedValueOnce({
+ classification: { type: 'receipt', confidence: 0.8, reasoning: 'Receipt' },
+ receipt: null,
+ invoice: null,
+ })
+ .mockResolvedValueOnce(mockReceiptResponse) // fallback extractReceipt call
+
+ const result = await analyzeDocument('base64data', 'image/jpeg')
+
+ expect(result.classification.type).toBe('receipt')
+ expect(result.receipt).toBeDefined()
+ expect(mockCallVision).toHaveBeenCalledTimes(2) // unified + fallback
+ })
+
+ it('falls back to extractInvoice when type=supplier_invoice but invoice data missing', async () => {
+ mockCallVision
+ .mockResolvedValueOnce({
+ classification: { type: 'supplier_invoice', confidence: 0.9, reasoning: 'Invoice' },
+ receipt: null,
+ invoice: null,
+ })
+ .mockResolvedValueOnce(mockInvoiceResponse)
+
+ const result = await analyzeDocument('base64data', 'application/pdf')
+
+ expect(result.classification.type).toBe('supplier_invoice')
+ expect(result.invoice).toBeDefined()
+ expect(mockCallVision).toHaveBeenCalledTimes(2)
+ })
+
+ it('returns classification only for government_letter', async () => {
+ mockCallVision.mockResolvedValueOnce({
+ classification: { type: 'government_letter', confidence: 0.88, reasoning: 'From Skatteverket' },
+ })
+
+ const result = await analyzeDocument('base64data', 'application/pdf')
+
+ expect(result.classification.type).toBe('government_letter')
+ expect(result.receipt).toBeUndefined()
+ expect(result.invoice).toBeUndefined()
+ expect(mockCallVision).toHaveBeenCalledTimes(1)
+ })
+
+ it('returns classification only for unknown', async () => {
+ mockCallVision.mockResolvedValueOnce({
+ classification: { type: 'unknown', confidence: 0.5, reasoning: 'Cannot determine' },
+ })
+
+ const result = await analyzeDocument('base64data', 'image/png')
+
+ expect(result.classification.type).toBe('unknown')
+ expect(mockCallVision).toHaveBeenCalledTimes(1)
+ })
+})
+
+describe('validateExtractionConsistency', () => {
+ it('passes for valid extraction', () => {
+ const extraction = {
+ ...mockReceiptResponse,
+ merchant: { ...mockReceiptResponse.merchant, orgNumber: '556123-4567' },
+ } as unknown as import('@/types').ReceiptExtractionResult
+
+ const result = validateExtractionConsistency(extraction, 'receipt')
+
+ expect(result.valid).toBe(true)
+ expect(result.issues).toHaveLength(0)
+ })
+
+ it('detects line item sum mismatch', () => {
+ const extraction = {
+ lineItems: [
+ { description: 'Item 1', lineTotal: 100, vatRate: 25 },
+ ],
+ totals: { subtotal: 80, vatAmount: 20, total: 200 },
+ confidence: 0.9,
+ } as unknown as import('@/types').ReceiptExtractionResult
+
+ const result = validateExtractionConsistency(extraction, 'receipt')
+
+ expect(result.valid).toBe(false)
+ expect(result.issues).toContainEqual(expect.stringContaining('Line items sum'))
+ })
+
+ it('allows small rounding differences (±1 SEK)', () => {
+ const extraction = {
+ lineItems: [
+ { description: 'Item', lineTotal: 99.5, vatRate: 25 },
+ ],
+ totals: { subtotal: 80, vatAmount: 20, total: 100 },
+ confidence: 0.9,
+ } as unknown as import('@/types').ReceiptExtractionResult
+
+ const result = validateExtractionConsistency(extraction, 'receipt')
+
+ expect(result.valid).toBe(true)
+ })
+
+ it('detects invalid VAT rates', () => {
+ const extraction = {
+ lineItems: [
+ { description: 'Item', lineTotal: 100, vatRate: 18 },
+ ],
+ totals: { subtotal: 85, vatAmount: 15, total: 100 },
+ confidence: 0.9,
+ } as unknown as import('@/types').ReceiptExtractionResult
+
+ const result = validateExtractionConsistency(extraction, 'receipt')
+
+ expect(result.valid).toBe(false)
+ expect(result.issues).toContainEqual(expect.stringContaining('Invalid VAT rate 18'))
+ })
+
+ it('detects empty descriptions', () => {
+ const extraction = {
+ lineItems: [
+ { description: '', lineTotal: 100, vatRate: 25 },
+ ],
+ totals: { subtotal: 80, vatAmount: 20, total: 100 },
+ confidence: 0.9,
+ } as unknown as import('@/types').ReceiptExtractionResult
+
+ const result = validateExtractionConsistency(extraction, 'receipt')
+
+ expect(result.valid).toBe(false)
+ expect(result.issues).toContainEqual(expect.stringContaining('empty description'))
+ })
+
+ it('detects non-positive totals', () => {
+ const extraction = {
+ lineItems: [],
+ totals: { subtotal: 0, vatAmount: 0, total: -5 },
+ confidence: 0.9,
+ } as unknown as import('@/types').ReceiptExtractionResult
+
+ const result = validateExtractionConsistency(extraction, 'receipt')
+
+ expect(result.valid).toBe(false)
+ expect(result.issues).toContainEqual(expect.stringContaining('Total is -5'))
+ })
+
+ it('passes when total is null (not extracted)', () => {
+ const extraction = {
+ lineItems: [
+ { description: 'Item', lineTotal: 100, vatRate: 25 },
+ ],
+ totals: { subtotal: null, vatAmount: null, total: null },
+ confidence: 0.5,
+ } as unknown as import('@/types').ReceiptExtractionResult
+
+ const result = validateExtractionConsistency(extraction, 'receipt')
+
+ expect(result.valid).toBe(true)
+ })
+})
diff --git a/lib/ai/__tests__/preprocess-image.test.ts b/lib/ai/__tests__/preprocess-image.test.ts
new file mode 100644
index 00000000..e7f1c6c4
--- /dev/null
+++ b/lib/ai/__tests__/preprocess-image.test.ts
@@ -0,0 +1,68 @@
+import { describe, it, expect, vi } from 'vitest'
+
+// Mock server-only
+vi.mock('server-only', () => ({}))
+
+// Mock sharp - use vi.hoisted to avoid initialization order issues
+const { mockSharp, mockSharpChain, mockToBuffer } = vi.hoisted(() => {
+ const mockToBuffer = vi.fn()
+ const mockSharpChain = {
+ grayscale: vi.fn().mockReturnThis(),
+ normalize: vi.fn().mockReturnThis(),
+ sharpen: vi.fn().mockReturnThis(),
+ jpeg: vi.fn().mockReturnThis(),
+ toBuffer: mockToBuffer,
+ }
+ const mockSharp = vi.fn().mockReturnValue(mockSharpChain)
+ return { mockSharp, mockSharpChain, mockToBuffer }
+})
+
+vi.mock('sharp', () => ({ default: mockSharp }))
+
+import { preprocessImage } from '../preprocess-image'
+
+describe('preprocessImage', () => {
+ it('returns PDFs unchanged', async () => {
+ const result = await preprocessImage('pdf-base64-data', 'application/pdf')
+
+ expect(result.base64).toBe('pdf-base64-data')
+ expect(result.mimeType).toBe('application/pdf')
+ expect(mockSharp).not.toHaveBeenCalled()
+ })
+
+ it('processes JPEG images through sharp pipeline', async () => {
+ const inputBase64 = Buffer.from('fake-image-data').toString('base64')
+ const outputBuffer = Buffer.from('processed-image')
+ mockToBuffer.mockResolvedValueOnce(outputBuffer)
+
+ const result = await preprocessImage(inputBase64, 'image/jpeg')
+
+ expect(mockSharp).toHaveBeenCalled()
+ expect(mockSharpChain.grayscale).toHaveBeenCalled()
+ expect(mockSharpChain.normalize).toHaveBeenCalled()
+ expect(mockSharpChain.sharpen).toHaveBeenCalledWith({ sigma: 1.5 })
+ expect(mockSharpChain.jpeg).toHaveBeenCalledWith({ quality: 90 })
+ expect(result.mimeType).toBe('image/jpeg')
+ expect(result.base64).toBe(outputBuffer.toString('base64'))
+ })
+
+ it('processes PNG images and outputs as JPEG', async () => {
+ const inputBase64 = Buffer.from('fake-png-data').toString('base64')
+ const outputBuffer = Buffer.from('processed-png')
+ mockToBuffer.mockResolvedValueOnce(outputBuffer)
+
+ const result = await preprocessImage(inputBase64, 'image/png')
+
+ expect(result.mimeType).toBe('image/jpeg')
+ expect(mockSharp).toHaveBeenCalled()
+ })
+
+ it('processes WebP images', async () => {
+ const inputBase64 = Buffer.from('fake-webp').toString('base64')
+ mockToBuffer.mockResolvedValueOnce(Buffer.from('out'))
+
+ const result = await preprocessImage(inputBase64, 'image/webp')
+
+ expect(result.mimeType).toBe('image/jpeg')
+ })
+})
diff --git a/lib/ai/__tests__/validation-helpers.test.ts b/lib/ai/__tests__/validation-helpers.test.ts
new file mode 100644
index 00000000..5308a9cd
--- /dev/null
+++ b/lib/ai/__tests__/validation-helpers.test.ts
@@ -0,0 +1,131 @@
+import { describe, it, expect } from 'vitest'
+import {
+ validateString,
+ validateNumber,
+ validateDate,
+ validateTime,
+ validateOrgNumber,
+ validateVatNumber,
+ isSwedishOrgNumber,
+ validateAccountNumber,
+} from '../validation-helpers'
+
+describe('validateString', () => {
+ it('returns trimmed string for valid input', () => {
+ expect(validateString(' hello ')).toBe('hello')
+ expect(validateString('test')).toBe('test')
+ })
+
+ it('returns null for empty or non-string', () => {
+ expect(validateString('')).toBeNull()
+ expect(validateString(' ')).toBeNull()
+ expect(validateString(null)).toBeNull()
+ expect(validateString(undefined)).toBeNull()
+ expect(validateString(123)).toBeNull()
+ })
+})
+
+describe('validateNumber', () => {
+ it('returns number for valid numeric input', () => {
+ expect(validateNumber(42)).toBe(42)
+ expect(validateNumber(3.14)).toBe(3.14)
+ expect(validateNumber(0)).toBe(0)
+ expect(validateNumber(-5)).toBe(-5)
+ })
+
+ it('parses numeric strings', () => {
+ expect(validateNumber('42')).toBe(42)
+ expect(validateNumber('3.14')).toBe(3.14)
+ expect(validateNumber('100 SEK')).toBe(100)
+ })
+
+ it('returns null for invalid values', () => {
+ expect(validateNumber(NaN)).toBeNull()
+ expect(validateNumber(null)).toBeNull()
+ expect(validateNumber(undefined)).toBeNull()
+ expect(validateNumber('abc')).toBeNull()
+ expect(validateNumber({})).toBeNull()
+ })
+})
+
+describe('validateDate', () => {
+ it('returns ISO date for valid date strings', () => {
+ expect(validateDate('2024-06-15')).toBe('2024-06-15')
+ expect(validateDate('2024-01-01T12:00:00Z')).toBe('2024-01-01')
+ })
+
+ it('returns null for invalid dates', () => {
+ expect(validateDate('not a date')).toBeNull()
+ expect(validateDate(null)).toBeNull()
+ expect(validateDate(42)).toBeNull()
+ })
+})
+
+describe('validateTime', () => {
+ it('returns HH:MM for valid time strings', () => {
+ expect(validateTime('14:30')).toBe('14:30')
+ expect(validateTime('09:05')).toBe('09:05')
+ expect(validateTime('14:30:00')).toBe('14:30')
+ })
+
+ it('returns null for invalid times', () => {
+ expect(validateTime('2pm')).toBeNull()
+ expect(validateTime(null)).toBeNull()
+ expect(validateTime('1:30')).toBeNull() // needs 2-digit hour
+ })
+})
+
+describe('validateOrgNumber', () => {
+ it('formats valid Swedish org numbers', () => {
+ expect(validateOrgNumber('5561234567')).toBe('556123-4567')
+ expect(validateOrgNumber('556123-4567')).toBe('556123-4567')
+ expect(validateOrgNumber('556 123 4567')).toBe('556123-4567')
+ })
+
+ it('returns null for invalid org numbers', () => {
+ expect(validateOrgNumber('12345')).toBeNull()
+ expect(validateOrgNumber('123456789012')).toBeNull()
+ expect(validateOrgNumber(null)).toBeNull()
+ expect(validateOrgNumber(123)).toBeNull()
+ })
+})
+
+describe('validateVatNumber', () => {
+ it('validates Swedish VAT numbers', () => {
+ expect(validateVatNumber('SE556123456701')).toBe('SE556123456701')
+ expect(validateVatNumber('se556123456701')).toBe('SE556123456701')
+ })
+
+ it('returns null for non-Swedish VAT numbers', () => {
+ expect(validateVatNumber('DE123456789')).toBeNull()
+ expect(validateVatNumber('SE12345')).toBeNull() // too short
+ expect(validateVatNumber(null)).toBeNull()
+ })
+})
+
+describe('isSwedishOrgNumber', () => {
+ it('returns true for 10-digit numbers', () => {
+ expect(isSwedishOrgNumber('5561234567')).toBe(true)
+ expect(isSwedishOrgNumber('556123-4567')).toBe(true)
+ })
+
+ it('returns false for non-10-digit numbers', () => {
+ expect(isSwedishOrgNumber('12345')).toBe(false)
+ expect(isSwedishOrgNumber('123456789012')).toBe(false)
+ })
+})
+
+describe('validateAccountNumber', () => {
+ it('validates 4-digit BAS account numbers', () => {
+ expect(validateAccountNumber('5410')).toBe('5410')
+ expect(validateAccountNumber('1930')).toBe('1930')
+ expect(validateAccountNumber('konto 6530')).toBe('6530')
+ })
+
+ it('returns null for invalid account numbers', () => {
+ expect(validateAccountNumber('999')).toBeNull() // too short
+ expect(validateAccountNumber('0500')).toBeNull() // below 1000
+ expect(validateAccountNumber(null)).toBeNull()
+ expect(validateAccountNumber(undefined)).toBeNull()
+ })
+})
diff --git a/lib/ai/__tests__/vision-client.test.ts b/lib/ai/__tests__/vision-client.test.ts
new file mode 100644
index 00000000..67a3fe55
--- /dev/null
+++ b/lib/ai/__tests__/vision-client.test.ts
@@ -0,0 +1,256 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+// Mock server-only
+vi.mock('server-only', () => ({}))
+
+// Mock preprocessing
+vi.mock('../preprocess-image', () => ({
+ preprocessImage: vi.fn().mockResolvedValue({ base64: 'preprocessed', mimeType: 'image/jpeg' }),
+}))
+
+// Mock Anthropic SDK
+const { mockCreate } = vi.hoisted(() => {
+ const mockCreate = vi.fn()
+ return { mockCreate }
+})
+
+vi.mock('@anthropic-ai/sdk', () => ({
+ default: class MockAnthropic {
+ messages = { create: mockCreate }
+ },
+}))
+
+import { callVision, stripJsonFences } from '../vision-client'
+import { preprocessImage } from '../preprocess-image'
+
+function makeResponse(json: Record) {
+ return {
+ content: [{ type: 'text', text: JSON.stringify(json) }],
+ }
+}
+
+describe('callVision', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('returns parsed JSON from Claude response', async () => {
+ mockCreate.mockResolvedValueOnce(
+ makeResponse({ type: 'receipt', confidence: 0.95 })
+ )
+
+ const result = await callVision({
+ base64: 'base64data',
+ mimeType: 'image/jpeg',
+ systemPrompt: 'System prompt',
+ userPrompt: 'User prompt',
+ })
+
+ expect(result).toEqual({ type: 'receipt', confidence: 0.95 })
+ })
+
+ it('preprocesses images by default', async () => {
+ mockCreate.mockResolvedValueOnce(makeResponse({ ok: true }))
+
+ await callVision({
+ base64: 'raw-image',
+ mimeType: 'image/jpeg',
+ systemPrompt: 'S',
+ userPrompt: 'U',
+ })
+
+ expect(preprocessImage).toHaveBeenCalledWith('raw-image', 'image/jpeg')
+ })
+
+ it('skips preprocessing when preprocess=false', async () => {
+ mockCreate.mockResolvedValueOnce(makeResponse({ ok: true }))
+
+ await callVision({
+ base64: 'raw-image',
+ mimeType: 'image/jpeg',
+ systemPrompt: 'S',
+ userPrompt: 'U',
+ preprocess: false,
+ })
+
+ expect(preprocessImage).not.toHaveBeenCalled()
+ })
+
+ it('skips preprocessing for PDFs', async () => {
+ mockCreate.mockResolvedValueOnce(makeResponse({ ok: true }))
+
+ await callVision({
+ base64: 'pdf-data',
+ mimeType: 'application/pdf',
+ systemPrompt: 'S',
+ userPrompt: 'U',
+ })
+
+ expect(preprocessImage).not.toHaveBeenCalled()
+ })
+
+ it('uses document content block for PDFs', async () => {
+ mockCreate.mockResolvedValueOnce(makeResponse({ type: 'invoice' }))
+
+ await callVision({
+ base64: 'pdf-data',
+ mimeType: 'application/pdf',
+ systemPrompt: 'S',
+ userPrompt: 'U',
+ })
+
+ expect(mockCreate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ messages: [
+ {
+ role: 'user',
+ content: expect.arrayContaining([
+ expect.objectContaining({ type: 'document' }),
+ ]),
+ },
+ ],
+ })
+ )
+ })
+
+ it('uses image content block for images', async () => {
+ mockCreate.mockResolvedValueOnce(makeResponse({ type: 'receipt' }))
+
+ await callVision({
+ base64: 'img-data',
+ mimeType: 'image/png',
+ systemPrompt: 'S',
+ userPrompt: 'U',
+ preprocess: false,
+ })
+
+ expect(mockCreate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ messages: [
+ {
+ role: 'user',
+ content: expect.arrayContaining([
+ expect.objectContaining({ type: 'image' }),
+ ]),
+ },
+ ],
+ })
+ )
+ })
+
+ it('retries on API error', async () => {
+ mockCreate
+ .mockRejectedValueOnce(new Error('API timeout'))
+ .mockResolvedValueOnce(makeResponse({ ok: true }))
+
+ const result = await callVision({
+ base64: 'data',
+ mimeType: 'image/jpeg',
+ systemPrompt: 'S',
+ userPrompt: 'U',
+ })
+
+ expect(result).toEqual({ ok: true })
+ expect(mockCreate).toHaveBeenCalledTimes(2)
+ })
+
+ it('fast-fails on SyntaxError without retrying', async () => {
+ mockCreate.mockResolvedValueOnce({
+ content: [{ type: 'text', text: 'not valid json at all' }],
+ })
+
+ await expect(
+ callVision({
+ base64: 'data',
+ mimeType: 'image/jpeg',
+ systemPrompt: 'S',
+ userPrompt: 'U',
+ })
+ ).rejects.toThrow('Failed to parse AI response')
+
+ expect(mockCreate).toHaveBeenCalledTimes(1)
+ })
+
+ it('throws after max retries', async () => {
+ mockCreate
+ .mockRejectedValueOnce(new Error('err1'))
+ .mockRejectedValueOnce(new Error('err2'))
+ .mockRejectedValueOnce(new Error('err3'))
+
+ await expect(
+ callVision({
+ base64: 'data',
+ mimeType: 'image/jpeg',
+ systemPrompt: 'S',
+ userPrompt: 'U',
+ })
+ ).rejects.toThrow('Vision API call failed after 3 attempts')
+
+ expect(mockCreate).toHaveBeenCalledTimes(3)
+ })
+
+ it('throws on unsupported file type', async () => {
+ await expect(
+ callVision({
+ base64: 'data',
+ mimeType: 'text/plain',
+ systemPrompt: 'S',
+ userPrompt: 'U',
+ })
+ ).rejects.toThrow('Unsupported file type: text/plain')
+ })
+
+ it('uses custom maxTokens', async () => {
+ mockCreate.mockResolvedValueOnce(makeResponse({ ok: true }))
+
+ await callVision({
+ base64: 'data',
+ mimeType: 'application/pdf',
+ systemPrompt: 'S',
+ userPrompt: 'U',
+ maxTokens: 1024,
+ })
+
+ expect(mockCreate).toHaveBeenCalledWith(
+ expect.objectContaining({ max_tokens: 1024 })
+ )
+ })
+
+ it('strips JSON fences from response', async () => {
+ mockCreate.mockResolvedValueOnce({
+ content: [
+ {
+ type: 'text',
+ text: '```json\n{"type":"receipt","confidence":0.9}\n```',
+ },
+ ],
+ })
+
+ const result = await callVision({
+ base64: 'data',
+ mimeType: 'image/jpeg',
+ systemPrompt: 'S',
+ userPrompt: 'U',
+ })
+
+ expect(result).toEqual({ type: 'receipt', confidence: 0.9 })
+ })
+})
+
+describe('stripJsonFences', () => {
+ it('strips ```json ... ``` fences', () => {
+ expect(stripJsonFences('```json\n{"a":1}\n```')).toBe('{"a":1}')
+ })
+
+ it('strips plain ``` fences', () => {
+ expect(stripJsonFences('```\n{"a":1}\n```')).toBe('{"a":1}')
+ })
+
+ it('returns plain JSON unchanged', () => {
+ expect(stripJsonFences('{"a":1}')).toBe('{"a":1}')
+ })
+
+ it('trims whitespace', () => {
+ expect(stripJsonFences(' {"a":1} ')).toBe('{"a":1}')
+ })
+})
diff --git a/lib/ai/document-analyzer.ts b/lib/ai/document-analyzer.ts
new file mode 100644
index 00000000..133d370b
--- /dev/null
+++ b/lib/ai/document-analyzer.ts
@@ -0,0 +1,666 @@
+/**
+ * Unified Document Analyzer
+ *
+ * SERVER-ONLY: Uses the shared vision client (Anthropic SDK).
+ *
+ * Provides four modes:
+ * - analyzeDocument(): Single Claude call that classifies AND extracts
+ * - extractReceipt(): Standalone receipt extraction
+ * - extractInvoice(): Standalone invoice extraction
+ * - classifyDocument(): Lightweight classify-only
+ */
+
+import 'server-only'
+import type {
+ DocumentClassificationType,
+ ReceiptExtractionResult,
+ InvoiceExtractionResult,
+ ExtractedInvoiceLineItem,
+ VatBreakdownItem,
+} from '@/types'
+import { callVision } from './vision-client'
+import {
+ validateString,
+ validateNumber,
+ validateDate,
+ validateTime,
+ validateOrgNumber,
+ validateVatNumber,
+ validateAccountNumber,
+} from './validation-helpers'
+import { buildTemplatePromptSection } from '@/lib/bookkeeping/template-prompt'
+
+// ============================================================
+// Types
+// ============================================================
+
+export interface DocumentClassification {
+ type: DocumentClassificationType
+ confidence: number
+ reasoning: string
+ isReverseCharge?: boolean
+}
+
+export interface UnifiedExtractionResult {
+ classification: DocumentClassification
+ receipt?: ReceiptExtractionResult
+ invoice?: InvoiceExtractionResult
+}
+
+interface ConsistencyResult {
+ valid: boolean
+ issues: string[]
+}
+
+// ============================================================
+// Prompts
+// ============================================================
+
+const CLASSIFY_SYSTEM_PROMPT = `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 RECEIPT_SYSTEM_PROMPT = `Du är expert på att extrahera data från svenska kvitton och fakturor.
+Din uppgift är att noggrant analysera kvittobilden och extrahera all relevant information.
+
+VIKTIGT:
+- Extrahera VARJE artikelrad, inte bara summan
+- Identifiera momssats per rad om möjligt (25%, 12%, 6%)
+- Flagga om detta är: restaurang, Systembolaget, eller utländsk handlare
+- Svenska organisationsnummer är i format XXXXXX-XXXX
+- Momsregistreringsnummer börjar med SE
+- Datum ska vara i ISO-format (YYYY-MM-DD)
+- Belopp ska vara numeriska värden utan valutasymboler
+- Ange konfidenstal (0.0-1.0) för hela extraheringen baserat på bildkvalitet`
+
+const INVOICE_SYSTEM_PROMPT = `Du är expert på att extrahera data från svenska leverantörsfakturor.
+Din uppgift är att noggrant analysera fakturan och extrahera all relevant information.
+
+VIKTIGT:
+- Extrahera leverantörens organisationsnummer (XXXXXX-XXXX format)
+- Extrahera bankgiro och/eller plusgiro
+- Extrahera varje fakturaradspost med belopp, moms
+- Identifiera momssatser (25%, 12%, 6%, 0%)
+- Extrahera OCR-nummer eller betalningsreferens
+- Datum ska vara i ISO-format (YYYY-MM-DD)
+- Belopp ska vara numeriska värden utan valutasymboler
+- Ange konfidenstal (0.0-1.0) för hela extraheringen`
+
+function buildReceiptUserPrompt(): string {
+ const templateSection = buildTemplatePromptSection()
+
+ return `Analysera detta kvitto och extrahera strukturerad data.
+
+Returnera ett JSON-objekt med följande struktur:
+
+{
+ "merchant": {
+ "name": "Handlarens namn",
+ "orgNumber": "XXXXXX-XXXX eller null",
+ "vatNumber": "SE... eller null",
+ "isForeign": false
+ },
+ "receipt": {
+ "date": "YYYY-MM-DD",
+ "time": "HH:MM eller null",
+ "currency": "SEK"
+ },
+ "lineItems": [
+ {
+ "description": "Artikelbeskrivning",
+ "quantity": 1,
+ "unitPrice": 100.00,
+ "lineTotal": 100.00,
+ "vatRate": 25,
+ "suggestedCategory": "equipment|software|travel|office|marketing|professional_services|education|other",
+ "suggestedTemplateId": "mall-id eller null"
+ }
+ ],
+ "totals": {
+ "subtotal": 100.00,
+ "vatAmount": 25.00,
+ "total": 125.00
+ },
+ "flags": {
+ "isRestaurant": false,
+ "isSystembolaget": false,
+ "isForeignMerchant": false
+ },
+ "confidence": 0.95,
+ "suggestedTemplateId": "mall-id för hela kvittot eller null"
+}
+
+${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
+- office: Kontorsmaterial, möbler, hyra
+- marketing: Reklam, marknadsföring, PR
+- professional_services: Konsulter, redovisning, juridik
+- education: Kurser, böcker, utbildning
+- other: Övrigt
+
+Returnera ENDAST JSON-objektet, ingen annan text.`
+}
+
+function buildInvoiceUserPrompt(): string {
+ const templateSection = buildTemplatePromptSection()
+
+ return `Analysera denna leverantörsfaktura och extrahera strukturerad data.
+
+Returnera ett JSON-objekt med följande struktur:
+
+{
+ "supplier": {
+ "name": "Leverantörens namn",
+ "orgNumber": "XXXXXX-XXXX eller null",
+ "vatNumber": "SE... eller null",
+ "address": "Fullständig adress eller null",
+ "bankgiro": "XXX-XXXX eller null",
+ "plusgiro": "XXXXXX-X eller null"
+ },
+ "invoice": {
+ "invoiceNumber": "Fakturanummer",
+ "invoiceDate": "YYYY-MM-DD",
+ "dueDate": "YYYY-MM-DD",
+ "paymentReference": "OCR-nummer eller referens eller null",
+ "currency": "SEK"
+ },
+ "lineItems": [
+ {
+ "description": "Beskrivning av rad",
+ "quantity": 1,
+ "unitPrice": 100.00,
+ "lineTotal": 100.00,
+ "vatRate": 25,
+ "accountSuggestion": "BAS-kontonummer som 5410 eller null",
+ "suggestedTemplateId": "mall-id eller null"
+ }
+ ],
+ "totals": {
+ "subtotal": 100.00,
+ "vatAmount": 25.00,
+ "total": 125.00
+ },
+ "vatBreakdown": [
+ {
+ "rate": 25,
+ "base": 100.00,
+ "amount": 25.00
+ }
+ ],
+ "confidence": 0.95,
+ "suggestedTemplateId": "mall-id för hela fakturan eller null"
+}
+
+${templateSection}
+
+KONTOKATEGORIER (BAS, backup om ingen mall matchar):
+- 4000-4999: Varuinköp, material
+- 5010: Lokalhyra
+- 5410: Förbrukningsinventarier
+- 5420: Programvaror
+- 5800-5899: Resekostnader
+- 6100-6199: Kontorsmaterial
+- 6200-6299: Telefon, internet
+- 6310: Företagsförsäkringar
+- 6530: Redovisningstjänster
+- 6570: Bankkostnader
+
+Returnera ENDAST JSON-objektet, ingen annan text.`
+}
+
+function buildUnifiedUserPrompt(): string {
+ const templateSection = buildTemplatePromptSection()
+
+ return `Analysera detta dokument. Gör BÅDA stegen:
+
+STEG 1: Klassificera dokumenttypen.
+STEG 2: Om det är ett kvitto eller leverantörsfaktura, extrahera all data.
+
+Returnera ett JSON-objekt med följande struktur:
+
+{
+ "classification": {
+ "type": "supplier_invoice" | "receipt" | "government_letter" | "unknown",
+ "confidence": 0.95,
+ "reasoning": "Kort förklaring",
+ "isReverseCharge": false
+ },
+ "receipt": null,
+ "invoice": null
+}
+
+Om type = "receipt", fyll i "receipt" med:
+{
+ "merchant": { "name": "...", "orgNumber": "XXXXXX-XXXX eller null", "vatNumber": "SE... eller null", "isForeign": false },
+ "receipt": { "date": "YYYY-MM-DD", "time": "HH:MM eller null", "currency": "SEK" },
+ "lineItems": [{ "description": "...", "quantity": 1, "unitPrice": 100, "lineTotal": 100, "vatRate": 25, "suggestedCategory": "equipment|software|travel|office|marketing|professional_services|education|other", "suggestedTemplateId": null }],
+ "totals": { "subtotal": 100, "vatAmount": 25, "total": 125 },
+ "flags": { "isRestaurant": false, "isSystembolaget": false, "isForeignMerchant": false },
+ "confidence": 0.95,
+ "suggestedTemplateId": null
+}
+
+Om type = "supplier_invoice", fyll i "invoice" med:
+{
+ "supplier": { "name": "...", "orgNumber": "XXXXXX-XXXX eller null", "vatNumber": "SE... eller null", "address": "...", "bankgiro": "...", "plusgiro": "..." },
+ "invoice": { "invoiceNumber": "...", "invoiceDate": "YYYY-MM-DD", "dueDate": "YYYY-MM-DD", "paymentReference": "...", "currency": "SEK" },
+ "lineItems": [{ "description": "...", "quantity": 1, "unitPrice": 100, "lineTotal": 100, "vatRate": 25, "accountSuggestion": "5410", "suggestedTemplateId": null }],
+ "totals": { "subtotal": 100, "vatAmount": 25, "total": 125 },
+ "vatBreakdown": [{ "rate": 25, "base": 100, "amount": 25 }],
+ "confidence": 0.95,
+ "suggestedTemplateId": null
+}
+
+${templateSection}
+
+Om type = "government_letter" eller "unknown": lämna receipt och invoice som null.
+
+Returnera ENDAST JSON-objektet, ingen annan text.`
+}
+
+// ============================================================
+// Validation
+// ============================================================
+
+const VALID_CLASSIFICATION_TYPES: DocumentClassificationType[] = [
+ 'supplier_invoice',
+ 'receipt',
+ 'government_letter',
+ 'unknown',
+]
+
+const VALID_VAT_RATES = [0, 6, 12, 25]
+
+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_CLASSIFICATION_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 validateReceiptExtraction(raw: unknown): ReceiptExtractionResult {
+ if (!raw || typeof raw !== 'object') {
+ throw new Error('Invalid receipt extraction: not an object')
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const data = raw as any
+
+ const merchant = data.merchant || {}
+ const receipt = data.receipt || {}
+ const totals = data.totals || {}
+ const flags = data.flags || {}
+
+ return {
+ merchant: {
+ name: validateString(merchant.name),
+ orgNumber: validateOrgNumber(merchant.orgNumber),
+ vatNumber: validateVatNumber(merchant.vatNumber),
+ isForeign: Boolean(flags.isForeignMerchant || merchant.isForeign),
+ },
+ receipt: {
+ date: validateDate(receipt.date),
+ time: validateTime(receipt.time),
+ currency: validateString(receipt.currency) || 'SEK',
+ },
+ lineItems: validateReceiptLineItems(data.lineItems),
+ totals: {
+ subtotal: validateNumber(totals.subtotal),
+ vatAmount: validateNumber(totals.vatAmount),
+ total: validateNumber(totals.total),
+ },
+ flags: {
+ isRestaurant: Boolean(flags.isRestaurant),
+ isSystembolaget: Boolean(flags.isSystembolaget),
+ isForeignMerchant: Boolean(flags.isForeignMerchant || merchant.isForeign),
+ },
+ confidence: validateNumber(data.confidence) || 0.5,
+ suggestedTemplateId: validateString(data.suggestedTemplateId) || undefined,
+ }
+}
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+function validateReceiptLineItems(data: any): ReceiptExtractionResult['lineItems'] {
+ if (!Array.isArray(data)) return []
+
+ return data
+ .filter((item: unknown) => item && typeof item === 'object')
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ .map((item: any) => ({
+ description: String(item.description || '').trim(),
+ quantity: validateNumber(item.quantity) || 1,
+ unitPrice: validateNumber(item.unitPrice),
+ lineTotal: validateNumber(item.lineTotal) || 0,
+ vatRate: validateNumber(item.vatRate),
+ suggestedCategory: validateString(item.suggestedCategory),
+ suggestedTemplateId: validateString(item.suggestedTemplateId) || undefined,
+ confidence: validateNumber(item.confidence) || undefined,
+ }))
+ .filter((item: { lineTotal: number; description: string }) => item.lineTotal > 0 || item.description.length > 0)
+}
+
+function validateInvoiceExtraction(raw: unknown): InvoiceExtractionResult {
+ if (!raw || typeof raw !== 'object') {
+ throw new Error('Invalid invoice extraction: not an object')
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const data = raw as any
+
+ const supplier = data.supplier || {}
+ const invoice = data.invoice || {}
+ const totals = data.totals || {}
+
+ return {
+ supplier: {
+ name: validateString(supplier.name),
+ orgNumber: validateOrgNumber(supplier.orgNumber),
+ vatNumber: validateVatNumber(supplier.vatNumber),
+ address: validateString(supplier.address),
+ bankgiro: validateString(supplier.bankgiro),
+ plusgiro: validateString(supplier.plusgiro),
+ },
+ invoice: {
+ invoiceNumber: validateString(invoice.invoiceNumber),
+ invoiceDate: validateDate(invoice.invoiceDate),
+ dueDate: validateDate(invoice.dueDate),
+ paymentReference: validateString(invoice.paymentReference),
+ currency: validateString(invoice.currency) || 'SEK',
+ },
+ lineItems: validateInvoiceLineItems(data.lineItems),
+ totals: {
+ subtotal: validateNumber(totals.subtotal),
+ vatAmount: validateNumber(totals.vatAmount),
+ total: validateNumber(totals.total),
+ },
+ vatBreakdown: validateVatBreakdown(data.vatBreakdown),
+ confidence: validateNumber(data.confidence) || 0.5,
+ suggestedTemplateId: validateString(data.suggestedTemplateId) || undefined,
+ }
+}
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+function validateInvoiceLineItems(data: any): ExtractedInvoiceLineItem[] {
+ if (!Array.isArray(data)) return []
+
+ return data
+ .filter((item: unknown) => item && typeof item === 'object')
+ .map((item: Record) => ({
+ description: String(item.description || '').trim(),
+ quantity: validateNumber(item.quantity) || 1,
+ unitPrice: validateNumber(item.unitPrice),
+ 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)
+}
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+function validateVatBreakdown(data: any): VatBreakdownItem[] {
+ if (!Array.isArray(data)) return []
+
+ return data
+ .filter((item: unknown) => item && typeof item === 'object')
+ .map((item: Record) => ({
+ rate: validateNumber(item.rate) || 0,
+ base: validateNumber(item.base) || 0,
+ amount: validateNumber(item.amount) || 0,
+ }))
+ .filter((item: VatBreakdownItem) => item.amount > 0 || item.base > 0)
+}
+
+// ============================================================
+// Consistency Validation
+// ============================================================
+
+/**
+ * Post-extraction validation that catches AI hallucinations.
+ */
+export function validateExtractionConsistency(
+ extraction: ReceiptExtractionResult | InvoiceExtractionResult,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ _type: 'receipt' | 'invoice'
+): ConsistencyResult {
+ const issues: string[] = []
+
+ const total = extraction.totals.total
+ const lineItems = extraction.lineItems
+
+ // Check: total > 0
+ if (total !== null && total <= 0) {
+ issues.push(`Total is ${total} — expected positive value`)
+ }
+
+ // Check: line item totals sum to receipt/invoice total (±1 SEK tolerance)
+ if (total !== null && lineItems.length > 0) {
+ const lineSum = lineItems.reduce((sum, item) => sum + item.lineTotal, 0)
+ const diff = Math.abs(lineSum - total)
+ if (diff > 1) {
+ issues.push(`Line items sum to ${lineSum} but total is ${total} (diff: ${diff})`)
+ }
+ }
+
+ // Check: VAT rates are valid (0, 6, 12, 25)
+ for (const item of lineItems) {
+ if (item.vatRate !== null && !VALID_VAT_RATES.includes(item.vatRate)) {
+ issues.push(`Invalid VAT rate ${item.vatRate} on "${item.description}"`)
+ }
+ }
+
+ // Check: descriptions aren't empty
+ for (const item of lineItems) {
+ if (!item.description || item.description.trim().length === 0) {
+ issues.push('Line item has empty description')
+ }
+ }
+
+ return {
+ valid: issues.length === 0,
+ issues,
+ }
+}
+
+// ============================================================
+// Public API
+// ============================================================
+
+/**
+ * Unified document analysis: classifies AND extracts in a single Claude call.
+ * Falls back to dedicated extraction if misclassification detected.
+ */
+export async function analyzeDocument(
+ base64: string,
+ mimeType: string
+): Promise {
+ const raw = await callVision({
+ base64,
+ mimeType,
+ systemPrompt: `${CLASSIFY_SYSTEM_PROMPT}\n\n${RECEIPT_SYSTEM_PROMPT}\n\n${INVOICE_SYSTEM_PROMPT}`,
+ userPrompt: buildUnifiedUserPrompt(),
+ maxTokens: 4096,
+ })
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const data = raw as any
+
+ const classification = validateClassification(data.classification || data)
+
+ const result: UnifiedExtractionResult = { classification }
+
+ if (classification.type === 'receipt') {
+ if (data.receipt) {
+ result.receipt = validateReceiptExtraction(data.receipt)
+ const consistency = validateExtractionConsistency(result.receipt, 'receipt')
+ if (!consistency.valid && result.receipt.confidence > 0.5) {
+ // Retry with dedicated extraction
+ try {
+ result.receipt = await extractReceipt(base64, mimeType)
+ } catch {
+ // Keep original with reduced confidence
+ result.receipt = { ...result.receipt, confidence: result.receipt.confidence * 0.7 }
+ }
+ }
+ } else {
+ // Misclassification fallback: type says receipt but no receipt data
+ try {
+ result.receipt = await extractReceipt(base64, mimeType)
+ } catch {
+ // Classification only — no extraction available
+ }
+ }
+ } else if (classification.type === 'supplier_invoice') {
+ if (data.invoice) {
+ result.invoice = validateInvoiceExtraction(data.invoice)
+ const consistency = validateExtractionConsistency(result.invoice, 'invoice')
+ if (!consistency.valid && result.invoice.confidence > 0.5) {
+ try {
+ result.invoice = await extractInvoice(base64, mimeType)
+ } catch {
+ result.invoice = { ...result.invoice, confidence: result.invoice.confidence * 0.7 }
+ }
+ }
+ } else {
+ try {
+ result.invoice = await extractInvoice(base64, mimeType)
+ } catch {
+ // Classification only
+ }
+ }
+ }
+
+ return result
+}
+
+/**
+ * Standalone receipt extraction.
+ * For use when caller already knows it's a receipt.
+ */
+export async function extractReceipt(
+ base64: string,
+ mimeType: string
+): Promise {
+ const raw = await callVision({
+ base64,
+ mimeType,
+ systemPrompt: RECEIPT_SYSTEM_PROMPT,
+ userPrompt: buildReceiptUserPrompt(),
+ maxTokens: 4096,
+ })
+
+ const result = validateReceiptExtraction(raw)
+
+ const consistency = validateExtractionConsistency(result, 'receipt')
+ if (!consistency.valid && result.confidence > 0.5) {
+ // Retry with correction prompt
+ try {
+ const correctionPrompt = `Föregående extraheringen hade dessa problem: ${consistency.issues.join('; ')}. Var god extrahera igen med korrigeringar.\n\n${buildReceiptUserPrompt()}`
+ const retryRaw = await callVision({
+ base64,
+ mimeType,
+ systemPrompt: RECEIPT_SYSTEM_PROMPT,
+ userPrompt: correctionPrompt,
+ maxTokens: 4096,
+ })
+ return validateReceiptExtraction(retryRaw)
+ } catch {
+ // Return original with reduced confidence
+ return { ...result, confidence: result.confidence * 0.7 }
+ }
+ }
+
+ return result
+}
+
+/**
+ * Standalone invoice extraction.
+ * For use when caller already knows it's an invoice.
+ */
+export async function extractInvoice(
+ base64: string,
+ mimeType: string
+): Promise {
+ const raw = await callVision({
+ base64,
+ mimeType,
+ systemPrompt: INVOICE_SYSTEM_PROMPT,
+ userPrompt: buildInvoiceUserPrompt(),
+ maxTokens: 4096,
+ })
+
+ const result = validateInvoiceExtraction(raw)
+
+ const consistency = validateExtractionConsistency(result, 'invoice')
+ if (!consistency.valid && result.confidence > 0.5) {
+ try {
+ const correctionPrompt = `Föregående extraheringen hade dessa problem: ${consistency.issues.join('; ')}. Var god extrahera igen med korrigeringar.\n\n${buildInvoiceUserPrompt()}`
+ const retryRaw = await callVision({
+ base64,
+ mimeType,
+ systemPrompt: INVOICE_SYSTEM_PROMPT,
+ userPrompt: correctionPrompt,
+ maxTokens: 4096,
+ })
+ return validateInvoiceExtraction(retryRaw)
+ } catch {
+ return { ...result, confidence: result.confidence * 0.7 }
+ }
+ }
+
+ return result
+}
+
+/**
+ * Lightweight classify-only (no extraction).
+ * Kept for backward compatibility.
+ */
+export async function classifyDocument(
+ base64: string,
+ mimeType: string
+): Promise {
+ 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 raw = await callVision({
+ base64,
+ mimeType,
+ systemPrompt: CLASSIFY_SYSTEM_PROMPT,
+ userPrompt,
+ maxTokens: 1024,
+ })
+
+ return validateClassification(raw)
+}
diff --git a/lib/ai/preprocess-image.ts b/lib/ai/preprocess-image.ts
new file mode 100644
index 00000000..3b4250ed
--- /dev/null
+++ b/lib/ai/preprocess-image.ts
@@ -0,0 +1,43 @@
+/**
+ * Image Preprocessing for OCR
+ *
+ * SERVER-ONLY: Uses sharp for image processing.
+ *
+ * Preprocesses receipt/invoice images before sending to Claude Vision.
+ * Targets faded thermal prints with low contrast — the primary source
+ * of garbled OCR output.
+ */
+
+import 'server-only'
+import sharp from 'sharp'
+
+/**
+ * Preprocess an image for better OCR accuracy.
+ * Converts to grayscale, normalizes contrast, and sharpens.
+ * Returns base64-encoded JPEG.
+ *
+ * For PDFs: returns input unchanged (sharp doesn't handle PDFs).
+ */
+export async function preprocessImage(
+ base64: string,
+ mimeType: string
+): Promise<{ base64: string; mimeType: string }> {
+ // PDFs are not image files — return as-is
+ if (mimeType === 'application/pdf') {
+ return { base64, mimeType }
+ }
+
+ const buffer = Buffer.from(base64, 'base64')
+
+ const processed = await sharp(buffer)
+ .grayscale()
+ .normalize()
+ .sharpen({ sigma: 1.5 })
+ .jpeg({ quality: 90 })
+ .toBuffer()
+
+ return {
+ base64: processed.toString('base64'),
+ mimeType: 'image/jpeg',
+ }
+}
diff --git a/lib/ai/validation-helpers.ts b/lib/ai/validation-helpers.ts
new file mode 100644
index 00000000..16fad511
--- /dev/null
+++ b/lib/ai/validation-helpers.ts
@@ -0,0 +1,117 @@
+/**
+ * Validation Helpers for AI Extraction Results
+ *
+ * Shared validation functions used by receipt-analyzer and invoice-analyzer
+ * to sanitize and validate AI-extracted field values.
+ */
+
+/**
+ * Validate and trim a string value.
+ * Returns null for empty/non-string values.
+ */
+export function validateString(value: unknown): string | null {
+ if (typeof value === 'string' && value.trim()) {
+ return value.trim()
+ }
+ return null
+}
+
+/**
+ * Validate a numeric value.
+ * Handles both number and string inputs, stripping non-numeric characters.
+ * Returns null for invalid values.
+ */
+export function validateNumber(value: unknown): number | null {
+ if (typeof value === 'number' && !isNaN(value)) {
+ return value
+ }
+ if (typeof value === 'string') {
+ const parsed = parseFloat(value.replace(/[^\d.-]/g, ''))
+ if (!isNaN(parsed)) {
+ return parsed
+ }
+ }
+ return null
+}
+
+/**
+ * Validate and normalize a date to ISO format (YYYY-MM-DD).
+ * Returns null for invalid dates.
+ */
+export function validateDate(value: unknown): string | null {
+ if (typeof value !== 'string') return null
+
+ const date = new Date(value)
+ if (isNaN(date.getTime())) return null
+
+ return date.toISOString().split('T')[0]
+}
+
+/**
+ * Validate and normalize a time value to HH:MM format.
+ * Returns null for invalid times.
+ */
+export function validateTime(value: unknown): string | null {
+ if (typeof value !== 'string') return null
+
+ // Match HH:MM or HH:MM:SS
+ const match = value.match(/^(\d{2}):(\d{2})(:\d{2})?$/)
+ if (match) {
+ return `${match[1]}:${match[2]}`
+ }
+ return null
+}
+
+/**
+ * Validate and normalize a Swedish org number (XXXXXX-XXXX).
+ * Swedish org numbers are 10 digits.
+ * Returns null for invalid values.
+ */
+export function validateOrgNumber(value: unknown): string | null {
+ if (typeof value !== 'string') return null
+
+ const digits = value.replace(/\D/g, '')
+
+ // Swedish org numbers are 10 digits
+ if (digits.length === 10) {
+ return `${digits.slice(0, 6)}-${digits.slice(6)}`
+ }
+
+ return null
+}
+
+/**
+ * Validate a Swedish VAT number (SE prefix, at least 12 chars).
+ * Returns null for invalid values.
+ */
+export function validateVatNumber(value: unknown): string | null {
+ if (typeof value !== 'string') return null
+
+ const cleaned = value.trim().toUpperCase()
+ if (cleaned.startsWith('SE') && cleaned.length >= 12) {
+ return cleaned
+ }
+
+ return null
+}
+
+/**
+ * Check if an org number is in Swedish format (10 digits).
+ */
+export function isSwedishOrgNumber(value: string): boolean {
+ const digits = value.replace(/\D/g, '')
+ return digits.length === 10
+}
+
+/**
+ * Validate a 4-digit BAS account number (1000-9999).
+ * Returns null for invalid values.
+ */
+export function validateAccountNumber(value: string | undefined | null): string | null {
+ if (!value) return null
+ const digits = value.replace(/\D/g, '')
+ if (digits.length === 4 && parseInt(digits) >= 1000 && parseInt(digits) <= 9999) {
+ return digits
+ }
+ return null
+}
diff --git a/lib/ai/vision-client.ts b/lib/ai/vision-client.ts
new file mode 100644
index 00000000..ccb2ef1b
--- /dev/null
+++ b/lib/ai/vision-client.ts
@@ -0,0 +1,140 @@
+/**
+ * Shared Vision Client for Claude Haiku
+ *
+ * SERVER-ONLY: Uses the Anthropic SDK.
+ *
+ * Consolidates the duplicated Anthropic SDK logic from classifier.ts,
+ * receipt-analyzer.ts, and invoice-analyzer.ts into one shared module.
+ */
+
+import 'server-only'
+import Anthropic from '@anthropic-ai/sdk'
+import { preprocessImage } from './preprocess-image'
+
+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 VisionRequest {
+ base64: string
+ mimeType: string
+ systemPrompt: string
+ userPrompt: string
+ maxTokens?: number
+ preprocess?: boolean
+}
+
+/**
+ * Call Claude Haiku Vision with retry logic.
+ * Handles PDF vs image content blocks, preprocessing, retries,
+ * and JSON fence stripping.
+ *
+ * Returns parsed JSON from the model response.
+ */
+export async function callVision(request: VisionRequest): Promise {
+ const {
+ systemPrompt,
+ userPrompt,
+ maxTokens = 4096,
+ preprocess = true,
+ } = request
+
+ let { base64, mimeType } = request
+
+ const isPdf = mimeType === 'application/pdf'
+ const isImage = mimeType.startsWith('image/')
+
+ if (!isPdf && !isImage) {
+ throw new Error(`Unsupported file type: ${mimeType}`)
+ }
+
+ // Preprocess images (not PDFs) unless explicitly disabled
+ if (preprocess && !isPdf) {
+ const preprocessed = await preprocessImage(base64, mimeType)
+ base64 = preprocessed.base64
+ mimeType = preprocessed.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: maxTokens,
+ messages: [{ role: 'user', content: contentBlocks }],
+ system: systemPrompt,
+ })
+
+ const content = message.content[0]
+ if (content.type !== 'text') {
+ throw new Error('Unexpected response type from AI')
+ }
+
+ const jsonText = stripJsonFences(content.text)
+ return JSON.parse(jsonText)
+ } catch (error) {
+ lastError = error instanceof Error ? error : new Error('Unknown error')
+
+ // Don't retry on JSON parse errors
+ 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(`Vision API call failed after ${MAX_RETRIES} attempts: ${lastError?.message}`)
+}
+
+/**
+ * Strip markdown JSON fences from AI response text.
+ */
+export function stripJsonFences(text: string): string {
+ let result = text.trim()
+
+ if (result.startsWith('```json')) {
+ result = result.slice(7)
+ } else if (result.startsWith('```')) {
+ result = result.slice(3)
+ }
+ if (result.endsWith('```')) {
+ result = result.slice(0, -3)
+ }
+
+ return result.trim()
+}
+
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms))
+}
diff --git a/lib/documents/__tests__/classifier.test.ts b/lib/documents/__tests__/classifier.test.ts
index 148ad61c..604a7da0 100644
--- a/lib/documents/__tests__/classifier.test.ts
+++ b/lib/documents/__tests__/classifier.test.ts
@@ -3,42 +3,29 @@ 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 }
-})
+// Mock the core document-analyzer module
+const { mockClassify } = vi.hoisted(() => ({
+ mockClassify: vi.fn(),
+}))
-vi.mock('@anthropic-ai/sdk', () => {
- return {
- default: class MockAnthropic {
- messages = { create: mockCreate }
- },
- }
-})
+vi.mock('@/lib/ai/document-analyzer', () => ({
+ classifyDocument: mockClassify,
+}))
import { classifyDocument } from '../classifier'
-function makeResponse(json: Record) {
- 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,
- })
- )
+ mockClassify.mockResolvedValueOnce({
+ type: 'supplier_invoice',
+ confidence: 0.95,
+ reasoning: 'Contains invoice number, bankgiro, and supplier details',
+ isReverseCharge: false,
+ })
const result = await classifyDocument('base64data', 'application/pdf')
@@ -48,13 +35,11 @@ describe('classifyDocument', () => {
})
it('classifies a receipt', async () => {
- mockCreate.mockResolvedValueOnce(
- makeResponse({
- type: 'receipt',
- confidence: 0.92,
- reasoning: 'Store receipt with line items and total',
- })
- )
+ mockClassify.mockResolvedValueOnce({
+ type: 'receipt',
+ confidence: 0.92,
+ reasoning: 'Store receipt with line items and total',
+ })
const result = await classifyDocument('base64data', 'image/jpeg')
@@ -64,13 +49,11 @@ describe('classifyDocument', () => {
})
it('classifies a government letter', async () => {
- mockCreate.mockResolvedValueOnce(
- makeResponse({
- type: 'government_letter',
- confidence: 0.88,
- reasoning: 'Letter from Skatteverket',
- })
- )
+ mockClassify.mockResolvedValueOnce({
+ type: 'government_letter',
+ confidence: 0.88,
+ reasoning: 'Letter from Skatteverket',
+ })
const result = await classifyDocument('base64data', 'application/pdf')
@@ -80,13 +63,11 @@ describe('classifyDocument', () => {
})
it('classifies unknown documents', async () => {
- mockCreate.mockResolvedValueOnce(
- makeResponse({
- type: 'unknown',
- confidence: 0.5,
- reasoning: 'Cannot determine document type',
- })
- )
+ mockClassify.mockResolvedValueOnce({
+ type: 'unknown',
+ confidence: 0.5,
+ reasoning: 'Cannot determine document type',
+ })
const result = await classifyDocument('base64data', 'image/png')
@@ -95,14 +76,12 @@ describe('classifyDocument', () => {
})
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,
- })
- )
+ mockClassify.mockResolvedValueOnce({
+ type: 'supplier_invoice',
+ confidence: 0.93,
+ reasoning: 'EU invoice with reverse charge',
+ isReverseCharge: true,
+ })
const result = await classifyDocument('base64data', 'application/pdf')
@@ -110,129 +89,31 @@ describe('classifyDocument', () => {
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' }],
+ it('delegates to core classify', async () => {
+ mockClassify.mockResolvedValueOnce({
+ type: 'receipt',
+ confidence: 0.9,
+ reasoning: 'Receipt',
})
- await expect(classifyDocument('base64data', 'image/jpeg')).rejects.toThrow(
- 'Failed to parse AI response'
- )
- expect(mockCreate).toHaveBeenCalledTimes(1)
+ await classifyDocument('base64data', 'image/jpeg')
+
+ expect(mockClassify).toHaveBeenCalledWith('base64data', 'image/jpeg')
})
- it('throws on unsupported MIME type', async () => {
+ it('propagates errors from core', async () => {
+ mockClassify.mockRejectedValueOnce(new Error('Vision API call failed after 3 attempts'))
+
+ await expect(classifyDocument('base64data', 'image/jpeg')).rejects.toThrow(
+ 'Vision API call failed after 3 attempts'
+ )
+ })
+
+ it('propagates unsupported MIME type errors', async () => {
+ mockClassify.mockRejectedValueOnce(new Error('Unsupported file type: text/plain'))
+
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)
})
})
diff --git a/lib/documents/classifier.ts b/lib/documents/classifier.ts
index f8059df4..5b502f56 100644
--- a/lib/documents/classifier.ts
+++ b/lib/documents/classifier.ts
@@ -1,30 +1,19 @@
/**
- * Document Classifier using Claude Haiku Vision API
+ * Document Classifier — thin wrapper around lib/ai/document-analyzer.
*
- * SERVER-ONLY: This module uses the Anthropic SDK and must only be imported
- * in server components or API routes.
+ * SERVER-ONLY: delegates to the shared vision client.
*
* Classifies documents as supplier invoices, receipts, government letters,
* or unknown. Also detects EU reverse charge for supplier invoices.
+ *
+ * This module preserves the original public API — existing callers see no change.
*/
import 'server-only'
-import Anthropic from '@anthropic-ai/sdk'
-import type { DocumentClassificationType } from '@/types'
+import { classifyDocument as classifyCore } from '@/lib/ai/document-analyzer'
-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
-}
+// Re-export the type so existing imports work
+export type { DocumentClassification } from '@/lib/ai/document-analyzer'
/**
* Classify a document using Claude Haiku Vision.
@@ -33,132 +22,6 @@ export interface DocumentClassification {
export async function classifyDocument(
base64: string,
mimeType: string
-): Promise {
- 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 {
- return new Promise((resolve) => setTimeout(resolve, ms))
+) {
+ return classifyCore(base64, mimeType)
}
diff --git a/lib/extensions/__tests__/invoice-inbox-utils.test.ts b/lib/extensions/__tests__/invoice-inbox-utils.test.ts
index 9ccafd30..6f8f2bbd 100644
--- a/lib/extensions/__tests__/invoice-inbox-utils.test.ts
+++ b/lib/extensions/__tests__/invoice-inbox-utils.test.ts
@@ -65,6 +65,7 @@ describe('formatExtractionSummary', () => {
supplierName: '',
total: 0,
lineCount: 0,
+ currency: 'SEK',
})
})
@@ -73,6 +74,7 @@ describe('formatExtractionSummary', () => {
supplierName: '',
total: 0,
lineCount: 0,
+ currency: 'SEK',
})
})
@@ -106,6 +108,7 @@ describe('formatExtractionSummary', () => {
supplierName: 'Acme AB',
total: 250,
lineCount: 2,
+ currency: 'SEK',
})
})
@@ -123,6 +126,7 @@ describe('formatExtractionSummary', () => {
supplierName: '',
total: 0,
lineCount: 0,
+ currency: 'SEK',
})
})
})
diff --git a/lib/extensions/_generated/sector-definitions.ts b/lib/extensions/_generated/sector-definitions.ts
index 76e91350..9369e95a 100644
--- a/lib/extensions/_generated/sector-definitions.ts
+++ b/lib/extensions/_generated/sector-definitions.ts
@@ -83,8 +83,8 @@ export const EXTENSION_DEFINITIONS: Record = {
"category": "operations",
"icon": "Calendar",
"dataPattern": "core",
- "description": "Fullstandig kalendervy med manads-, vecko- och dagsvisning",
- "longDescription": "Se alla fakturadatum och deadlines i en interaktiv kalender med manads-, vecko- och dagsvy.",
+ "description": "Fullständig kalendervy med månads-, vecko- och dagsvisning",
+ "longDescription": "Se alla fakturadatum och deadlines i en interaktiv kalender med månads-, vecko- och dagsvy.",
"readsCoreTables": [
"invoices",
"deadlines",
diff --git a/lib/extensions/invoice-inbox-utils.ts b/lib/extensions/invoice-inbox-utils.ts
index 93916ae9..f9f82e9f 100644
--- a/lib/extensions/invoice-inbox-utils.ts
+++ b/lib/extensions/invoice-inbox-utils.ts
@@ -41,14 +41,15 @@ export function getConfidenceLabel(confidence: number | null): { label: string;
export function formatExtractionSummary(
data: InvoiceExtractionResult | null | undefined
-): { supplierName: string; total: number; lineCount: number } {
+): { supplierName: string; total: number; lineCount: number; currency: string } {
if (!data) {
- return { supplierName: '', total: 0, lineCount: 0 }
+ return { supplierName: '', total: 0, lineCount: 0, currency: 'SEK' }
}
return {
supplierName: data.supplier?.name ?? '',
total: data.totals?.total ?? 0,
lineCount: data.lineItems?.length ?? 0,
+ currency: data.invoice?.currency || 'SEK',
}
}
diff --git a/package-lock.json b/package-lock.json
index a68ff7c8..4886d206 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -47,6 +47,7 @@
"recharts": "^3.7.0",
"resend": "^6.9.1",
"server-only": "^0.0.1",
+ "sharp": "^0.34.5",
"svix": "^1.85.0",
"tailwind-merge": "^3.4.0",
"web-push": "^3.6.7",
@@ -57,6 +58,7 @@
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
+ "@types/sharp": "^0.31.1",
"@types/web-push": "^3.6.4",
"dotenv": "^17.2.3",
"eslint": "^9",
@@ -1080,7 +1082,6 @@
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
"license": "MIT",
- "optional": true,
"engines": {
"node": ">=18"
}
@@ -4439,6 +4440,16 @@
"@types/react": "^19.2.0"
}
},
+ "node_modules/@types/sharp": {
+ "version": "0.31.1",
+ "resolved": "https://registry.npmjs.org/@types/sharp/-/sharp-0.31.1.tgz",
+ "integrity": "sha512-5nWwamN9ZFHXaYEincMSuza8nNfOof8nmO+mcI+Agx1uMUk4/pQnNIcix+9rLPXzKrm1pS34+6WRDbDV0Jn7ag==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/unist": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
@@ -6256,7 +6267,6 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
- "devOptional": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
@@ -11214,7 +11224,6 @@
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"license": "Apache-2.0",
- "optional": true,
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
@@ -11258,7 +11267,6 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"license": "ISC",
- "optional": true,
"bin": {
"semver": "bin/semver.js"
},
diff --git a/package.json b/package.json
index 9555543e..2dde3633 100644
--- a/package.json
+++ b/package.json
@@ -52,6 +52,7 @@
"recharts": "^3.7.0",
"resend": "^6.9.1",
"server-only": "^0.0.1",
+ "sharp": "^0.34.5",
"svix": "^1.85.0",
"tailwind-merge": "^3.4.0",
"web-push": "^3.6.7",
@@ -62,6 +63,7 @@
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
+ "@types/sharp": "^0.31.1",
"@types/web-push": "^3.6.4",
"dotenv": "^17.2.3",
"eslint": "^9",
diff --git a/supabase/migrations/20240101000046_add_journal_entry_reversal_columns.sql b/supabase/migrations/20240101000046_add_journal_entry_reversal_columns.sql
new file mode 100644
index 00000000..4207dcf2
--- /dev/null
+++ b/supabase/migrations/20240101000046_add_journal_entry_reversal_columns.sql
@@ -0,0 +1,21 @@
+-- Add storno/correction link columns to journal_entries
+-- These are required for ändringsverifikationer (correction entries) per BFL 5 kap.
+-- The reverseEntry() and correctEntry() functions in engine.ts / storno-service.ts
+-- depend on these columns to create bidirectional links between entries.
+
+-- Link to storno entry that reversed this entry
+ALTER TABLE public.journal_entries
+ ADD COLUMN IF NOT EXISTS reversed_by_id uuid REFERENCES public.journal_entries(id) ON DELETE SET NULL;
+
+-- Link to the original entry that this storno reverses
+ALTER TABLE public.journal_entries
+ ADD COLUMN IF NOT EXISTS reverses_id uuid REFERENCES public.journal_entries(id) ON DELETE SET NULL;
+
+-- Link to the original entry in a correction chain (storno + new correct entry)
+ALTER TABLE public.journal_entries
+ ADD COLUMN IF NOT EXISTS correction_of_id uuid REFERENCES public.journal_entries(id) ON DELETE SET NULL;
+
+-- Indexes for FK lookups
+CREATE INDEX IF NOT EXISTS idx_journal_entries_reversed_by_id ON public.journal_entries (reversed_by_id);
+CREATE INDEX IF NOT EXISTS idx_journal_entries_reverses_id ON public.journal_entries (reverses_id);
+CREATE INDEX IF NOT EXISTS idx_journal_entries_correction_of_id ON public.journal_entries (correction_of_id);