From 9cd950de88028dd8a4a0c291773064c4040f52a0 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Thu, 26 Feb 2026 19:55:50 +0100 Subject: [PATCH] feat: add inline supplier creation in invoice inbox and remove dead code - Add inline new supplier form in InboxDetailDialog with pre-populated fields from AI extraction - Support new_supplier payload in confirm endpoint to create suppliers with user-editable fields (type, org number, bankgiro, etc.) - Improve line item amount calculation using cross-checked extraction totals - Remove unused estimateProductValue function and LangChain imports from receipt-analyzer - Add debug logging to invoice inbox confirm flow Co-Authored-By: Claude Opus 4.6 --- .../general/InvoiceInboxWorkspace.tsx | 14 +- .../invoice-inbox/InboxDetailDialog.tsx | 136 +++++++++++++++- .../general/invoice-inbox/api-routes.ts | 150 ++++++++++++++---- .../receipt-ocr/lib/receipt-analyzer.ts | 89 ----------- 4 files changed, 257 insertions(+), 132 deletions(-) diff --git a/components/extensions/general/InvoiceInboxWorkspace.tsx b/components/extensions/general/InvoiceInboxWorkspace.tsx index 50e1895c..1e4ea8fb 100644 --- a/components/extensions/general/InvoiceInboxWorkspace.tsx +++ b/components/extensions/general/InvoiceInboxWorkspace.tsx @@ -118,9 +118,17 @@ export default function InvoiceInboxWorkspace({ userId }: WorkspaceComponentProp } } - async function handleConfirm(itemId: string, supplierId?: string) { - const body: Record = {} - if (supplierId) body.supplier_id = supplierId + async function handleConfirm( + itemId: string, + supplierId?: string, + newSupplierData?: import('@/components/extensions/general/invoice-inbox/InboxDetailDialog').NewSupplierData + ) { + const body: Record = {} + if (supplierId) { + body.supplier_id = supplierId + } else if (newSupplierData) { + body.new_supplier = newSupplierData + } const res = await fetch(`/api/extensions/ext/invoice-inbox/inbox/${itemId}/confirm`, { method: 'POST', diff --git a/components/extensions/general/invoice-inbox/InboxDetailDialog.tsx b/components/extensions/general/invoice-inbox/InboxDetailDialog.tsx index 157c4c4c..fa164181 100644 --- a/components/extensions/general/invoice-inbox/InboxDetailDialog.tsx +++ b/components/extensions/general/invoice-inbox/InboxDetailDialog.tsx @@ -1,7 +1,7 @@ 'use client' -import { useState } from 'react' -import type { InvoiceInboxItem, Supplier } from '@/types' +import { useState, useEffect } from 'react' +import type { InvoiceInboxItem, Supplier, SupplierType } from '@/types' import type { InvoiceExtractionResult } from '@/extensions/general/invoice-inbox/types' import { Dialog, @@ -13,6 +13,8 @@ import { import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Separator } from '@/components/ui/separator' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' import { Select, SelectContent, @@ -34,13 +36,24 @@ import { getStatusVariant, getConfidenceLabel, } from '@/lib/extensions/invoice-inbox-utils' -import { Loader2, RefreshCw, Check, X } from 'lucide-react' +import { Loader2, RefreshCw, Check, X, ChevronDown } from 'lucide-react' + +export interface NewSupplierData { + name: string + supplier_type: SupplierType + org_number: string + vat_number: string + bankgiro: string + plusgiro: string + default_expense_account: string + default_currency: string +} interface InboxDetailDialogProps { item: InvoiceInboxItem | null open: boolean onOpenChange: (open: boolean) => void - onConfirm: (itemId: string, supplierId?: string) => Promise + onConfirm: (itemId: string, supplierId?: string, newSupplierData?: NewSupplierData) => Promise onReject: (itemId: string) => Promise onReprocess: (itemId: string) => Promise suppliers: Supplier[] @@ -67,6 +80,34 @@ export default function InboxDetailDialog({ }: InboxDetailDialogProps) { const [loading, setLoading] = useState<'confirm' | 'reject' | 'reprocess' | null>(null) const [selectedSupplierId, setSelectedSupplierId] = useState(undefined) + const [supplierFormOpen, setSupplierFormOpen] = useState(false) + const [newSupplier, setNewSupplier] = useState({ + name: '', + supplier_type: 'swedish_business', + org_number: '', + vat_number: '', + bankgiro: '', + plusgiro: '', + default_expense_account: '6200', + default_currency: 'SEK', + }) + + // Pre-populate supplier form when item changes + const extractionForEffect = item?.extracted_data as unknown as InvoiceExtractionResult | null + useEffect(() => { + if (!extractionForEffect?.supplier) return + const s = extractionForEffect.supplier + setNewSupplier({ + name: s.name ?? '', + supplier_type: 'swedish_business', + org_number: s.orgNumber ?? '', + vat_number: s.vatNumber ?? '', + bankgiro: s.bankgiro ?? '', + plusgiro: s.plusgiro ?? '', + default_expense_account: '6200', + default_currency: extractionForEffect.invoice?.currency || 'SEK', + }) + }, [extractionForEffect]) if (!item) return null @@ -78,16 +119,25 @@ export default function InboxDetailDialog({ const matchedSupplierName = (item.supplier as { name?: string } | undefined)?.name const supplierId = selectedSupplierId ?? item.matched_supplier_id ?? undefined + const isCreatingNewSupplier = !supplierId const canConfirm = item.status === 'ready' && extraction != null const canReprocess = item.status !== 'confirmed' const canReject = item.status !== 'confirmed' && item.status !== 'rejected' + function updateSupplierField(field: K, value: NewSupplierData[K]) { + setNewSupplier((prev) => ({ ...prev, [field]: value })) + } + async function handleAction(action: 'confirm' | 'reject' | 'reprocess') { setLoading(action) try { if (action === 'confirm') { - await onConfirm(item!.id, supplierId) + await onConfirm( + item!.id, + supplierId, + isCreatingNewSupplier ? newSupplier : undefined + ) } else if (action === 'reject') { await onReject(item!.id) } else { @@ -185,6 +235,82 @@ export default function InboxDetailDialog({ + + {/* Inline new supplier form */} + {isCreatingNewSupplier && ( +
+ + {supplierFormOpen && ( +
+
+ + updateSupplierField('name', e.target.value)} + /> +
+
+ + +
+
+ + updateSupplierField('org_number', e.target.value)} + /> +
+
+ + updateSupplierField('bankgiro', e.target.value)} + /> +
+
+ + updateSupplierField('plusgiro', e.target.value)} + /> +
+
+ + updateSupplierField('default_expense_account', e.target.value)} + /> +
+
+ + updateSupplierField('default_currency', e.target.value)} + /> +
+
+ )} +
+ )} diff --git a/extensions/general/invoice-inbox/api-routes.ts b/extensions/general/invoice-inbox/api-routes.ts index cce5fbdb..ee34c415 100644 --- a/extensions/general/invoice-inbox/api-routes.ts +++ b/extensions/general/invoice-inbox/api-routes.ts @@ -604,34 +604,53 @@ async function handleConfirmInboxItem( const body = await request.json().catch(() => ({})) try { + console.log('[invoice-inbox] Confirm: starting for item', id, 'extraction keys:', Object.keys(extraction)) + // Resolve supplier: use matched, use body override, or create new let supplierId = body.supplier_id || inboxItem.matched_supplier_id if (!supplierId) { - // Create new supplier from extracted data - const supplierName = extraction.supplier?.name + // Use frontend-provided supplier data if available, otherwise fall back to extraction + const frontendSupplier = body.new_supplier as { + name?: string + supplier_type?: string + org_number?: string + vat_number?: string + bankgiro?: string + plusgiro?: string + default_expense_account?: string + default_currency?: string + } | undefined + + const supplierName = frontendSupplier?.name || extraction.supplier?.name if (!supplierName) { return NextResponse.json({ error: 'Supplier name is required' }, { status: 400 }) } + const validSupplierTypes = ['swedish_business', 'eu_business', 'non_eu_business'] + const supplierType = frontendSupplier?.supplier_type && validSupplierTypes.includes(frontendSupplier.supplier_type) + ? frontendSupplier.supplier_type + : 'swedish_business' + const { data: newSupplier, error: supplierError } = await supabase .from('suppliers') .insert({ user_id: userId, name: supplierName, - supplier_type: 'swedish_business', - org_number: extraction.supplier?.orgNumber || null, - vat_number: extraction.supplier?.vatNumber || null, - bankgiro: extraction.supplier?.bankgiro || null, - plusgiro: extraction.supplier?.plusgiro || null, - default_expense_account: '6200', + supplier_type: supplierType, + org_number: frontendSupplier?.org_number || extraction.supplier?.orgNumber || null, + vat_number: frontendSupplier?.vat_number || extraction.supplier?.vatNumber || null, + bankgiro: frontendSupplier?.bankgiro || extraction.supplier?.bankgiro || null, + plusgiro: frontendSupplier?.plusgiro || extraction.supplier?.plusgiro || null, + default_expense_account: frontendSupplier?.default_expense_account || '6200', default_payment_terms: 30, - default_currency: extraction.invoice?.currency || 'SEK', + default_currency: frontendSupplier?.default_currency || extraction.invoice?.currency || 'SEK', }) .select() .single() if (supplierError || !newSupplier) { + console.error('[invoice-inbox] Confirm: supplier creation failed:', supplierError) return NextResponse.json({ error: 'Failed to create supplier' }, { status: 500 }) } @@ -647,6 +666,7 @@ async function handleConfirmInboxItem( .single() if (supplierCheckError || !supplier) { + console.error('[invoice-inbox] Confirm: supplier check failed:', supplierCheckError, 'supplierId=', supplierId) return NextResponse.json({ error: 'Supplier not found' }, { status: 404 }) } @@ -655,45 +675,99 @@ async function handleConfirmInboxItem( .rpc('get_next_arrival_number', { p_user_id: userId }) if (arrivalError) { + console.error('[invoice-inbox] Confirm: arrival number error:', arrivalError) return NextResponse.json({ error: 'Failed to get arrival number' }, { status: 500 }) } - // Build line items from extraction - const items = (extraction.lineItems || []).map((item, index) => { - const vatRate = item.vatRate != null ? item.vatRate / 100 : 0.25 - const lineTotal = Math.round(item.lineTotal * 100) / 100 - const vatAmount = Math.round(lineTotal * vatRate * 100) / 100 - return { - sort_order: index, - description: item.description, - quantity: item.quantity || 1, - unit: 'st', - unit_price: item.unitPrice != null ? item.unitPrice : lineTotal, - line_total: lineTotal, - account_number: item.accountSuggestion || supplier.default_expense_account || '6200', - vat_code: null, - vat_rate: vatRate, - vat_amount: vatAmount, + console.log('[invoice-inbox] Confirm: supplierId=', supplierId, 'arrivalNum=', arrivalNum) + + // Build line items from extraction. + // The AI extraction may return lineTotal as gross (incl. VAT) or net. + // Cross-check with extraction.totals to determine the correct split. + const extractedTotals = extraction.totals + const rawLineItems = extraction.lineItems || [] + + const items: Array<{ + sort_order: number + description: string + quantity: number + unit: string + unit_price: number + line_total: number + account_number: string + vat_code: null + vat_rate: number + vat_amount: number + }> = [] + + if (rawLineItems.length > 0 && extractedTotals?.subtotal != null && extractedTotals?.vatAmount != null) { + // We have both line items and reliable totals — use totals for amounts + // and distribute proportionally across line items + const rawSum = rawLineItems.reduce((s, i) => s + (i.lineTotal || 0), 0) + const knownSubtotal = extractedTotals.subtotal + const knownVat = extractedTotals.vatAmount + const knownVatRate = knownSubtotal > 0 + ? Math.round((knownVat / knownSubtotal) * 100) / 100 + : 0.25 + + for (let index = 0; index < rawLineItems.length; index++) { + const item = rawLineItems[index] + // Distribute the known subtotal proportionally by each line's share + const proportion = rawSum > 0 ? (item.lineTotal || 0) / rawSum : 1 / rawLineItems.length + const lineNet = Math.round(knownSubtotal * proportion * 100) / 100 + const lineVat = Math.round(knownVat * proportion * 100) / 100 + items.push({ + sort_order: index, + description: item.description, + quantity: item.quantity || 1, + unit: 'st', + unit_price: lineNet, + line_total: lineNet, + account_number: item.accountSuggestion || supplier.default_expense_account || '6200', + vat_code: null, + vat_rate: knownVatRate, + vat_amount: lineVat, + }) } - }) + } else if (rawLineItems.length > 0) { + // Line items but no reliable totals — use per-line vatRate + for (let index = 0; index < rawLineItems.length; index++) { + const item = rawLineItems[index] + const vatRate = item.vatRate != null ? item.vatRate / 100 : 0.25 + const lineTotal = Math.round(item.lineTotal * 100) / 100 + const vatAmount = Math.round(lineTotal * vatRate * 100) / 100 + items.push({ + sort_order: index, + description: item.description, + quantity: item.quantity || 1, + unit: 'st', + unit_price: item.unitPrice != null ? item.unitPrice : lineTotal, + line_total: lineTotal, + account_number: item.accountSuggestion || supplier.default_expense_account || '6200', + vat_code: null, + vat_rate: vatRate, + vat_amount: vatAmount, + }) + } + } // If no line items, create a single item from totals - if (items.length === 0 && extraction.totals?.total) { - const total = extraction.totals.total - const vatAmount = extraction.totals.vatAmount || 0 - const subtotal = extraction.totals.subtotal || total - vatAmount - const vatRate = subtotal > 0 ? Math.round((vatAmount / subtotal) * 100) / 100 : 0.25 + if (items.length === 0 && extractedTotals?.total) { + const total = extractedTotals.total + const vatAmt = extractedTotals.vatAmount || 0 + const sub = extractedTotals.subtotal || total - vatAmt + const vatRate = sub > 0 ? Math.round((vatAmt / sub) * 100) / 100 : 0.25 items.push({ sort_order: 0, description: 'Fakturabelopp', quantity: 1, unit: 'st', - unit_price: subtotal, - line_total: subtotal, + unit_price: sub, + line_total: sub, account_number: supplier.default_expense_account || '6200', vat_code: null, vat_rate: vatRate, - vat_amount: Math.round(vatAmount * 100) / 100, + vat_amount: Math.round(vatAmt * 100) / 100, }) } @@ -708,6 +782,8 @@ async function handleConfirmInboxItem( else if (primaryVatRate === 0.06) vatTreatment = 'reduced_6' else if (primaryVatRate === 0) vatTreatment = 'exempt' + console.log('[invoice-inbox] Confirm: items=', items.length, 'subtotal=', subtotal, 'vat=', vatAmount, 'total=', total, 'vatTreatment=', vatTreatment) + // Insert supplier invoice const { data: invoice, error: invoiceError } = await supabase .from('supplier_invoices') @@ -733,9 +809,12 @@ async function handleConfirmInboxItem( .single() if (invoiceError || !invoice) { + console.error('[invoice-inbox] Confirm: invoice insert error:', invoiceError) return NextResponse.json({ error: invoiceError?.message || 'Failed to create invoice' }, { status: 500 }) } + console.log('[invoice-inbox] Confirm: invoice created id=', invoice.id) + // Insert line items const itemInserts = items.map((item) => ({ supplier_invoice_id: invoice.id, @@ -747,6 +826,7 @@ async function handleConfirmInboxItem( .insert(itemInserts) if (itemsError) { + console.error('[invoice-inbox] Confirm: items insert error:', itemsError, 'payload:', JSON.stringify(itemInserts)) await supabase.from('supplier_invoices').delete().eq('id', invoice.id) return NextResponse.json({ error: itemsError.message }, { status: 500 }) } @@ -784,7 +864,7 @@ async function handleConfirmInboxItem( }, }) } catch (error) { - console.error('[invoice-inbox] Confirm failed:', error) + console.error('[invoice-inbox] Confirm failed:', error instanceof Error ? error.stack : error) return NextResponse.json({ error: 'Confirmation failed' }, { status: 500 }) } } diff --git a/extensions/general/receipt-ocr/lib/receipt-analyzer.ts b/extensions/general/receipt-ocr/lib/receipt-analyzer.ts index 5a23e130..dd46f1ef 100644 --- a/extensions/general/receipt-ocr/lib/receipt-analyzer.ts +++ b/extensions/general/receipt-ocr/lib/receipt-analyzer.ts @@ -3,14 +3,9 @@ * then applies receipt-specific validation and enhancement. * * SERVER-ONLY: uses the shared vision client via document-analyzer. - * - * Preserved public API: analyzeReceipt() and estimateProductValue(). */ import 'server-only' -import { ChatAnthropic } from '@langchain/anthropic' -import { HumanMessage, SystemMessage } from '@langchain/core/messages' -import { z } from 'zod' import type { ReceiptExtractionResult } from '@/types' import { extractReceipt } from '@/lib/ai/document-analyzer' import { @@ -26,10 +21,6 @@ export { detectRestaurant, } from './receipt-utils' -// 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. * Delegates extraction to the shared core, then applies receipt-specific enhancements. @@ -102,83 +93,3 @@ function detectForeignMerchant( return false } - -// ============================================================ -// Product Value Estimation (LangChain — separate from receipt analysis) -// ============================================================ - -const ProductEstimationSchema = z.object({ - estimatedValue: z.number().describe('Uppskattat marknadsvärde i SEK'), - confidence: z.number().min(0).max(1).describe('Hur säker du är på uppskattningen (0.0-1.0)'), - description: z.string().describe('Kort beskrivning av produkten'), - brand: z.string().nullable().describe('Varumärke om identifierbart, annars null'), -}) - -/** - * Estimate product value from image using Claude Vision via LangChain. - * Used for gift/product registration without receipt. - */ -export async function estimateProductValue( - imageBase64: string, - mimeType: 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif' = 'image/jpeg' -): Promise<{ - estimatedValue: number - confidence: number - description: string - brand: string | null -}> { - const model = new ChatAnthropic({ - modelName: 'claude-haiku-4-5-20251001', - maxTokens: 1024, - temperature: 0, - anthropicApiKey: process.env.ANTHROPIC_API_KEY, - }) - - const structuredModel = model.withStructuredOutput(ProductEstimationSchema) - - const systemPrompt = `Du är expert på att uppskatta marknadsvärdet på produkter baserat på bilder. -Din uppgift är att identifiera produkten och ge en rimlig uppskattning av dess marknadsvärde i svenska kronor.` - - const userPrompt = `Analysera produkten i bilden och uppskatta dess marknadsvärde i SEK. Identifiera varumärke om möjligt.` - - let lastError: Error | null = null - - for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { - try { - const result = await structuredModel.invoke([ - new SystemMessage(systemPrompt), - new HumanMessage({ - content: [ - { - type: 'image_url', - image_url: { url: `data:${mimeType};base64,${imageBase64}` }, - }, - { - type: 'text', - text: userPrompt, - }, - ], - }), - ]) - - return { - estimatedValue: result.estimatedValue || 0, - confidence: result.confidence || 0.5, - description: result.description || 'Okänd produkt', - brand: result.brand, - } - } catch (error) { - lastError = error instanceof Error ? error : new Error('Unknown error') - - if (attempt < MAX_RETRIES - 1) { - await sleep(RETRY_DELAY_MS * (attempt + 1)) - } - } - } - - throw new Error(`Product value estimation failed after ${MAX_RETRIES} attempts: ${lastError?.message}`) -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)) -}