feat: invoice-inbox polish + SIE source voucher traceability (#299)

* fix: consolidate commit_journal_entry to single 4-arg signature

Replaces the phantom-overload drop migration with an idempotent consolidation
that leaves only the 4-arg-with-defaults signature, callable with either 2 or
4 named args. Fixes the "Could not choose the best candidate function"
ambiguity caused when the commit-metadata migration CREATE OR REPLACE'd a
4-arg version alongside the existing 2-arg one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: preserve SIE source voucher identity on journal entries

Adds source_voucher_series / source_voucher_number columns to journal_entries
so per-verifikat traceability survives the importer's skip-empty-voucher
logic. The SIE importer populates the original series/number even when
skipped vouchers cause gnubok's target numbering to drift from the source
file's sequence. Required for BFNAR 2013:2 kap 8 behandlingshistorik.

- Migration adds columns + partial index + extends immutability trigger
- importVouchers() records rawSeries/rawNumber per voucher
- JournalEntry type + test fixtures gain the new fields
- Bookkeeping detail page surfaces "Ursprungligt verifikat" when present

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: polish invoice-inbox workspace for production use

- Bedrock image fit: shrink images > 5 MB via sharp before Bedrock upload
  so HEIC/high-res phone photos don't fail with the 5 MB cap
- Swedish error mapping: toSwedishInboxError translates Bedrock /
  infrastructure errors to Swedish sentences stored in error_message
- History timeline endpoint (GET /items/:id/history) returns the
  processing_history events correlated to the inbox item
- Workspace UI: inline diagnostic timeline inside the convert dialog,
  same-email row grouping ("+N dokument" chip), inferred-VAT affordance
  with "needs review" signalling, Riksbanken exchange-rate prefill for
  foreign-currency invoices so the supplier-invoice create path populates
  *_sek audit columns

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: extend inbox-smart-match to supplier invoices

Both receipts and supplier invoices expose structurally identical match
anchors (date, amount, currency, counterparty name) so the matcher can
reuse the same narrowing + LLM prompt. Adds getMatchAnchors() as a shared
extractor across ReceiptExtractionResult / InvoiceExtractionResult, and
updates the event handlers to process supplier_invoice items alongside
receipts. LLM prompt re-phrased as "dokument" rather than "kvitto" and
loosened the date-window heuristic since invoice payments can lag behind
the invoice date by weeks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: drop unused category selector from TransactionForm

The manual "Lägg till transaktion" dialog predates the current categorization
flow (SwipeCategorizationView, BatchCategorySelector, AI suggestions). The
category dropdown here never drove journal-entry creation — onSubmit fanned
it out to CreateTransactionInput.category, which is optional. Removes the
dropdown, the unused watch() hook, and the categories lookup table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(migrations): restore drop-phantom file and rebump timestamps

Supabase branch DB failed with PK violation on schema_migrations because
my two migrations collided with timestamps already on main:
  20260421120000 → journal_entries_with_related_rpc (PR #298)
  20260421130000 → drop_legacy_supplier_invoice_user_id_uniqueness (PR #296)

Rebumped to 20260421140000 and 20260421150000 so each migration has a
unique version (Supabase uses only the 14-digit prefix as the PK).

Also restored the 20260420130000_drop_phantom_commit_journal_entry_overload
migration I had deleted — CLAUDE.md rule #5 forbids modifying existing
migrations. My consolidate migration is still compatible: drop_phantom
drops the 4-arg overload (no-op where absent), then consolidate recreates
it with defaults.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(inbox-smart-match): anchor invoices on dueDate with wider window

The original ±7d window around invoiceDate filtered out all real payments
for invoices with standard 30–60 day terms — the matcher would see zero
candidates before the LLM was called, making the supplier-invoice matcher
effectively dead.

New anchor selection:
- Receipts: receipt date ±7 days (unchanged; paid on the spot)
- Invoices with dueDate: dueDate ±14 days (covers early/late payments)
- Invoices without dueDate: invoiceDate -7/+45 days (covers 30-day terms)

MatchAnchors now carries windowDaysBefore/After so the window can vary per
document shape. Added three getMatchAnchors tests asserting window sizes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-04-21 14:33:38 +02:00
committed by GitHub
parent 129664533c
commit b5df2fb292
17 changed files with 832 additions and 123 deletions
@@ -230,6 +230,14 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
<span className="text-muted-foreground">Typ</span>
<span>{sourceTypeLabels[entry.source_type] || entry.source_type}</span>
</div>
{entry.source_voucher_series && entry.source_voucher_number != null && (
<div className="flex justify-between">
<span className="text-muted-foreground">Ursprungligt verifikat</span>
<span className="font-mono tabular-nums">
{entry.source_voucher_series}{entry.source_voucher_number}
</span>
</div>
)}
{/* Notes — always editable (internal metadata, not BFL verifikation content) */}
<div className="border-t pt-2 mt-2">
<div className="flex items-center justify-between mb-1">
@@ -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, unknown>): 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<number | null>(null)
const [exchangeRateDate, setExchangeRateDate] = useState<string | null>(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<Array<{
event_id: string
event_type: string
occurred_at: string
payload: Record<string, unknown> | 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<string, InboxItem[]>()
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) {
/>
) : (
<div className="overflow-hidden rounded-lg border bg-card divide-y divide-border/60">
{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' && (
<Mail className="h-3 w-3 text-muted-foreground shrink-0" aria-label="Från e-post" />
)}
{hiddenCount > 0 && (
<span
className="inline-flex items-center gap-1 rounded-full bg-muted/80 px-2 py-0.5 text-[10px] font-medium text-muted-foreground shrink-0"
title="Fler bilagor från samma e-post"
>
+{hiddenCount} dokument
</span>
)}
</div>
<div className="flex items-baseline gap-2 shrink-0">
{amount != null && (
@@ -934,6 +1155,64 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
</div>
)}
{/* 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 (
<div className="flex items-start gap-2.5 rounded-md border border-amber-500/25 bg-amber-500/5 p-3">
<Info className="h-4 w-4 text-amber-700 dark:text-amber-400 shrink-0 mt-0.5" />
<div className="space-y-1.5 text-xs">
<p className="font-medium text-amber-900 dark:text-amber-200">
AI är osäker summan av raderna stämmer inte med totalen
</p>
<div className="space-y-0.5 text-amber-900/80 dark:text-amber-200/80 tabular-nums leading-relaxed">
<p>
Summa rader: <span className="font-medium">{formatCurrency(recon.sumOfLines, currency)}</span>
</p>
<p>
Dokumentets nettosumma: <span className="font-medium">{formatCurrency(recon.anchor, currency)}</span>
</p>
<p>
Differens: <span className="font-medium">{formatCurrency(recon.delta, currency)}</span>
{' '}(tillåten avvikelse {formatCurrency(recon.tolerance, currency)})
</p>
</div>
<p className="text-amber-900/80 dark:text-amber-200/80 leading-relaxed pt-0.5">
En rad kan saknas, dubblerats, eller haft fel tecken rabatten. Kontrollera raderna nedan.
</p>
</div>
</div>
)
}
return (
<div className="flex items-start gap-2.5 rounded-md border border-amber-500/25 bg-amber-500/5 p-3 text-xs">
<Info className="h-4 w-4 text-amber-700 dark:text-amber-400 shrink-0 mt-0.5" />
<p className="text-amber-900 dark:text-amber-200 leading-relaxed">
AI är osäker extraktionen ({Math.round(convertItem.confidence! * 100)}%). igenom fälten innan du bokför.
</p>
</div>
)
})()}
{/* Foreign-supplier hint — informational only, never auto-overrides VAT */}
{convertForm.currency && convertForm.currency !== 'SEK' && (
<div className="flex items-start gap-2.5 rounded-md border border-blue-500/20 bg-blue-500/5 p-3 text-sm">
<Globe className="h-4 w-4 text-blue-600 dark:text-blue-400 shrink-0 mt-0.5" />
<div className="space-y-1 text-xs">
<p className="font-medium text-blue-900 dark:text-blue-200">
Utländsk leverantör ({convertForm.currency}
{convertItem.extracted_data?.supplier?.address ? ` · ${convertItem.extracted_data.supplier.address}` : ''})
</p>
<p className="text-blue-900/80 dark:text-blue-200/80 leading-relaxed">
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.
</p>
</div>
</div>
)}
{/* Supplier selector */}
<div className="space-y-2">
<Label>Leverantör *</Label>
@@ -1060,7 +1339,9 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
value={String(lineItem.vat_rate)}
onValueChange={(v) => updateLineItem(index, 'vat_rate', parseFloat(v))}
>
<SelectTrigger>
<SelectTrigger
className={lineItem.vat_inferred ? 'border-amber-500/40 bg-amber-500/5' : ''}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -1069,6 +1350,9 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
))}
</SelectContent>
</Select>
{lineItem.vat_inferred && (
<p className="text-[10px] text-amber-700 dark:text-amber-400 leading-tight">Uppskattad kontrollera</p>
)}
</div>
<div className="col-span-1 space-y-1">
{index === 0 && <Label className="text-xs text-muted-foreground">&nbsp;</Label>}
@@ -1087,8 +1371,57 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
<div className="text-right">
<p className="text-xs text-muted-foreground">Totalt inkl. moms</p>
<p className="text-lg font-semibold tabular-nums">{formatCurrency(formTotal, convertForm.currency)}</p>
{convertForm.currency && convertForm.currency !== 'SEK' && exchangeRate && (
<p className="text-xs text-muted-foreground tabular-nums mt-0.5">
{formatCurrency(Math.round(formTotal * exchangeRate * 100) / 100, 'SEK')}
<span className="ml-1 opacity-70">
({exchangeRate.toFixed(4)}
{exchangeRateDate ? ` · ${exchangeRateDate}` : ''})
</span>
</p>
)}
</div>
</div>
{/* Processing history timeline (behandlingshistorik) */}
{historyEvents && historyEvents.length > 0 && (
<div className="border-t pt-3">
<button
type="button"
onClick={() => setHistoryOpen((v) => !v)}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<Sparkles className="h-3 w-3" />
<span>Behandlingshistorik ({historyEvents.length})</span>
<span className="ml-1">{historyOpen ? '▾' : '▸'}</span>
</button>
{historyOpen && (
<ul className="mt-2 space-y-1 text-xs font-mono">
{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 (
<li key={evt.event_id} className="flex items-baseline gap-2 text-muted-foreground">
<span className="tabular-nums opacity-60 w-14 shrink-0">
{i === 0 ? 'start' : `+${delta.toFixed(2)}s`}
</span>
<span className="text-foreground shrink-0 font-medium">{evt.event_type}</span>
{summary && <span className="truncate">{summary}</span>}
</li>
)
})}
</ul>
)}
</div>
)}
</div>
)}
+1 -49
View File
@@ -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<FormData>({
@@ -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
</p>
</div>
<div className="space-y-2">
<Label>Kategori (valfritt)</Label>
<Controller
name="category"
control={control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Välj kategori" />
</SelectTrigger>
<SelectContent>
{categories.map((category) => (
<SelectItem key={category.value} value={category.value}>
{category.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="notes">Anteckningar</Label>
<Textarea
@@ -1,7 +1,10 @@
import { describe, it, expect } from 'vitest'
import { fetchCandidateTransactions } from '@/extensions/general/inbox-smart-match/lib/fetch-candidates'
import {
fetchCandidateTransactions,
getMatchAnchors,
} from '@/extensions/general/inbox-smart-match/lib/fetch-candidates'
import { createQueuedMockSupabase } from '@/tests/helpers'
import type { ReceiptExtractionResult } from '@/types'
import type { InvoiceExtractionResult, ReceiptExtractionResult } from '@/types'
function makeReceipt(overrides?: Partial<ReceiptExtractionResult>): ReceiptExtractionResult {
return {
@@ -74,6 +77,46 @@ describe('fetchCandidateTransactions', () => {
expect(ids).toContain('t3')
})
it('anchors invoices on dueDate with a ±14d window', () => {
const invoice: InvoiceExtractionResult = {
supplier: { name: 'Acme AB', orgNumber: null, vatNumber: null, address: null, bankgiro: null, plusgiro: null },
invoice: { invoiceNumber: 'INV-1', invoiceDate: '2026-03-01', dueDate: '2026-03-31', paymentReference: null, currency: 'SEK' },
lineItems: [],
totals: { subtotal: 800, vatAmount: 200, total: 1000 },
vatBreakdown: [],
confidence: 0.9,
}
const anchors = getMatchAnchors(invoice)
expect(anchors).not.toBeNull()
expect(anchors!.date).toBe('2026-03-31')
expect(anchors!.windowDaysBefore).toBe(14)
expect(anchors!.windowDaysAfter).toBe(14)
})
it('anchors invoices without dueDate on invoiceDate with a -7/+45 day window', () => {
const invoice: InvoiceExtractionResult = {
supplier: { name: 'Acme AB', orgNumber: null, vatNumber: null, address: null, bankgiro: null, plusgiro: null },
invoice: { invoiceNumber: 'INV-1', invoiceDate: '2026-03-01', dueDate: null, paymentReference: null, currency: 'SEK' },
lineItems: [],
totals: { subtotal: 800, vatAmount: 200, total: 1000 },
vatBreakdown: [],
confidence: 0.9,
}
const anchors = getMatchAnchors(invoice)
expect(anchors).not.toBeNull()
expect(anchors!.date).toBe('2026-03-01')
expect(anchors!.windowDaysBefore).toBe(7)
expect(anchors!.windowDaysAfter).toBe(45)
})
it('anchors receipts on receipt date with a ±7d window', () => {
const anchors = getMatchAnchors(makeReceipt())
expect(anchors).not.toBeNull()
expect(anchors!.date).toBe('2026-04-15')
expect(anchors!.windowDaysBefore).toBe(7)
expect(anchors!.windowDaysAfter).toBe(7)
})
it('uses amount_sek when receipt is foreign currency', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [] }) // no already-matched
@@ -30,8 +30,11 @@ export const inboxSmartMatchExtension: Extension = {
{
eventType: 'inbox_item.classified',
handler: async (payload: EventPayload<'inbox_item.classified'>) => {
// Only act on receipts for v1
if (payload.documentType !== 'receipt') return
// Match both receipts and supplier invoices — other document types
// (government letters, unknown) have nothing to match against.
if (payload.documentType !== 'receipt' && payload.documentType !== 'supplier_invoice') {
return
}
const supabase = getServiceSupabase()
@@ -64,14 +67,14 @@ export const inboxSmartMatchExtension: Extension = {
const supabase = getServiceSupabase()
// Find receipts in pending state for this company.
// Cap at 10 per sync so one big bank import doesn't time out the
// handler; leftover pending items pick up on the next sync.
// Find pending receipts/invoices for this company. Cap at 10 per sync
// so one big bank import doesn't time out the handler; leftover pending
// items pick up on the next sync.
const { data: pendingItems, error } = await supabase
.from('invoice_inbox_items')
.select('*')
.eq('company_id', payload.companyId)
.eq('document_type', 'receipt')
.in('document_type', ['receipt', 'supplier_invoice'])
.eq('status', 'ready')
.eq('match_method', 'pending_transaction')
.order('created_at', { ascending: false })
@@ -1,15 +1,20 @@
/**
* Candidate transaction fetcher — deterministic narrowing before the LLM call.
*
* Pulls unbooked expense transactions within ±7 days of the receipt date,
* ordered by how close their amount is to the receipt total. Limits to top 5
* Pulls unbooked expense transactions near the document's payment date,
* ordered by how close their amount is to the document total. Limits to top 5
* so the LLM has a focused candidate set and the token cost stays bounded.
*
* Anchor date selection:
* - Receipts: receipt date ±7 days (paid on the spot)
* - Invoices with dueDate: dueDate ±14 days (covers early and late payments)
* - Invoices without dueDate: invoiceDate, window shifted forward to cover
* standard 30-day terms (invoiceDate-7 .. invoiceDate+45)
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import type { ReceiptExtractionResult } from '@/types'
import type { InvoiceExtractionResult, ReceiptExtractionResult } from '@/types'
const DATE_WINDOW_DAYS = 7
const MAX_CANDIDATES = 5
export interface CandidateTransaction {
@@ -22,40 +27,80 @@ export interface CandidateTransaction {
merchant_name: string | null
}
/**
* Extract the reference date and absolute amount from a classified receipt's
* extracted data. Returns null if required fields are missing.
*/
function getReceiptMatchAnchors(
extracted: ReceiptExtractionResult | null
): { date: string; amount: number; currency: string } | null {
if (!extracted) return null
const date = extracted.receipt?.date ?? null
const amount = extracted.totals?.total ?? null
const currency = extracted.receipt?.currency ?? 'SEK'
if (!date || amount == null || amount <= 0) return null
return { date, amount, currency }
export type ExtractedDocument = ReceiptExtractionResult | InvoiceExtractionResult
export interface MatchAnchors {
date: string
amount: number
currency: string
counterpartyName: string | null
windowDaysBefore: number
windowDaysAfter: number
}
function isInvoiceExtraction(e: ExtractedDocument): e is InvoiceExtractionResult {
return 'invoice' in e && typeof (e as InvoiceExtractionResult).invoice === 'object'
}
/**
* Fetch up to MAX_CANDIDATES unbooked expense transactions near the receipt's
* Extract the reference date and absolute amount from a classified document's
* extracted data. Returns null if required fields are missing.
*/
export function getMatchAnchors(extracted: ExtractedDocument | null): MatchAnchors | null {
if (!extracted) return null
let date: string | null
let currency: string
let counterpartyName: string | null
let windowDaysBefore: number
let windowDaysAfter: number
if (isInvoiceExtraction(extracted)) {
const dueDate = extracted.invoice?.dueDate ?? null
const invoiceDate = extracted.invoice?.invoiceDate ?? null
if (dueDate) {
date = dueDate
windowDaysBefore = 14
windowDaysAfter = 14
} else {
date = invoiceDate
windowDaysBefore = 7
windowDaysAfter = 45
}
currency = extracted.invoice?.currency ?? 'SEK'
counterpartyName = extracted.supplier?.name ?? null
} else {
date = extracted.receipt?.date ?? null
currency = extracted.receipt?.currency ?? 'SEK'
counterpartyName = extracted.merchant?.name ?? null
windowDaysBefore = 7
windowDaysAfter = 7
}
const amount = extracted.totals?.total ?? null
if (!date || amount == null || amount <= 0) return null
return { date, amount, currency, counterpartyName, windowDaysBefore, windowDaysAfter }
}
/**
* Fetch up to MAX_CANDIDATES unbooked expense transactions near the document's
* date + amount. Ordering prefers exact amount matches first.
*/
export async function fetchCandidateTransactions(
supabase: SupabaseClient,
companyId: string,
extracted: ReceiptExtractionResult | null
extracted: ExtractedDocument | null
): Promise<CandidateTransaction[]> {
const anchors = getReceiptMatchAnchors(extracted)
const anchors = getMatchAnchors(extracted)
if (!anchors) return []
const receiptDate = new Date(anchors.date)
if (isNaN(receiptDate.getTime())) return []
const anchorDate = new Date(anchors.date)
if (isNaN(anchorDate.getTime())) return []
const windowStart = new Date(receiptDate)
windowStart.setUTCDate(windowStart.getUTCDate() - DATE_WINDOW_DAYS)
const windowEnd = new Date(receiptDate)
windowEnd.setUTCDate(windowEnd.getUTCDate() + DATE_WINDOW_DAYS)
const windowStart = new Date(anchorDate)
windowStart.setUTCDate(windowStart.getUTCDate() - anchors.windowDaysBefore)
const windowEnd = new Date(anchorDate)
windowEnd.setUTCDate(windowEnd.getUTCDate() + anchors.windowDaysAfter)
// Exclude transactions already claimed by any other inbox item in this
// company. The partial unique index on (company_id, matched_transaction_id)
@@ -99,14 +144,14 @@ export async function fetchCandidateTransactions(
: data
if (filtered.length === 0) return []
// Rank candidates by amount proximity. For SEK receipts we compare directly,
// for other currencies we prefer amount_sek if the receipt amount has been converted.
const receiptAbs = Math.abs(anchors.amount)
// Rank candidates by amount proximity. For SEK documents we compare directly,
// for other currencies we prefer amount_sek if the document amount has been converted.
const anchorAbs = Math.abs(anchors.amount)
const scored = filtered.map((tx) => {
const txAmount = Math.abs(Number(tx.amount) || 0)
const txSek = tx.amount_sek == null ? null : Math.abs(Number(tx.amount_sek))
const primaryDiff = Math.abs(txAmount - receiptAbs)
const sekDiff = txSek == null ? Infinity : Math.abs(txSek - receiptAbs)
const primaryDiff = Math.abs(txAmount - anchorAbs)
const sekDiff = txSek == null ? Infinity : Math.abs(txSek - anchorAbs)
const bestDiff = Math.min(primaryDiff, sekDiff)
return { tx, diff: bestDiff }
})
@@ -12,8 +12,8 @@ import {
type Message,
type ToolConfiguration,
} from '@aws-sdk/client-bedrock-runtime'
import type { ReceiptExtractionResult } from '@/types'
import type { CandidateTransaction } from './fetch-candidates'
import type { CandidateTransaction, ExtractedDocument } from './fetch-candidates'
import { getMatchAnchors } from './fetch-candidates'
export interface ReceiptMatchResult {
matched: boolean
@@ -38,19 +38,18 @@ function getClient(): BedrockRuntimeClient {
return _client
}
const SYSTEM_PROMPT = `Du är en expert på att matcha svenska kvitton mot banktransaktioner.
const SYSTEM_PROMPT = `Du är en expert på att matcha svenska bokföringsdokument (kvitton, fakturor) mot banktransaktioner.
Du får:
- Kvittodata (handlare, belopp, valuta, datum) från AI-extraktion
- En lista med kandidat-banktransaktioner (id, beskrivning, belopp, valuta, datum, MCC)
- Dokumentdata (handlare/leverantör, belopp, valuta, datum) från AI-extraktion
- En lista med kandidat-banktransaktioner (id, beskrivning, belopp, valuta, datum)
Uppgift: identifiera vilken (om någon) banktransaktion som motsvarar kvittot.
Uppgift: identifiera vilken (om någon) banktransaktion som motsvarar dokumentet.
Resonera utifrån:
- Belopp: bör vara identiskt eller mycket nära (ta hänsyn till valutaväxling om olika valutor)
- Datum: banktransaktion bokförs ofta 0-3 dagar efter kvittot
- Handlare: bankens beskrivning är ofta förkortad/versaler ("WILLYS SÖDERM" = "Willys Hemma Södermalm"). Matcha semantiskt, inte bokstavligt
- MCC-koder kan bekräfta branschtyp
- Datum: för kvitton bokförs banktransaktionen ofta 0-3 dagar efter köpet; för leverantörsfakturor kan betalningen ske flera dagar till veckor efter fakturadatum
- Handlare/leverantör: bankens beskrivning är ofta förkortad/versaler ("WILLYS SÖDERM" = "Willys Hemma Södermalm"). Matcha semantiskt, inte bokstavligt
Om inget förslag är trovärdigt — returnera matched=false.
Anropa ALLTID verktyget match_receipt med resultatet.
@@ -95,7 +94,7 @@ const MATCH_TOOL: ToolConfiguration = {
}
export interface MatchReceiptInput {
extracted: ReceiptExtractionResult
extracted: ExtractedDocument
candidates: CandidateTransaction[]
}
@@ -107,11 +106,12 @@ export interface MatchReceiptInput {
export async function matchReceiptToCandidate(
input: MatchReceiptInput
): Promise<ReceiptMatchResult> {
const anchors = getMatchAnchors(input.extracted)
const receiptBrief = {
merchant: input.extracted.merchant?.name ?? null,
amount: input.extracted.totals?.total ?? null,
currency: input.extracted.receipt?.currency ?? 'SEK',
date: input.extracted.receipt?.date ?? null,
merchant: anchors?.counterpartyName ?? null,
amount: anchors?.amount ?? null,
currency: anchors?.currency ?? 'SEK',
date: anchors?.date ?? null,
vat_amount: input.extracted.totals?.vatAmount ?? null,
}
@@ -125,13 +125,13 @@ export async function matchReceiptToCandidate(
merchant_name: c.merchant_name,
}))
const userPrompt = `Kvitto:
const userPrompt = `Dokument:
${JSON.stringify(receiptBrief, null, 2)}
Kandidat-transaktioner:
${JSON.stringify(candidateLines, null, 2)}
Vilken transaktion matchar kvittot? Om ingen matchar, returnera matched=false.`
Vilken transaktion matchar dokumentet? Om ingen matchar, returnera matched=false.`
const messages: Message[] = [
{
@@ -5,9 +5,9 @@
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import type { InvoiceInboxItem, ReceiptExtractionResult } from '@/types'
import type { InvoiceInboxItem } from '@/types'
import { appendProcessingHistory } from '@/lib/processing-history/append'
import { fetchCandidateTransactions } from './fetch-candidates'
import { fetchCandidateTransactions, type ExtractedDocument } from './fetch-candidates'
import { matchReceiptToCandidate } from './match-receipt'
export interface MatchContext {
@@ -36,8 +36,10 @@ export async function processInboxItemMatch(
): Promise<MatchOutcome> {
const tag = `[inbox-smart-match] item=${item.id} trigger=${ctx.triggerReason}`
// We only operate on receipts for v1
if (item.document_type !== 'receipt') {
// Match both receipts and supplier invoices — both have comparable anchors
// (date, amount, counterparty, currency) and the downstream LLM prompt is
// shape-agnostic.
if (item.document_type !== 'receipt' && item.document_type !== 'supplier_invoice') {
return { status: 'skipped', transactionId: null, confidence: 0, reasoning: '' }
}
if (item.status !== 'ready') {
@@ -62,7 +64,7 @@ export async function processInboxItemMatch(
}
}
const extracted = item.extracted_data as unknown as ReceiptExtractionResult
const extracted = item.extracted_data as unknown as ExtractedDocument
const candidates = await fetchCandidateTransactions(ctx.supabase, ctx.companyId, extracted)
// Append DeterministicMatch event — records that the narrowing ran
+43 -1
View File
@@ -16,6 +16,7 @@ import {
getActiveInbox,
composeInboxAddress,
} from './lib/inbox-provisioning'
import { toSwedishInboxError } from './lib/error-messages'
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
import { CreateSupplierInvoiceSchema } from '@/lib/api/schemas'
import { appendProcessingHistory } from '@/lib/processing-history/append'
@@ -97,7 +98,9 @@ async function uploadAndClassify(
fileName: file.name,
})
} catch (err) {
classificationError = err instanceof Error ? err.message : 'Classification failed'
// Keep the technical message in the server log; present Swedish to users.
console.error('[invoice-inbox/classify] Bedrock classify failed:', err)
classificationError = toSwedishInboxError(err)
}
// Audit: DocumentExtractionAttempted (fires whether classification succeeded or failed)
@@ -343,6 +346,7 @@ export const invoiceInboxExtension: Extension = {
.select(`
id, status, document_type, confidence, source, created_at, extracted_data,
matched_supplier_id, document_id, email_from, email_subject, error_message,
resend_email_id,
matched_transaction_id, match_confidence, match_method, match_reasoning,
matched_transaction:transactions!matched_transaction_id(id, description, amount, currency, date)
`)
@@ -360,6 +364,44 @@ export const invoiceInboxExtension: Extension = {
},
},
// ── Get processing_history timeline for an inbox item ───
{
method: 'GET',
path: '/items/:id/history',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const url = new URL(request.url)
const id = url.searchParams.get('_id')
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 })
// Resolve correlation_id via the inbox item (also enforces company scope)
const { data: item } = await ctx.supabase
.from('invoice_inbox_items')
.select('id, correlation_id, company_id')
.eq('id', id)
.eq('company_id', ctx.companyId)
.maybeSingle()
if (!item) return NextResponse.json({ error: 'Not found' }, { status: 404 })
if (!item.correlation_id) {
// Legacy rows created before the correlation_id column have no history
return NextResponse.json({ data: { events: [] } })
}
const { data: events, error } = await ctx.supabase
.from('processing_history')
.select('event_id, event_type, occurred_at, payload, actor, causation_id')
.eq('company_id', ctx.companyId)
.eq('correlation_id', item.correlation_id)
.order('occurred_at', { ascending: true })
.limit(100)
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ data: { events: events ?? [] } })
},
},
// ── Get single inbox item ───────────────────────────────
{
method: 'GET',
@@ -68,6 +68,45 @@ const MIME_TO_IMAGE_FORMAT: Record<string, string> = {
'image/gif': 'gif',
}
// Bedrock rejects image bytes > 5 MB. Keep headroom under that ceiling.
const BEDROCK_IMAGE_BYTE_LIMIT = 4_500_000
// Shrink an image until it fits Bedrock's 5 MB cap. Steps down the longest edge
// and JPEG quality in sequence — preserves legibility of receipt text while
// guaranteeing we stay under the limit (or throwing if a photo is so dense it
// can't be compressed enough, which in practice never happens below 500px).
async function fitImageForBedrock(
buffer: Buffer,
mimeType: string
): Promise<{ buffer: Buffer; format: 'jpeg' | 'png' | 'webp' | 'gif' }> {
const originalFormat = MIME_TO_IMAGE_FORMAT[mimeType] as 'jpeg' | 'png' | 'webp' | 'gif'
if (buffer.byteLength <= BEDROCK_IMAGE_BYTE_LIMIT) {
return { buffer, format: originalFormat }
}
// Re-encode to JPEG while shrinking. PNG at receipt-scale is usually 3-5×
// larger than an equivalent JPEG, so JPEG is the right target format even
// for PNG input.
const dimensionSteps = [2400, 1800, 1400, 1000, 800]
const qualitySteps = [85, 75, 60]
for (const maxDim of dimensionSteps) {
for (const quality of qualitySteps) {
const candidate = await sharp(buffer)
.rotate() // respect EXIF orientation
.resize({ width: maxDim, height: maxDim, fit: 'inside', withoutEnlargement: true })
.jpeg({ quality, mozjpeg: true })
.toBuffer()
if (candidate.byteLength <= BEDROCK_IMAGE_BYTE_LIMIT) {
return { buffer: candidate, format: 'jpeg' }
}
}
}
throw new Error('Bilden kunde inte komprimeras tillräckligt för AI-tolkning.')
}
// ── System prompt ────────────────────────────────────────────
const SYSTEM_PROMPT = `Du är en svensk bokföringsdokumentklassificerare och dataextraktor.
@@ -260,24 +299,26 @@ async function buildContentBlock(input: ClassificationInput): Promise<ContentBlo
}
}
// HEIC → convert to JPEG via sharp
// HEIC → convert to JPEG via sharp, then fit to Bedrock's byte limit
if (mimeType === 'image/heic' || mimeType === 'image/heif') {
const jpegBuffer = await sharp(fileBuffer).jpeg({ quality: 90 }).toBuffer()
const jpegBuffer = await sharp(fileBuffer).rotate().jpeg({ quality: 90, mozjpeg: true }).toBuffer()
const fitted = await fitImageForBedrock(jpegBuffer, 'image/jpeg')
return {
image: {
format: 'jpeg',
source: { bytes: new Uint8Array(jpegBuffer) },
format: fitted.format,
source: { bytes: new Uint8Array(fitted.buffer) },
},
}
}
// Standard image formats
// Standard image formats — downscale if the buffer exceeds Bedrock's 5 MB cap
const imageFormat = MIME_TO_IMAGE_FORMAT[mimeType]
if (imageFormat) {
const fitted = await fitImageForBedrock(fileBuffer, mimeType)
return {
image: {
format: imageFormat as 'jpeg' | 'png' | 'webp' | 'gif',
source: { bytes: new Uint8Array(fileBuffer) },
format: fitted.format,
source: { bytes: new Uint8Array(fitted.buffer) },
},
}
}
@@ -0,0 +1,45 @@
/**
* Maps raw AWS Bedrock / infrastructure errors to Swedish user-facing sentences
* for the invoice-inbox error_message column. We keep this local to the
* extension rather than in lib/errors so the patterns can evolve with the
* Bedrock SDK without churning the shared helper.
*/
const PATTERNS: Array<[RegExp, (match: RegExpMatchArray) => string]> = [
[
/image exceeds 5 MB maximum: (\d+) bytes/i,
(m) => {
const mb = (Number(m[1]) / 1024 / 1024).toFixed(1)
return `Bilden är för stor för AI-tolkning (${mb} MB, max 5 MB). Skicka ett mindre foto eller en PDF.`
},
],
[
/image exceeds .+ maximum/i,
() => 'Bilden är för stor för AI-tolkning. Skicka ett mindre foto eller en PDF.',
],
[/ThrottlingException|TooManyRequestsException|Rate exceeded/i, () => 'AI-tjänsten är överbelastad just nu. Försök igen om en stund.'],
[/AccessDeniedException/i, () => 'Åtkomst till AI-tjänsten nekades. Kontakta support.'],
[/ValidationException.+modelId/i, () => 'AI-modellen är felkonfigurerad. Kontakta support.'],
[/InternalServerException|ServiceUnavailable/i, () => 'AI-tjänsten är tillfälligt otillgänglig. Försök igen om en stund.'],
[/Unsupported MIME type: (.+)/i, (m) => `Filformatet stöds inte (${m[1]}). Använd PDF, JPEG, PNG, HEIC eller WebP.`],
[/No content in Bedrock response|No tool use result in Bedrock response/i, () => 'AI-tjänsten svarade inte med strukturerad data. Försök igen.'],
[/Failed to fetch received email/i, () => 'Kunde inte hämta e-postmeddelandet från inkorgstjänsten. Försök igen.'],
[/Failed to fetch attachment|Download URL returned/i, () => 'Kunde inte ladda ner bilagan från inkorgstjänsten.'],
]
export function toSwedishInboxError(raw: unknown): string {
const message = raw instanceof Error ? raw.message : typeof raw === 'string' ? raw : 'Okänt fel'
for (const [pattern, build] of PATTERNS) {
const match = message.match(pattern)
if (match) return build(match)
}
// Preserve any message that's already Swedish (heuristic: contains å/ä/ö
// or a known Swedish word). Otherwise surface a generic fallback and log
// the technical detail through stderr rather than the user's screen.
if (/[åäö]|bild|faktura|inkorg|leverant/i.test(message)) {
return message
}
return 'Kunde inte bearbeta dokumentet. Försök igen eller kontakta support.'
}
+52
View File
@@ -707,4 +707,56 @@ describe('importVouchers — per-voucher series preservation', () => {
expect(bNumbers).toEqual([1, 2, 3])
expect(cNumbers).toEqual([1, 2])
})
it('preserves original source series/number on each imported entry, even across skipped vouchers', async () => {
const { supabase, journalEntryInserts } = buildCapturingSupabase()
// A2 is an empty voucher (no lines) — will be skipped. A1 and A3 survive.
// Gnubok assigns target numbers 1 and 2 (contiguous), but source_voucher_number
// must preserve the SIE originals (1 and 3) so traceability is not lost.
const parsed = makeParsedFile({
vouchers: [
makeVoucher('A', 1),
{ ...makeVoucher('A', 2), lines: [] },
makeVoucher('A', 3),
],
})
const result = await importVouchers(
supabase,
'company-1',
'user-1',
'period-1',
parsed,
baseMap,
'A',
)
expect(result.created).toBe(2)
expect(result.skippedEmpty).toBe(1)
expect(journalEntryInserts.map((r) => r.voucher_number)).toEqual([1, 2])
expect(journalEntryInserts.map((r) => r.source_voucher_series)).toEqual(['A', 'A'])
expect(journalEntryInserts.map((r) => r.source_voucher_number)).toEqual([1, 3])
})
it('stores NULL source series/number when the source voucher has no series (SIE4I subsystem import)', async () => {
const { supabase, journalEntryInserts } = buildCapturingSupabase()
const parsed = makeParsedFile({
vouchers: [
{ ...makeVoucher('', 1) },
],
})
await importVouchers(
supabase,
'company-1',
'user-1',
'period-1',
parsed,
baseMap,
'V',
)
expect(journalEntryInserts[0].source_voucher_series).toBeNull()
expect(journalEntryInserts[0].source_voucher_number).toBe(1)
})
})
+12
View File
@@ -540,6 +540,11 @@ export async function importVouchers(
series: string
date: string
description: string
// Original series/number as written in the source SIE file. NULL for SIE4I
// subsystem imports where series/verno are optional. Stored per-entry for
// traceability alongside the aggregate sie_imports.migration_documentation.
sourceSeries: string | null
sourceNumber: number | null
lines: { account_number: string; debit_amount: number; credit_amount: number; line_description: string | null }[]
}
@@ -673,11 +678,16 @@ export async function importVouchers(
? voucher.series.trim()
: defaultSeries
const rawSourceSeries = voucher.series && voucher.series.trim() ? voucher.series.trim() : null
const rawSourceNumber = Number.isFinite(voucher.number) ? voucher.number : null
preparedVouchers.push({
sourceId: voucherId,
series: resolvedSeries,
date: formatDate(voucher.date),
description: voucher.description || `Import: ${voucher.series}${voucher.number}`,
sourceSeries: rawSourceSeries,
sourceNumber: rawSourceNumber,
lines,
})
}
@@ -775,6 +785,8 @@ export async function importVouchers(
entry_date: v.date,
description: v.description,
source_type: 'import',
source_voucher_series: v.sourceSeries,
source_voucher_number: v.sourceNumber,
status: 'posted',
committed_at: new Date().toISOString(),
}))
@@ -0,0 +1,67 @@
-- Consolidate commit_journal_entry to a single 4-argument signature with defaults.
--
-- History:
-- - 20260402100200 created the canonical 2-arg (p_company_id, p_entry_id) version.
-- - journal_entry_commit_metadata added commit_method + rubric_version columns and
-- created a 4-arg overload via CREATE OR REPLACE; because the signature differs
-- from the 2-arg, both versions coexisted in prod, producing the
-- "Could not choose the best candidate function" ambiguity error on 2-arg calls.
--
-- Final state after this migration: only the 4-arg-with-defaults signature remains.
-- Callable with either 2 or 4 named args (defaults fill in the rest), so both the
-- currently-deployed 2-arg caller and the post-commit-metadata 4-arg caller work.
--
-- Idempotent: safe on any of the possible prior states (both, 2-arg only,
-- or 4-arg only). Columns commit_method / rubric_version are assumed to exist
-- (created earlier in the journal_entry_commit_metadata migration).
DROP FUNCTION IF EXISTS public.commit_journal_entry(uuid, uuid);
CREATE OR REPLACE FUNCTION public.commit_journal_entry(
p_company_id uuid,
p_entry_id uuid,
p_commit_method text DEFAULT NULL,
p_rubric_version text DEFAULT NULL
)
RETURNS TABLE (voucher_number integer)
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_next integer;
v_fiscal_period_id uuid;
v_series text;
BEGIN
SELECT je.fiscal_period_id, COALESCE(je.voucher_series, 'A')
INTO v_fiscal_period_id, v_series
FROM public.journal_entries je
WHERE je.id = p_entry_id
AND je.company_id = p_company_id
AND je.status = 'draft'
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'Draft journal entry not found: %', p_entry_id;
END IF;
INSERT INTO public.voucher_sequences (company_id, user_id, fiscal_period_id, voucher_series, last_number)
VALUES (p_company_id, auth.uid(), v_fiscal_period_id, v_series, 1)
ON CONFLICT (company_id, fiscal_period_id, voucher_series)
DO UPDATE SET
last_number = public.voucher_sequences.last_number + 1,
updated_at = now()
RETURNING last_number INTO v_next;
UPDATE public.journal_entries
SET voucher_number = v_next,
status = 'posted',
commit_method = p_commit_method,
rubric_version = p_rubric_version
WHERE id = p_entry_id
AND company_id = p_company_id;
RETURN QUERY SELECT v_next;
END;
$$;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,60 @@
-- Persist the original voucher identity (series + number) from an SIE source file
-- on each journal entry, for per-verifikat traceability from source system → gnubok.
--
-- Context: when the SIE importer skips an empty/single-line/unbalanced voucher,
-- subsequent vouchers end up with gnubok numbers that drift from the source
-- numbers. Today the source→target mapping lives only on
-- sie_imports.migration_documentation (JSONB array), so an individual
-- verifikat has no way to expose its original SIE id for search or display.
--
-- BFNAR 2013:2 kap 8 behandlingshistorik: a migration must preserve auditable
-- traceability. These columns denormalize the mapping onto each entry for
-- per-verifikat lookup without altering the aggregate JSONB audit record.
--
-- Scope: populated only by SIE import bulk insert (source_type='import'). Left
-- NULL for opening-balance and reconciliation entries (no single source VER),
-- and for all non-SIE sources (manual, invoice, bank, etc.).
ALTER TABLE public.journal_entries
ADD COLUMN source_voucher_series TEXT,
ADD COLUMN source_voucher_number INTEGER;
-- Index supports "find imported entry by original SIE number" lookups.
CREATE INDEX idx_journal_entries_source_voucher
ON public.journal_entries (company_id, source_voucher_series, source_voucher_number)
WHERE source_voucher_series IS NOT NULL;
-- Extend the immutability trigger to cover the new columns — matches the
-- pattern applied to commit_method/rubric_version in 20260420120000.
CREATE OR REPLACE FUNCTION public.enforce_journal_entry_immutability()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
RAISE EXCEPTION 'Cannot delete journal entries (id: %, status: %). Use cancelled status instead.',
OLD.id, OLD.status;
END IF;
IF OLD.status = 'draft' AND NEW.status IN ('draft', 'posted', 'cancelled') THEN
RETURN NEW;
END IF;
IF OLD.status = 'posted' AND NEW.status IN ('reversed', 'cancelled') THEN
IF NEW.status = 'reversed' THEN
IF NEW.description != OLD.description OR NEW.entry_date != OLD.entry_date
OR NEW.fiscal_period_id != OLD.fiscal_period_id
OR NEW.voucher_number != OLD.voucher_number
OR NEW.commit_method IS DISTINCT FROM OLD.commit_method
OR NEW.rubric_version IS DISTINCT FROM OLD.rubric_version
OR NEW.source_voucher_series IS DISTINCT FROM OLD.source_voucher_series
OR NEW.source_voucher_number IS DISTINCT FROM OLD.source_voucher_number THEN
RAISE EXCEPTION 'Cannot modify fields of a posted entry during reversal (id: %)', OLD.id;
END IF;
END IF;
RETURN NEW;
END IF;
RAISE EXCEPTION 'Cannot modify a % journal entry (id: %). Committed entries are immutable per Bokforingslagen.',
OLD.status, OLD.id;
END; $$;
NOTIFY pgrst, 'reload schema';
+2
View File
@@ -233,6 +233,8 @@ export function makeJournalEntry(overrides: Partial<JournalEntry> = {}): Journal
description: 'Test entry',
source_type: 'manual',
source_id: null,
source_voucher_series: null,
source_voucher_number: null,
status: 'posted',
committed_at: '2024-06-15T14:30:00Z',
reversed_by_id: null,
+2
View File
@@ -978,6 +978,8 @@ export interface JournalEntry {
notes: string | null
commit_method: string | null
rubric_version: string | null
source_voucher_series: string | null
source_voucher_number: number | null
created_at: string
updated_at: string
// Relations