From 28f7cefc8670071ac30a13e2d0bd4f9015e78b86 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Sat, 30 May 2026 10:23:14 +0200 Subject: [PATCH] feat(bulk-book): manual booking mode + document inheritance (#610) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(bulk-book): manual booking mode + document inheritance Two pieces of user feedback from PR #606: 1. "How come it is only mallar? Is it not possible to have manuell bokfoering?" - BulkBookDialog was template-only. Added a Tabs primitive with Mall / Manuell tabs. Manual tab pre-fills lines from the selected txs (one line per tx on 1930 + counterparty placeholder on 3001/5800 by direction), then the user edits Konto / Debet / Kredit / Beskrivning. Live balance + bank-leg checks drive the confirm button - same invariants the RPC enforces server-side. 2. "Documents attached does not follow into the bookkeeping. And if there are two different documents attached, none of them follow." The bulk_book_transactions RPC now propagates each tx's document onto the target verifikat (new in Branch B, existing in Branch A) as verifikationsunderlag. Per BFL 5 kap 6§ + BFNAR 2013:2 kap 4 a verifikat may have multiple underlag; every receipt that justified a tx is now retention-protected on the combined entry. The dialog shows a small count chip ("N bilagor foeljer med") so the user sees what will inherit. Also dropped p_user_id from the RPC signature (round-3 hardening pattern applied consistently across all multi-tx RPCs after PR #607). Caller resolves from auth.uid() inside the function. Schema: BulkBookSchema is now a 3-way XOR (existing_journal_entry_id | template_id+mode | manual_lines), with manual_lines validated as accountNumber + nonNegativeAmount per line. pg-real tests: - doc inheritance into a new combined verifikat (mixed: 2 of 3 txs have docs - docs_linked should be 2, not 3) - doc inheritance into an existing posted verifikat (link branch) - manual lines path (no template expansion artifacts in the resulting JE - just the 2 user lines) - unbalanced manual lines still rejected by BULK_BOOK_UNBALANCED Migration applied to remote. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(bulk-book): PR #610 review - pg-real signature, account allowlist, account-number validity Three review findings on PR #610: 1. pg-real failure: 2 link-existing tests still used 5-arg SELECT bulk_book_transactions($1::uuid[], $2, $3, $4, $5) after the userId removal. My earlier replace_all caught only the patterns that had ::jsonb on $3; the link-existing tests pass null for new_entry and used a bare $3 so they slipped through. (Greptile P1) 2. Manual lines bypassed chart_of_accounts validation. A typo or adversarial caller could post to a BAS account that doesn't exist in this company's chart, corrupting the hauptbok and breaking SIE export. Both compliance-swarm (OWASP V2.3) and swedish-compliance flagged this. Added a single-roundtrip allowlist check in the route: query chart_of_accounts for distinct account_numbers in manual_lines and reject with BULK_BOOK_INVALID_ACCOUNT if any are missing or inactive. 3. UI canConfirm guard missed invalid account numbers. Account input allows 1-3 digits and JS string comparison '193' >= '1900' is false, so a 3-digit entry escapes bankLineNet, the bank match could pass via other lines, and the server returned 400 only after submit. Added previewLines.every(l => /^\d{4}$/.test(l.account_number)) to canConfirm so the Confirm button stays disabled inline. (Greptile P2) Co-Authored-By: Claude Opus 4.7 (1M context) * fix(bulk-book): PR #610 round 2 - RPC chart-of-accounts, doc tenant isolation, GRANTs Seven compliance findings from the round-1 bot reviews: Migration (20260602121000_bulk_book_round2_fixes.sql): - RPC chart-of-accounts allowlist (defense-in-depth): every line in p_new_entry.lines is now verified to be an active BAS account for p_company_id. Closes the gap where the template branch and direct DB callers (psql, future MCP) bypassed the route's manual-branch check. Returns BULK_BOOK_INVALID_ACCOUNT with the offending list. (OWASP V8.2.1 + SOC 2 CC6.3) - Document inheritance CTE: added "AND d.company_id = p_company_id" to the UPDATE join so the tenant isolation is enforced on both sides (tx + doc), not just the tx side. Four bots converged on this finding (V1.2.5, A.8.2, CC6.6, swedish-compliance). - Bank-leg range check: "length(account_number) = 4 AND account_number BETWEEN '1900' AND '1999'" replaces the bare lexicographic comparison. Lexicographic-on-4-digit is safe today; the length guard is defense-in-depth against schema drift. (swedish-compliance) - Explicit role grants: REVOKE ALL FROM PUBLIC + GRANT EXECUTE TO authenticated on both bulk_book_transactions and match_batch_allocate. (SOC 2 CC6.1) UI (BulkBookDialog): - Manual-mode prefill no longer suggests a hardcoded 3001/5800 counterpart. Reason (swedish-compliance): a user accepting the prefill could submit a verifikat with no VAT line (26xx), under-reporting utgaaende moms. The bank side stays pre-filled (unambiguous); the counterpart row scaffolds blank for the user to choose. Schema (BulkBookSchema): - manual_lines.debit_amount + credit_amount bounded at 99,999,999 SEK per line. Catches typos before the RPC. (compliance-swarm V4.5) i18n: - docs_inherit_hint terminology: "bilaga" -> "verifikationsunderlag" and an explicit "sparas i 7 ar enligt BFL 7 kap" reminder. swedish-compliance flagged that "bilaga" risks users treating the files as deletable attachments rather than retention-bound raekenskapsinformation. Migration applied to remote. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(test): seed chart_of_accounts in bulk-book pg-real seedTenant The round-2 RPC fix added a chart_of_accounts allowlist check inside bulk_book_transactions, but the test fixtures don't seed COA — so every existing test that submits lines (1930, 3001, 2611, etc.) now returns BULK_BOOK_INVALID_ACCOUNT instead of the expected error code. Seed the 8 accounts the suite actually uses directly in seedTenant (cheaper than calling seed_chart_of_accounts which inserts the full BAS 2026 chart). Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- app/api/transactions/bulk-book/route.ts | 58 ++- components/transactions/BulkBookDialog.tsx | 270 +++++++++++- lib/api/schemas.ts | 33 +- lib/errors/structured-errors.ts | 7 + messages/en.json | 7 + messages/sv.json | 7 + ...0000_bulk_book_documents_and_signature.sql | 355 ++++++++++++++++ .../20260602121000_bulk_book_round2_fixes.sql | 399 ++++++++++++++++++ tests/pg/bulk-book-transactions.pg.test.ts | 238 ++++++++++- 9 files changed, 1338 insertions(+), 36 deletions(-) create mode 100644 supabase/migrations/20260602120000_bulk_book_documents_and_signature.sql create mode 100644 supabase/migrations/20260602121000_bulk_book_round2_fixes.sql 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 && (