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:
co-authored by
Claude Opus 4.6
parent
6d647b956c
commit
3f255ea201
@@ -145,7 +145,7 @@ export default function DeadlinesPage() {
|
||||
|
||||
toast({
|
||||
title: 'Deadline uppdaterad',
|
||||
description: 'Dina andringar har sparats',
|
||||
description: 'Dina ändringar har sparats',
|
||||
})
|
||||
|
||||
fetchData()
|
||||
|
||||
@@ -181,8 +181,21 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
const data = await response.json()
|
||||
throw new Error(data.error || 'Kunde inte markera som betald')
|
||||
}
|
||||
} else if (status === 'cancelled') {
|
||||
// Only drafts and proformas can be cancelled directly — sent/overdue/paid
|
||||
// invoices have committed journal entries and require a credit note instead
|
||||
if (invoice.status !== 'draft') {
|
||||
const docType = ((invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice') as InvoiceDocumentType
|
||||
if (docType !== 'proforma') {
|
||||
throw new Error('Bokförda fakturor kan inte makuleras. Skapa en kreditfaktura istället.')
|
||||
}
|
||||
}
|
||||
const { error } = await supabase
|
||||
.from('invoices')
|
||||
.update({ status })
|
||||
.eq('id', invoice.id)
|
||||
if (error) throw new Error(error.message)
|
||||
} else {
|
||||
// Other status changes (cancelled) — direct update is fine
|
||||
const { error } = await supabase
|
||||
.from('invoices')
|
||||
.update({ status })
|
||||
@@ -870,15 +883,6 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
Skapa kreditfaktura
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => updateStatus('cancelled')}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<XCircle className="mr-2 h-4 w-4" />
|
||||
Makulera
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{invoice.status === 'paid' && isRealInvoice && (
|
||||
|
||||
@@ -31,9 +31,9 @@ vi.mock('@/extensions/general/invoice-inbox/lib/email-handler', () => ({
|
||||
resolveUserFromEmail: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock invoice analyzer
|
||||
vi.mock('@/extensions/general/invoice-inbox/lib/invoice-analyzer', () => ({
|
||||
analyzeInvoice: vi.fn(),
|
||||
// Mock unified document analyzer
|
||||
vi.mock('@/lib/ai/document-analyzer', () => ({
|
||||
analyzeDocument: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock supplier matcher
|
||||
@@ -41,6 +41,11 @@ vi.mock('@/extensions/general/invoice-inbox/lib/supplier-matcher', () => ({
|
||||
matchSupplier: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock receipt pipeline
|
||||
vi.mock('@/extensions/general/receipt-ocr/lib/receipt-pipeline', () => ({
|
||||
processReceiptFromDocument: vi.fn(),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
import { parseInboundPayload, extractAttachments, resolveUserFromEmail } from '@/extensions/general/invoice-inbox/lib/email-handler'
|
||||
|
||||
|
||||
@@ -2,9 +2,8 @@ import { createServerClient } from '@supabase/ssr'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { Webhook } from 'svix'
|
||||
import { parseInboundPayload, extractAttachments, resolveUserFromEmail } from '@/extensions/general/invoice-inbox/lib/email-handler'
|
||||
import { analyzeInvoice } from '@/extensions/general/invoice-inbox/lib/invoice-analyzer'
|
||||
import { matchSupplier } from '@/extensions/general/invoice-inbox/lib/supplier-matcher'
|
||||
import { classifyDocument } from '@/lib/documents/classifier'
|
||||
import { analyzeDocument } from '@/lib/ai/document-analyzer'
|
||||
import { processReceiptFromDocument } from '@/extensions/general/receipt-ocr/lib/receipt-pipeline'
|
||||
import crypto from 'crypto'
|
||||
|
||||
@@ -145,14 +144,13 @@ export async function POST(request: Request) {
|
||||
|
||||
if (docError || !document) continue
|
||||
|
||||
// Classify document type
|
||||
// Unified classify + extract in a single Claude call
|
||||
let documentType: 'supplier_invoice' | 'receipt' | 'government_letter' | 'unknown' = 'supplier_invoice'
|
||||
let isReverseCharge = false
|
||||
let unifiedResult: Awaited<ReturnType<typeof analyzeDocument>> | null = null
|
||||
try {
|
||||
const classification = await classifyDocument(attachment.content, attachment.content_type)
|
||||
documentType = classification.type
|
||||
isReverseCharge = classification.isReverseCharge ?? false
|
||||
console.log(`[document-inbox] Classified as ${documentType} (confidence: ${classification.confidence})`)
|
||||
unifiedResult = await analyzeDocument(attachment.content, attachment.content_type)
|
||||
documentType = unifiedResult.classification.type
|
||||
console.log(`[document-inbox] Classified as ${documentType} (confidence: ${unifiedResult.classification.confidence})`)
|
||||
} catch (classifyErr) {
|
||||
console.error('[document-inbox] Classification failed, defaulting to supplier_invoice:', classifyErr)
|
||||
}
|
||||
@@ -180,10 +178,15 @@ export async function POST(request: Request) {
|
||||
try {
|
||||
switch (documentType) {
|
||||
case 'supplier_invoice': {
|
||||
// Existing flow: analyze invoice + supplier match
|
||||
const extraction = await analyzeInvoice(attachment.content, attachment.content_type)
|
||||
// Use pre-extracted invoice data from unified call
|
||||
const extraction = unifiedResult?.invoice
|
||||
if (!extraction) {
|
||||
throw new Error('No invoice extraction available')
|
||||
}
|
||||
|
||||
// Store reverse charge flag from classifier in extracted data
|
||||
const isReverseCharge = unifiedResult?.classification.isReverseCharge ?? false
|
||||
|
||||
// Store reverse charge flag in extracted data
|
||||
const extractedData = {
|
||||
...(extraction as unknown as Record<string, unknown>),
|
||||
isReverseCharge,
|
||||
@@ -216,7 +219,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
case 'receipt': {
|
||||
// Receipt pipeline: extract + categorize + match transactions
|
||||
// Use pre-extracted receipt data from unified call
|
||||
const { data: urlData } = supabase.storage.from('documents').getPublicUrl(storagePath)
|
||||
|
||||
const result = await processReceiptFromDocument(supabase, userId, attachment.content, attachment.content_type, {
|
||||
@@ -224,6 +227,7 @@ export async function POST(request: Request) {
|
||||
source: 'email',
|
||||
emailFrom: payload.from,
|
||||
storageUrl: urlData.publicUrl,
|
||||
preExtracted: unifiedResult?.receipt ?? undefined,
|
||||
})
|
||||
|
||||
await supabase
|
||||
|
||||
@@ -24,8 +24,8 @@ export default function ExtensionWorkspaceLoader({
|
||||
<WorkspaceComponent userId={userId} />
|
||||
) : (
|
||||
<EmptyExtensionState
|
||||
title="Kommer snart"
|
||||
description={`${definition.name} \u00e4r under utveckling och kommer snart att vara tillg\u00e4ngligt.`}
|
||||
title="Bakgrundstjänst"
|
||||
description={`${definition.name} körs i bakgrunden och har ingen egen vy. Du kan hantera inställningar under Inställningar.`}
|
||||
/>
|
||||
)}
|
||||
</ExtensionWorkspaceShell>
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function ExtensionWorkspaceShell({
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-1.5 text-sm text-muted-foreground mb-6">
|
||||
<Link href="/extensions" className="hover:text-foreground transition-colors">
|
||||
Till\u00e4gg
|
||||
Tillägg
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<Link
|
||||
|
||||
@@ -8,7 +8,7 @@ export default function AiCategorizationWorkspace({ userId }: WorkspaceComponent
|
||||
return (
|
||||
<EmptyExtensionState
|
||||
title="AI-kategorisering"
|
||||
description="AI-kategorisering k\u00f6rs automatiskt n\u00e4r nya transaktioner synkas. G\u00e5 till Transaktioner f\u00f6r att se f\u00f6rslag."
|
||||
description="AI-kategorisering körs automatiskt när nya transaktioner synkas. Gå till Transaktioner för att se förslag."
|
||||
icon={<Sparkles className="h-12 w-12 text-muted-foreground/40 mb-4" />}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ export default function AiChatWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
return (
|
||||
<EmptyExtensionState
|
||||
title="AI-assistent"
|
||||
description="Anv\u00e4nd chattwidgeten i nedre h\u00f6gra h\u00f6rnet f\u00f6r att st\u00e4lla fr\u00e5gor om bokf\u00f6ring och skatt."
|
||||
description="Använd chattwidgeten i nedre högra hörnet för att ställa frågor om bokföring och skatt."
|
||||
icon={<MessageSquare className="h-12 w-12 text-muted-foreground/40 mb-4" />}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ export default function PushNotificationsWorkspace({ userId }: WorkspaceComponen
|
||||
return (
|
||||
<EmptyExtensionState
|
||||
title="Push-notiser"
|
||||
description="Konfigurera push-notiser under Inst\u00e4llningar. Notiser skickas automatiskt vid viktiga h\u00e4ndelser."
|
||||
description="Konfigurera push-notiser under Inställningar. Notiser skickas automatiskt vid viktiga händelser."
|
||||
icon={<Bell className="h-12 w-12 text-muted-foreground/40 mb-4" />}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ export default function ReceiptOcrWorkspace({ userId }: WorkspaceComponentProps)
|
||||
return (
|
||||
<EmptyExtensionState
|
||||
title="Kvittoscanning"
|
||||
description="Ladda upp och skanna kvitton direkt fr\u00e5n till\u00e4ggets arbetsyta. G\u00e5 till Kvitton i sidomenyn f\u00f6r att komma ig\u00e5ng."
|
||||
description="Ladda upp och skanna kvitton direkt från tillägets arbetsyta. Gå till Kvitton i sidomenyn för att komma igång."
|
||||
icon={<Camera className="h-12 w-12 text-muted-foreground/40 mb-4" />}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -33,10 +33,10 @@ function formatRelativeTime(dateStr: string): string {
|
||||
return `${diffD} dagar sedan`
|
||||
}
|
||||
|
||||
function formatSEK(amount: number): string {
|
||||
function formatAmount(amount: number, currency: string = 'SEK'): string {
|
||||
return new Intl.NumberFormat('sv-SE', {
|
||||
style: 'currency',
|
||||
currency: 'SEK',
|
||||
currency,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount)
|
||||
@@ -53,7 +53,7 @@ function getDocumentIcon(type: DocumentClassificationType) {
|
||||
}
|
||||
}
|
||||
|
||||
function getSummaryText(item: InvoiceInboxItem): { label: string; total: number } {
|
||||
function getSummaryText(item: InvoiceInboxItem): { label: string; total: number; currency: string } {
|
||||
const type = item.document_type ?? 'supplier_invoice'
|
||||
|
||||
switch (type) {
|
||||
@@ -63,23 +63,26 @@ function getSummaryText(item: InvoiceInboxItem): { label: string; total: number
|
||||
return {
|
||||
label: (item.supplier as { name?: string } | undefined)?.name ?? (summary.supplierName || 'Okänd leverantör'),
|
||||
total: summary.total,
|
||||
currency: summary.currency,
|
||||
}
|
||||
}
|
||||
case 'receipt': {
|
||||
const receipt = item.receipt as { merchant_name?: string; total_amount?: number } | undefined
|
||||
const receipt = item.receipt as { merchant_name?: string; total_amount?: number; currency?: string } | undefined
|
||||
return {
|
||||
label: receipt?.merchant_name ?? 'Okänd handlare',
|
||||
total: receipt?.total_amount ?? 0,
|
||||
currency: receipt?.currency || 'SEK',
|
||||
}
|
||||
}
|
||||
case 'government_letter': {
|
||||
return {
|
||||
label: item.email_from ?? 'Okänd avsändare',
|
||||
total: 0,
|
||||
currency: 'SEK',
|
||||
}
|
||||
}
|
||||
default: {
|
||||
return { label: 'Granska manuellt', total: 0 }
|
||||
return { label: 'Granska manuellt', total: 0, currency: 'SEK' }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,7 +95,7 @@ export default function DocumentInboxCard({ item, onClick }: DocumentInboxCardPr
|
||||
const docTypeVariant = getDocumentTypeVariant(docType) as 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning'
|
||||
|
||||
const fileName = (item.document as { file_name?: string } | undefined)?.file_name ?? 'Okänd fil'
|
||||
const { label: summaryLabel, total } = getSummaryText(item)
|
||||
const { label: summaryLabel, total, currency } = getSummaryText(item)
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -122,7 +125,7 @@ export default function DocumentInboxCard({ item, onClick }: DocumentInboxCardPr
|
||||
|
||||
<div className="flex flex-col items-end gap-1 shrink-0">
|
||||
{total > 0 && (
|
||||
<span className="text-sm font-medium">{formatSEK(total)}</span>
|
||||
<span className="text-sm font-medium">{formatAmount(total, currency)}</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Badge variant={docTypeVariant} className="text-[10px] px-1.5 py-0">
|
||||
|
||||
@@ -34,10 +34,10 @@ interface ReceiptInboxDetailProps {
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
function formatSEK(amount: number): string {
|
||||
function formatAmount(amount: number, currency: string = 'SEK'): string {
|
||||
return new Intl.NumberFormat('sv-SE', {
|
||||
style: 'currency',
|
||||
currency: 'SEK',
|
||||
currency,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount)
|
||||
@@ -180,7 +180,7 @@ export default function ReceiptInboxDetail({
|
||||
<div>
|
||||
<span className="text-muted-foreground">Totalbelopp</span>
|
||||
<p className="font-medium">
|
||||
{receipt.total_amount ? formatSEK(receipt.total_amount) : '-'}
|
||||
{receipt.total_amount ? formatAmount(receipt.total_amount) : '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
@@ -217,7 +217,7 @@ export default function ReceiptInboxDetail({
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm truncate">{li.description}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatSEK(li.line_total)}
|
||||
{formatAmount(li.line_total)}
|
||||
{li.vat_rate != null && ` (${li.vat_rate}% moms)`}
|
||||
</p>
|
||||
</div>
|
||||
@@ -237,8 +237,8 @@ export default function ReceiptInboxDetail({
|
||||
{/* Totals */}
|
||||
{lineItems.length > 0 && (
|
||||
<div className="flex gap-4 text-sm">
|
||||
<Badge variant="default">Företag: {formatSEK(Math.round(businessTotal * 100) / 100)}</Badge>
|
||||
<Badge variant="secondary">Privat: {formatSEK(Math.round(privateTotal * 100) / 100)}</Badge>
|
||||
<Badge variant="default">Företag: {formatAmount(Math.round(businessTotal * 100) / 100)}</Badge>
|
||||
<Badge variant="secondary">Privat: {formatAmount(Math.round(privateTotal * 100) / 100)}</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -46,11 +46,11 @@ interface InboxDetailDialogProps {
|
||||
suppliers: Supplier[]
|
||||
}
|
||||
|
||||
function formatSEK(amount: number | null): string {
|
||||
function formatAmount(amount: number | null, currency: string = 'SEK'): string {
|
||||
if (amount == null) return '-'
|
||||
return new Intl.NumberFormat('sv-SE', {
|
||||
style: 'currency',
|
||||
currency: 'SEK',
|
||||
currency,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount)
|
||||
@@ -71,6 +71,7 @@ export default function InboxDetailDialog({
|
||||
if (!item) return null
|
||||
|
||||
const extraction = item.extracted_data as unknown as InvoiceExtractionResult | null
|
||||
const currency = extraction?.invoice.currency || 'SEK'
|
||||
const confidence = getConfidenceLabel(item.confidence)
|
||||
const confidenceVariant = confidence.variant as 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning'
|
||||
const statusVariant = getStatusVariant(item.status) as 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning'
|
||||
@@ -240,10 +241,10 @@ export default function InboxDetailDialog({
|
||||
<TableCell className="text-sm">{line.description}</TableCell>
|
||||
<TableCell className="text-right text-sm">{line.quantity}</TableCell>
|
||||
<TableCell className="text-right text-sm">
|
||||
{line.unitPrice != null ? formatSEK(line.unitPrice) : '-'}
|
||||
{line.unitPrice != null ? formatAmount(line.unitPrice, currency) : '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm font-medium">
|
||||
{formatSEK(line.lineTotal)}
|
||||
{formatAmount(line.lineTotal, currency)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm">
|
||||
{line.vatRate != null ? `${line.vatRate}%` : '-'}
|
||||
@@ -263,16 +264,16 @@ export default function InboxDetailDialog({
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Netto</span>
|
||||
<span>{formatSEK(extraction.totals.subtotal)}</span>
|
||||
<span>{formatAmount(extraction.totals.subtotal, currency)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Moms</span>
|
||||
<span>{formatSEK(extraction.totals.vatAmount)}</span>
|
||||
<span>{formatAmount(extraction.totals.vatAmount, currency)}</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex justify-between font-medium text-base">
|
||||
<span>Totalt</span>
|
||||
<span>{formatSEK(extraction.totals.total)}</span>
|
||||
<span>{formatAmount(extraction.totals.total, currency)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -31,18 +31,17 @@ function formatRelativeTime(dateStr: string): string {
|
||||
return `${diffD} dagar sedan`
|
||||
}
|
||||
|
||||
function formatSEK(amount: number): string {
|
||||
function formatAmount(amount: number, currency: string = 'SEK'): string {
|
||||
return new Intl.NumberFormat('sv-SE', {
|
||||
style: 'currency',
|
||||
currency: 'SEK',
|
||||
currency,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
export default function InboxItemCard({ item, onClick }: InboxItemCardProps) {
|
||||
const extraction = item.extracted_data as unknown as InvoiceExtractionResult | null
|
||||
const summary = formatExtractionSummary(extraction)
|
||||
const summary = formatExtractionSummary(item.extracted_data as unknown as InvoiceExtractionResult | null)
|
||||
const confidence = getConfidenceLabel(item.confidence)
|
||||
const statusVariant = getStatusVariant(item.status) as 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning'
|
||||
const confidenceVariant = confidence.variant as 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning'
|
||||
@@ -80,7 +79,7 @@ export default function InboxItemCard({ item, onClick }: InboxItemCardProps) {
|
||||
|
||||
<div className="flex flex-col items-end gap-1 shrink-0">
|
||||
{summary.total > 0 && (
|
||||
<span className="text-sm font-medium">{formatSEK(summary.total)}</span>
|
||||
<span className="text-sm font-medium">{formatAmount(summary.total, summary.currency)}</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{item.confidence != null && (
|
||||
|
||||
@@ -43,9 +43,9 @@ export default function DateRangeFilter({ onRangeChange, className }: DateRangeF
|
||||
}
|
||||
|
||||
const periods: { key: Period; label: string }[] = [
|
||||
{ key: 'month', label: 'M\u00e5nad' },
|
||||
{ key: 'month', label: 'Månad' },
|
||||
{ key: 'quarter', label: 'Kvartal' },
|
||||
{ key: 'year', label: '\u00c5r' },
|
||||
{ key: 'year', label: 'År' },
|
||||
]
|
||||
|
||||
return (
|
||||
|
||||
@@ -7,8 +7,8 @@ interface EmptyExtensionStateProps {
|
||||
}
|
||||
|
||||
export default function EmptyExtensionState({
|
||||
title = 'Ingen data \u00e4nnu',
|
||||
description = 'Data kommer att visas h\u00e4r n\u00e4r det finns tillg\u00e4ngligt.',
|
||||
title = 'Ingen data ännu',
|
||||
description = 'Data kommer att visas här när det finns tillgängligt.',
|
||||
icon,
|
||||
}: EmptyExtensionStateProps) {
|
||||
return (
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
])
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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}')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Generated
+12
-4
@@ -47,6 +47,7 @@
|
||||
"recharts": "^3.7.0",
|
||||
"resend": "^6.9.1",
|
||||
"server-only": "^0.0.1",
|
||||
"sharp": "^0.34.5",
|
||||
"svix": "^1.85.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"web-push": "^3.6.7",
|
||||
@@ -57,6 +58,7 @@
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/sharp": "^0.31.1",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"dotenv": "^17.2.3",
|
||||
"eslint": "^9",
|
||||
@@ -1080,7 +1082,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
|
||||
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -4439,6 +4440,16 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/sharp": {
|
||||
"version": "0.31.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/sharp/-/sharp-0.31.1.tgz",
|
||||
"integrity": "sha512-5nWwamN9ZFHXaYEincMSuza8nNfOof8nmO+mcI+Agx1uMUk4/pQnNIcix+9rLPXzKrm1pS34+6WRDbDV0Jn7ag==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||
@@ -6256,7 +6267,6 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -11214,7 +11224,6 @@
|
||||
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@img/colour": "^1.0.0",
|
||||
"detect-libc": "^2.1.2",
|
||||
@@ -11258,7 +11267,6 @@
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"recharts": "^3.7.0",
|
||||
"resend": "^6.9.1",
|
||||
"server-only": "^0.0.1",
|
||||
"sharp": "^0.34.5",
|
||||
"svix": "^1.85.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"web-push": "^3.6.7",
|
||||
@@ -62,6 +63,7 @@
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/sharp": "^0.31.1",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"dotenv": "^17.2.3",
|
||||
"eslint": "^9",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Add storno/correction link columns to journal_entries
|
||||
-- These are required for ändringsverifikationer (correction entries) per BFL 5 kap.
|
||||
-- The reverseEntry() and correctEntry() functions in engine.ts / storno-service.ts
|
||||
-- depend on these columns to create bidirectional links between entries.
|
||||
|
||||
-- Link to storno entry that reversed this entry
|
||||
ALTER TABLE public.journal_entries
|
||||
ADD COLUMN IF NOT EXISTS reversed_by_id uuid REFERENCES public.journal_entries(id) ON DELETE SET NULL;
|
||||
|
||||
-- Link to the original entry that this storno reverses
|
||||
ALTER TABLE public.journal_entries
|
||||
ADD COLUMN IF NOT EXISTS reverses_id uuid REFERENCES public.journal_entries(id) ON DELETE SET NULL;
|
||||
|
||||
-- Link to the original entry in a correction chain (storno + new correct entry)
|
||||
ALTER TABLE public.journal_entries
|
||||
ADD COLUMN IF NOT EXISTS correction_of_id uuid REFERENCES public.journal_entries(id) ON DELETE SET NULL;
|
||||
|
||||
-- Indexes for FK lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_journal_entries_reversed_by_id ON public.journal_entries (reversed_by_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_journal_entries_reverses_id ON public.journal_entries (reverses_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_journal_entries_correction_of_id ON public.journal_entries (correction_of_id);
|
||||
Reference in New Issue
Block a user