diff --git a/app/api/transactions/bulk-book/route.ts b/app/api/transactions/bulk-book/route.ts index 020752d0..cb1d9a9a 100644 --- a/app/api/transactions/bulk-book/route.ts +++ b/app/api/transactions/bulk-book/route.ts @@ -18,6 +18,7 @@ interface RpcOk { voucher_number: number | null linked_tx_count: number tx_sum: number + docs_linked: number } interface RpcErr { @@ -70,11 +71,58 @@ export const POST = withRouteContext( const opLog = log.child({ txCount: body.tx_ids.length }) - // Branch 2 needs the template + tx amounts; branch 1 hands off to the - // RPC directly with a null new_entry. + // Three paths now (PR #608): + // 1. existing_journal_entry_id → null new_entry, RPC links txs to JE. + // 2. template_id → route expands template per mode, builds lines. + // 3. manual_lines → caller-built lines pass straight through. let newEntryPayload: { description: string; lines: ComputedLine[] } | null = null - if (body.template_id && body.mode && body.entry_description) { + if (body.manual_lines && body.entry_description) { + // Manual mode. The Zod schema validated the 4-digit format; the + // RPC's balance + bank-leg + negative-amount + both-sides-nonzero + // guards still run downstream. What's missing is verifying the + // account_numbers exist in this company's chart_of_accounts — + // without it a typo or adversarial caller could post to a BAS + // account that doesn't exist, corrupting the hauptbok and + // breaking SIE export. Single roundtrip allowlist check. + const accountNumbers = Array.from( + new Set(body.manual_lines.map((l) => l.account_number)), + ) + const { data: knownAccounts, error: accountsError } = await supabase + .from('chart_of_accounts') + .select('account_number') + .eq('company_id', companyId) + .eq('is_active', true) + .in('account_number', accountNumbers) + if (accountsError) { + opLog.error('chart_of_accounts lookup failed', accountsError) + return errorResponseFromCode('BULK_BOOK_RPC_FAILED', opLog, { + requestId, + details: { message: accountsError.message }, + }) + } + const validSet = new Set( + (knownAccounts ?? []).map((a: { account_number: string }) => a.account_number), + ) + const invalid = accountNumbers.filter((n) => !validSet.has(n)) + if (invalid.length > 0) { + return errorResponseFromCode('BULK_BOOK_INVALID_ACCOUNT', opLog, { + requestId, + details: { invalid_accounts: invalid }, + }) + } + newEntryPayload = { + description: body.entry_description, + lines: body.manual_lines.map((l, i) => ({ + account_number: l.account_number, + debit_amount: round2(l.debit_amount), + credit_amount: round2(l.credit_amount), + currency: l.currency, + line_description: l.line_description, + sort_order: i, + })), + } + } else if (body.template_id && body.mode && body.entry_description) { // Fetch the template. RLS scopes to user's companies + system templates, // so we don't need a company_id filter here. const { data: template, error: templateError } = await supabase @@ -184,11 +232,12 @@ export const POST = withRouteContext( } } + // p_user_id removed in PR #608 (round-3 hardening pattern applied + // consistently). RPC resolves the caller via auth.uid(). const { data, error } = await supabase.rpc('bulk_book_transactions', { p_tx_ids: body.tx_ids, p_existing_journal_entry_id: body.existing_journal_entry_id ?? null, p_new_entry: newEntryPayload, - p_user_id: user.id, p_company_id: companyId, }) @@ -247,6 +296,7 @@ export const POST = withRouteContext( voucher_number: result.voucher_number, linked_tx_count: result.linked_tx_count, tx_sum: result.tx_sum, + docs_linked: result.docs_linked, }, }) }, diff --git a/components/transactions/BulkBookDialog.tsx b/components/transactions/BulkBookDialog.tsx index 8abe2b8b..58ad8668 100644 --- a/components/transactions/BulkBookDialog.tsx +++ b/components/transactions/BulkBookDialog.tsx @@ -17,11 +17,12 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Badge } from '@/components/ui/badge' import { Skeleton } from '@/components/ui/skeleton' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { useToast } from '@/components/ui/use-toast' import { getErrorMessage } from '@/lib/errors/get-error-message' import { applyTemplate } from '@/lib/bookkeeping/template-library' import { formatCurrency, formatDate, cn } from '@/lib/utils' -import { Loader2, FileText, AlertTriangle, Check } from 'lucide-react' +import { Loader2, FileText, AlertTriangle, Check, Plus, Trash2, Paperclip } from 'lucide-react' import type { BookingTemplateLibrary, BookingTemplateLibraryLine } from '@/types' import type { TransactionWithInvoice } from './transaction-types' @@ -33,6 +34,7 @@ interface BulkBookDialogProps { } type Mode = 'one_line_per_tx' | 'sum_per_account' +type Tab = 'template' | 'manual' interface PreviewLine { account_number: string @@ -41,10 +43,29 @@ interface PreviewLine { line_description: string | undefined } +interface ManualLine { + id: string + account_number: string + debit_amount: string // form-state strings; parsed on send + credit_amount: string + line_description: string +} + function round2(n: number): number { return Math.round(n * 100) / 100 } +function parseAmount(s: string): number { + if (!s) return 0 + const cleaned = s.replace(/\s/g, '').replace(',', '.') + const n = Number.parseFloat(cleaned) + return Number.isFinite(n) ? n : 0 +} + +function newManualLineId(): string { + return `ml-${Math.random().toString(36).slice(2, 10)}` +} + export default function BulkBookDialog({ open, onOpenChange, @@ -56,13 +77,23 @@ export default function BulkBookDialog({ const supabase = useMemo(() => createClient(), []) const t = useTranslations('tx_bulk_book') + const [tab, setTab] = useState('template') const [templates, setTemplates] = useState([]) const [loadingTemplates, setLoadingTemplates] = useState(true) const [selectedTemplateId, setSelectedTemplateId] = useState(null) const [mode, setMode] = useState('one_line_per_tx') const [description, setDescription] = useState('') + const [manualLines, setManualLines] = useState([]) const [submitting, setSubmitting] = useState(false) + // Documents that will inherit onto the new verifikat. Computed from + // transactions.document_id; the RPC reads these and updates each doc's + // journal_entry_id atomically with the verifikat commit. + const docCount = useMemo( + () => transactions.filter((tx) => tx.document_id).length, + [transactions], + ) + const txCount = transactions.length const sharedDate = transactions[0]?.date const sharedCurrency = transactions[0]?.currency ?? 'SEK' @@ -109,17 +140,62 @@ export default function BulkBookDialog({ // Reset state when dialog closes so the next open starts clean. useEffect(() => { if (!open) { + setTab('template') setSelectedTemplateId(null) setMode('one_line_per_tx') setDescription('') + setManualLines([]) } else if (sharedDate) { // Pre-fill description with a sensible default the user can edit. setDescription(t('default_description', { date: sharedDate })) } }, [open, sharedDate, t]) - // Live line preview — recomputes when template/mode/tx-set changes. + // Pre-fill the bank side from the txs (one line per tx on 1930 with + // the correct Dr/Cr direction). We intentionally do NOT pre-fill a + // counterpart account: swedish-compliance flagged that a hardcoded + // 3001/5800 prefill nudges users into submitting verifikat without a + // VAT line (26xx) for momsregistrerade affärshändelser. The bank + // side is the unambiguous part the user always wants; the + // counterpart (and any VAT split) is the user's responsibility. + useEffect(() => { + if (tab !== 'manual') return + if (manualLines.length > 0) return + if (transactions.length === 0) return + const isIncome = direction === 'income' + const bankLines: ManualLine[] = transactions.map((tx) => ({ + id: newManualLineId(), + account_number: '1930', + debit_amount: isIncome ? Math.abs(tx.amount).toFixed(2).replace('.', ',') : '', + credit_amount: isIncome ? '' : Math.abs(tx.amount).toFixed(2).replace('.', ','), + line_description: (tx.description || '').slice(0, 40).trim(), + })) + // One empty counterpart row to scaffold the next entry. Account + // left blank — user must choose, which avoids the no-VAT trap. + const counterpart: ManualLine = { + id: newManualLineId(), + account_number: '', + debit_amount: '', + credit_amount: '', + line_description: '', + } + setManualLines([...bankLines, counterpart]) + }, [tab, manualLines.length, transactions, direction]) + + // Live line preview — driven by either the template/mode pair (template + // tab) or the user-edited manual lines (manual tab). Same downstream + // invariants (balance + bank-leg match) apply to both paths. const previewLines = useMemo(() => { + if (tab === 'manual') { + return manualLines + .map((ml) => ({ + account_number: ml.account_number, + debit_amount: round2(parseAmount(ml.debit_amount)), + credit_amount: round2(parseAmount(ml.credit_amount)), + line_description: ml.line_description.trim() || undefined, + })) + .filter((l) => l.debit_amount > 0 || l.credit_amount > 0) + } if (!selectedTemplate) return [] const templateLines = (selectedTemplate.lines ?? []) as BookingTemplateLibraryLine[] const lines: PreviewLine[] = [] @@ -156,7 +232,7 @@ export default function BulkBookDialog({ } } return lines - }, [selectedTemplate, mode, transactions, txSumAbs]) + }, [tab, manualLines, selectedTemplate, mode, transactions, txSumAbs]) const previewTotals = useMemo(() => { const debit = previewLines.reduce((s, l) => s + l.debit_amount, 0) @@ -173,27 +249,74 @@ export default function BulkBookDialog({ const expectedBankNet = direction === 'income' ? txSumAbs : -txSumAbs const bankMatches = Math.abs(bankLineNet - expectedBankNet) < 0.005 + // The active tab gates which selector must be valid. Both paths still + // need a non-empty description, ≥2 lines, balance, bank-leg match, + // and (for manual mode) valid 4-digit account numbers — without this, + // a 1–3-digit entry escapes the lexicographic bank-account range + // check ('193' < '1900' is true), bank match could pass, and the + // server's Zod schema rejects with a 400 only after submit. + const tabReady = tab === 'template' ? selectedTemplate !== null : manualLines.length > 0 + const allAccountsValid = previewLines.every((l) => /^\d{4}$/.test(l.account_number)) const canConfirm = !submitting && - selectedTemplate !== null && + tabReady && description.trim().length > 0 && previewLines.length >= 2 && isBalanced && - bankMatches + bankMatches && + allAccountsValid + + function updateManualLine(id: string, patch: Partial>) { + setManualLines((prev) => prev.map((l) => (l.id === id ? { ...l, ...patch } : l))) + } + + function removeManualLine(id: string) { + setManualLines((prev) => prev.filter((l) => l.id !== id)) + } + + function addManualLine() { + setManualLines((prev) => [ + ...prev, + { + id: newManualLineId(), + account_number: '', + debit_amount: '', + credit_amount: '', + line_description: '', + }, + ]) + } async function handleConfirm() { if (!canConfirm) return setSubmitting(true) try { + // Build the payload per the active tab. Template path uses the + // existing schema branch (template_id + mode). Manual path sends + // the user-edited lines directly. + const payload = + tab === 'manual' + ? { + tx_ids: transactions.map((tx) => tx.id), + entry_description: description.trim(), + manual_lines: previewLines.map((l) => ({ + account_number: l.account_number, + debit_amount: l.debit_amount, + credit_amount: l.credit_amount, + currency: sharedCurrency, + line_description: l.line_description ?? undefined, + })), + } + : { + tx_ids: transactions.map((tx) => tx.id), + template_id: selectedTemplateId, + mode, + entry_description: description.trim(), + } const response = await fetch('/api/transactions/bulk-book', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - tx_ids: transactions.map((tx) => tx.id), - template_id: selectedTemplateId, - mode, - entry_description: description.trim(), - }), + body: JSON.stringify(payload), }) if (!response.ok) { const body = await response.json().catch(() => null) @@ -260,6 +383,16 @@ export default function BulkBookDialog({

+ {/* Tab: Mall (template) / Manuell (hand-built lines). Default + template; manual is the "I want to book it myself" escape + hatch the user asked for after PR #606. */} + setTab(v as Tab)} className="space-y-4"> + + {t('tab_template')} + {t('tab_manual')} + + + {/* Template picker */}
@@ -343,9 +476,107 @@ export default function BulkBookDialog({
)} +
- {/* Description */} - {selectedTemplate && ( + + {/* Manual line editor. Lines are pre-filled from txs on first + switch to this tab (one line per tx on 1930 + counterpart + line on 3001/5800). User adjusts accounts, amounts, and + descriptions. Live balance + bank-leg checks below drive + the confirm button. */} +
+
+ + +
+
+ + + + + + + + + + + {manualLines.map((line) => ( + + + + + + + + ))} + +
{t('col_account')}{t('col_description')}{t('col_debit')}{t('col_credit')} +
+ + updateManualLine(line.id, { account_number: e.target.value.replace(/\D/g, '').slice(0, 4) }) + } + placeholder="1930" + className="h-8 text-xs font-mono" + /> + + + updateManualLine(line.id, { line_description: e.target.value.slice(0, 200) }) + } + placeholder={t('manual_description_placeholder')} + className="h-8 text-xs" + /> + + + updateManualLine(line.id, { debit_amount: e.target.value }) + } + placeholder="0,00" + className="h-8 text-xs text-right" + /> + + + updateManualLine(line.id, { credit_amount: e.target.value }) + } + placeholder="0,00" + className="h-8 text-xs text-right" + /> + + +
+
+
+
+
+ + {/* Description — shared by both tabs once the user has either a + template selected or manual lines drafted. */} + {tabReady && (
)} + {/* Document inheritance hint — informs the user which receipts + follow the txs onto the combined verifikat. Zero is fine + (txs without docs don't break anything); we only render + when the count is non-zero to avoid clutter. */} + {docCount > 0 && tabReady && ( +
+ + {t('docs_inherit_hint', { count: docCount })} +
+ )} + {/* Live preview */} - {selectedTemplate && previewLines.length > 0 && ( + {tabReady && previewLines.length > 0 && (