feat(bulk-book): manual booking mode + document inheritance (#610)
* 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
@@ -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<Tab>('template')
|
||||
const [templates, setTemplates] = useState<BookingTemplateLibrary[]>([])
|
||||
const [loadingTemplates, setLoadingTemplates] = useState(true)
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null)
|
||||
const [mode, setMode] = useState<Mode>('one_line_per_tx')
|
||||
const [description, setDescription] = useState('')
|
||||
const [manualLines, setManualLines] = useState<ManualLine[]>([])
|
||||
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<PreviewLine[]>(() => {
|
||||
if (tab === 'manual') {
|
||||
return manualLines
|
||||
.map<PreviewLine>((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<Omit<ManualLine, 'id'>>) {
|
||||
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({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as Tab)} className="space-y-4">
|
||||
<TabsList className="grid grid-cols-2 w-full">
|
||||
<TabsTrigger value="template">{t('tab_template')}</TabsTrigger>
|
||||
<TabsTrigger value="manual">{t('tab_manual')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="template" className="space-y-4 mt-0">
|
||||
{/* Template picker */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t('template_label')}</Label>
|
||||
@@ -343,9 +476,107 @@ export default function BulkBookDialog({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Description */}
|
||||
{selectedTemplate && (
|
||||
<TabsContent value="manual" className="space-y-4 mt-0">
|
||||
{/* 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. */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>{t('manual_lines_label')}</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addManualLine}
|
||||
>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
{t('manual_add_line')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card overflow-hidden">
|
||||
<table className="w-full text-xs tabular-nums">
|
||||
<thead>
|
||||
<tr className="border-b text-muted-foreground bg-muted/30">
|
||||
<th className="px-2 py-2 text-left font-medium w-[90px]">{t('col_account')}</th>
|
||||
<th className="px-2 py-2 text-left font-medium">{t('col_description')}</th>
|
||||
<th className="px-2 py-2 text-right font-medium w-[110px]">{t('col_debit')}</th>
|
||||
<th className="px-2 py-2 text-right font-medium w-[110px]">{t('col_credit')}</th>
|
||||
<th className="w-8" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{manualLines.map((line) => (
|
||||
<tr key={line.id} className="border-b border-border/40 last:border-b-0">
|
||||
<td className="px-2 py-1">
|
||||
<Input
|
||||
value={line.account_number}
|
||||
onChange={(e) =>
|
||||
updateManualLine(line.id, { account_number: e.target.value.replace(/\D/g, '').slice(0, 4) })
|
||||
}
|
||||
placeholder="1930"
|
||||
className="h-8 text-xs font-mono"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-1">
|
||||
<Input
|
||||
value={line.line_description}
|
||||
onChange={(e) =>
|
||||
updateManualLine(line.id, { line_description: e.target.value.slice(0, 200) })
|
||||
}
|
||||
placeholder={t('manual_description_placeholder')}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-1">
|
||||
<Input
|
||||
inputMode="decimal"
|
||||
value={line.debit_amount}
|
||||
onChange={(e) =>
|
||||
updateManualLine(line.id, { debit_amount: e.target.value })
|
||||
}
|
||||
placeholder="0,00"
|
||||
className="h-8 text-xs text-right"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-1">
|
||||
<Input
|
||||
inputMode="decimal"
|
||||
value={line.credit_amount}
|
||||
onChange={(e) =>
|
||||
updateManualLine(line.id, { credit_amount: e.target.value })
|
||||
}
|
||||
placeholder="0,00"
|
||||
className="h-8 text-xs text-right"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-1 py-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => removeManualLine(line.id)}
|
||||
aria-label={t('manual_remove_line')}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Description — shared by both tabs once the user has either a
|
||||
template selected or manual lines drafted. */}
|
||||
{tabReady && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bulk-description">{t('description_label')}</Label>
|
||||
<Input
|
||||
@@ -357,8 +588,19 @@ export default function BulkBookDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 && (
|
||||
<div className="flex items-center gap-2 rounded-lg border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
|
||||
<Paperclip className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span>{t('docs_inherit_hint', { count: docCount })}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Live preview */}
|
||||
{selectedTemplate && previewLines.length > 0 && (
|
||||
{tabReady && previewLines.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{t('preview_label', { count: previewLines.length })}
|
||||
|
||||
+31
-2
@@ -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'],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -2084,6 +2084,13 @@ const BULK_BOOK: Record<string, StructuredErrorEntry> = {
|
||||
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.',
|
||||
},
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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<string> {
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user