refactor: consolidate AI analyzers into shared lib/ai module and add journal entry reversal columns

Extract shared vision/document analysis logic into lib/ai (vision-client, document-analyzer,
image preprocessing, validation helpers), simplifying invoice-analyzer, receipt-analyzer, and
document classifier. Add migration 046 for journal entry reversal/correction link columns
(reversed_by_id, reverses_id, correction_of_id) required by storno service. Update extension
components and shared UI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-02-26 17:14:44 +01:00
co-authored by Claude Opus 4.6
parent 6d647b956c
commit 3f255ea201
42 changed files with 2090 additions and 1127 deletions
+378
View File
@@ -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)
})
})
+68
View File
@@ -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')
})
})
+131
View File
@@ -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()
})
})
+256
View File
@@ -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<string, unknown>) {
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}')
})
})
+666
View File
@@ -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<string, unknown>) => ({
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<string, unknown>) => ({
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<UnifiedExtractionResult> {
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<ReceiptExtractionResult> {
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<InvoiceExtractionResult> {
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<DocumentClassification> {
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)
}
+43
View File
@@ -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',
}
}
+117
View File
@@ -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
}
+140
View File
@@ -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<unknown> {
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<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
+53 -172
View File
@@ -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<string, unknown>) {
return {
content: [{ type: 'text', text: JSON.stringify(json) }],
}
}
describe('classifyDocument', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('classifies a supplier invoice', async () => {
mockCreate.mockResolvedValueOnce(
makeResponse({
type: 'supplier_invoice',
confidence: 0.95,
reasoning: 'Contains invoice number, bankgiro, and supplier details',
isReverseCharge: false,
})
)
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)
})
})
+9 -146
View File
@@ -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<DocumentClassification> {
const systemPrompt = `Du är expert på att klassificera svenska affärsdokument.
Din uppgift är att avgöra vilken typ av dokument som visas.
DOKUMENTTYPER:
- supplier_invoice: Leverantörsfaktura (har fakturanummer, bankgiro/plusgiro, förfallodatum, leverantörsuppgifter)
- receipt: Kvitto (butiks-/restaurangkvitto, kort betalningsbevis med artikelrader)
- government_letter: Myndighetspost (från Skatteverket, Bolagsverket, Försäkringskassan, kommun, etc.)
- unknown: Annat dokument som inte passar ovan
FÖR LEVERANTÖRSFAKTUROR - kontrollera även:
- Är fakturan från en utländsk/EU-leverantör utan svensk moms?
- Nämner dokumentet "reverse charge", "omvänd skattskyldighet", eller "artikel 196"?
- Har leverantören ett VAT-nummer som INTE börjar med SE?
Om ja: flagga isReverseCharge = true`
const userPrompt = `Klassificera detta dokument. Returnera ENDAST ett JSON-objekt:
{
"type": "supplier_invoice" | "receipt" | "government_letter" | "unknown",
"confidence": 0.95,
"reasoning": "Kort förklaring",
"isReverseCharge": false
}
Returnera ENDAST JSON-objektet, ingen annan text.`
const isPdf = mimeType === 'application/pdf'
const isImage = mimeType.startsWith('image/')
if (!isPdf && !isImage) {
throw new Error(`Unsupported file type: ${mimeType}`)
}
let lastError: Error | null = null
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
const contentBlocks: Anthropic.MessageCreateParams['messages'][0]['content'] = isPdf
? [
{
type: 'document' as const,
source: {
type: 'base64' as const,
media_type: 'application/pdf' as const,
data: base64,
},
},
{ type: 'text' as const, text: userPrompt },
]
: [
{
type: 'image' as const,
source: {
type: 'base64' as const,
media_type: mimeType as ImageMediaType,
data: base64,
},
},
{ type: 'text' as const, text: userPrompt },
]
const message = await anthropic.messages.create({
model: 'claude-haiku-4-5-20251001',
max_tokens: 1024,
messages: [{ role: 'user', content: contentBlocks }],
system: systemPrompt,
})
const content = message.content[0]
if (content.type !== 'text') {
throw new Error('Unexpected response type from AI')
}
let jsonText = content.text.trim()
if (jsonText.startsWith('```json')) {
jsonText = jsonText.slice(7)
} else if (jsonText.startsWith('```')) {
jsonText = jsonText.slice(3)
}
if (jsonText.endsWith('```')) {
jsonText = jsonText.slice(0, -3)
}
jsonText = jsonText.trim()
const parsed = JSON.parse(jsonText)
return validateClassification(parsed)
} catch (error) {
lastError = error instanceof Error ? error : new Error('Unknown error')
if (error instanceof SyntaxError) {
throw new Error(`Failed to parse AI response: ${lastError.message}`)
}
if (attempt < MAX_RETRIES - 1) {
await sleep(RETRY_DELAY_MS * (attempt + 1))
}
}
}
throw new Error(`Document classification failed after ${MAX_RETRIES} attempts: ${lastError?.message}`)
}
const VALID_TYPES: DocumentClassificationType[] = [
'supplier_invoice',
'receipt',
'government_letter',
'unknown',
]
function validateClassification(raw: unknown): DocumentClassification {
if (!raw || typeof raw !== 'object') {
throw new Error('Invalid classification result: not an object')
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = raw as any
const type = VALID_TYPES.includes(data.type) ? data.type : 'unknown'
const confidence = typeof data.confidence === 'number' ? data.confidence : 0.5
const reasoning = typeof data.reasoning === 'string' ? data.reasoning : ''
const isReverseCharge = type === 'supplier_invoice' ? Boolean(data.isReverseCharge) : undefined
return { type, confidence, reasoning, isReverseCharge }
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
) {
return classifyCore(base64, mimeType)
}
@@ -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',
})
})
})
@@ -83,8 +83,8 @@ export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {
"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",
+3 -2
View File
@@ -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',
}
}