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:
Jakob Wennberg
2026-05-29 14:55:24 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 6629964780
commit 4da87e5e4c
13 changed files with 2310 additions and 2 deletions
+56
View File
@@ -518,6 +518,62 @@ export const LinkSupplierInvoiceToVoucherSchema = z.object({
notes: z.string().max(2000).optional(),
})
/**
* Bulk-book N bank transactions on the same date into one combined verifikat
* (samlingsverifikation per BFL 5 kap 6§). Two flows multiplexed by which
* field is set:
*
* - `existing_journal_entry_id`: link the txs to an already-posted voucher
* (no new JE created). The voucher's 19xx net must equal the tx sum.
*
* - `template_id` + `mode` + `entry_description`: build a new verifikat
* by applying the booking template to each tx. The route does the ratio
* expansion (one_line_per_tx OR sum_per_account) and passes the final
* lines to the RPC.
*
* Exactly one of the two paths must be set — enforced by superRefine.
*/
export const BulkBookSchema = z
.object({
tx_ids: z
.array(uuid)
.min(1, 'At least one transaction is required')
.max(200, 'At most 200 transactions per batch'),
existing_journal_entry_id: uuid.optional(),
template_id: uuid.optional(),
mode: z.enum(['one_line_per_tx', 'sum_per_account']).optional(),
entry_description: z.string().min(1).max(500).optional(),
})
.superRefine((data, ctx) => {
const hasExisting = !!data.existing_journal_entry_id
const hasTemplate = !!data.template_id
if (hasExisting === hasTemplate) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
'Provide either existing_journal_entry_id (link) or template_id (create new) — not both, and not neither',
path: ['existing_journal_entry_id'],
})
return
}
if (hasTemplate) {
if (!data.mode) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'mode is required when template_id is set',
path: ['mode'],
})
}
if (!data.entry_description) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'entry_description is required when template_id is set',
path: ['entry_description'],
})
}
}
})
/**
* Allocate one bank transaction across N customer OR N supplier invoices.
* Backed by the match_batch_allocate PL/pgSQL RPC, which builds a single
+136
View File
@@ -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,
@@ -0,0 +1,74 @@
import { describe, it, expect } from 'vitest'
import { isTransactionBooked, getPrimaryJournalEntryId } from '../is-booked'
describe('isTransactionBooked', () => {
it('returns false for a tx with no journal entry, payments, or voucher links', () => {
const tx = { id: 'tx-1', journal_entry_id: null }
expect(isTransactionBooked(tx)).toBe(false)
expect(isTransactionBooked(tx, [], [])).toBe(false)
})
it('returns true when transactions.journal_entry_id is set (1:1 case)', () => {
const tx = { id: 'tx-1', journal_entry_id: 'je-1' }
expect(isTransactionBooked(tx)).toBe(true)
})
it('returns true when a matching invoice_payments row exists (multi-allocation)', () => {
const tx = { id: 'tx-1', journal_entry_id: null }
const payments = [{ transaction_id: 'tx-1' }]
expect(isTransactionBooked(tx, payments)).toBe(true)
})
it('returns true when a matching supplier_invoice_payments row exists', () => {
const tx = { id: 'tx-1', journal_entry_id: null }
const payments = [{ transaction_id: 'tx-1' }]
expect(isTransactionBooked(tx, payments)).toBe(true)
})
it('returns true when a transaction_voucher_links row references the tx (bulk-book)', () => {
const tx = { id: 'tx-1', journal_entry_id: null }
const links = [{ transaction_id: 'tx-1' }]
expect(isTransactionBooked(tx, [], links)).toBe(true)
})
it('ignores payment / voucher-link rows that reference a different tx', () => {
const tx = { id: 'tx-1', journal_entry_id: null }
const payments = [{ transaction_id: 'tx-other' }]
const links = [{ transaction_id: 'tx-other' }]
expect(isTransactionBooked(tx, payments, links)).toBe(false)
})
})
describe('getPrimaryJournalEntryId', () => {
it('returns null when nothing is anchored', () => {
const tx = { id: 'tx-1', journal_entry_id: null }
expect(getPrimaryJournalEntryId(tx)).toBeNull()
})
it('prefers transactions.journal_entry_id when set', () => {
const tx = { id: 'tx-1', journal_entry_id: 'je-1' }
const payments = [{ transaction_id: 'tx-1', journal_entry_id: 'je-payment' }]
const links = [{ transaction_id: 'tx-1', journal_entry_id: 'je-link' }]
expect(getPrimaryJournalEntryId(tx, payments, links)).toBe('je-1')
})
it('falls back to voucher-link when tx.journal_entry_id is null', () => {
const tx = { id: 'tx-1', journal_entry_id: null }
const links = [{ transaction_id: 'tx-1', journal_entry_id: 'je-link' }]
expect(getPrimaryJournalEntryId(tx, [], links)).toBe('je-link')
})
it('falls back to invoice_payments JE when no link exists', () => {
const tx = { id: 'tx-1', journal_entry_id: null }
const payments = [{ transaction_id: 'tx-1', journal_entry_id: 'je-payment' }]
expect(getPrimaryJournalEntryId(tx, payments, [])).toBe('je-payment')
})
it('returns null when matching payment has journal_entry_id=null', () => {
// Edge: an invoice_payments row that pre-dates the JE creation (the
// engine's non-blocking JE write can leave this null briefly).
const tx = { id: 'tx-1', journal_entry_id: null }
const payments = [{ transaction_id: 'tx-1', journal_entry_id: null }]
expect(getPrimaryJournalEntryId(tx, payments, [])).toBeNull()
})
})
+87
View File
@@ -0,0 +1,87 @@
/**
* Centralised predicate for "is this bank transaction anchored to a
* verifikat?" — single source of truth that readers across the inbox,
* history list, and MCP filters use to decide whether a tx is unbooked
* (needs categorisation) vs already attached to a journal entry.
*
* Three storage locations to consider, all of which can independently
* make a tx "booked":
*
* 1. transactions.journal_entry_id — the 1:1 case (single tx → single
* verifikat via categorisation, match-invoice, or match-supplier-invoice).
*
* 2. invoice_payments / supplier_invoice_payments — the multi-allocation
* case (PR #603's match_batch_allocate). One tx with multiple payment
* rows pointing at the same combined verifikat; the row in transactions
* itself has journal_entry_id = NULL because no single invoice ID
* captures the full picture.
*
* 3. transaction_voucher_links — the N-tx-to-1-JE case (the bulk-book
* flow). Same combined verifikat, multiple bank lines, each tx's row
* in transactions has journal_entry_id = NULL for N>1.
*
* If a reader only checks `tx.journal_entry_id`, every multi-tx and
* multi-allocation case falsely shows as "unbooked" and would re-surface
* in the inbox or hide the "Open verifikat" affordance. Use this helper
* to avoid that.
*
* The Postgres mirror is `public.is_transaction_booked(uuid)`
* (migration 20260529120000_transaction_voucher_links.sql) — same
* predicate, three storage locations, in SQL.
*/
interface TxLike {
id: string
journal_entry_id: string | null
}
interface PaymentLike {
transaction_id: string | null
}
interface VoucherLinkLike {
transaction_id: string
}
/**
* @param tx - the bank transaction row (must include `journal_entry_id`)
* @param payments - rows from invoice_payments AND supplier_invoice_payments
* filtered to ones whose transaction_id might equal tx.id.
* May be empty if the reader didn't fetch them.
* @param voucherLinks - rows from transaction_voucher_links filtered to ones
* whose transaction_id might equal tx.id. May be empty.
*/
export function isTransactionBooked(
tx: TxLike,
payments: PaymentLike[] = [],
voucherLinks: VoucherLinkLike[] = [],
): boolean {
if (tx.journal_entry_id != null) return true
if (payments.some((p) => p.transaction_id === tx.id)) return true
if (voucherLinks.some((v) => v.transaction_id === tx.id)) return true
return false
}
/**
* Resolve the "primary" journal_entry_id to link to from the UI when a
* tx has multiple anchoring rows. Order of precedence:
*
* 1. tx.journal_entry_id (the 1:1 case — always the right answer)
* 2. First voucher-link row (multi-tx bulk-book points all txs at one JE)
* 3. First payment row (multi-allocation puts each invoice on its own
* payment row but they all share the combined verifikat)
*
* Returns null if none of the three are present, in which case the tx
* is not booked at all.
*/
export function getPrimaryJournalEntryId(
tx: TxLike,
payments: { transaction_id: string | null; journal_entry_id: string | null }[] = [],
voucherLinks: { transaction_id: string; journal_entry_id: string }[] = [],
): string | null {
if (tx.journal_entry_id != null) return tx.journal_entry_id
const link = voucherLinks.find((v) => v.transaction_id === tx.id)
if (link) return link.journal_entry_id
const payment = payments.find((p) => p.transaction_id === tx.id && p.journal_entry_id != null)
return payment?.journal_entry_id ?? null
}