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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-02-26 17:14:44 +01:00
co-authored by Claude Opus 4.6
parent 6d647b956c
commit 3f255ea201
42 changed files with 2090 additions and 1127 deletions
@@ -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>
)}