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. */}
+
{t('description_label')}
)}
+ {/* 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 && (
{t('preview_label', { count: previewLines.length })}
diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts
index 068213b2..87ce1ad8 100644
--- a/lib/api/schemas.ts
+++ b/lib/api/schemas.ts
@@ -567,15 +567,37 @@ export const BulkBookSchema = z
template_id: uuid.optional(),
mode: z.enum(['one_line_per_tx', 'sum_per_account']).optional(),
entry_description: z.string().min(1).max(500).optional(),
+ // PR #608: manual lines path. Mutually exclusive with template_id /
+ // existing_journal_entry_id. The route passes these straight through
+ // to the RPC's p_new_entry.lines.
+ manual_lines: z
+ .array(
+ z.object({
+ account_number: accountNumber,
+ // Bound at 99,999,999 SEK per line (compliance-swarm V4.5).
+ // Real-world max is in the millions; an 8-digit ceiling catches
+ // typos (1000000 mistyped as 10000000000) before they hit the
+ // RPC, without blocking legitimate large bookings.
+ debit_amount: nonNegativeAmount.max(99_999_999, 'Line amount exceeds maximum'),
+ credit_amount: nonNegativeAmount.max(99_999_999, 'Line amount exceeds maximum'),
+ currency: z.string().min(3).max(3).default('SEK'),
+ line_description: z.string().max(200).optional(),
+ })
+ )
+ .min(2, 'A verifikat needs at least two lines')
+ .max(200)
+ .optional(),
})
.superRefine((data, ctx) => {
const hasExisting = !!data.existing_journal_entry_id
const hasTemplate = !!data.template_id
- if (hasExisting === hasTemplate) {
+ const hasManual = !!data.manual_lines
+ const paths = [hasExisting, hasTemplate, hasManual].filter(Boolean).length
+ if (paths !== 1) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
- 'Provide either existing_journal_entry_id (link) or template_id (create new) — not both, and not neither',
+ 'Provide exactly one of: existing_journal_entry_id (link), template_id (template), or manual_lines (manual)',
path: ['existing_journal_entry_id'],
})
return
@@ -596,6 +618,13 @@ export const BulkBookSchema = z
})
}
}
+ if (hasManual && !data.entry_description) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'entry_description is required when manual_lines is set',
+ path: ['entry_description'],
+ })
+ }
})
/**
diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts
index f4067015..35685a9a 100644
--- a/lib/errors/structured-errors.ts
+++ b/lib/errors/structured-errors.ts
@@ -2084,6 +2084,13 @@ const BULK_BOOK: Record = {
message_en: 'Database error during bulk booking. Please retry.',
retryable: true,
},
+ BULK_BOOK_INVALID_ACCOUNT: {
+ httpStatus: 400,
+ message_sv:
+ 'Ett eller flera konton finns inte i kontoplanen eller är inaktiva. Välj giltiga BAS-konton.',
+ message_en:
+ 'One or more accounts are not in the chart of accounts or are inactive. Pick valid BAS accounts.',
+ },
}
// ─────────────────────────────────────────────────────────────────
diff --git a/messages/en.json b/messages/en.json
index a634c54d..0149b673 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -1860,6 +1860,13 @@
"success_title": "Combined verifikat created",
"success_description": "{count} transactions booked to verifikat {voucher}.",
"unknown_voucher": "(no number)",
+ "tab_template": "Template",
+ "tab_manual": "Manual",
+ "manual_lines_label": "Lines",
+ "manual_add_line": "Add line",
+ "manual_remove_line": "Remove line",
+ "manual_description_placeholder": "Description (optional)",
+ "docs_inherit_hint": "{count, plural, one {# supporting document will follow (7-year retention per BFL 7 kap)} other {# supporting documents will follow (7-year retention per BFL 7 kap)}}",
"cancel": "Cancel",
"confirm": "Confirm booking"
},
diff --git a/messages/sv.json b/messages/sv.json
index 65b82191..b8da9932 100644
--- a/messages/sv.json
+++ b/messages/sv.json
@@ -1860,6 +1860,13 @@
"success_title": "Samlingsverifikation skapad",
"success_description": "{count} transaktioner bokförda till verifikat {voucher}.",
"unknown_voucher": "(utan nummer)",
+ "tab_template": "Mall",
+ "tab_manual": "Manuell",
+ "manual_lines_label": "Rader",
+ "manual_add_line": "Lägg till rad",
+ "manual_remove_line": "Ta bort rad",
+ "manual_description_placeholder": "Beskrivning (valfritt)",
+ "docs_inherit_hint": "{count, plural, one {# verifikationsunderlag följer med (sparas i 7 år enligt BFL 7 kap)} other {# verifikationsunderlag följer med (sparas i 7 år enligt BFL 7 kap)}}",
"cancel": "Avbryt",
"confirm": "Bekräfta bokföring"
},
diff --git a/supabase/migrations/20260602120000_bulk_book_documents_and_signature.sql b/supabase/migrations/20260602120000_bulk_book_documents_and_signature.sql
new file mode 100644
index 00000000..395cdd04
--- /dev/null
+++ b/supabase/migrations/20260602120000_bulk_book_documents_and_signature.sql
@@ -0,0 +1,355 @@
+-- PR #608 — bulk_book_transactions: drop p_user_id + propagate documents.
+--
+-- Two changes on top of 20260530120000_bulk_book_transactions.sql:
+--
+-- 1. Drop p_user_id from the function signature (round-3 hardening
+-- pattern applied to match_batch_allocate in PR #607). Caller is
+-- resolved from auth.uid() inside the function.
+--
+-- 2. Propagate document_attachments from each constituent tx onto the
+-- target verifikat (new in Branch B, existing in Branch A). User
+-- feedback on PR #606: "the documents attached does not follow into
+-- the bookkeeping. And if there are two different documents
+-- attached, none of them follow." Per BFL 5 kap 6§ + BFNAR 2013:2
+-- kap 4, a verifikat may have multiple verifikationsunderlag —
+-- every receipt that justified a tx remains evidence for the
+-- combined business event, retention-protected under the same
+-- WORM/7-year guarantees.
+--
+-- Manual-mode UX (BulkBookDialog "Manuell" tab) does NOT need a new RPC
+-- parameter. The existing p_new_entry.lines path accepts arbitrary
+-- caller-supplied lines and validates balance + bank-leg match in the
+-- existing loop. The route swaps out template expansion for user-built
+-- lines when the manual_lines schema branch is taken.
+
+DROP FUNCTION IF EXISTS public.bulk_book_transactions(uuid[], uuid, jsonb, uuid, uuid);
+
+CREATE OR REPLACE FUNCTION public.bulk_book_transactions(
+ p_tx_ids uuid[],
+ p_existing_journal_entry_id uuid,
+ p_new_entry jsonb,
+ p_company_id uuid
+)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path TO 'public'
+AS $$
+DECLARE
+ v_tx RECORD;
+ v_tx_id uuid;
+ v_tx_date date;
+ v_total_amount numeric := 0;
+ v_total_amount_abs numeric;
+ v_direction text;
+ v_tx_count int := 0;
+
+ v_voucher RECORD;
+ v_voucher_bank_net numeric := 0;
+
+ v_fiscal_period_id uuid;
+ v_period_is_closed boolean;
+ v_period_locked_at timestamptz;
+
+ v_journal_entry_id uuid;
+ v_voucher_series text := 'A';
+ v_voucher_number int;
+ v_entry_description text;
+
+ v_line jsonb;
+ v_line_account text;
+ v_line_debit numeric;
+ v_line_credit numeric;
+ v_line_currency text;
+ v_lines_total_debit numeric := 0;
+ v_lines_total_credit numeric := 0;
+ v_lines_bank_net numeric := 0;
+ v_sort_order int := 0;
+
+ v_docs_linked int := 0;
+ v_target_je uuid;
+
+ v_now timestamptz := now();
+ v_caller uuid := auth.uid();
+BEGIN
+ IF v_caller IS NULL THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_UNAUTHORIZED');
+ END IF;
+ IF NOT EXISTS (
+ SELECT 1 FROM public.company_members
+ WHERE user_id = v_caller AND company_id = p_company_id
+ ) THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_UNAUTHORIZED');
+ END IF;
+
+ IF p_tx_ids IS NULL OR array_length(p_tx_ids, 1) IS NULL THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_NO_TXS');
+ END IF;
+
+ IF (p_existing_journal_entry_id IS NULL AND p_new_entry IS NULL)
+ OR (p_existing_journal_entry_id IS NOT NULL AND p_new_entry IS NOT NULL) THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_INVALID_PAYLOAD');
+ END IF;
+
+ FOR v_tx IN
+ SELECT * FROM public.transactions
+ WHERE id = ANY(p_tx_ids) AND company_id = p_company_id
+ ORDER BY id
+ FOR UPDATE
+ LOOP
+ v_tx_count := v_tx_count + 1;
+ IF v_tx.journal_entry_id IS NOT NULL THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_TX_ALREADY_BOOKED',
+ 'details', jsonb_build_object('tx_id', v_tx.id));
+ END IF;
+ IF EXISTS (
+ SELECT 1 FROM public.transaction_voucher_links tvl
+ WHERE tvl.transaction_id = v_tx.id
+ ) THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_TX_ALREADY_BOOKED',
+ 'details', jsonb_build_object('tx_id', v_tx.id, 'via', 'transaction_voucher_links'));
+ END IF;
+ IF v_tx.amount = 0 THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_TX_ZERO_AMOUNT',
+ 'details', jsonb_build_object('tx_id', v_tx.id));
+ END IF;
+
+ IF v_tx_date IS NULL THEN
+ v_tx_date := v_tx.date;
+ ELSIF v_tx_date <> v_tx.date THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_DATE_MISMATCH',
+ 'details', jsonb_build_object('first_date', v_tx_date, 'other_date', v_tx.date));
+ END IF;
+
+ IF v_direction IS NULL THEN
+ v_direction := CASE WHEN v_tx.amount > 0 THEN 'income' ELSE 'expense' END;
+ ELSIF (v_direction = 'income' AND v_tx.amount < 0)
+ OR (v_direction = 'expense' AND v_tx.amount > 0) THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_DIRECTION_MISMATCH',
+ 'details', jsonb_build_object('expected', v_direction, 'tx_id', v_tx.id));
+ END IF;
+
+ v_total_amount := v_total_amount + v_tx.amount;
+ END LOOP;
+
+ IF v_tx_count = 0 THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_TXS_NOT_FOUND');
+ END IF;
+
+ IF v_tx_count <> COALESCE(array_length(p_tx_ids, 1), 0) THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_TXS_NOT_FOUND',
+ 'details', jsonb_build_object('expected', array_length(p_tx_ids, 1), 'found', v_tx_count));
+ END IF;
+
+ v_total_amount_abs := ABS(v_total_amount);
+
+ -- ── Branch A: link to existing posted verifikat ──────────────────
+ IF p_existing_journal_entry_id IS NOT NULL THEN
+ SELECT * INTO v_voucher FROM public.journal_entries
+ WHERE id = p_existing_journal_entry_id AND company_id = p_company_id
+ FOR UPDATE;
+
+ IF NOT FOUND THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_JE_NOT_FOUND',
+ 'details', jsonb_build_object('journal_entry_id', p_existing_journal_entry_id));
+ END IF;
+
+ IF v_voucher.status <> 'posted' THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_JE_NOT_POSTED',
+ 'details', jsonb_build_object('status', v_voucher.status));
+ END IF;
+
+ SELECT COALESCE(SUM(debit_amount - credit_amount), 0) INTO v_voucher_bank_net
+ FROM public.journal_entry_lines
+ WHERE journal_entry_id = p_existing_journal_entry_id
+ AND account_number >= '1900' AND account_number <= '1999';
+
+ IF ABS(v_voucher_bank_net - v_total_amount) > 0.005 THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_AMOUNT_MISMATCH',
+ 'details', jsonb_build_object(
+ 'tx_sum', v_total_amount, 'voucher_bank_net', v_voucher_bank_net));
+ END IF;
+
+ FOR v_tx IN
+ SELECT * FROM public.transactions
+ WHERE id = ANY(p_tx_ids) AND company_id = p_company_id
+ ORDER BY id
+ LOOP
+ INSERT INTO public.transaction_voucher_links
+ (user_id, company_id, transaction_id, journal_entry_id, allocated_amount, role)
+ VALUES
+ (v_caller, p_company_id, v_tx.id, p_existing_journal_entry_id, v_tx.amount, 'bank_line');
+ END LOOP;
+
+ IF v_tx_count = 1 THEN
+ UPDATE public.transactions
+ SET journal_entry_id = p_existing_journal_entry_id,
+ reconciliation_method = 'manual',
+ is_business = TRUE,
+ updated_at = v_now
+ WHERE id = p_tx_ids[1];
+ ELSE
+ UPDATE public.transactions
+ SET is_business = TRUE, updated_at = v_now
+ WHERE id = ANY(p_tx_ids);
+ END IF;
+
+ -- Carry the existing JE's series/number through the merged return.
+ v_target_je := p_existing_journal_entry_id;
+ v_voucher_series := v_voucher.voucher_series;
+ v_voucher_number := v_voucher.voucher_number;
+
+ ELSE
+ -- ── Branch B: create new combined verifikat ─────────────────────
+ v_entry_description := p_new_entry->>'description';
+ IF v_entry_description IS NULL OR LENGTH(TRIM(v_entry_description)) = 0 THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_MISSING_DESCRIPTION');
+ END IF;
+
+ IF jsonb_typeof(p_new_entry->'lines') IS DISTINCT FROM 'array'
+ OR jsonb_array_length(p_new_entry->'lines') < 2 THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_NO_LINES');
+ END IF;
+
+ FOR v_line IN SELECT * FROM jsonb_array_elements(p_new_entry->'lines')
+ LOOP
+ v_line_account := v_line->>'account_number';
+ v_line_debit := COALESCE((v_line->>'debit_amount')::numeric, 0);
+ v_line_credit := COALESCE((v_line->>'credit_amount')::numeric, 0);
+ IF v_line_debit < 0 OR v_line_credit < 0 THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_NEGATIVE_LINE',
+ 'details', jsonb_build_object('account', v_line_account));
+ END IF;
+ IF v_line_debit > 0 AND v_line_credit > 0 THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_BOTH_SIDES_NONZERO',
+ 'details', jsonb_build_object('account', v_line_account));
+ END IF;
+ v_lines_total_debit := v_lines_total_debit + v_line_debit;
+ v_lines_total_credit := v_lines_total_credit + v_line_credit;
+ IF v_line_account >= '1900' AND v_line_account <= '1999' THEN
+ v_lines_bank_net := v_lines_bank_net + v_line_debit - v_line_credit;
+ END IF;
+ END LOOP;
+
+ IF ABS(v_lines_total_debit - v_lines_total_credit) > 0.005 THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_UNBALANCED',
+ 'details', jsonb_build_object(
+ 'debit_sum', v_lines_total_debit, 'credit_sum', v_lines_total_credit));
+ END IF;
+
+ IF ABS(v_lines_bank_net - v_total_amount) > 0.005 THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_AMOUNT_MISMATCH',
+ 'details', jsonb_build_object(
+ 'tx_sum', v_total_amount,
+ 'lines_bank_net', v_lines_bank_net));
+ END IF;
+
+ SELECT id, is_closed, locked_at INTO v_fiscal_period_id, v_period_is_closed, v_period_locked_at
+ FROM public.fiscal_periods
+ WHERE company_id = p_company_id AND v_tx_date BETWEEN period_start AND period_end
+ ORDER BY period_start DESC LIMIT 1;
+
+ IF v_fiscal_period_id IS NULL THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_NO_FISCAL_PERIOD',
+ 'details', jsonb_build_object('tx_date', v_tx_date));
+ END IF;
+
+ IF v_period_is_closed OR v_period_locked_at IS NOT NULL THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_PERIOD_LOCKED',
+ 'details', jsonb_build_object('fiscal_period_id', v_fiscal_period_id));
+ END IF;
+
+ v_journal_entry_id := gen_random_uuid();
+
+ INSERT INTO public.journal_entries
+ (id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
+ entry_date, description, source_type, status)
+ VALUES
+ (v_journal_entry_id, v_caller, p_company_id, v_fiscal_period_id, 0, v_voucher_series,
+ v_tx_date, v_entry_description, 'manual', 'draft');
+
+ v_sort_order := 0;
+ FOR v_line IN SELECT * FROM jsonb_array_elements(p_new_entry->'lines')
+ LOOP
+ v_line_account := v_line->>'account_number';
+ v_line_debit := COALESCE((v_line->>'debit_amount')::numeric, 0);
+ v_line_credit := COALESCE((v_line->>'credit_amount')::numeric, 0);
+ v_line_currency := COALESCE(v_line->>'currency', 'SEK');
+
+ INSERT INTO public.journal_entry_lines
+ (journal_entry_id, account_number, debit_amount, credit_amount, currency,
+ sort_order, line_description)
+ VALUES
+ (v_journal_entry_id, v_line_account, v_line_debit, v_line_credit, v_line_currency,
+ COALESCE((v_line->>'sort_order')::int, v_sort_order),
+ v_line->>'line_description');
+
+ v_sort_order := v_sort_order + 1;
+ END LOOP;
+
+ SELECT voucher_number INTO v_voucher_number
+ FROM public.commit_journal_entry(p_company_id, v_journal_entry_id);
+
+ FOR v_tx IN
+ SELECT * FROM public.transactions
+ WHERE id = ANY(p_tx_ids) AND company_id = p_company_id
+ ORDER BY id
+ LOOP
+ INSERT INTO public.transaction_voucher_links
+ (user_id, company_id, transaction_id, journal_entry_id, allocated_amount, role)
+ VALUES
+ (v_caller, p_company_id, v_tx.id, v_journal_entry_id, v_tx.amount, 'bank_line');
+ END LOOP;
+
+ IF v_tx_count = 1 THEN
+ UPDATE public.transactions
+ SET journal_entry_id = v_journal_entry_id,
+ is_business = TRUE,
+ updated_at = v_now
+ WHERE id = p_tx_ids[1];
+ ELSE
+ UPDATE public.transactions
+ SET is_business = TRUE, updated_at = v_now
+ WHERE id = ANY(p_tx_ids);
+ END IF;
+
+ v_target_je := v_journal_entry_id;
+ END IF;
+
+ -- ── Document inheritance ─────────────────────────────────────────
+ -- Each tx has at most one document_id (1:1 relation enforced
+ -- elsewhere). Set those documents' journal_entry_id to the target
+ -- verifikat so every receipt that justified a tx is now also
+ -- verifikationsunderlag for the combined entry. Only updates docs
+ -- whose journal_entry_id is currently NULL — never overwrites an
+ -- existing link (BFL document immutability via trigger).
+ WITH linked AS (
+ UPDATE public.document_attachments AS d
+ SET journal_entry_id = v_target_je,
+ updated_at = v_now
+ FROM public.transactions AS t
+ WHERE t.id = ANY(p_tx_ids)
+ AND t.company_id = p_company_id
+ AND t.document_id = d.id
+ AND d.journal_entry_id IS NULL
+ RETURNING d.id
+ )
+ SELECT COUNT(*)::int INTO v_docs_linked FROM linked;
+
+ RETURN jsonb_build_object(
+ 'ok', true,
+ 'mode', CASE WHEN p_existing_journal_entry_id IS NOT NULL THEN 'link_existing' ELSE 'create_new' END,
+ 'journal_entry_id', v_target_je,
+ 'voucher_series', v_voucher_series,
+ 'voucher_number', v_voucher_number,
+ 'linked_tx_count', v_tx_count,
+ 'tx_sum', v_total_amount,
+ 'docs_linked', v_docs_linked
+ );
+END;
+$$;
+
+COMMENT ON FUNCTION public.bulk_book_transactions(uuid[], uuid, jsonb, uuid) IS
+ 'Bulk-book N bank transactions sharing the same date into a single combined verifikat (samlingsverifikation per BFL 5 kap 6§). Two branches: link to an existing posted verifikat, or create a new one from caller-supplied lines (template expansion OR manual lines done by the route). Documents attached to constituent txs are propagated onto the target verifikat as additional verifikationsunderlag. Caller resolved via auth.uid().';
+
+NOTIFY pgrst, 'reload schema';
diff --git a/supabase/migrations/20260602121000_bulk_book_round2_fixes.sql b/supabase/migrations/20260602121000_bulk_book_round2_fixes.sql
new file mode 100644
index 00000000..c65169a5
--- /dev/null
+++ b/supabase/migrations/20260602121000_bulk_book_round2_fixes.sql
@@ -0,0 +1,399 @@
+-- PR #610 round-2 fixes for bulk_book_transactions.
+--
+-- Genuine findings from compliance-swarm + swedish-compliance on the
+-- round-1 (PR #608) migration:
+--
+-- 1. Chart-of-accounts validation missing inside the RPC. Route's
+-- manual branch validates account_numbers; the template branch
+-- does not, and a direct DB caller (psql, future MCP) can bypass
+-- both. Defense-in-depth: validate inside the RPC's Branch B
+-- line-build loop so every line, regardless of how it got there,
+-- is checked against the company's active chart_of_accounts.
+-- (OWASP V8.2.1, SOC 2 CC6.3, swedish-compliance)
+--
+-- 2. Document inheritance CTE missing tenant isolation on the doc
+-- side. UPDATE joined on t.document_id = d.id without filtering
+-- d.company_id = p_company_id. If a tx's document_id somehow
+-- pointed at a cross-company doc (multi-tenant bug scenario),
+-- the update would link a foreign tenant's document to the
+-- target verifikat. Adding the explicit predicate closes the
+-- door at the data layer.
+-- (OWASP V1.2.5, ISO A.8.2, SOC 2 CC6.6, swedish-compliance —
+-- four bots converge on the same finding)
+--
+-- 3. Bank-leg range check was a bare lexicographic comparison on a
+-- text column. With the schema-level 4-digit format guard it
+-- works today, but a 5-digit number or one with a stray space
+-- would silently pass/fail. Add an explicit length(4) guard
+-- alongside the range so the check is robust to schema drift.
+-- (swedish-compliance)
+--
+-- 4. SECURITY DEFINER without explicit role grants. Add
+-- REVOKE ALL FROM PUBLIC + GRANT EXECUTE TO authenticated on
+-- both bulk_book_transactions and match_batch_allocate for
+-- least-privilege.
+-- (SOC 2 CC6.1)
+--
+-- Function body otherwise byte-identical to round 1.
+
+DROP FUNCTION IF EXISTS public.bulk_book_transactions(uuid[], uuid, jsonb, uuid);
+
+CREATE OR REPLACE FUNCTION public.bulk_book_transactions(
+ p_tx_ids uuid[],
+ p_existing_journal_entry_id uuid,
+ p_new_entry jsonb,
+ p_company_id uuid
+)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path TO 'public'
+AS $$
+DECLARE
+ v_tx RECORD;
+ v_tx_date date;
+ v_total_amount numeric := 0;
+ v_total_amount_abs numeric;
+ v_direction text;
+ v_tx_count int := 0;
+
+ v_voucher RECORD;
+ v_voucher_bank_net numeric := 0;
+
+ v_fiscal_period_id uuid;
+ v_period_is_closed boolean;
+ v_period_locked_at timestamptz;
+
+ v_journal_entry_id uuid;
+ v_voucher_series text := 'A';
+ v_voucher_number int;
+ v_entry_description text;
+
+ v_line jsonb;
+ v_line_account text;
+ v_line_debit numeric;
+ v_line_credit numeric;
+ v_line_currency text;
+ v_lines_total_debit numeric := 0;
+ v_lines_total_credit numeric := 0;
+ v_lines_bank_net numeric := 0;
+ v_sort_order int := 0;
+
+ v_docs_linked int := 0;
+ v_target_je uuid;
+
+ v_invalid_accounts text[];
+
+ v_now timestamptz := now();
+ v_caller uuid := auth.uid();
+BEGIN
+ IF v_caller IS NULL THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_UNAUTHORIZED');
+ END IF;
+ IF NOT EXISTS (
+ SELECT 1 FROM public.company_members
+ WHERE user_id = v_caller AND company_id = p_company_id
+ ) THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_UNAUTHORIZED');
+ END IF;
+
+ IF p_tx_ids IS NULL OR array_length(p_tx_ids, 1) IS NULL THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_NO_TXS');
+ END IF;
+
+ IF (p_existing_journal_entry_id IS NULL AND p_new_entry IS NULL)
+ OR (p_existing_journal_entry_id IS NOT NULL AND p_new_entry IS NOT NULL) THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_INVALID_PAYLOAD');
+ END IF;
+
+ FOR v_tx IN
+ SELECT * FROM public.transactions
+ WHERE id = ANY(p_tx_ids) AND company_id = p_company_id
+ ORDER BY id
+ FOR UPDATE
+ LOOP
+ v_tx_count := v_tx_count + 1;
+ IF v_tx.journal_entry_id IS NOT NULL THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_TX_ALREADY_BOOKED',
+ 'details', jsonb_build_object('tx_id', v_tx.id));
+ END IF;
+ IF EXISTS (
+ SELECT 1 FROM public.transaction_voucher_links tvl
+ WHERE tvl.transaction_id = v_tx.id
+ ) THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_TX_ALREADY_BOOKED',
+ 'details', jsonb_build_object('tx_id', v_tx.id, 'via', 'transaction_voucher_links'));
+ END IF;
+ IF v_tx.amount = 0 THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_TX_ZERO_AMOUNT',
+ 'details', jsonb_build_object('tx_id', v_tx.id));
+ END IF;
+
+ IF v_tx_date IS NULL THEN
+ v_tx_date := v_tx.date;
+ ELSIF v_tx_date <> v_tx.date THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_DATE_MISMATCH',
+ 'details', jsonb_build_object('first_date', v_tx_date, 'other_date', v_tx.date));
+ END IF;
+
+ IF v_direction IS NULL THEN
+ v_direction := CASE WHEN v_tx.amount > 0 THEN 'income' ELSE 'expense' END;
+ ELSIF (v_direction = 'income' AND v_tx.amount < 0)
+ OR (v_direction = 'expense' AND v_tx.amount > 0) THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_DIRECTION_MISMATCH',
+ 'details', jsonb_build_object('expected', v_direction, 'tx_id', v_tx.id));
+ END IF;
+
+ v_total_amount := v_total_amount + v_tx.amount;
+ END LOOP;
+
+ IF v_tx_count = 0 THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_TXS_NOT_FOUND');
+ END IF;
+
+ IF v_tx_count <> COALESCE(array_length(p_tx_ids, 1), 0) THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_TXS_NOT_FOUND',
+ 'details', jsonb_build_object('expected', array_length(p_tx_ids, 1), 'found', v_tx_count));
+ END IF;
+
+ v_total_amount_abs := ABS(v_total_amount);
+
+ IF p_existing_journal_entry_id IS NOT NULL THEN
+ SELECT * INTO v_voucher FROM public.journal_entries
+ WHERE id = p_existing_journal_entry_id AND company_id = p_company_id
+ FOR UPDATE;
+
+ IF NOT FOUND THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_JE_NOT_FOUND',
+ 'details', jsonb_build_object('journal_entry_id', p_existing_journal_entry_id));
+ END IF;
+
+ IF v_voucher.status <> 'posted' THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_JE_NOT_POSTED',
+ 'details', jsonb_build_object('status', v_voucher.status));
+ END IF;
+
+ -- Round-2 fix: explicit 4-digit length guard alongside the BETWEEN
+ -- range. The lexicographic comparison is safe on 4-digit strings;
+ -- the length guard is defense-in-depth against schema drift.
+ SELECT COALESCE(SUM(debit_amount - credit_amount), 0) INTO v_voucher_bank_net
+ FROM public.journal_entry_lines
+ WHERE journal_entry_id = p_existing_journal_entry_id
+ AND length(account_number) = 4
+ AND account_number BETWEEN '1900' AND '1999';
+
+ IF ABS(v_voucher_bank_net - v_total_amount) > 0.005 THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_AMOUNT_MISMATCH',
+ 'details', jsonb_build_object(
+ 'tx_sum', v_total_amount, 'voucher_bank_net', v_voucher_bank_net));
+ END IF;
+
+ FOR v_tx IN
+ SELECT * FROM public.transactions
+ WHERE id = ANY(p_tx_ids) AND company_id = p_company_id
+ ORDER BY id
+ LOOP
+ INSERT INTO public.transaction_voucher_links
+ (user_id, company_id, transaction_id, journal_entry_id, allocated_amount, role)
+ VALUES
+ (v_caller, p_company_id, v_tx.id, p_existing_journal_entry_id, v_tx.amount, 'bank_line');
+ END LOOP;
+
+ IF v_tx_count = 1 THEN
+ UPDATE public.transactions
+ SET journal_entry_id = p_existing_journal_entry_id,
+ reconciliation_method = 'manual',
+ is_business = TRUE,
+ updated_at = v_now
+ WHERE id = p_tx_ids[1];
+ ELSE
+ UPDATE public.transactions
+ SET is_business = TRUE, updated_at = v_now
+ WHERE id = ANY(p_tx_ids);
+ END IF;
+
+ v_target_je := p_existing_journal_entry_id;
+ v_voucher_series := v_voucher.voucher_series;
+ v_voucher_number := v_voucher.voucher_number;
+
+ ELSE
+ v_entry_description := p_new_entry->>'description';
+ IF v_entry_description IS NULL OR LENGTH(TRIM(v_entry_description)) = 0 THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_MISSING_DESCRIPTION');
+ END IF;
+
+ IF jsonb_typeof(p_new_entry->'lines') IS DISTINCT FROM 'array'
+ OR jsonb_array_length(p_new_entry->'lines') < 2 THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_NO_LINES');
+ END IF;
+
+ -- Round-2 fix: chart-of-accounts allowlist check inside the RPC.
+ -- The route's manual branch validates account_numbers, but the
+ -- template branch and any direct DB caller bypass that check.
+ -- Doing it here ensures every line, regardless of path, is verified
+ -- against the company's active BAS chart.
+ WITH submitted AS (
+ SELECT DISTINCT value->>'account_number' AS acct
+ FROM jsonb_array_elements(p_new_entry->'lines')
+ )
+ SELECT array_agg(s.acct ORDER BY s.acct) INTO v_invalid_accounts
+ FROM submitted s
+ WHERE NOT EXISTS (
+ SELECT 1 FROM public.chart_of_accounts coa
+ WHERE coa.account_number = s.acct
+ AND coa.company_id = p_company_id
+ AND coa.is_active = true
+ );
+ IF v_invalid_accounts IS NOT NULL AND array_length(v_invalid_accounts, 1) > 0 THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_INVALID_ACCOUNT',
+ 'details', jsonb_build_object('invalid_accounts', v_invalid_accounts));
+ END IF;
+
+ FOR v_line IN SELECT * FROM jsonb_array_elements(p_new_entry->'lines')
+ LOOP
+ v_line_account := v_line->>'account_number';
+ v_line_debit := COALESCE((v_line->>'debit_amount')::numeric, 0);
+ v_line_credit := COALESCE((v_line->>'credit_amount')::numeric, 0);
+ IF v_line_debit < 0 OR v_line_credit < 0 THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_NEGATIVE_LINE',
+ 'details', jsonb_build_object('account', v_line_account));
+ END IF;
+ IF v_line_debit > 0 AND v_line_credit > 0 THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_BOTH_SIDES_NONZERO',
+ 'details', jsonb_build_object('account', v_line_account));
+ END IF;
+ v_lines_total_debit := v_lines_total_debit + v_line_debit;
+ v_lines_total_credit := v_lines_total_credit + v_line_credit;
+ -- Round-2 fix: length(4) guard alongside the BETWEEN range.
+ IF length(v_line_account) = 4 AND v_line_account BETWEEN '1900' AND '1999' THEN
+ v_lines_bank_net := v_lines_bank_net + v_line_debit - v_line_credit;
+ END IF;
+ END LOOP;
+
+ IF ABS(v_lines_total_debit - v_lines_total_credit) > 0.005 THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_UNBALANCED',
+ 'details', jsonb_build_object(
+ 'debit_sum', v_lines_total_debit, 'credit_sum', v_lines_total_credit));
+ END IF;
+
+ IF ABS(v_lines_bank_net - v_total_amount) > 0.005 THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_AMOUNT_MISMATCH',
+ 'details', jsonb_build_object(
+ 'tx_sum', v_total_amount,
+ 'lines_bank_net', v_lines_bank_net));
+ END IF;
+
+ SELECT id, is_closed, locked_at INTO v_fiscal_period_id, v_period_is_closed, v_period_locked_at
+ FROM public.fiscal_periods
+ WHERE company_id = p_company_id AND v_tx_date BETWEEN period_start AND period_end
+ ORDER BY period_start DESC LIMIT 1;
+
+ IF v_fiscal_period_id IS NULL THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_NO_FISCAL_PERIOD',
+ 'details', jsonb_build_object('tx_date', v_tx_date));
+ END IF;
+
+ IF v_period_is_closed OR v_period_locked_at IS NOT NULL THEN
+ RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_PERIOD_LOCKED',
+ 'details', jsonb_build_object('fiscal_period_id', v_fiscal_period_id));
+ END IF;
+
+ v_journal_entry_id := gen_random_uuid();
+
+ INSERT INTO public.journal_entries
+ (id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
+ entry_date, description, source_type, status)
+ VALUES
+ (v_journal_entry_id, v_caller, p_company_id, v_fiscal_period_id, 0, v_voucher_series,
+ v_tx_date, v_entry_description, 'manual', 'draft');
+
+ v_sort_order := 0;
+ FOR v_line IN SELECT * FROM jsonb_array_elements(p_new_entry->'lines')
+ LOOP
+ v_line_account := v_line->>'account_number';
+ v_line_debit := COALESCE((v_line->>'debit_amount')::numeric, 0);
+ v_line_credit := COALESCE((v_line->>'credit_amount')::numeric, 0);
+ v_line_currency := COALESCE(v_line->>'currency', 'SEK');
+
+ INSERT INTO public.journal_entry_lines
+ (journal_entry_id, account_number, debit_amount, credit_amount, currency,
+ sort_order, line_description)
+ VALUES
+ (v_journal_entry_id, v_line_account, v_line_debit, v_line_credit, v_line_currency,
+ COALESCE((v_line->>'sort_order')::int, v_sort_order),
+ v_line->>'line_description');
+
+ v_sort_order := v_sort_order + 1;
+ END LOOP;
+
+ SELECT voucher_number INTO v_voucher_number
+ FROM public.commit_journal_entry(p_company_id, v_journal_entry_id);
+
+ FOR v_tx IN
+ SELECT * FROM public.transactions
+ WHERE id = ANY(p_tx_ids) AND company_id = p_company_id
+ ORDER BY id
+ LOOP
+ INSERT INTO public.transaction_voucher_links
+ (user_id, company_id, transaction_id, journal_entry_id, allocated_amount, role)
+ VALUES
+ (v_caller, p_company_id, v_tx.id, v_journal_entry_id, v_tx.amount, 'bank_line');
+ END LOOP;
+
+ IF v_tx_count = 1 THEN
+ UPDATE public.transactions
+ SET journal_entry_id = v_journal_entry_id,
+ is_business = TRUE,
+ updated_at = v_now
+ WHERE id = p_tx_ids[1];
+ ELSE
+ UPDATE public.transactions
+ SET is_business = TRUE, updated_at = v_now
+ WHERE id = ANY(p_tx_ids);
+ END IF;
+
+ v_target_je := v_journal_entry_id;
+ END IF;
+
+ -- Round-2 fix: explicit tenant isolation on the document side.
+ -- Without d.company_id = p_company_id, a cross-tenant document_id
+ -- on a transactions row (multi-tenant bug scenario) could link a
+ -- foreign tenant's doc onto this verifikat.
+ WITH linked AS (
+ UPDATE public.document_attachments AS d
+ SET journal_entry_id = v_target_je,
+ updated_at = v_now
+ FROM public.transactions AS t
+ WHERE t.id = ANY(p_tx_ids)
+ AND t.company_id = p_company_id
+ AND t.document_id = d.id
+ AND d.company_id = p_company_id
+ AND d.journal_entry_id IS NULL
+ RETURNING d.id
+ )
+ SELECT COUNT(*)::int INTO v_docs_linked FROM linked;
+
+ RETURN jsonb_build_object(
+ 'ok', true,
+ 'mode', CASE WHEN p_existing_journal_entry_id IS NOT NULL THEN 'link_existing' ELSE 'create_new' END,
+ 'journal_entry_id', v_target_je,
+ 'voucher_series', v_voucher_series,
+ 'voucher_number', v_voucher_number,
+ 'linked_tx_count', v_tx_count,
+ 'tx_sum', v_total_amount,
+ 'docs_linked', v_docs_linked
+ );
+END;
+$$;
+
+COMMENT ON FUNCTION public.bulk_book_transactions(uuid[], uuid, jsonb, uuid) IS
+ 'Bulk-book N bank transactions sharing the same date into a single combined verifikat (samlingsverifikation per BFL 5 kap 6§). PR #610 round 2: chart_of_accounts allowlist enforced inside the RPC (defense-in-depth against direct callers + template path); doc inheritance CTE tenant-scoped on both transaction and document sides; bank-leg range guarded by length(4) + BETWEEN.';
+
+-- Round-2 fix: explicit role grants (SOC 2 CC6.1) on both new RPCs.
+REVOKE ALL ON FUNCTION public.bulk_book_transactions(uuid[], uuid, jsonb, uuid) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.bulk_book_transactions(uuid[], uuid, jsonb, uuid) TO authenticated;
+
+REVOKE ALL ON FUNCTION public.match_batch_allocate(uuid, jsonb, uuid) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.match_batch_allocate(uuid, jsonb, uuid) TO authenticated;
+
+NOTIFY pgrst, 'reload schema';
diff --git a/tests/pg/bulk-book-transactions.pg.test.ts b/tests/pg/bulk-book-transactions.pg.test.ts
index 3b6b5763..cdb5f328 100644
--- a/tests/pg/bulk-book-transactions.pg.test.ts
+++ b/tests/pg/bulk-book-transactions.pg.test.ts
@@ -49,6 +49,26 @@ async function seedTenant() {
periodStart: '2026-01-01',
periodEnd: '2026-12-31',
})
+ // PR #610 round 2: the RPC now validates every line's account_number
+ // against the company's active chart_of_accounts. Seed just the
+ // accounts the tests touch (cheaper than calling
+ // seed_chart_of_accounts which inserts the full BAS).
+ await getPool().query(
+ `INSERT INTO public.chart_of_accounts
+ (user_id, company_id, account_number, account_name, account_class, account_type, normal_balance, is_active)
+ SELECT $1, $2, n, name, cls, atype, nbal, true
+ FROM (VALUES
+ ('1510', 'Kundfordringar', 1, 'asset', 'debit'),
+ ('1930', 'Bankkonto', 1, 'asset', 'debit'),
+ ('2440', 'Leverantörsskulder', 2, 'liability', 'credit'),
+ ('2611', 'Utgående moms 25%', 2, 'liability', 'credit'),
+ ('3001', 'Försäljning 25% moms', 3, 'revenue', 'credit'),
+ ('3960', 'Valutakursvinster', 3, 'revenue', 'credit'),
+ ('5800', 'Resekostnader', 5, 'expense', 'debit'),
+ ('7960', 'Valutakursförluster', 7, 'expense', 'debit')
+ ) AS t(n, name, cls, atype, nbal)`,
+ [userId, companyId],
+ )
return { userId, companyId, fiscalPeriodId }
}
@@ -84,8 +104,8 @@ describe('bulk_book_transactions — create new', () => {
await withUserContext(userId, async (client) => {
const r = await client.query<{ bulk_book_transactions: RpcResult }>(
- `SELECT bulk_book_transactions($1::uuid[], $2, $3::jsonb, $4, $5)`,
- [[tx1, tx2, tx3], null, JSON.stringify(newEntry), userId, companyId],
+ `SELECT bulk_book_transactions($1::uuid[], $2, $3::jsonb, $4)`,
+ [[tx1, tx2, tx3], null, JSON.stringify(newEntry), companyId],
)
const result = r.rows[0]!.bulk_book_transactions
expect(result.ok).toBe(true)
@@ -139,8 +159,8 @@ describe('bulk_book_transactions — create new', () => {
await withUserContext(userId, async (client) => {
const r = await client.query<{ bulk_book_transactions: RpcResult }>(
- `SELECT bulk_book_transactions($1::uuid[], $2, $3::jsonb, $4, $5)`,
- [[tx1, tx2], null, JSON.stringify(newEntry), userId, companyId],
+ `SELECT bulk_book_transactions($1::uuid[], $2, $3::jsonb, $4)`,
+ [[tx1, tx2], null, JSON.stringify(newEntry), companyId],
)
const result = r.rows[0]!.bulk_book_transactions
expect(result.ok).toBe(false)
@@ -162,8 +182,8 @@ describe('bulk_book_transactions — create new', () => {
await withUserContext(userId, async (client) => {
const r = await client.query<{ bulk_book_transactions: RpcResult }>(
- `SELECT bulk_book_transactions($1::uuid[], $2, $3::jsonb, $4, $5)`,
- [[tx1, tx2], null, JSON.stringify(newEntry), userId, companyId],
+ `SELECT bulk_book_transactions($1::uuid[], $2, $3::jsonb, $4)`,
+ [[tx1, tx2], null, JSON.stringify(newEntry), companyId],
)
const result = r.rows[0]!.bulk_book_transactions
expect(result.ok).toBe(false)
@@ -187,8 +207,8 @@ describe('bulk_book_transactions — create new', () => {
await withUserContext(userId, async (client) => {
const r = await client.query<{ bulk_book_transactions: RpcResult }>(
- `SELECT bulk_book_transactions($1::uuid[], $2, $3::jsonb, $4, $5)`,
- [[tx1, tx2], null, JSON.stringify(newEntry), userId, companyId],
+ `SELECT bulk_book_transactions($1::uuid[], $2, $3::jsonb, $4)`,
+ [[tx1, tx2], null, JSON.stringify(newEntry), companyId],
)
const result = r.rows[0]!.bulk_book_transactions
expect(result.ok).toBe(false)
@@ -210,8 +230,8 @@ describe('bulk_book_transactions — create new', () => {
await withUserContext(userId, async (client) => {
const r = await client.query<{ bulk_book_transactions: RpcResult }>(
- `SELECT bulk_book_transactions($1::uuid[], $2, $3::jsonb, $4, $5)`,
- [[tx1], null, JSON.stringify(newEntry), userId, companyId],
+ `SELECT bulk_book_transactions($1::uuid[], $2, $3::jsonb, $4)`,
+ [[tx1], null, JSON.stringify(newEntry), companyId],
)
const result = r.rows[0]!.bulk_book_transactions
expect(result.ok).toBe(false)
@@ -244,8 +264,8 @@ describe('bulk_book_transactions — link existing', () => {
await withUserContext(userId, async (client) => {
const r = await client.query<{ bulk_book_transactions: RpcResult }>(
- `SELECT bulk_book_transactions($1::uuid[], $2, $3, $4, $5)`,
- [[tx1, tx2], jeId, null, userId, companyId],
+ `SELECT bulk_book_transactions($1::uuid[], $2, $3, $4)`,
+ [[tx1, tx2], jeId, null, companyId],
)
const result = r.rows[0]!.bulk_book_transactions
expect(result.ok).toBe(true)
@@ -286,8 +306,8 @@ describe('bulk_book_transactions — link existing', () => {
await withUserContext(userId, async (client) => {
const r = await client.query<{ bulk_book_transactions: RpcResult }>(
- `SELECT bulk_book_transactions($1::uuid[], $2, $3, $4, $5)`,
- [[tx1, tx2], jeId, null, userId, companyId],
+ `SELECT bulk_book_transactions($1::uuid[], $2, $3, $4)`,
+ [[tx1, tx2], jeId, null, companyId],
)
const result = r.rows[0]!.bulk_book_transactions
expect(result.ok).toBe(false)
@@ -310,8 +330,8 @@ describe('bulk_book_transactions — link existing', () => {
await withUserContext(outsiderId, async (client) => {
const r = await client.query<{ bulk_book_transactions: RpcResult }>(
- `SELECT bulk_book_transactions($1::uuid[], $2, $3::jsonb, $4, $5)`,
- [[tx1], null, JSON.stringify(newEntry), outsiderId, companyId],
+ `SELECT bulk_book_transactions($1::uuid[], $2, $3::jsonb, $4)`,
+ [[tx1], null, JSON.stringify(newEntry), companyId],
)
const result = r.rows[0]!.bulk_book_transactions
expect(result.ok).toBe(false)
@@ -319,3 +339,189 @@ describe('bulk_book_transactions — link existing', () => {
})
})
})
+
+// PR #608 — document inheritance + manual lines path.
+async function insertDocumentForTx(params: {
+ userId: string
+ companyId: string
+ txId: string
+ fileName?: string
+}): Promise {
+ const docId = randomUUID()
+ await getPool().query(
+ `INSERT INTO public.document_attachments
+ (id, user_id, company_id, storage_path, file_name, sha256_hash)
+ VALUES ($1, $2, $3, $4, $5, $6)`,
+ [
+ docId,
+ params.userId,
+ params.companyId,
+ `test/${docId}.pdf`,
+ params.fileName ?? 'receipt.pdf',
+ // 64-char hex string — sha256 placeholder for the test.
+ docId.replace(/-/g, '').padEnd(64, '0'),
+ ],
+ )
+ await getPool().query(
+ `UPDATE public.transactions SET document_id = $1 WHERE id = $2`,
+ [docId, params.txId],
+ )
+ return docId
+}
+
+describe('bulk_book_transactions — document inheritance (PR #608)', () => {
+ it('copies each constituent tx document onto the combined new verifikat', async () => {
+ const { userId, companyId } = await seedTenant()
+ const tx1 = await insertTransaction({ userId, companyId, amount: 100 })
+ const tx2 = await insertTransaction({ userId, companyId, amount: 200 })
+ const tx3 = await insertTransaction({ userId, companyId, amount: 300 })
+
+ const doc1 = await insertDocumentForTx({ userId, companyId, txId: tx1, fileName: 'kvitto-1.pdf' })
+ const doc2 = await insertDocumentForTx({ userId, companyId, txId: tx2, fileName: 'kvitto-2.pdf' })
+ // tx3 intentionally without a doc — the RPC should not break and
+ // should report docs_linked = 2 (not 3).
+
+ const newEntry = {
+ description: 'Samlingsverifikation kiosk 2026-06-05',
+ lines: [
+ { account_number: '1930', debit_amount: 600, credit_amount: 0, currency: 'SEK' },
+ { account_number: '3001', debit_amount: 0, credit_amount: 480, currency: 'SEK' },
+ { account_number: '2611', debit_amount: 0, credit_amount: 120, currency: 'SEK' },
+ ],
+ }
+
+ await withUserContext(userId, async (client) => {
+ const r = await client.query<{ bulk_book_transactions: RpcResult & { docs_linked?: number } }>(
+ `SELECT bulk_book_transactions($1::uuid[], $2, $3::jsonb, $4)`,
+ [[tx1, tx2, tx3], null, JSON.stringify(newEntry), companyId],
+ )
+ const result = r.rows[0]!.bulk_book_transactions
+ expect(result.ok).toBe(true)
+ expect(result.docs_linked).toBe(2)
+
+ const docs = await client.query<{ id: string; journal_entry_id: string | null }>(
+ `SELECT id, journal_entry_id FROM public.document_attachments
+ WHERE id = ANY($1) ORDER BY id`,
+ [[doc1, doc2]],
+ )
+ expect(docs.rows).toHaveLength(2)
+ // Both docs now point at the new verifikat — verifikationsunderlag
+ // per BFL 5 kap 6§ + BFNAR 2013:2 kap 4.
+ for (const row of docs.rows) {
+ expect(row.journal_entry_id).toBe(result.journal_entry_id)
+ }
+ })
+ })
+
+ it('copies docs onto an existing posted verifikat (link-existing branch)', async () => {
+ const { userId, companyId, fiscalPeriodId } = await seedTenant()
+ const tx1 = await insertTransaction({ userId, companyId, amount: 100 })
+ const tx2 = await insertTransaction({ userId, companyId, amount: 200 })
+ const doc1 = await insertDocumentForTx({ userId, companyId, txId: tx1 })
+ const doc2 = await insertDocumentForTx({ userId, companyId, txId: tx2 })
+
+ // Manually pre-post a day-summary verifikat the user wants the txs
+ // linked to. Bank net must equal sum(tx.amount) = 300.
+ const jeId = randomUUID()
+ await getPool().query(
+ `INSERT INTO public.journal_entries
+ (id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
+ entry_date, description, source_type, status)
+ VALUES ($1, $2, $3, $4, 1, 'A', '2026-06-05', 'Manual day summary', 'manual', 'posted')`,
+ [jeId, userId, companyId, fiscalPeriodId],
+ )
+ await getPool().query(
+ `INSERT INTO public.journal_entry_lines (journal_entry_id, account_number, debit_amount, credit_amount, currency, sort_order)
+ VALUES ($1, '1930', 300, 0, 'SEK', 0),
+ ($1, '3001', 0, 240, 'SEK', 1),
+ ($1, '2611', 0, 60, 'SEK', 2)`,
+ [jeId],
+ )
+
+ await withUserContext(userId, async (client) => {
+ const r = await client.query<{ bulk_book_transactions: RpcResult & { docs_linked?: number } }>(
+ `SELECT bulk_book_transactions($1::uuid[], $2, $3::jsonb, $4)`,
+ [[tx1, tx2], jeId, null, companyId],
+ )
+ const result = r.rows[0]!.bulk_book_transactions
+ expect(result.ok).toBe(true)
+ expect(result.mode).toBe('link_existing')
+ expect(result.docs_linked).toBe(2)
+
+ const docs = await client.query<{ journal_entry_id: string | null }>(
+ `SELECT journal_entry_id FROM public.document_attachments WHERE id = ANY($1)`,
+ [[doc1, doc2]],
+ )
+ for (const row of docs.rows) expect(row.journal_entry_id).toBe(jeId)
+ })
+ })
+})
+
+describe('bulk_book_transactions — manual lines path (PR #608)', () => {
+ it('accepts user-built lines (no template expansion) and commits the combined verifikat', async () => {
+ const { userId, companyId } = await seedTenant()
+ // 2 expense txs of −400 each. Manual booking: 800 to a kostnadskonto
+ // (e.g. 5800 Resekostnader) + 800 from 1930.
+ const tx1 = await insertTransaction({ userId, companyId, amount: -400 })
+ const tx2 = await insertTransaction({ userId, companyId, amount: -400 })
+
+ const manualEntry = {
+ description: 'Resekostnader 2026-06-05 (manuell)',
+ lines: [
+ { account_number: '5800', debit_amount: 800, credit_amount: 0, currency: 'SEK', line_description: 'Tåg + taxi' },
+ { account_number: '1930', debit_amount: 0, credit_amount: 800, currency: 'SEK', line_description: 'Företagskontot' },
+ ],
+ }
+
+ await withUserContext(userId, async (client) => {
+ const r = await client.query<{ bulk_book_transactions: RpcResult }>(
+ `SELECT bulk_book_transactions($1::uuid[], $2, $3::jsonb, $4)`,
+ [[tx1, tx2], null, JSON.stringify(manualEntry), companyId],
+ )
+ const result = r.rows[0]!.bulk_book_transactions
+ expect(result.ok).toBe(true)
+ expect(result.mode).toBe('create_new')
+ expect(result.linked_tx_count).toBe(2)
+ expect(result.tx_sum).toBe(-800)
+
+ // Verify the verifikat has exactly the 2 user-supplied lines
+ // (no template expansion artifacts).
+ const lines = await client.query<{ account_number: string; debit_amount: string; credit_amount: string }>(
+ `SELECT account_number, debit_amount, credit_amount FROM public.journal_entry_lines
+ WHERE journal_entry_id = $1 ORDER BY sort_order`,
+ [result.journal_entry_id],
+ )
+ expect(lines.rows).toHaveLength(2)
+ expect(lines.rows[0]!.account_number).toBe('5800')
+ expect(Number(lines.rows[0]!.debit_amount)).toBe(800)
+ expect(lines.rows[1]!.account_number).toBe('1930')
+ expect(Number(lines.rows[1]!.credit_amount)).toBe(800)
+ })
+ })
+
+ it('rejects unbalanced manual lines (BFL 5 kap 6§ verifikat balance)', async () => {
+ const { userId, companyId } = await seedTenant()
+ const tx1 = await insertTransaction({ userId, companyId, amount: -400 })
+
+ // Debit ≠ credit on purpose. The RPC's existing BULK_BOOK_UNBALANCED
+ // guard catches this regardless of whether the lines came from the
+ // template path or the manual path.
+ const manualEntry = {
+ description: 'Test',
+ lines: [
+ { account_number: '5800', debit_amount: 500, credit_amount: 0, currency: 'SEK' },
+ { account_number: '1930', debit_amount: 0, credit_amount: 400, currency: 'SEK' },
+ ],
+ }
+
+ await withUserContext(userId, async (client) => {
+ const r = await client.query<{ bulk_book_transactions: RpcResult }>(
+ `SELECT bulk_book_transactions($1::uuid[], $2, $3::jsonb, $4)`,
+ [[tx1], null, JSON.stringify(manualEntry), companyId],
+ )
+ const result = r.rows[0]!.bulk_book_transactions
+ expect(result.ok).toBe(false)
+ expect(result.code).toBe('BULK_BOOK_UNBALANCED')
+ })
+ })
+})