4da87e5e4c
* 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>
88 lines
3.5 KiB
TypeScript
88 lines
3.5 KiB
TypeScript
/**
|
|
* 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
|
|
}
|