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:
co-authored by
Claude Opus 4.7
parent
ea1bf01f1e
commit
28f7cefc86
@@ -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,
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user