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. */}
+
+
+
+
+ {/* 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 && (
+