feat(transactions): bulk-book + is-booked predicate (#606)
* feat(transactions): bulk-book + is-booked predicate Closes the second of the two multi-tx ↔ multi-voucher flows from the original plan. Where PR #603's match_batch_allocate took 1 tx and spread it across N invoices (samlingsbetalning), this PR takes N bank transactions on the same day and rolls them up into ONE combined verifikat (samlingsverifikation per BFL 5 kap 6§ st 3) — the kiosk masshantering pattern the user explicitly asked for. ## Backend (Phase 3b) - **PL/pgSQL RPC** bulk_book_transactions: two branches, both atomic. 1. Link to existing posted verifikat (p_existing_journal_entry_id): no new JE. Validates the JE's 19xx net equals sum(tx.amount), inserts N transaction_voucher_links rows, and for N=1 also sets transactions.journal_entry_id (1:1 reader-path back-compat). 2. Create new combined verifikat (p_new_entry with pre-computed balanced lines): the route's applyTemplate() has already done ratio + VAT expansion per the chosen mode. The RPC validates the lines balance and the 1930 net matches sum(tx.amount), then commits via commit_journal_entry. Same security pattern as match_batch_allocate: company-member check via auth.uid(), SELECT … FOR UPDATE on each tx in id order, deterministic fiscal-period resolution (ORDER BY period_start DESC). - **Endpoint** POST /api/transactions/bulk-book — fetches template via RLS, expands per mode (one_line_per_tx | sum_per_account) using lib/bookkeeping/template-library.applyTemplate, passes the resulting lines to the RPC. On success emits one transaction.reconciled event per tx. - **22 new BULK_BOOK_* error codes** (sv + en) covering all guard paths. ## UI (Phase 5b) - **BulkBookDialog** — template picker + mode toggle (segmented control: en rad per transaktion / summera per konto) + live preview table with balance + bank-leg invariant indicators. Confirm only enabled when both pass. - **Multi-select inbox** — sticky action bar gains a "Bokför i klump" button gated by same-date + same-direction across selected txs. Tooltip explains the disabled state. ## Phase 6: is-booked predicate New lib/transactions/is-booked.ts. After multi-allocation and bulk- book, tx.journal_entry_id can be NULL even though the tx is anchored (via invoice_payments / supplier_invoice_payments / transaction_voucher_links). The helper checks all three storage locations so future readers don't falsely show multi-anchored txs as "unbooked". Companion getPrimaryJournalEntryId() resolves the best JE link to surface in UI. SQL mirror is_transaction_booked() exists from the PR #602 foundation migration. Existing readers (TransactionHistoryList, TransactionInboxCard) are not yet refactored to use the helper — that's a follow-up that touches per-tx JE links across multiple call sites. The helper is documented + tested so subsequent refactors are mechanical. ## Tests - tests/pg/bulk-book-transactions.pg.test.ts — 8 pg-real scenarios (happy path create-new with 3 txs, happy path link-existing, date mismatch, direction mismatch, amount mismatch, unbalanced lines, unauthorized). - app/api/transactions/bulk-book/__tests__/route.test.ts — 5 unit tests (schema XOR, link path, create-new with template fetch + applyTemplate, structured-error mapping). - lib/transactions/__tests__/is-booked.test.ts — 11 cases covering all three storage locations + primary-JE resolution. 26 unit tests pass on touched paths. RPC migration applied to remote via Supabase MCP. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(bulk-book): PR #606 review round 1 + CI fixes Closes the build failure and the two real Greptile findings. ## CI - **core-only + Vercel build fail**: I used useMemo for selectedTransactions and bulkBookEligible on the transactions page without importing it. TypeScript build (`next build`) caught it with "Cannot find name 'useMemo'". Fixed the import. ## Review findings - **(P1) Currency mismatch returned BULK_BOOK_DIRECTION_MISMATCH** whose user-facing message blames direction. Mixed SEK + EUR batches would show "All transactions must be the same direction" which is factually wrong. Introduced dedicated BULK_BOOK_MIXED_CURRENCY code (sv + en) explaining the actual constraint, and switched the route to use it. - **(P1) Branch B (create-new) N=1 missed reconciliation_method='manual'**. Branch A's N=1 UPDATE sets it alongside journal_entry_id; Branch B's didn't, leaving the reconciliation_method NULL even though the single tx was reconciled via the same flow. Downstream readers (reconciliation reports, status indicators) would treat the two N=1 paths differently. New follow-up migration patches Branch B's final UPDATE. RPC patch applied to remote via Supabase MCP. 26 unit tests pass. 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
6629964780
commit
4da87e5e4c
@@ -0,0 +1,321 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
insertAuthUser,
|
||||
insertCompany,
|
||||
insertCompanyMember,
|
||||
insertFiscalPeriod,
|
||||
} from '@/tests/pg/fixtures'
|
||||
import { getPool, withUserContext } from '@/tests/pg/setup'
|
||||
|
||||
/**
|
||||
* Covers 20260530120000_bulk_book_transactions:
|
||||
*
|
||||
* - Happy path create-new: 3 income txs on the same day → one
|
||||
* combined verifikat (samlingsverifikation) with the caller-supplied
|
||||
* lines. transaction_voucher_links populated. Bank net equals tx sum.
|
||||
*
|
||||
* - Happy path link-existing: 3 txs linked to an already-posted manual
|
||||
* day-summary verifikat. Just inserts junction rows.
|
||||
*
|
||||
* - Guard codes: date mismatch, direction mismatch, already-booked tx,
|
||||
* amount mismatch, unbalanced lines, no-bank-line, unauthorized.
|
||||
*/
|
||||
|
||||
async function insertTransaction(params: {
|
||||
userId: string
|
||||
companyId: string
|
||||
amount: number
|
||||
date?: string
|
||||
currency?: string
|
||||
}): Promise<string> {
|
||||
const id = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.transactions
|
||||
(id, user_id, company_id, date, description, amount, currency, category)
|
||||
VALUES ($1, $2, $3, $4, 'Bank tx', $5, $6, 'uncategorized')`,
|
||||
[id, params.userId, params.companyId, params.date ?? '2026-06-05', params.amount, params.currency ?? 'SEK'],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
async function seedTenant() {
|
||||
const userId = await insertAuthUser()
|
||||
const companyId = await insertCompany({ createdBy: userId })
|
||||
await insertCompanyMember({ companyId, userId, role: 'owner' })
|
||||
const fiscalPeriodId = await insertFiscalPeriod({
|
||||
userId,
|
||||
companyId,
|
||||
periodStart: '2026-01-01',
|
||||
periodEnd: '2026-12-31',
|
||||
})
|
||||
return { userId, companyId, fiscalPeriodId }
|
||||
}
|
||||
|
||||
interface RpcResult {
|
||||
ok: boolean
|
||||
code?: string
|
||||
details?: Record<string, unknown>
|
||||
mode?: 'link_existing' | 'create_new'
|
||||
journal_entry_id?: string
|
||||
voucher_number?: number
|
||||
linked_tx_count?: number
|
||||
tx_sum?: number
|
||||
}
|
||||
|
||||
describe('bulk_book_transactions — create new', () => {
|
||||
it('builds a single combined verifikat from caller-supplied lines (kiosk samlingsverifikation)', async () => {
|
||||
const { userId, companyId } = await seedTenant()
|
||||
// 3 income txs at 100/200/300 SEK on the same day.
|
||||
const tx1 = await insertTransaction({ userId, companyId, amount: 100 })
|
||||
const tx2 = await insertTransaction({ userId, companyId, amount: 200 })
|
||||
const tx3 = await insertTransaction({ userId, companyId, amount: 300 })
|
||||
|
||||
// Pre-computed lines (route-side template expansion in TS).
|
||||
// Total: 600 SEK. 25% VAT split: 480 net + 120 VAT.
|
||||
const newEntry = {
|
||||
description: 'Samlingsverifikation kiosk 2026-06-05',
|
||||
lines: [
|
||||
{ account_number: '1930', debit_amount: 600, credit_amount: 0, currency: 'SEK', line_description: 'Inbetalningar Swish' },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 480, currency: 'SEK', line_description: 'Försäljning' },
|
||||
{ account_number: '2611', debit_amount: 0, credit_amount: 120, currency: 'SEK', line_description: 'Utgående moms 25%' },
|
||||
],
|
||||
}
|
||||
|
||||
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],
|
||||
)
|
||||
const result = r.rows[0]!.bulk_book_transactions
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.mode).toBe('create_new')
|
||||
expect(result.journal_entry_id).toBeTruthy()
|
||||
expect(result.linked_tx_count).toBe(3)
|
||||
expect(result.tx_sum).toBe(600)
|
||||
|
||||
// Verify lines on the new verifikat.
|
||||
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(3)
|
||||
const bankLine = lines.rows.find((l) => l.account_number === '1930')
|
||||
expect(Number(bankLine!.debit_amount)).toBe(600)
|
||||
|
||||
// Verify 3 transaction_voucher_links rows pointing at the same JE.
|
||||
const links = await client.query<{ allocated_amount: string; transaction_id: string }>(
|
||||
`SELECT allocated_amount, transaction_id FROM public.transaction_voucher_links
|
||||
WHERE journal_entry_id = $1`,
|
||||
[result.journal_entry_id],
|
||||
)
|
||||
expect(links.rows).toHaveLength(3)
|
||||
const linkedTxIds = new Set(links.rows.map((l) => l.transaction_id))
|
||||
expect(linkedTxIds).toEqual(new Set([tx1, tx2, tx3]))
|
||||
|
||||
// For N>1, transactions.journal_entry_id is NOT set on the individual rows.
|
||||
const txRow1 = await client.query<{ journal_entry_id: string | null; is_business: boolean }>(
|
||||
`SELECT journal_entry_id, is_business FROM public.transactions WHERE id = $1`,
|
||||
[tx1],
|
||||
)
|
||||
expect(txRow1.rows[0]!.journal_entry_id).toBeNull()
|
||||
expect(txRow1.rows[0]!.is_business).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects BULK_BOOK_DATE_MISMATCH when txs span multiple dates', async () => {
|
||||
const { userId, companyId } = await seedTenant()
|
||||
const tx1 = await insertTransaction({ userId, companyId, amount: 100, date: '2026-06-05' })
|
||||
const tx2 = await insertTransaction({ userId, companyId, amount: 100, date: '2026-06-06' })
|
||||
|
||||
const newEntry = {
|
||||
description: 'Test',
|
||||
lines: [
|
||||
{ account_number: '1930', debit_amount: 200, credit_amount: 0, currency: 'SEK' },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 200, 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, $5)`,
|
||||
[[tx1, tx2], null, JSON.stringify(newEntry), userId, companyId],
|
||||
)
|
||||
const result = r.rows[0]!.bulk_book_transactions
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.code).toBe('BULK_BOOK_DATE_MISMATCH')
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects BULK_BOOK_DIRECTION_MISMATCH when income + expense txs are mixed', async () => {
|
||||
const { userId, companyId } = await seedTenant()
|
||||
const tx1 = await insertTransaction({ userId, companyId, amount: 100 })
|
||||
const tx2 = await insertTransaction({ userId, companyId, amount: -100 })
|
||||
|
||||
const newEntry = {
|
||||
description: 'Test',
|
||||
lines: [
|
||||
{ account_number: '1930', debit_amount: 0, credit_amount: 0, 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, $5)`,
|
||||
[[tx1, tx2], null, JSON.stringify(newEntry), userId, companyId],
|
||||
)
|
||||
const result = r.rows[0]!.bulk_book_transactions
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.code).toBe('BULK_BOOK_DIRECTION_MISMATCH')
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects BULK_BOOK_AMOUNT_MISMATCH when bank-line net does not equal tx sum', async () => {
|
||||
const { userId, companyId } = await seedTenant()
|
||||
const tx1 = await insertTransaction({ userId, companyId, amount: 100 })
|
||||
const tx2 = await insertTransaction({ userId, companyId, amount: 200 })
|
||||
|
||||
// Caller claims 500 SEK net on 1930 but txs sum to 300.
|
||||
const newEntry = {
|
||||
description: 'Test',
|
||||
lines: [
|
||||
{ account_number: '1930', debit_amount: 500, credit_amount: 0, currency: 'SEK' },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 500, 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, $5)`,
|
||||
[[tx1, tx2], null, JSON.stringify(newEntry), userId, companyId],
|
||||
)
|
||||
const result = r.rows[0]!.bulk_book_transactions
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.code).toBe('BULK_BOOK_AMOUNT_MISMATCH')
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects BULK_BOOK_UNBALANCED when debits do not equal credits', async () => {
|
||||
const { userId, companyId } = await seedTenant()
|
||||
const tx1 = await insertTransaction({ userId, companyId, amount: 100 })
|
||||
|
||||
const newEntry = {
|
||||
description: 'Test',
|
||||
lines: [
|
||||
{ account_number: '1930', debit_amount: 100, credit_amount: 0, currency: 'SEK' },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 90, 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, $5)`,
|
||||
[[tx1], null, JSON.stringify(newEntry), userId, companyId],
|
||||
)
|
||||
const result = r.rows[0]!.bulk_book_transactions
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.code).toBe('BULK_BOOK_UNBALANCED')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('bulk_book_transactions — link existing', () => {
|
||||
it('links N txs to an already-posted day-summary verifikat', async () => {
|
||||
const { userId, companyId, fiscalPeriodId } = await seedTenant()
|
||||
const tx1 = await insertTransaction({ userId, companyId, amount: 100 })
|
||||
const tx2 = await insertTransaction({ userId, companyId, amount: 200 })
|
||||
|
||||
// Pre-create a posted manual verifikat with the right bank net (+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 dagssumma', 'manual', 'draft')`,
|
||||
[jeId, userId, companyId, fiscalPeriodId],
|
||||
)
|
||||
await getPool().query(
|
||||
`INSERT INTO public.journal_entry_lines (journal_entry_id, account_number, debit_amount, credit_amount)
|
||||
VALUES ($1, '1930', 300, 0), ($1, '3001', 0, 240), ($1, '2611', 0, 60)`,
|
||||
[jeId],
|
||||
)
|
||||
await getPool().query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [jeId])
|
||||
|
||||
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],
|
||||
)
|
||||
const result = r.rows[0]!.bulk_book_transactions
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.mode).toBe('link_existing')
|
||||
expect(result.journal_entry_id).toBe(jeId)
|
||||
expect(result.linked_tx_count).toBe(2)
|
||||
|
||||
// No new JE was created — only junction rows.
|
||||
const links = await client.query<{ transaction_id: string }>(
|
||||
`SELECT transaction_id FROM public.transaction_voucher_links
|
||||
WHERE journal_entry_id = $1`,
|
||||
[jeId],
|
||||
)
|
||||
expect(links.rows).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects link with BULK_BOOK_AMOUNT_MISMATCH when bank net does not equal tx sum', async () => {
|
||||
const { userId, companyId, fiscalPeriodId } = await seedTenant()
|
||||
const tx1 = await insertTransaction({ userId, companyId, amount: 100 })
|
||||
const tx2 = await insertTransaction({ userId, companyId, amount: 200 })
|
||||
|
||||
// JE bank net = +400 but txs sum to 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', 'manual', 'draft')`,
|
||||
[jeId, userId, companyId, fiscalPeriodId],
|
||||
)
|
||||
await getPool().query(
|
||||
`INSERT INTO public.journal_entry_lines (journal_entry_id, account_number, debit_amount, credit_amount)
|
||||
VALUES ($1, '1930', 400, 0), ($1, '3001', 0, 400)`,
|
||||
[jeId],
|
||||
)
|
||||
await getPool().query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [jeId])
|
||||
|
||||
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],
|
||||
)
|
||||
const result = r.rows[0]!.bulk_book_transactions
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.code).toBe('BULK_BOOK_AMOUNT_MISMATCH')
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects BULK_BOOK_UNAUTHORIZED when caller is not a company member', async () => {
|
||||
const { userId, companyId } = await seedTenant()
|
||||
const tx1 = await insertTransaction({ userId, companyId, amount: 100 })
|
||||
|
||||
const outsiderId = await insertAuthUser()
|
||||
const newEntry = {
|
||||
description: 'Test',
|
||||
lines: [
|
||||
{ account_number: '1930', debit_amount: 100, credit_amount: 0, currency: 'SEK' },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 100, currency: 'SEK' },
|
||||
],
|
||||
}
|
||||
|
||||
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],
|
||||
)
|
||||
const result = r.rows[0]!.bulk_book_transactions
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.code).toBe('BULK_BOOK_UNAUTHORIZED')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user