From 3f255ea2015efd9aa5106baf48214695cde21702 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Thu, 26 Feb 2026 17:14:44 +0100 Subject: [PATCH] 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 --- app/(dashboard)/deadlines/page.tsx | 2 +- app/(dashboard)/invoices/[id]/page.tsx | 24 +- .../webhook/__tests__/route.test.ts | 11 +- .../extensions/invoice-inbox/webhook/route.ts | 28 +- .../extensions/ExtensionWorkspaceLoader.tsx | 4 +- .../extensions/ExtensionWorkspaceShell.tsx | 2 +- .../general/AiCategorizationWorkspace.tsx | 2 +- .../extensions/general/AiChatWorkspace.tsx | 2 +- .../general/PushNotificationsWorkspace.tsx | 2 +- .../general/ReceiptOcrWorkspace.tsx | 2 +- .../document-inbox/DocumentInboxCard.tsx | 17 +- .../document-inbox/ReceiptInboxDetail.tsx | 12 +- .../invoice-inbox/InboxDetailDialog.tsx | 15 +- .../general/invoice-inbox/InboxItemCard.tsx | 9 +- .../extensions/shared/DateRangeFilter.tsx | 4 +- .../extensions/shared/EmptyExtensionState.tsx | 4 +- extensions/general/calendar/manifest.json | 4 +- .../lib/__tests__/invoice-analyzer.test.ts | 134 +--- .../invoice-inbox/lib/invoice-analyzer.ts | 305 +------- extensions/general/receipt-ocr/api-routes.ts | 7 +- .../lib/__tests__/receipt-categorizer.test.ts | 4 +- .../lib/__tests__/receipt-pipeline.test.ts | 5 + .../receipt-ocr/lib/receipt-analyzer.ts | 367 ++-------- .../receipt-ocr/lib/receipt-categorizer.ts | 2 +- .../receipt-ocr/lib/receipt-pipeline.ts | 16 +- .../receipt-ocr/pages/ReceiptsPage.tsx | 2 +- lib/ai/__tests__/document-analyzer.test.ts | 378 ++++++++++ lib/ai/__tests__/preprocess-image.test.ts | 68 ++ lib/ai/__tests__/validation-helpers.test.ts | 131 ++++ lib/ai/__tests__/vision-client.test.ts | 256 +++++++ lib/ai/document-analyzer.ts | 666 ++++++++++++++++++ lib/ai/preprocess-image.ts | 43 ++ lib/ai/validation-helpers.ts | 117 +++ lib/ai/vision-client.ts | 140 ++++ lib/documents/__tests__/classifier.test.ts | 225 ++---- lib/documents/classifier.ts | 155 +--- .../__tests__/invoice-inbox-utils.test.ts | 4 + .../_generated/sector-definitions.ts | 4 +- lib/extensions/invoice-inbox-utils.ts | 5 +- package-lock.json | 16 +- package.json | 2 + ...046_add_journal_entry_reversal_columns.sql | 21 + 42 files changed, 2090 insertions(+), 1127 deletions(-) create mode 100644 lib/ai/__tests__/document-analyzer.test.ts create mode 100644 lib/ai/__tests__/preprocess-image.test.ts create mode 100644 lib/ai/__tests__/validation-helpers.test.ts create mode 100644 lib/ai/__tests__/vision-client.test.ts create mode 100644 lib/ai/document-analyzer.ts create mode 100644 lib/ai/preprocess-image.ts create mode 100644 lib/ai/validation-helpers.ts create mode 100644 lib/ai/vision-client.ts create mode 100644 supabase/migrations/20240101000046_add_journal_entry_reversal_columns.sql diff --git a/app/(dashboard)/deadlines/page.tsx b/app/(dashboard)/deadlines/page.tsx index 1be6fde3..211863e0 100644 --- a/app/(dashboard)/deadlines/page.tsx +++ b/app/(dashboard)/deadlines/page.tsx @@ -145,7 +145,7 @@ export default function DeadlinesPage() { toast({ title: 'Deadline uppdaterad', - description: 'Dina andringar har sparats', + description: 'Dina ändringar har sparats', }) fetchData() diff --git a/app/(dashboard)/invoices/[id]/page.tsx b/app/(dashboard)/invoices/[id]/page.tsx index cfbf62ac..3071929b 100644 --- a/app/(dashboard)/invoices/[id]/page.tsx +++ b/app/(dashboard)/invoices/[id]/page.tsx @@ -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 - )} {invoice.status === 'paid' && isRealInvoice && ( diff --git a/app/api/extensions/invoice-inbox/webhook/__tests__/route.test.ts b/app/api/extensions/invoice-inbox/webhook/__tests__/route.test.ts index e950fc4d..3473c180 100644 --- a/app/api/extensions/invoice-inbox/webhook/__tests__/route.test.ts +++ b/app/api/extensions/invoice-inbox/webhook/__tests__/route.test.ts @@ -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' diff --git a/app/api/extensions/invoice-inbox/webhook/route.ts b/app/api/extensions/invoice-inbox/webhook/route.ts index 0123f26c..4eaae8bf 100644 --- a/app/api/extensions/invoice-inbox/webhook/route.ts +++ b/app/api/extensions/invoice-inbox/webhook/route.ts @@ -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> | 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), 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 diff --git a/components/extensions/ExtensionWorkspaceLoader.tsx b/components/extensions/ExtensionWorkspaceLoader.tsx index d337e314..745125b6 100644 --- a/components/extensions/ExtensionWorkspaceLoader.tsx +++ b/components/extensions/ExtensionWorkspaceLoader.tsx @@ -24,8 +24,8 @@ export default function ExtensionWorkspaceLoader({ ) : ( )} diff --git a/components/extensions/ExtensionWorkspaceShell.tsx b/components/extensions/ExtensionWorkspaceShell.tsx index f90e016e..9c0e4016 100644 --- a/components/extensions/ExtensionWorkspaceShell.tsx +++ b/components/extensions/ExtensionWorkspaceShell.tsx @@ -20,7 +20,7 @@ export default function ExtensionWorkspaceShell({ {/* Breadcrumb */}