diff --git a/app/(dashboard)/bookkeeping/[id]/page.tsx b/app/(dashboard)/bookkeeping/[id]/page.tsx index e9f81979..f360b258 100644 --- a/app/(dashboard)/bookkeeping/[id]/page.tsx +++ b/app/(dashboard)/bookkeeping/[id]/page.tsx @@ -230,6 +230,14 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i Typ {sourceTypeLabels[entry.source_type] || entry.source_type} + {entry.source_voucher_series && entry.source_voucher_number != null && ( +
+ Ursprungligt verifikat + + {entry.source_voucher_series}{entry.source_voucher_number} + +
+ )} {/* Notes — always editable (internal metadata, not BFL verifikation content) */}
diff --git a/components/extensions/general/InvoiceInboxWorkspace.tsx b/components/extensions/general/InvoiceInboxWorkspace.tsx index 83b0c16b..7bde57da 100644 --- a/components/extensions/general/InvoiceInboxWorkspace.tsx +++ b/components/extensions/general/InvoiceInboxWorkspace.tsx @@ -41,6 +41,8 @@ import { RotateCcw, ArrowRight, Sparkles, + Globe, + Info, } from 'lucide-react' import Link from 'next/link' import { cn, formatCurrency } from '@/lib/utils' @@ -63,6 +65,7 @@ interface InboxItem { email_from: string | null email_subject: string | null error_message: string | null + resend_email_id: string | null matched_transaction_id: string | null match_confidence: number | null match_method: string | null @@ -99,6 +102,9 @@ interface ConvertFormItem { amount: number account_number: string vat_rate: number + // True when the rate was inferred from the document's totals rather than + // read off this specific line — surfaces "needs review" UI affordances. + vat_inferred?: boolean } // ── Constants ──────────────────────────────────────────────── @@ -246,6 +252,47 @@ function MatchBlock({ item }: { item: InboxItem }) { return null } +// One-line payload summary per processing_history event_type — keeps the +// timeline scannable without dumping raw JSON on the user. +function formatHistorySummary(eventType: string, payload: Record): string { + const mime = payload.mime_type as string | undefined + const size = typeof payload.size_bytes === 'number' ? payload.size_bytes : null + const tokensIn = typeof payload.llm_input_tokens === 'number' ? payload.llm_input_tokens : null + const tokensOut = typeof payload.llm_output_tokens === 'number' ? payload.llm_output_tokens : null + const conf = typeof payload.confidence === 'number' ? payload.confidence : null + const cls = payload.classification as string | undefined + const matched = payload.matched as boolean | undefined + const candidates = typeof payload.candidate_count === 'number' ? payload.candidate_count : null + const errMsg = payload.error as string | null | undefined + + switch (eventType) { + case 'DocumentIngested': + return `${mime || 'okänd'}${size ? ` · ${(size / 1024).toFixed(1)} kB` : ''}` + case 'DocumentExtractionAttempted': + if (errMsg) return `fel: ${errMsg.slice(0, 80)}` + return [ + tokensIn != null && tokensOut != null ? `${tokensIn} + ${tokensOut} tokens` : null, + conf != null ? `${Math.round(conf * 100)}%` : null, + ] + .filter(Boolean) + .join(' · ') + case 'DocumentClassified': + return [cls, conf != null ? `${Math.round(conf * 100)}%` : null].filter(Boolean).join(' · ') + case 'MatchAttemptedDeterministic': + return `${candidates ?? 0} kandidater` + case 'MatchAttemptedLlm': + return [ + matched === true ? 'matchad' : matched === false ? 'ingen match' : null, + conf != null ? `${Math.round(conf * 100)}%` : null, + tokensIn != null && tokensOut != null ? `${tokensIn} + ${tokensOut} tokens` : null, + ] + .filter(Boolean) + .join(' · ') + default: + return '' + } +} + function timeAgo(isoDate: string): string { const diff = Date.now() - new Date(isoDate).getTime() const minutes = Math.floor(diff / 60000) @@ -257,9 +304,78 @@ function timeAgo(isoDate: string): string { return `${days} dag${days > 1 ? 'ar' : ''} sedan` } +// Infer a default VAT rate (as decimal, e.g. 0.25) from the document's own +// totals and vatBreakdown so null-rate line items don't silently default to 25%. +// Rules, in order: +// 1. If vatAmount total is 0 → 0% (document has no VAT) +// 2. If vatBreakdown has exactly one entry → that rate +// 3. If all non-null line rates agree → that rate +// 4. Else → 25% fallback +// Mirror of the server-side reconciliation check in classify-document.ts so the +// UI can show the same math that drove the confidence cap. Returns null when +// there's nothing to compare against (no lines or no totals). +function computeReconciliation(data: InvoiceExtractionResult | null): { + sumOfLines: number + subtotal: number | null + vatAmount: number + total: number | null + anchor: number + delta: number + tolerance: number + reconciles: boolean +} | null { + if (!data?.lineItems?.length) return null + const subtotal = data.totals?.subtotal ?? null + const total = data.totals?.total ?? null + const vatAmount = data.totals?.vatAmount ?? 0 + if (subtotal == null && total == null) return null + + const sumOfLines = data.lineItems.reduce((acc, li) => acc + (li.lineTotal ?? 0), 0) + const anchor = subtotal != null ? subtotal : (total ?? 0) - vatAmount + const tolerance = Math.max(0.02, Math.abs(anchor) * 0.02) + const delta = sumOfLines - anchor + + return { + sumOfLines: Math.round(sumOfLines * 100) / 100, + subtotal, + vatAmount, + total, + anchor: Math.round(anchor * 100) / 100, + delta: Math.round(delta * 100) / 100, + tolerance: Math.round(tolerance * 100) / 100, + reconciles: Math.abs(delta) <= tolerance, + } +} + +function inferDocumentDefaultVat(data: InvoiceExtractionResult | null): number { + if (!data) return 0.25 + + const vatAmount = data.totals?.vatAmount + if (vatAmount === 0) return 0 + + const breakdown = data.vatBreakdown ?? [] + if (breakdown.length === 1) { + return (breakdown[0].rate ?? 25) / 100 + } + + const explicitRates = (data.lineItems ?? []) + .map((li) => li.vatRate) + .filter((r): r is number => r != null) + + if (explicitRates.length > 0) { + const unique = new Set(explicitRates) + if (unique.size === 1) { + return explicitRates[0] / 100 + } + } + + return 0.25 +} + function buildInitialForm(item: InboxItem, defaultExpenseAccount?: string): ConvertForm { const data = item.extracted_data const fallbackAccount = defaultExpenseAccount || '5410' + const inferredDefault = inferDocumentDefaultVat(data) let formItems: ConvertFormItem[] if (data?.lineItems?.length) { @@ -267,7 +383,8 @@ function buildInitialForm(item: InboxItem, defaultExpenseAccount?: string): Conv description: li.description, amount: li.lineTotal ?? 0, account_number: li.accountSuggestion || fallbackAccount, - vat_rate: li.vatRate != null ? li.vatRate / 100 : 0.25, + vat_rate: li.vatRate != null ? li.vatRate / 100 : inferredDefault, + vat_inferred: li.vatRate == null, })) // If all line item amounts are 0 but we have a total, distribute evenly @@ -346,6 +463,22 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { } | null>(null) const [isConfirmingMatch, setIsConfirmingMatch] = useState(false) + // Exchange rate for non-SEK invoices, fetched when the convert dialog opens. + // null = not yet fetched, undefined = SEK (rate=1 implicit), number = resolved. + const [exchangeRate, setExchangeRate] = useState(null) + const [exchangeRateDate, setExchangeRateDate] = useState(null) + + // processing_history events for the open inbox item, shown as a diagnostic + // timeline inside the convert dialog. Empty array = fetched but no events. + const [historyEvents, setHistoryEvents] = useState | null + actor: { type?: string; id?: string } | null + }> | null>(null) + const [historyOpen, setHistoryOpen] = useState(false) + // ── Data fetching ──────────────────────────────────────── const fetchItems = useCallback(async () => { @@ -489,12 +622,33 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { const matchedSupplier = item.matched_supplier_id ? suppliers.find((s) => s.id === item.matched_supplier_id) : null - setConvertForm(buildInitialForm(item, matchedSupplier?.default_expense_account || undefined)) + const initialForm = buildInitialForm(item, matchedSupplier?.default_expense_account || undefined) + setConvertForm(initialForm) setFormErrors({}) setDocumentUrl(null) setDocumentMimeType(null) + setExchangeRate(null) + setExchangeRateDate(null) + setHistoryEvents(null) + setHistoryOpen(false) fetchSuppliers() + // Fetch processing_history timeline (diagnostic panel inside dialog). + // Runs in parallel with the preview/rate fetches below. + void (async () => { + try { + const res = await fetch(`/api/extensions/ext/invoice-inbox/items/${item.id}/history`) + if (res.ok) { + const { data } = await res.json() + setHistoryEvents(data?.events ?? []) + } else { + setHistoryEvents([]) + } + } catch { + setHistoryEvents([]) + } + })() + // Fetch document preview URL if (item.document_id) { try { @@ -506,6 +660,26 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { } } catch { /* silent */ } } + + // Prefill Riksbanken exchange rate for foreign-currency invoices. The + // supplier-invoice create handler only populates *_sek columns when + // exchange_rate is sent, so without this the SEK-equivalent audit fields + // stay null on foreign invoices. + const currency = initialForm.currency + if (currency && currency !== 'SEK' && initialForm.invoice_date) { + try { + const res = await fetch( + `/api/currency/rate?currency=${encodeURIComponent(currency)}&date=${encodeURIComponent(initialForm.invoice_date)}` + ) + if (res.ok) { + const { data } = await res.json() + if (data?.rate) { + setExchangeRate(Number(data.rate)) + setExchangeRateDate(typeof data.date === 'string' ? data.date : null) + } + } + } catch { /* silent — SEK conversion is a nice-to-have, not required */ } + } }, [fetchSuppliers, suppliers]) // ── Convert form handlers ──────────────────────────────── @@ -559,7 +733,9 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { setConvertForm((prev) => { if (!prev) return prev const items = [...prev.items] - items[index] = { ...items[index], [field]: value } + // Editing the VAT rate promotes an inferred guess to user-confirmed. + const clearInferred = field === 'vat_rate' ? { vat_inferred: false } : {} + items[index] = { ...items[index], [field]: value, ...clearInferred } return { ...prev, items } }) setFormErrors((prev) => { @@ -614,6 +790,10 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { invoice_date: convertForm.invoice_date, due_date: convertForm.due_date, currency: convertForm.currency || 'SEK', + exchange_rate: + convertForm.currency && convertForm.currency !== 'SEK' && exchangeRate + ? exchangeRate + : undefined, payment_reference: convertForm.payment_reference || undefined, notes: convertForm.notes || undefined, items: convertForm.items.map((item) => ({ @@ -657,7 +837,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { } finally { setIsConverting(false) } - }, [convertItem, convertForm, validateForm, toast]) + }, [convertItem, convertForm, validateForm, toast, exchangeRate]) const handleConfirmMatch = useCallback(async () => { if (!suggestedMatch) return @@ -683,6 +863,39 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { // ── Computed ───────────────────────────────────────────── + // Collapse same-email rows: emails often ship both an invoice PDF and a + // receipt PDF for the same transaction. Prefer the supplier_invoice as the + // primary row and surface the rest as a "+N dokument" chip. Rows without a + // resend_email_id (manual uploads, legacy rows) pass through unchanged. + const visibleItems = (() => { + const groups = new Map() + const standalone: InboxItem[] = [] + for (const item of items) { + if (!item.resend_email_id) { + standalone.push(item) + continue + } + const existing = groups.get(item.resend_email_id) + if (existing) existing.push(item) + else groups.set(item.resend_email_id, [item]) + } + + const collapsed: Array<{ primary: InboxItem; hiddenCount: number }> = [] + for (const group of groups.values()) { + const primary = + group.find((g) => g.document_type === 'supplier_invoice') ?? + group.find((g) => g.document_type === 'receipt') ?? + group[0] + collapsed.push({ primary, hiddenCount: group.length - 1 }) + } + for (const item of standalone) { + collapsed.push({ primary: item, hiddenCount: 0 }) + } + // Re-sort by primary.created_at desc to preserve the original ordering. + collapsed.sort((a, b) => b.primary.created_at.localeCompare(a.primary.created_at)) + return collapsed + })() + const readyCount = items.filter((i) => i.status === 'ready').length const confirmedCount = items.filter((i) => i.status === 'confirmed').length const formTotal = convertForm @@ -784,7 +997,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { /> ) : (
- {items.map((item) => { + {visibleItems.map(({ primary: item, hiddenCount }) => { const supplierName = extractSupplierName(item) const amount = extractAmount(item) const currency = extractCurrency(item) @@ -825,6 +1038,14 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { {item.source === 'email' && ( )} + {hiddenCount > 0 && ( + + +{hiddenCount} dokument + + )}
{amount != null && ( @@ -934,6 +1155,64 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
)} + {/* Low-confidence reconciliation hint — surfaces the math that drove the 50% cap */} + {convertItem.confidence != null && convertItem.confidence <= 0.5 && (() => { + const recon = computeReconciliation(convertItem.extracted_data) + if (recon && !recon.reconciles) { + const currency = convertForm.currency || 'SEK' + return ( +
+ +
+

+ AI är osäker — summan av raderna stämmer inte med totalen +

+
+

+ Summa rader: {formatCurrency(recon.sumOfLines, currency)} +

+

+ Dokumentets nettosumma: {formatCurrency(recon.anchor, currency)} +

+

+ Differens: {formatCurrency(recon.delta, currency)} + {' '}(tillåten avvikelse {formatCurrency(recon.tolerance, currency)}) +

+
+

+ En rad kan saknas, dubblerats, eller haft fel tecken på rabatten. Kontrollera raderna nedan. +

+
+
+ ) + } + return ( +
+ +

+ AI är osäker på extraktionen ({Math.round(convertItem.confidence! * 100)}%). Gå igenom fälten innan du bokför. +

+
+ ) + })()} + + {/* Foreign-supplier hint — informational only, never auto-overrides VAT */} + {convertForm.currency && convertForm.currency !== 'SEK' && ( +
+ +
+

+ Utländsk leverantör ({convertForm.currency} + {convertItem.extracted_data?.supplier?.address ? ` · ${convertItem.extracted_data.supplier.address}` : ''}) +

+

+ Kontrollera momsbehandlingen: använd den sats fakturan anger (t.ex. 25% om leverantören är OSS-registrerad), + 0% vid export, eller omvänd skattskyldighet för EU-tjänster. Bokföringen ändrar inte det AI läste. +

+
+
+ )} + {/* Supplier selector */}
@@ -1060,7 +1339,9 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { value={String(lineItem.vat_rate)} onValueChange={(v) => updateLineItem(index, 'vat_rate', parseFloat(v))} > - + @@ -1069,6 +1350,9 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { ))} + {lineItem.vat_inferred && ( +

Uppskattad — kontrollera

+ )}
{index === 0 && } @@ -1087,8 +1371,57 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {

Totalt inkl. moms

{formatCurrency(formTotal, convertForm.currency)}

+ {convertForm.currency && convertForm.currency !== 'SEK' && exchangeRate && ( +

+ ≈ {formatCurrency(Math.round(formTotal * exchangeRate * 100) / 100, 'SEK')} + + ({exchangeRate.toFixed(4)} + {exchangeRateDate ? ` · ${exchangeRateDate}` : ''}) + +

+ )}
+ + {/* Processing history timeline (behandlingshistorik) */} + {historyEvents && historyEvents.length > 0 && ( +
+ + {historyOpen && ( +
    + {historyEvents.map((evt, i) => { + const prev = i > 0 ? historyEvents[i - 1] : null + const delta = prev + ? Math.round( + (new Date(evt.occurred_at).getTime() - + new Date(prev.occurred_at).getTime()) / + 10 + ) / 100 + : 0 + const payload = evt.payload ?? {} + const summary = formatHistorySummary(evt.event_type, payload) + return ( +
  • + + {i === 0 ? 'start' : `+${delta.toFixed(2)}s`} + + {evt.event_type} + {summary && {summary}} +
  • + ) + })} +
+ )} +
+ )}
)} diff --git a/components/transactions/TransactionForm.tsx b/components/transactions/TransactionForm.tsx index e4912330..1fbcac7b 100644 --- a/components/transactions/TransactionForm.tsx +++ b/components/transactions/TransactionForm.tsx @@ -11,15 +11,13 @@ import { Label } from '@/components/ui/label' import { Textarea } from '@/components/ui/textarea' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Loader2 } from 'lucide-react' -import type { CreateTransactionInput, TransactionCategory, Currency } from '@/types' +import type { CreateTransactionInput, Currency } from '@/types' const schema = z.object({ date: z.string().min(1, 'Datum krävs'), description: z.string().min(1, 'Beskrivning krävs'), amount: z.number().refine((n) => n !== 0, 'Belopp måste anges'), currency: z.enum(['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']), - category: z.string().optional(), - is_business: z.boolean().optional(), notes: z.string().optional(), }) @@ -30,21 +28,6 @@ interface TransactionFormProps { isLoading: boolean } -const categories: { value: TransactionCategory; label: string; isIncome?: boolean }[] = [ - { value: 'income_services', label: 'Intäkt: Tjänster', isIncome: true }, - { value: 'income_products', label: 'Intäkt: Produkter', isIncome: true }, - { value: 'income_other', label: 'Intäkt: Övrigt', isIncome: true }, - { value: 'expense_equipment', label: 'Kostnad: Utrustning' }, - { value: 'expense_software', label: 'Kostnad: Programvara' }, - { value: 'expense_travel', label: 'Kostnad: Resor' }, - { value: 'expense_office', label: 'Kostnad: Kontor' }, - { value: 'expense_marketing', label: 'Kostnad: Marknadsföring' }, - { value: 'expense_professional_services', label: 'Kostnad: Konsulter' }, - { value: 'expense_education', label: 'Kostnad: Utbildning' }, - { value: 'expense_other', label: 'Kostnad: Övrigt' }, - { value: 'private', label: 'Privat (ej avdragsgillt)' }, -] - const currencies: Currency[] = ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK'] export default function TransactionForm({ onSubmit, isLoading }: TransactionFormProps) { @@ -52,7 +35,6 @@ export default function TransactionForm({ onSubmit, isLoading }: TransactionForm register, handleSubmit, control, - watch, setValue, formState: { errors }, } = useForm({ @@ -62,8 +44,6 @@ export default function TransactionForm({ onSubmit, isLoading }: TransactionForm description: '', amount: 0, currency: 'SEK', - category: undefined, - is_business: undefined, notes: '', }, }) @@ -73,18 +53,12 @@ export default function TransactionForm({ onSubmit, isLoading }: TransactionForm setValue('date', format(new Date(), 'yyyy-MM-dd')) }, []) - const watchCategory = watch('category') - const isPrivate = watchCategory === 'private' - const isIncome = categories.find((c) => c.value === watchCategory)?.isIncome - const onFormSubmit = (data: FormData) => { onSubmit({ date: data.date, description: data.description, amount: data.amount, currency: data.currency, - category: data.category as TransactionCategory, - is_business: undefined, notes: data.notes, }) } @@ -151,28 +125,6 @@ export default function TransactionForm({ onSubmit, isLoading }: TransactionForm

-
- - ( - - )} - /> -
-