From b0890c7c79b230b8575f5d590301eb79a3b58ecb Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Fri, 15 May 2026 00:49:59 +0200 Subject: [PATCH] Add/docs skv mcp (#494) * feat: add "book directly" functionality for invoice inbox items - Extend JournalEntrySourceTypeSchema to include 'inbox_item'. - Introduce BookInboxItemDirectlySchema for direct journal entry creation. - Update InvoiceInboxItem type to include matched_transaction_id and created_journal_entry_id. - Implement BookDirectlyDialog component for user interaction. - Create API route for booking directly from inbox items with appropriate validations. - Add SQL migration to support new journal entry references in the invoice inbox items table. - Implement tests for the new booking functionality and ensure proper error handling. * fix(invoice-inbox): update status handling for resolved inbox items * feat: enforce unique journal entry constraint for invoice inbox items --- .../extensions/general/BookDirectlyDialog.tsx | 690 ++++++++++++++++++ .../general/InvoiceInboxWorkspace.tsx | 112 ++- .../__tests__/book-direct-route.test.ts | 250 +++++++ extensions/general/invoice-inbox/index.ts | 180 ++++- lib/api/schemas.ts | 10 + ...260514120000_invoice_inbox_book_direct.sql | 17 + ...000_invoice_inbox_unique_journal_entry.sql | 17 + tests/helpers.ts | 2 + types/index.ts | 3 + 9 files changed, 1271 insertions(+), 10 deletions(-) create mode 100644 components/extensions/general/BookDirectlyDialog.tsx create mode 100644 extensions/general/invoice-inbox/__tests__/book-direct-route.test.ts create mode 100644 supabase/migrations/20260514120000_invoice_inbox_book_direct.sql create mode 100644 supabase/migrations/20260515090000_invoice_inbox_unique_journal_entry.sql diff --git a/components/extensions/general/BookDirectlyDialog.tsx b/components/extensions/general/BookDirectlyDialog.tsx new file mode 100644 index 00000000..326e4414 --- /dev/null +++ b/components/extensions/general/BookDirectlyDialog.tsx @@ -0,0 +1,690 @@ +'use client' + +import { useState, useEffect, useMemo, useCallback } from 'react' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Switch } from '@/components/ui/switch' +import { Badge } from '@/components/ui/badge' +import { useToast } from '@/components/ui/use-toast' +import { Loader2, Plus, Trash2, AlertTriangle, Search, Check } from 'lucide-react' +import { cn, formatCurrency } from '@/lib/utils' +import AccountCombobox from '@/components/bookkeeping/AccountCombobox' +import type { BASAccount, FiscalPeriod, InvoiceExtractionResult } from '@/types' + +interface InboxItem { + id: string + document_id: string | null + matched_transaction_id: string | null + extracted_data: InvoiceExtractionResult | null +} + +interface PickerTransaction { + id: string + date: string + description: string + amount: number + currency: string | null +} + +interface FormLine { + account_number: string + debit_amount: string + credit_amount: string +} + +const BLANK_LINE: FormLine = { account_number: '', debit_amount: '', credit_amount: '' } + +interface Props { + open: boolean + onOpenChange: (v: boolean) => void + item: InboxItem + onSuccess: () => void | Promise +} + +// Compute the prefill lines. Booking is always in SEK (BFL/BFNAR), so when +// a transaction is selected and the document is in a foreign currency, the +// transaction's SEK amount is the canonical figure. The cost-account row +// stays blank — the user must pick a cost account themselves. +function buildPrefillLines( + item: InboxItem, + selectedTransactionAmount: number | null = null +): FormLine[] { + const docTotal = item.extracted_data?.totals?.total ?? null + const docVat = item.extracted_data?.totals?.vatAmount ?? null + const docCurrency = item.extracted_data?.invoice?.currency ?? 'SEK' + + // Prefer the transaction amount when available — it's already in SEK and + // matches the bank movement we'll be marking as booked. + const total = selectedTransactionAmount != null + ? Math.abs(selectedTransactionAmount) + : docTotal + + if (total == null || total <= 0) { + return [{ ...BLANK_LINE }, { ...BLANK_LINE }] + } + + const totalRounded = Math.round(total * 100) / 100 + + // VAT prefill rules: + // - Foreign-currency document → skip VAT (reverse charge is the common + // case; user can add it manually if needed). + // - SEK-denominated document with extracted VAT → split it out on 2641. + // - SEK without extracted VAT → leave VAT row out, single net row. + const useDocVat = + docCurrency === 'SEK' && + selectedTransactionAmount == null && + docVat != null && + docVat > 0 + const vatRounded = useDocVat ? Math.round((docVat ?? 0) * 100) / 100 : 0 + const net = Math.round((totalRounded - vatRounded) * 100) / 100 + + const lines: FormLine[] = [ + { + account_number: '', + debit_amount: String(net), + credit_amount: '', + }, + ] + if (vatRounded > 0) { + lines.push({ + account_number: '2641', + debit_amount: String(vatRounded), + credit_amount: '', + }) + } + lines.push({ + account_number: '1930', + debit_amount: '', + credit_amount: String(totalRounded), + }) + return lines +} + +function rankByAmount( + rows: PickerTransaction[], + target: number | null +): PickerTransaction[] { + if (target == null) return rows + const abs = Math.abs(target) + return [...rows].sort((a, b) => { + const da = Math.abs(Math.abs(a.amount) - abs) + const db = Math.abs(Math.abs(b.amount) - abs) + return da - db + }) +} + +export default function BookDirectlyDialog({ open, onOpenChange, item, onSuccess }: Props) { + const { toast } = useToast() + const [periods, setPeriods] = useState([]) + const [accounts, setAccounts] = useState([]) + const [entryDate, setEntryDate] = useState( + item.extracted_data?.invoice?.invoiceDate || new Date().toISOString().slice(0, 10) + ) + const [periodId, setPeriodId] = useState('') + const [description, setDescription] = useState(() => { + const supplier = item.extracted_data?.supplier?.name?.trim() || '' + const invoiceNum = item.extracted_data?.invoice?.invoiceNumber?.trim() || '' + return [supplier, invoiceNum].filter(Boolean).join(' · ') || 'Bokföring från inkorg' + }) + const [notes, setNotes] = useState('') + const [lines, setLines] = useState(() => buildPrefillLines(item)) + + // Transaction link state + const [linkToTransaction, setLinkToTransaction] = useState(!!item.matched_transaction_id) + const [selectedTransactionId, setSelectedTransactionId] = useState( + item.matched_transaction_id + ) + const [transactions, setTransactions] = useState([]) + const [isLoadingTransactions, setIsLoadingTransactions] = useState(false) + const [txSearch, setTxSearch] = useState('') + + const [isSubmitting, setIsSubmitting] = useState(false) + + // Reset state when a different item opens the dialog + useEffect(() => { + if (!open) return + setEntryDate(item.extracted_data?.invoice?.invoiceDate || new Date().toISOString().slice(0, 10)) + setLines(buildPrefillLines(item)) + setLinkToTransaction(!!item.matched_transaction_id) + setSelectedTransactionId(item.matched_transaction_id) + const supplier = item.extracted_data?.supplier?.name?.trim() || '' + const invoiceNum = item.extracted_data?.invoice?.invoiceNumber?.trim() || '' + setDescription([supplier, invoiceNum].filter(Boolean).join(' · ') || 'Bokföring från inkorg') + setNotes('') + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, item.id]) + + // When the user picks a transaction (or the toggle changes), re-derive + // the prefilled amounts so foreign-currency invoices follow the SEK + // figure on the actual bank movement. + const selectedTransactionAmount = useMemo(() => { + if (!linkToTransaction || !selectedTransactionId) return null + const tx = transactions.find((t) => t.id === selectedTransactionId) + return tx?.amount ?? null + }, [linkToTransaction, selectedTransactionId, transactions]) + + useEffect(() => { + if (!open) return + // Update amounts when the transaction selection changes, but preserve + // user-entered account numbers. This handles "user typed cost account, + // then picked an SEK-denominated transaction" — we want the SEK figure + // to flow into the line amounts without forgetting their account pick. + setLines((current) => { + const next = buildPrefillLines(item, selectedTransactionAmount) + return next.map((nl, i) => { + const existing = current[i] + if (!existing) return nl + return { + ...nl, + account_number: existing.account_number || nl.account_number, + } + }) + }) + }, [open, item, selectedTransactionAmount]) + + // Fetch fiscal periods and accounts on first open + useEffect(() => { + if (!open) return + let cancelled = false + ;(async () => { + try { + const [periodsRes, accountsRes] = await Promise.all([ + fetch('/api/bookkeeping/fiscal-periods'), + fetch('/api/bookkeeping/accounts'), + ]) + const periodsJson = await periodsRes.json() + const accountsJson = await accountsRes.json() + if (cancelled) return + setPeriods(periodsJson.data || []) + setAccounts(accountsJson.data || []) + } catch (err) { + console.error('[book-direct] fetch reference data failed:', err) + } + })() + return () => { cancelled = true } + }, [open]) + + // Auto-select fiscal period matching the entry date + useEffect(() => { + if (periods.length === 0) return + const match = periods.find( + (p) => entryDate >= p.period_start && entryDate <= p.period_end + ) + if (match) { + setPeriodId(match.id) + } else if (!periodId && periods.length > 0) { + setPeriodId(periods[0].id) + } + }, [entryDate, periods, periodId]) + + // Fetch unmatched transactions when the link toggle turns on + useEffect(() => { + if (!open || !linkToTransaction) return + let cancelled = false + setIsLoadingTransactions(true) + const targetAmount = item.extracted_data?.totals?.total ?? null + ;(async () => { + try { + const res = await fetch('/api/transactions?unmatched=true') + const json = await res.json() + if (cancelled) return + const rows: PickerTransaction[] = (Array.isArray(json.data) ? json.data : []) + .map((t: PickerTransaction) => ({ + id: t.id, + date: t.date, + description: t.description, + amount: t.amount, + currency: t.currency || 'SEK', + })) + setTransactions(rankByAmount(rows, targetAmount)) + } catch (err) { + console.error('[book-direct] fetch transactions failed:', err) + } finally { + if (!cancelled) setIsLoadingTransactions(false) + } + })() + return () => { cancelled = true } + }, [open, linkToTransaction, item.extracted_data?.totals?.total]) + + const filteredTransactions = useMemo(() => { + const term = txSearch.trim().toLowerCase() + if (!term) return transactions + return transactions.filter((t) => (t.description || '').toLowerCase().includes(term)) + }, [transactions, txSearch]) + + const totals = useMemo(() => { + const debit = lines.reduce((sum, l) => sum + (parseFloat(l.debit_amount) || 0), 0) + const credit = lines.reduce((sum, l) => sum + (parseFloat(l.credit_amount) || 0), 0) + const roundedDebit = Math.round(debit * 100) / 100 + const roundedCredit = Math.round(credit * 100) / 100 + return { + debit: roundedDebit, + credit: roundedCredit, + balanced: roundedDebit === roundedCredit && roundedDebit > 0, + diff: Math.round((roundedDebit - roundedCredit) * 100) / 100, + } + }, [lines]) + + const updateLine = useCallback((idx: number, patch: Partial) => { + setLines((prev) => prev.map((l, i) => (i === idx ? { ...l, ...patch } : l))) + }, []) + + const addLine = useCallback(() => { + setLines((prev) => [...prev, { ...BLANK_LINE }]) + }, []) + + const removeLine = useCallback((idx: number) => { + setLines((prev) => prev.length <= 2 ? prev : prev.filter((_, i) => i !== idx)) + }, []) + + const disabledReason = useMemo(() => { + if (isSubmitting) return null + if (!entryDate) return 'Välj datum' + if (!periodId) return 'Välj räkenskapsperiod' + if (description.trim().length === 0) return 'Fyll i beskrivning' + if (lines.some((l) => l.account_number.trim().length === 0)) return 'Alla rader behöver ett konto' + if (!totals.balanced) return 'Debet och kredit måste vara lika' + if (linkToTransaction && !selectedTransactionId) return 'Välj en banktransaktion att koppla till' + return null + }, [isSubmitting, entryDate, periodId, description, lines, totals.balanced, linkToTransaction, selectedTransactionId]) + + const canSubmit = !isSubmitting && disabledReason === null + + const handleSubmit = useCallback(async () => { + if (!canSubmit) return + setIsSubmitting(true) + try { + const payload = { + fiscal_period_id: periodId, + entry_date: entryDate, + description: description.trim(), + notes: notes.trim() || undefined, + lines: lines.map((l) => ({ + account_number: l.account_number.trim(), + debit_amount: parseFloat(l.debit_amount) || 0, + credit_amount: parseFloat(l.credit_amount) || 0, + })), + transaction_id: linkToTransaction ? selectedTransactionId ?? undefined : undefined, + } + const res = await fetch( + `/api/extensions/ext/invoice-inbox/items/${item.id}/book-direct`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + } + ) + const json = await res.json().catch(() => ({})) + if (!res.ok) { + toast({ + title: 'Kunde inte bokföra', + description: json.error || 'Försök igen.', + variant: 'destructive', + }) + return + } + const voucher = json?.data?.journal_entry + toast({ + title: 'Bokfört', + description: voucher + ? `Verifikation ${voucher.voucher_series}${voucher.voucher_number} skapad.` + : 'Verifikation skapad.', + }) + await onSuccess() + onOpenChange(false) + } finally { + setIsSubmitting(false) + } + }, [ + canSubmit, periodId, entryDate, description, notes, lines, + linkToTransaction, selectedTransactionId, item.id, toast, onSuccess, onOpenChange, + ]) + + const targetAmount = item.extracted_data?.totals?.total ?? null + const targetCurrency = item.extracted_data?.invoice?.currency ?? 'SEK' + + return ( + + + + Bokför direkt + + Skapa en verifikation från underlaget. Dokumentet bifogas verifikationen som underlag. + + + +
+ {/* Metadata row */} +
+
+ + setEntryDate(e.target.value)} + disabled={isSubmitting} + className="tabular-nums" + /> +
+
+ + +
+
+ +
+ + setDescription(e.target.value)} + disabled={isSubmitting} + placeholder="Leverantör · fakturanummer" + /> +
+ + {/* Transaction link toggle + picker */} +
+
+
+ +

+ Slå på om dokumentet motsvarar en redan-bokad bankhändelse. Annars + bokförs det som en fristående verifikation. +

+
+ +
+ {linkToTransaction && ( +
+
+ + setTxSearch(e.target.value)} + className="pl-10" + disabled={isSubmitting} + /> +
+
+ {isLoadingTransactions ? ( +
+ Laddar… +
+ ) : filteredTransactions.length === 0 ? ( +

+ Inga okategoriserade transaktioner. +

+ ) : ( +
    + {filteredTransactions.slice(0, 30).map((tx) => { + const isSelected = selectedTransactionId === tx.id + return ( +
  • + +
  • + ) + })} +
+ )} +
+
+ )} +
+ + {/* Journal entry lines */} +
+
+ +
+ {targetAmount != null && ( + + Underlag:{' '} + + {formatCurrency(targetAmount, targetCurrency)} + + + )} + {selectedTransactionAmount != null && ( + + {targetAmount != null && ' · '} + Transaktion:{' '} + + {formatCurrency(Math.abs(selectedTransactionAmount), 'SEK')} + + + )} +
+
+ {targetCurrency !== 'SEK' && selectedTransactionAmount != null && ( +

+ Underlaget är i {targetCurrency}. Bokföringen sker i SEK enligt + transaktionens belopp. Momsraden har lämnats bort — vid behov + lägg till en rad för omvänd skattskyldighet manuellt. +

+ )} +
+ + + + + + + + + + {lines.map((line, idx) => ( + + + + + + + ))} + + + + + + + + +
KontoDebetKredit +
+ updateLine(idx, { account_number: v })} + /> + + updateLine(idx, { debit_amount: e.target.value, credit_amount: e.target.value ? '' : line.credit_amount })} + disabled={isSubmitting} + className="text-right tabular-nums" + placeholder="0,00" + /> + + updateLine(idx, { credit_amount: e.target.value, debit_amount: e.target.value ? '' : line.debit_amount })} + disabled={isSubmitting} + className="text-right tabular-nums" + placeholder="0,00" + /> + + +
+ Summa + + {totals.debit.toFixed(2)} + + {totals.credit.toFixed(2)} + +
+
+
+ + {totals.balanced ? ( + + Balanserad + + ) : ( + + + Diff {totals.diff.toFixed(2)} + + )} +
+
+ +
+ +