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