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
This commit is contained in:
@@ -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<void>
|
||||
}
|
||||
|
||||
// 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<FiscalPeriod[]>([])
|
||||
const [accounts, setAccounts] = useState<BASAccount[]>([])
|
||||
const [entryDate, setEntryDate] = useState<string>(
|
||||
item.extracted_data?.invoice?.invoiceDate || new Date().toISOString().slice(0, 10)
|
||||
)
|
||||
const [periodId, setPeriodId] = useState<string>('')
|
||||
const [description, setDescription] = useState<string>(() => {
|
||||
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<string>('')
|
||||
const [lines, setLines] = useState<FormLine[]>(() => buildPrefillLines(item))
|
||||
|
||||
// Transaction link state
|
||||
const [linkToTransaction, setLinkToTransaction] = useState<boolean>(!!item.matched_transaction_id)
|
||||
const [selectedTransactionId, setSelectedTransactionId] = useState<string | null>(
|
||||
item.matched_transaction_id
|
||||
)
|
||||
const [transactions, setTransactions] = useState<PickerTransaction[]>([])
|
||||
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<FormLine>) => {
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Bokför direkt</DialogTitle>
|
||||
<DialogDescription>
|
||||
Skapa en verifikation från underlaget. Dokumentet bifogas verifikationen som underlag.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 pt-2">
|
||||
{/* Metadata row */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="bd-date">Datum</Label>
|
||||
<Input
|
||||
id="bd-date"
|
||||
type="date"
|
||||
value={entryDate}
|
||||
onChange={(e) => setEntryDate(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
className="tabular-nums"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5 md:col-span-2">
|
||||
<Label htmlFor="bd-period">Räkenskapsperiod</Label>
|
||||
<Select
|
||||
value={periodId}
|
||||
onValueChange={setPeriodId}
|
||||
disabled={isSubmitting || periods.length === 0}
|
||||
>
|
||||
<SelectTrigger id="bd-period">
|
||||
<SelectValue placeholder="Välj period" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{periods.map((p) => {
|
||||
const lockState = p.locked_at
|
||||
? 'låst'
|
||||
: p.is_closed
|
||||
? 'stängd'
|
||||
: null
|
||||
return (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.period_start} – {p.period_end}
|
||||
{lockState && ` (${lockState})`}
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="bd-description">Beskrivning</Label>
|
||||
<Input
|
||||
id="bd-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
placeholder="Leverantör · fakturanummer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Transaction link toggle + picker */}
|
||||
<div className="rounded-lg border p-4 space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="bd-link-tx" className="text-sm">
|
||||
Koppla till banktransaktion
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Slå på om dokumentet motsvarar en redan-bokad bankhändelse. Annars
|
||||
bokförs det som en fristående verifikation.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="bd-link-tx"
|
||||
checked={linkToTransaction}
|
||||
onCheckedChange={setLinkToTransaction}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
{linkToTransaction && (
|
||||
<div className="space-y-2 pt-2 border-t">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Sök på beskrivning…"
|
||||
value={txSearch}
|
||||
onChange={(e) => setTxSearch(e.target.value)}
|
||||
className="pl-10"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-56 overflow-y-auto rounded-md border">
|
||||
{isLoadingTransactions ? (
|
||||
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> Laddar…
|
||||
</div>
|
||||
) : filteredTransactions.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
Inga okategoriserade transaktioner.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{filteredTransactions.slice(0, 30).map((tx) => {
|
||||
const isSelected = selectedTransactionId === tx.id
|
||||
return (
|
||||
<li key={tx.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'w-full flex items-center justify-between gap-3 px-3 py-2.5 text-left text-sm transition-colors',
|
||||
isSelected
|
||||
? 'bg-primary/10 border-l-2 border-primary'
|
||||
: 'border-l-2 border-transparent hover:bg-accent/40'
|
||||
)}
|
||||
onClick={() => setSelectedTransactionId(tx.id)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<span className="shrink-0 w-4 flex items-center justify-center">
|
||||
{isSelected ? (
|
||||
<Check className="h-3.5 w-3.5 text-primary" />
|
||||
) : null}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate">{tx.description}</p>
|
||||
<p className="text-xs text-muted-foreground tabular-nums">{tx.date}</p>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'tabular-nums text-sm shrink-0',
|
||||
tx.amount < 0 ? 'text-destructive' : 'text-foreground'
|
||||
)}
|
||||
>
|
||||
{formatCurrency(tx.amount, tx.currency || 'SEK')}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Journal entry lines */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label className="text-sm">Konteringsrader</Label>
|
||||
<div className="text-xs text-muted-foreground text-right">
|
||||
{targetAmount != null && (
|
||||
<span>
|
||||
Underlag:{' '}
|
||||
<span className="tabular-nums font-medium text-foreground">
|
||||
{formatCurrency(targetAmount, targetCurrency)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{selectedTransactionAmount != null && (
|
||||
<span>
|
||||
{targetAmount != null && ' · '}
|
||||
Transaktion:{' '}
|
||||
<span className="tabular-nums font-medium text-foreground">
|
||||
{formatCurrency(Math.abs(selectedTransactionAmount), 'SEK')}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{targetCurrency !== 'SEK' && selectedTransactionAmount != null && (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
)}
|
||||
<div className="rounded-lg border overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/40">
|
||||
<tr className="text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||
<th className="text-left font-medium px-3 py-2 w-[40%]">Konto</th>
|
||||
<th className="text-right font-medium px-3 py-2">Debet</th>
|
||||
<th className="text-right font-medium px-3 py-2">Kredit</th>
|
||||
<th className="w-10" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{lines.map((line, idx) => (
|
||||
<tr key={idx}>
|
||||
<td className="px-3 py-2">
|
||||
<AccountCombobox
|
||||
value={line.account_number}
|
||||
accounts={accounts}
|
||||
onChange={(v) => updateLine(idx, { account_number: v })}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
inputMode="decimal"
|
||||
value={line.debit_amount}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
inputMode="decimal"
|
||||
value={line.credit_amount}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => removeLine(idx)}
|
||||
disabled={isSubmitting || lines.length <= 2}
|
||||
aria-label="Ta bort rad"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className="bg-muted/20 text-xs">
|
||||
<tr>
|
||||
<td className="px-3 py-2 text-right font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Summa
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums font-medium">
|
||||
{totals.debit.toFixed(2)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums font-medium">
|
||||
{totals.credit.toFixed(2)}
|
||||
</td>
|
||||
<td />
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={addLine}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
||||
Lägg till rad
|
||||
</Button>
|
||||
{totals.balanced ? (
|
||||
<Badge variant="success" className="text-[11px]">
|
||||
Balanserad
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1.5 tabular-nums">
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-warning" />
|
||||
Diff {totals.diff.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="bd-notes" className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
Anteckningar (valfritt)
|
||||
</Label>
|
||||
<Textarea
|
||||
id="bd-notes"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
rows={2}
|
||||
placeholder="Intern kommentar om verifikationen"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 pt-2 border-t">
|
||||
<p
|
||||
className={cn(
|
||||
'text-xs tabular-nums',
|
||||
disabledReason ? 'text-warning-foreground' : 'text-muted-foreground'
|
||||
)}
|
||||
aria-live="polite"
|
||||
>
|
||||
{disabledReason ?? 'Klar att bokföra.'}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
title={disabledReason ?? undefined}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||
Bokför…
|
||||
</>
|
||||
) : (
|
||||
'Bokför'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -38,6 +38,9 @@ import {
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import BookDirectlyDialog from '@/components/extensions/general/BookDirectlyDialog'
|
||||
|
||||
type AccountingMethod = 'accrual' | 'cash'
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────
|
||||
|
||||
@@ -54,6 +57,7 @@ interface InboxItem {
|
||||
matched_supplier_id: string | null
|
||||
matched_transaction_id: string | null
|
||||
created_supplier_invoice_id: string | null
|
||||
created_journal_entry_id: string | null
|
||||
error_message: string | null
|
||||
// Set client-side only while a manual upload is in flight. Replaced by a
|
||||
// real server-side row once the AI extraction completes.
|
||||
@@ -147,6 +151,11 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
const [isRotating, setIsRotating] = useState(false)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [attachOpen, setAttachOpen] = useState(false)
|
||||
const [bookDirectOpen, setBookDirectOpen] = useState(false)
|
||||
// Cash method users see "Bokför direkt" as the primary CTA; accrual users
|
||||
// see "Skapa leverantörsfaktura". Defaults to 'accrual' until we've read
|
||||
// the company settings so we don't flicker the CTA order on first paint.
|
||||
const [accountingMethod, setAccountingMethod] = useState<AccountingMethod>('accrual')
|
||||
|
||||
// ── Data loading ───────────────────────────────────────────
|
||||
|
||||
@@ -177,6 +186,16 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
useEffect(() => {
|
||||
fetchItems()
|
||||
fetchInboxAddress()
|
||||
// Resolve the company's bookkeeping method — drives CTA hierarchy.
|
||||
fetch('/api/settings')
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((body) => {
|
||||
const method = body?.data?.accounting_method
|
||||
if (method === 'cash' || method === 'accrual') {
|
||||
setAccountingMethod(method)
|
||||
}
|
||||
})
|
||||
.catch(() => { /* keep 'accrual' default */ })
|
||||
}, [fetchItems, fetchInboxAddress])
|
||||
|
||||
// Read the onboarding-dismissed flag from localStorage after mount
|
||||
@@ -207,7 +226,10 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
const hasInboxAddress = !!inboxAddress
|
||||
const hasAnyItem = items.length > 0
|
||||
const hasResolvedItem = items.some(
|
||||
(it) => !!it.created_supplier_invoice_id || !!it.matched_transaction_id
|
||||
(it) =>
|
||||
!!it.created_supplier_invoice_id ||
|
||||
!!it.matched_transaction_id ||
|
||||
!!it.created_journal_entry_id
|
||||
)
|
||||
const showOnboarding =
|
||||
!onboardingDismissed && !(hasInboxAddress && hasAnyItem && hasResolvedItem)
|
||||
@@ -219,7 +241,10 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
return items.filter((item) => {
|
||||
// Status filter
|
||||
const isErr = item.status === 'error'
|
||||
const isDone = !!item.created_supplier_invoice_id || !!item.matched_transaction_id
|
||||
const isDone =
|
||||
!!item.created_supplier_invoice_id ||
|
||||
!!item.matched_transaction_id ||
|
||||
!!item.created_journal_entry_id
|
||||
const needsAction = !isErr && !isDone
|
||||
if (filter === 'error' && !isErr) return false
|
||||
if (filter === 'done' && !isDone) return false
|
||||
@@ -302,6 +327,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
matched_supplier_id: null,
|
||||
matched_transaction_id: null,
|
||||
created_supplier_invoice_id: null,
|
||||
created_journal_entry_id: null,
|
||||
error_message: null,
|
||||
isPlaceholder: true,
|
||||
fileName: file.name,
|
||||
@@ -423,7 +449,9 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
|
||||
// Skip items that the server would 409 on, surface the count to the user.
|
||||
const targets = items.filter((it) => selectedIds.has(it.id))
|
||||
const deletable = targets.filter((it) => !it.created_supplier_invoice_id)
|
||||
const deletable = targets.filter(
|
||||
(it) => !it.created_supplier_invoice_id && !it.created_journal_entry_id
|
||||
)
|
||||
const skipped = targets.length - deletable.length
|
||||
|
||||
setIsBulkDeleting(true)
|
||||
@@ -770,8 +798,10 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
{selected ? (
|
||||
<FieldsRail
|
||||
item={selected}
|
||||
accountingMethod={accountingMethod}
|
||||
onDelete={() => handleDelete(selected.id)}
|
||||
onAttach={() => setAttachOpen(true)}
|
||||
onBookDirect={() => setBookDirectOpen(true)}
|
||||
isDeleting={isDeleting}
|
||||
onRetryRequested={async () => {
|
||||
await Promise.all([fetchItems(), handleSelect(selected.id)])
|
||||
@@ -825,6 +855,16 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{selected && (
|
||||
<BookDirectlyDialog
|
||||
open={bookDirectOpen}
|
||||
onOpenChange={setBookDirectOpen}
|
||||
item={selected}
|
||||
onSuccess={async () => {
|
||||
await Promise.all([fetchItems(), handleSelect(selected.id)])
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1551,15 +1591,19 @@ function EmptyPreview({
|
||||
|
||||
function FieldsRail({
|
||||
item,
|
||||
accountingMethod,
|
||||
onDelete,
|
||||
onAttach,
|
||||
onBookDirect,
|
||||
isDeleting,
|
||||
onFieldsUpdated,
|
||||
onRetryRequested,
|
||||
}: {
|
||||
item: InboxItem
|
||||
accountingMethod: AccountingMethod
|
||||
onDelete: () => void
|
||||
onAttach: () => void
|
||||
onBookDirect: () => void
|
||||
isDeleting: boolean
|
||||
onFieldsUpdated: (data: InvoiceExtractionResult) => void
|
||||
onRetryRequested: () => Promise<void>
|
||||
@@ -1567,8 +1611,10 @@ function FieldsRail({
|
||||
const { toast } = useToast()
|
||||
const data = item.extracted_data
|
||||
const isProcessed = !!item.created_supplier_invoice_id
|
||||
const isLinkedToTransaction = !isProcessed && !!item.matched_transaction_id
|
||||
const isResolved = isProcessed || isLinkedToTransaction
|
||||
const isBookedDirectly = !isProcessed && !!item.created_journal_entry_id
|
||||
const isLinkedToTransaction =
|
||||
!isProcessed && !isBookedDirectly && !!item.matched_transaction_id
|
||||
const isResolved = isProcessed || isBookedDirectly || isLinkedToTransaction
|
||||
const [isRetrying, setIsRetrying] = useState(false)
|
||||
const [isCreatingSupplier, setIsCreatingSupplier] = useState(false)
|
||||
|
||||
@@ -1780,6 +1826,13 @@ function FieldsRail({
|
||||
Öppna leverantörsfaktura
|
||||
</Button>
|
||||
</Link>
|
||||
) : isBookedDirectly && item.created_journal_entry_id ? (
|
||||
<Link href={`/bookkeeping/${item.created_journal_entry_id}`} className="block">
|
||||
<Button variant="default" size="sm" className="w-full">
|
||||
<ArrowRight className="h-3.5 w-3.5 mr-1.5" />
|
||||
Öppna verifikation
|
||||
</Button>
|
||||
</Link>
|
||||
) : isLinkedToTransaction && item.matched_transaction_id ? (
|
||||
<Link href={`/transactions?highlight=${item.matched_transaction_id}`} className="block">
|
||||
<Button variant="default" size="sm" className="w-full">
|
||||
@@ -1787,6 +1840,33 @@ function FieldsRail({
|
||||
Bokför transaktionen
|
||||
</Button>
|
||||
</Link>
|
||||
) : accountingMethod === 'cash' ? (
|
||||
<>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={onBookDirect}
|
||||
>
|
||||
Bokför direkt
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={onAttach}
|
||||
disabled={!item.document_id}
|
||||
title={!item.document_id ? 'Ingen bilaga att koppla' : undefined}
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5 mr-1.5" />
|
||||
Koppla till transaktion
|
||||
</Button>
|
||||
<Link href={`/supplier-invoices/new?inbox_item_id=${item.id}`} className="block">
|
||||
<Button variant="ghost" size="sm" className="w-full">
|
||||
Skapa leverantörsfaktura
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
@@ -1805,6 +1885,14 @@ function FieldsRail({
|
||||
Skapa leverantörsfaktura
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={onBookDirect}
|
||||
>
|
||||
Bokför direkt
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
@@ -1816,9 +1904,11 @@ function FieldsRail({
|
||||
title={
|
||||
isProcessed
|
||||
? 'Kopplad till leverantörsfaktura — kan inte tas bort'
|
||||
: isLinkedToTransaction
|
||||
? 'Kopplad till transaktion — koppla loss innan borttagning'
|
||||
: undefined
|
||||
: isBookedDirectly
|
||||
? 'Bokförd — kan inte tas bort'
|
||||
: isLinkedToTransaction
|
||||
? 'Kopplad till transaktion — koppla loss innan borttagning'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{isDeleting ? (
|
||||
@@ -1834,6 +1924,12 @@ function FieldsRail({
|
||||
Bearbetad
|
||||
</Badge>
|
||||
)}
|
||||
{isBookedDirectly && (
|
||||
<Badge variant="secondary" className="w-full justify-center text-[10px]">
|
||||
<Check className="h-2.5 w-2.5 mr-1" />
|
||||
Bokförd
|
||||
</Badge>
|
||||
)}
|
||||
{isLinkedToTransaction && (
|
||||
<Badge variant="secondary" className="w-full justify-center text-[10px]">
|
||||
<Link2 className="h-2.5 w-2.5 mr-1" />
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox'
|
||||
import {
|
||||
createQueuedMockSupabase,
|
||||
createMockRequest,
|
||||
parseJsonResponse,
|
||||
makeInvoiceInboxItem,
|
||||
} from '@/tests/helpers'
|
||||
import type { ExtensionContext } from '@/lib/extensions/types'
|
||||
|
||||
const createJournalEntryMock = vi.fn()
|
||||
const linkToJournalEntryMock = vi.fn()
|
||||
|
||||
vi.mock('@/lib/bookkeeping/engine', () => ({
|
||||
createJournalEntry: (...args: unknown[]) => createJournalEntryMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/documents/document-service', () => ({
|
||||
linkToJournalEntry: (...args: unknown[]) => linkToJournalEntryMock(...args),
|
||||
}))
|
||||
|
||||
function findRoute(method: string, path: string) {
|
||||
return invoiceInboxExtension.apiRoutes!.find(
|
||||
(r) => r.method === method && r.path === path
|
||||
)!
|
||||
}
|
||||
|
||||
function buildCtx(supabase: unknown, overrides: Partial<ExtensionContext> = {}): ExtensionContext {
|
||||
return {
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
extensionId: 'invoice-inbox',
|
||||
supabase: supabase as ExtensionContext['supabase'],
|
||||
emit: vi.fn(),
|
||||
settings: { get: vi.fn(), set: vi.fn() },
|
||||
storage: { from: vi.fn() } as unknown as ExtensionContext['storage'],
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as unknown as ExtensionContext['log'],
|
||||
services: {},
|
||||
...overrides,
|
||||
} as ExtensionContext
|
||||
}
|
||||
|
||||
const PERIOD_UUID = '00000000-0000-4000-8000-000000000010'
|
||||
const TX_UUID = '00000000-0000-4000-8000-000000000020'
|
||||
|
||||
const VALID_BODY = {
|
||||
fiscal_period_id: PERIOD_UUID,
|
||||
entry_date: '2026-05-14',
|
||||
description: 'Kvitto från Spotify',
|
||||
lines: [
|
||||
{ account_number: '6540', debit_amount: 79.2, credit_amount: 0 },
|
||||
{ account_number: '2641', debit_amount: 19.8, credit_amount: 0 },
|
||||
{ account_number: '1930', debit_amount: 0, credit_amount: 99 },
|
||||
],
|
||||
}
|
||||
|
||||
describe('POST /items/:id/book-direct', () => {
|
||||
const route = findRoute('POST', '/items/:id/book-direct')
|
||||
|
||||
beforeEach(() => {
|
||||
createJournalEntryMock.mockReset()
|
||||
linkToJournalEntryMock.mockReset()
|
||||
createJournalEntryMock.mockResolvedValue({
|
||||
id: 'je-1',
|
||||
voucher_series: 'A',
|
||||
voucher_number: 42,
|
||||
})
|
||||
linkToJournalEntryMock.mockResolvedValue({ id: 'doc-1' })
|
||||
})
|
||||
|
||||
it('returns 401 when no context', async () => {
|
||||
const request = createMockRequest('/items/item-1/book-direct', {
|
||||
method: 'POST',
|
||||
body: VALID_BODY,
|
||||
searchParams: { _id: 'item-1' },
|
||||
})
|
||||
const res = await route.handler(request, undefined)
|
||||
const { status } = await parseJsonResponse(res)
|
||||
expect(status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 400 when body is invalid (unbalanced is checked by engine; here we check zod-level)', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
const ctx = buildCtx(supabase)
|
||||
const request = createMockRequest('/items/item-1/book-direct', {
|
||||
method: 'POST',
|
||||
body: { fiscal_period_id: 'not-a-uuid' },
|
||||
searchParams: { _id: 'item-1' },
|
||||
})
|
||||
const res = await route.handler(request, ctx)
|
||||
const { status } = await parseJsonResponse(res)
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 404 when inbox item not found', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: null })
|
||||
|
||||
const ctx = buildCtx(supabase)
|
||||
const request = createMockRequest('/items/item-1/book-direct', {
|
||||
method: 'POST',
|
||||
body: VALID_BODY,
|
||||
searchParams: { _id: 'item-1' },
|
||||
})
|
||||
const res = await route.handler(request, ctx)
|
||||
const { status } = await parseJsonResponse(res)
|
||||
expect(status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 409 when item already linked to a supplier invoice', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
data: makeInvoiceInboxItem({
|
||||
created_supplier_invoice_id: 'si-1',
|
||||
}),
|
||||
})
|
||||
|
||||
const ctx = buildCtx(supabase)
|
||||
const request = createMockRequest('/items/item-1/book-direct', {
|
||||
method: 'POST',
|
||||
body: VALID_BODY,
|
||||
searchParams: { _id: 'item-1' },
|
||||
})
|
||||
const res = await route.handler(request, ctx)
|
||||
const { status } = await parseJsonResponse(res)
|
||||
expect(status).toBe(409)
|
||||
})
|
||||
|
||||
it('returns 409 when item already has a journal entry', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
data: makeInvoiceInboxItem({
|
||||
created_journal_entry_id: 'je-existing',
|
||||
}),
|
||||
})
|
||||
|
||||
const ctx = buildCtx(supabase)
|
||||
const request = createMockRequest('/items/item-1/book-direct', {
|
||||
method: 'POST',
|
||||
body: VALID_BODY,
|
||||
searchParams: { _id: 'item-1' },
|
||||
})
|
||||
const res = await route.handler(request, ctx)
|
||||
const { status } = await parseJsonResponse(res)
|
||||
expect(status).toBe(409)
|
||||
})
|
||||
|
||||
it('books a standalone entry and links the document', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
// 1. fetch inbox item
|
||||
enqueue({ data: makeInvoiceInboxItem({ document_id: 'doc-1' }) })
|
||||
// 2. update inbox item (status=confirmed, created_journal_entry_id)
|
||||
enqueue({ data: null })
|
||||
|
||||
const ctx = buildCtx(supabase)
|
||||
const request = createMockRequest('/items/item-1/book-direct', {
|
||||
method: 'POST',
|
||||
body: VALID_BODY,
|
||||
searchParams: { _id: 'item-1' },
|
||||
})
|
||||
const res = await route.handler(request, ctx)
|
||||
const { status, body } = await parseJsonResponse(res)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body).toMatchObject({
|
||||
data: { journal_entry: { id: 'je-1' }, transaction_id: null },
|
||||
})
|
||||
expect(createJournalEntryMock).toHaveBeenCalledTimes(1)
|
||||
expect(createJournalEntryMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
'user-1',
|
||||
expect.objectContaining({
|
||||
source_type: 'inbox_item',
|
||||
fiscal_period_id: PERIOD_UUID,
|
||||
}),
|
||||
)
|
||||
expect(linkToJournalEntryMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
'doc-1',
|
||||
'je-1',
|
||||
)
|
||||
})
|
||||
|
||||
it('returns 404 when transaction_id is provided but not found', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: makeInvoiceInboxItem({}) })
|
||||
enqueue({ data: null }) // transaction lookup
|
||||
|
||||
const ctx = buildCtx(supabase)
|
||||
const request = createMockRequest('/items/item-1/book-direct', {
|
||||
method: 'POST',
|
||||
body: { ...VALID_BODY, transaction_id: TX_UUID },
|
||||
searchParams: { _id: 'item-1' },
|
||||
})
|
||||
const res = await route.handler(request, ctx)
|
||||
const { status } = await parseJsonResponse(res)
|
||||
expect(status).toBe(404)
|
||||
expect(createJournalEntryMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 409 when transaction is already booked', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: makeInvoiceInboxItem({}) })
|
||||
enqueue({ data: { id: TX_UUID, journal_entry_id: 'je-old' } })
|
||||
|
||||
const ctx = buildCtx(supabase)
|
||||
const request = createMockRequest('/items/item-1/book-direct', {
|
||||
method: 'POST',
|
||||
body: { ...VALID_BODY, transaction_id: TX_UUID },
|
||||
searchParams: { _id: 'item-1' },
|
||||
})
|
||||
const res = await route.handler(request, ctx)
|
||||
const { status } = await parseJsonResponse(res)
|
||||
expect(status).toBe(409)
|
||||
expect(createJournalEntryMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('books with transaction link: source_type=bank_transaction, source_id=transaction.id', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: makeInvoiceInboxItem({ document_id: 'doc-1' }) })
|
||||
enqueue({ data: { id: TX_UUID, journal_entry_id: null } })
|
||||
enqueue({ data: null }) // transaction update
|
||||
enqueue({ data: null }) // inbox item update
|
||||
|
||||
const ctx = buildCtx(supabase)
|
||||
const request = createMockRequest('/items/item-1/book-direct', {
|
||||
method: 'POST',
|
||||
body: { ...VALID_BODY, transaction_id: TX_UUID },
|
||||
searchParams: { _id: 'item-1' },
|
||||
})
|
||||
const res = await route.handler(request, ctx)
|
||||
const { status, body } = await parseJsonResponse(res)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body).toMatchObject({
|
||||
data: { transaction_id: TX_UUID },
|
||||
})
|
||||
expect(createJournalEntryMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
'user-1',
|
||||
expect.objectContaining({
|
||||
source_type: 'bank_transaction',
|
||||
source_id: TX_UUID,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -18,7 +18,10 @@ import {
|
||||
composeInboxAddress,
|
||||
} from './lib/inbox-provisioning'
|
||||
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||
import { CreateSupplierInvoiceSchema } from '@/lib/api/schemas'
|
||||
import { createJournalEntry } from '@/lib/bookkeeping/engine'
|
||||
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
|
||||
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
|
||||
import { CreateSupplierInvoiceSchema, BookInboxItemDirectlySchema } from '@/lib/api/schemas'
|
||||
import { appendProcessingHistory } from '@/lib/processing-history/append'
|
||||
import { checkInboxUploadRateLimit } from '@/lib/rate-limits/inbox'
|
||||
import type { InvoiceExtractionResult, InvoiceInboxItem, SupplierInvoice, SupplierInvoiceItem } from '@/types'
|
||||
@@ -321,6 +324,7 @@ export const invoiceInboxExtension: Extension = {
|
||||
id, status, source, created_at, extracted_data,
|
||||
matched_supplier_id, document_id, email_from, email_subject,
|
||||
email_received_at, error_message, created_supplier_invoice_id,
|
||||
matched_transaction_id, created_journal_entry_id,
|
||||
resend_email_id
|
||||
`)
|
||||
.eq('company_id', ctx.companyId)
|
||||
@@ -1027,7 +1031,7 @@ export const invoiceInboxExtension: Extension = {
|
||||
|
||||
const { data: item } = await ctx.supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('id, created_supplier_invoice_id')
|
||||
.select('id, created_supplier_invoice_id, created_journal_entry_id')
|
||||
.eq('id', id)
|
||||
.eq('company_id', ctx.companyId)
|
||||
.maybeSingle()
|
||||
@@ -1039,6 +1043,12 @@ export const invoiceInboxExtension: Extension = {
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
if (item.created_journal_entry_id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Posten är bokförd och kan inte tas bort.' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
const { error } = await ctx.supabase
|
||||
.from('invoice_inbox_items')
|
||||
@@ -1253,6 +1263,172 @@ export const invoiceInboxExtension: Extension = {
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
// ── Book inbox item directly as a manual journal entry ─
|
||||
// For kontantmetoden users (and ad-hoc receipts) — bypasses the
|
||||
// supplier-invoice flow entirely. Optionally links to a bank
|
||||
// transaction; otherwise produces a standalone verifikation
|
||||
// (e.g. private outlay, cash receipt). The source document is
|
||||
// attached to the new entry per BFL 5 kap. 6§.
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/items/:id/book-direct',
|
||||
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 })
|
||||
|
||||
let body: z.infer<typeof BookInboxItemDirectlySchema>
|
||||
try {
|
||||
const json = await request.json()
|
||||
body = BookInboxItemDirectlySchema.parse(json)
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Invalid request body' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { data: item, error: fetchError } = await ctx.supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('id, document_id, status, created_supplier_invoice_id, created_journal_entry_id, matched_transaction_id, correlation_id')
|
||||
.eq('id', id)
|
||||
.eq('company_id', ctx.companyId)
|
||||
.maybeSingle()
|
||||
|
||||
if (fetchError) {
|
||||
// Surface the real DB error instead of masking as 404. Common cause:
|
||||
// the migration adding `created_journal_entry_id` hasn't been
|
||||
// applied to this database (e.g. local dev DB lagging staging).
|
||||
console.error('[invoice-inbox/book-direct] Item lookup failed:', fetchError)
|
||||
return NextResponse.json(
|
||||
{ error: `Kunde inte slå upp posten: ${fetchError.message}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
if (!item) {
|
||||
return NextResponse.json({ error: 'Inbox item not found' }, { status: 404 })
|
||||
}
|
||||
if (item.created_supplier_invoice_id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Posten är redan kopplad till en leverantörsfaktura.' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
if (item.created_journal_entry_id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Posten är redan bokförd.' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
// If a transaction is provided, validate it before booking.
|
||||
let transaction: { id: string; journal_entry_id: string | null } | null = null
|
||||
if (body.transaction_id) {
|
||||
const { data: tx, error: txError } = await ctx.supabase
|
||||
.from('transactions')
|
||||
.select('id, journal_entry_id')
|
||||
.eq('id', body.transaction_id)
|
||||
.eq('company_id', ctx.companyId)
|
||||
.maybeSingle()
|
||||
if (txError || !tx) {
|
||||
return NextResponse.json({ error: 'Transaktion hittades inte' }, { status: 404 })
|
||||
}
|
||||
if (tx.journal_entry_id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Transaktionen är redan bokförd' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
transaction = tx
|
||||
}
|
||||
|
||||
// Create the journal entry via the engine. Source-tracks back to
|
||||
// the inbox item so the audit trail is preserved even when no
|
||||
// transaction is involved.
|
||||
let journalEntry
|
||||
try {
|
||||
journalEntry = await createJournalEntry(ctx.supabase, ctx.companyId, ctx.userId, {
|
||||
fiscal_period_id: body.fiscal_period_id,
|
||||
entry_date: body.entry_date,
|
||||
description: body.description,
|
||||
source_type: transaction ? 'bank_transaction' : 'inbox_item',
|
||||
source_id: transaction ? transaction.id : item.id,
|
||||
notes: body.notes,
|
||||
lines: body.lines,
|
||||
})
|
||||
} catch (err) {
|
||||
const typed = bookkeepingErrorResponse(err)
|
||||
if (typed) return typed
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Kunde inte skapa verifikation' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Link the source document to the new entry. Best-effort — the
|
||||
// entry itself is already posted; surfacing the failure shouldn't
|
||||
// roll it back, but log so support can re-link manually.
|
||||
if (item.document_id) {
|
||||
try {
|
||||
await linkToJournalEntry(
|
||||
ctx.supabase,
|
||||
ctx.companyId,
|
||||
item.document_id,
|
||||
journalEntry.id
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('[invoice-inbox/book-direct] Document link failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// If transaction-linked, mark the transaction as booked.
|
||||
if (transaction) {
|
||||
const { error: txUpdateError } = await ctx.supabase
|
||||
.from('transactions')
|
||||
.update({
|
||||
journal_entry_id: journalEntry.id,
|
||||
is_business: true,
|
||||
category: 'uncategorized',
|
||||
})
|
||||
.eq('id', transaction.id)
|
||||
.eq('company_id', ctx.companyId)
|
||||
if (txUpdateError) {
|
||||
console.error('[invoice-inbox/book-direct] Transaction link failed:', txUpdateError)
|
||||
}
|
||||
}
|
||||
|
||||
// Mark the inbox item as resolved by writing the FK. The status
|
||||
// column is intentionally left at 'received' — terminal state is
|
||||
// encoded via created_journal_entry_id / matched_transaction_id
|
||||
// (see migration 20260504180000_invoice_inbox_remove_ai_columns).
|
||||
const { error: updateError } = await ctx.supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({
|
||||
created_journal_entry_id: journalEntry.id,
|
||||
matched_transaction_id: transaction?.id ?? null,
|
||||
})
|
||||
.eq('id', id)
|
||||
.eq('company_id', ctx.companyId)
|
||||
if (updateError) {
|
||||
return NextResponse.json({ error: updateError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// The engine already emits journal_entry.committed — no need to
|
||||
// re-emit. Transaction categorization is implicit: the entry is
|
||||
// already source-linked to the transaction via source_type.
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
journal_entry: journalEntry,
|
||||
inbox_item_id: id,
|
||||
transaction_id: transaction?.id ?? null,
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -95,6 +95,7 @@ export const JournalEntrySourceTypeSchema = z.enum([
|
||||
'correction',
|
||||
'import',
|
||||
'system',
|
||||
'inbox_item',
|
||||
'supplier_invoice_registered',
|
||||
'supplier_invoice_paid',
|
||||
'supplier_invoice_cash_payment',
|
||||
@@ -345,6 +346,15 @@ export const BookTransactionSchema = z.object({
|
||||
lines: z.array(CreateJournalEntryLineSchema).min(1, 'At least one line is required'),
|
||||
})
|
||||
|
||||
export const BookInboxItemDirectlySchema = z.object({
|
||||
fiscal_period_id: uuid,
|
||||
entry_date: isoDate,
|
||||
description: z.string().min(1, 'Beskrivning krävs'),
|
||||
notes: z.string().max(2000).optional(),
|
||||
lines: z.array(CreateJournalEntryLineSchema).min(2, 'Minst två rader krävs för dubbel bokföring'),
|
||||
transaction_id: uuid.optional(),
|
||||
})
|
||||
|
||||
export const MatchInvoiceSchema = z.object({
|
||||
invoice_id: uuid,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Invoice inbox: support "book directly" flow for kontantmetoden users.
|
||||
--
|
||||
-- Cash-method users (and accrual users dealing with personal expenses,
|
||||
-- cash receipts, etc.) need to be able to turn an inbox item directly
|
||||
-- into a manual journal entry without going through a supplier invoice.
|
||||
-- This column gives the inbox the third terminal status, symmetric with
|
||||
-- `created_supplier_invoice_id` and `matched_transaction_id`.
|
||||
|
||||
ALTER TABLE public.invoice_inbox_items
|
||||
ADD COLUMN IF NOT EXISTS created_journal_entry_id uuid
|
||||
REFERENCES public.journal_entries(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_inbox_items_created_journal_entry
|
||||
ON public.invoice_inbox_items(company_id, created_journal_entry_id)
|
||||
WHERE created_journal_entry_id IS NOT NULL;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Invoice inbox: enforce one journal entry per inbox item.
|
||||
--
|
||||
-- The book-direct route does a check-then-write (409 guard on
|
||||
-- created_journal_entry_id, then createJournalEntry, then update).
|
||||
-- Without a UNIQUE constraint, two concurrent calls for the same
|
||||
-- inbox item can both pass the guard, both insert a journal entry,
|
||||
-- and the second update overwrites the FK — orphaning the first
|
||||
-- entry in an immutable ledger with no inbox reference.
|
||||
--
|
||||
-- PostgreSQL treats NULLs as distinct in UNIQUE constraints by
|
||||
-- default, so unbooked items (NULL) remain unconstrained.
|
||||
|
||||
ALTER TABLE public.invoice_inbox_items
|
||||
ADD CONSTRAINT invoice_inbox_items_journal_entry_unique
|
||||
UNIQUE (created_journal_entry_id);
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -570,6 +570,8 @@ export function makeInvoiceInboxItem(
|
||||
extracted_data: null,
|
||||
matched_supplier_id: null,
|
||||
created_supplier_invoice_id: null,
|
||||
matched_transaction_id: null,
|
||||
created_journal_entry_id: null,
|
||||
error_message: null,
|
||||
raw_email_payload: null,
|
||||
correlation_id: null,
|
||||
|
||||
@@ -929,6 +929,7 @@ export type JournalEntrySourceType =
|
||||
| 'correction'
|
||||
| 'import'
|
||||
| 'system'
|
||||
| 'inbox_item'
|
||||
| 'supplier_invoice_registered'
|
||||
| 'supplier_invoice_paid'
|
||||
| 'supplier_invoice_cash_payment'
|
||||
@@ -1716,6 +1717,8 @@ export interface InvoiceInboxItem {
|
||||
extracted_data: Record<string, unknown> | null
|
||||
matched_supplier_id: string | null
|
||||
created_supplier_invoice_id: string | null
|
||||
matched_transaction_id: string | null
|
||||
created_journal_entry_id: string | null
|
||||
error_message: string | null
|
||||
raw_email_payload: Record<string, unknown> | null
|
||||
|
||||
|
||||
Reference in New Issue
Block a user