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
+2 -2
View File
@@ -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."
}
}
@@ -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')
@@ -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<InvoiceExtractionResult> {
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<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)
}
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<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
return extractInvoice(fileBase64, mimeType)
}
+6 -1
View File
@@ -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,
}))
@@ -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()
})
@@ -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
@@ -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<ReceiptExtractionResult> {
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<void> {
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<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
@@ -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,
}
@@ -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<ProcessedReceipt> {
// 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,
}))
@@ -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'),
])