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 */}
Leverantör *
@@ -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 && (
+
+
setHistoryOpen((v) => !v)}
+ className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
+ >
+
+ Behandlingshistorik ({historyEvents.length})
+ {historyOpen ? '▾' : '▸'}
+
+ {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
-
- Kategori (valfritt)
- (
-
-
-
-
-
- {categories.map((category) => (
-
- {category.label}
-
- ))}
-
-
- )}
- />
-
-
Anteckningar