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
@@ -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