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
@@ -1930,6 +1930,141 @@ const MATCH_BATCH: Record<string, StructuredErrorEntry> = {
|
||||
},
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Bulk-book (bulk_book_transactions RPC): N txs → 1 verifikat
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const BULK_BOOK: Record<string, StructuredErrorEntry> = {
|
||||
BULK_BOOK_UNAUTHORIZED: {
|
||||
httpStatus: 403,
|
||||
message_sv: 'Du har inte behörighet att bokföra transaktioner för det här företaget.',
|
||||
message_en: 'You are not authorized to bulk-book transactions for this company.',
|
||||
},
|
||||
BULK_BOOK_NO_TXS: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Inga transaktioner att bokföra.',
|
||||
message_en: 'No transactions to book.',
|
||||
},
|
||||
BULK_BOOK_TXS_NOT_FOUND: {
|
||||
httpStatus: 404,
|
||||
message_sv: 'En eller flera transaktioner kunde inte hittas i det aktuella företaget.',
|
||||
message_en: 'One or more transactions could not be found in this company.',
|
||||
},
|
||||
BULK_BOOK_TX_ALREADY_BOOKED: {
|
||||
httpStatus: 409,
|
||||
message_sv:
|
||||
'En av de valda transaktionerna är redan bokförd. Avbokföra (storno) den först eller välj bort den.',
|
||||
message_en:
|
||||
'One of the selected transactions is already booked. Reverse the existing journal entry first or deselect it.',
|
||||
},
|
||||
BULK_BOOK_TX_ZERO_AMOUNT: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Transaktioner med beloppet 0 kan inte ingå i en samlingsbokföring.',
|
||||
message_en: 'Zero-amount transactions cannot be part of a bulk booking.',
|
||||
},
|
||||
BULK_BOOK_DATE_MISMATCH: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
'Alla transaktioner i en samlingsbokföring måste ha samma datum (BFL 5 kap 6§).',
|
||||
message_en:
|
||||
'All transactions in a bulk booking must share the same date (BFL 5 kap 6§).',
|
||||
},
|
||||
BULK_BOOK_DIRECTION_MISMATCH: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
'Alla transaktioner måste vara samma riktning (alla intäkter eller alla utgifter).',
|
||||
message_en: 'All transactions must be the same direction (all income or all expense).',
|
||||
},
|
||||
BULK_BOOK_MIXED_CURRENCY: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
'Samlingsbokföring stödjer endast transaktioner i samma valuta. Välj transaktioner i en valuta åt gången.',
|
||||
message_en:
|
||||
'Bulk booking supports only single-currency batches. Select transactions in one currency at a time.',
|
||||
},
|
||||
BULK_BOOK_INVALID_PAYLOAD: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
'Ange antingen existing_journal_entry_id (länkning) eller template_id (skapa ny) — inte båda, och inte ingen.',
|
||||
message_en:
|
||||
'Provide either existing_journal_entry_id (link) or template_id (create new) — not both, and not neither.',
|
||||
},
|
||||
BULK_BOOK_TEMPLATE_NOT_FOUND: {
|
||||
httpStatus: 404,
|
||||
message_sv: 'Den valda bokföringsmallen kunde inte hittas.',
|
||||
message_en: 'The selected booking template could not be found.',
|
||||
},
|
||||
BULK_BOOK_VOUCHER_NOT_FOUND: {
|
||||
httpStatus: 404,
|
||||
message_sv: 'Verifikationen kunde inte hittas.',
|
||||
message_en: 'The target journal entry could not be found.',
|
||||
},
|
||||
BULK_BOOK_VOUCHER_NOT_POSTED: {
|
||||
httpStatus: 409,
|
||||
message_sv: 'Endast bokförda verifikationer kan länkas mot banktransaktioner.',
|
||||
message_en: 'Only posted journal entries can be linked.',
|
||||
},
|
||||
BULK_BOOK_NO_BANK_LINE: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
'Verifikationen har ingen rad på bankkonto (19xx). Den kan inte länkas mot banktransaktioner.',
|
||||
message_en:
|
||||
'The journal entry has no bank-account (19xx) line and cannot be linked to bank transactions.',
|
||||
},
|
||||
BULK_BOOK_AMOUNT_MISMATCH: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
'Summan av transaktionerna stämmer inte med bankradens nettobelopp på verifikationen.',
|
||||
message_en:
|
||||
'The sum of the selected transactions does not match the bank-line net amount on the journal entry.',
|
||||
},
|
||||
BULK_BOOK_NO_LINES: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Verifikationen måste innehålla minst två rader (debit och kredit).',
|
||||
message_en: 'The journal entry must contain at least two lines (debit and credit).',
|
||||
},
|
||||
BULK_BOOK_UNBALANCED: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Verifikationen balanserar inte — summa debet måste lika summa kredit.',
|
||||
message_en: 'The journal entry does not balance — debits must equal credits.',
|
||||
},
|
||||
BULK_BOOK_NEGATIVE_LINE: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Verifikationsrader kan inte ha negativa belopp.',
|
||||
message_en: 'Journal entry lines cannot have negative amounts.',
|
||||
},
|
||||
BULK_BOOK_BOTH_SIDES_NONZERO: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'En verifikationsrad kan inte ha både debet och kredit nollskilda.',
|
||||
message_en: 'A journal entry line cannot have both debit and credit non-zero.',
|
||||
},
|
||||
BULK_BOOK_MISSING_DESCRIPTION: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Beskrivning krävs för en ny samlingsverifikation.',
|
||||
message_en: 'Description is required when creating a new combined journal entry.',
|
||||
},
|
||||
BULK_BOOK_NO_FISCAL_PERIOD: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
'Det finns ingen öppen räkenskapsperiod för transaktionsdatumet. Skapa perioden först.',
|
||||
message_en:
|
||||
'No fiscal period exists for the transaction date. Create the period first.',
|
||||
},
|
||||
BULK_BOOK_PERIOD_LOCKED: {
|
||||
httpStatus: 409,
|
||||
message_sv:
|
||||
'Räkenskapsperioden för transaktionsdatumet är stängd. Öppna perioden eller välj ett annat datum.',
|
||||
message_en:
|
||||
'The fiscal period for the transaction date is closed/locked.',
|
||||
},
|
||||
BULK_BOOK_RPC_FAILED: {
|
||||
httpStatus: 500,
|
||||
message_sv: 'Databasfel under samlingsbokföring. Försök igen.',
|
||||
message_en: 'Database error during bulk booking. Please retry.',
|
||||
retryable: true,
|
||||
},
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Combined registry
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
@@ -1943,6 +2078,7 @@ const REGISTRY: Record<string, StructuredErrorEntry> = {
|
||||
...LINK_INVOICE_VOUCHER,
|
||||
...LINK_SI_VOUCHER,
|
||||
...MATCH_BATCH,
|
||||
...BULK_BOOK,
|
||||
...MATCH_SI,
|
||||
...INVOICE,
|
||||
...SUPPLIER_INVOICE,
|
||||
|
||||
Reference in New Issue
Block a user