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
@@ -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