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:
@@ -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 på 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 på extraktionen ({Math.round(convertItem.confidence! * 100)}%). Gå 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"> </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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.'
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user