Files
accounted/app/api/transactions/bulk-book/__tests__/route.test.ts
T
Jakob WennbergandClaude Opus 4.7 4da87e5e4c 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>
2026-05-29 14:55:24 +02:00

194 lines
6.3 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest'
import {
createMockRequest,
parseJsonResponse,
createQueuedMockSupabase,
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/events/bus', () => ({
eventBus: { emit: vi.fn().mockResolvedValue(undefined) },
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
// applyTemplate is the pure ratio/VAT expander used by the route. The
// route test stubs it so we don't need a real template object.
vi.mock('@/lib/bookkeeping/template-library', () => ({
applyTemplate: vi.fn(),
}))
import { POST } from '../route'
import { applyTemplate } from '@/lib/bookkeeping/template-library'
const TX1 = '11111111-1111-4111-8111-111111111111'
const TX2 = '22222222-2222-4222-8222-222222222222'
const TPL = '33333333-3333-4333-8333-333333333333'
const JE = '44444444-4444-4444-8444-444444444444'
describe('POST /api/transactions/bulk-book', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
reset()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
it('returns 400 when neither template_id nor existing_journal_entry_id is set', async () => {
const request = createMockRequest('/api/transactions/bulk-book', {
method: 'POST',
body: { tx_ids: [TX1] },
})
const response = await POST(request)
expect(response.status).toBe(400)
})
it('returns 400 when both template_id and existing_journal_entry_id are set', async () => {
const request = createMockRequest('/api/transactions/bulk-book', {
method: 'POST',
body: {
tx_ids: [TX1],
template_id: TPL,
existing_journal_entry_id: JE,
mode: 'one_line_per_tx',
entry_description: 'Test',
},
})
const response = await POST(request)
expect(response.status).toBe(400)
})
it('link path passes through to RPC and returns the success envelope', async () => {
// RPC returns the link-existing happy path.
enqueue({
data: {
ok: true,
mode: 'link_existing',
journal_entry_id: JE,
voucher_series: 'A',
voucher_number: 12,
linked_tx_count: 2,
tx_sum: 300,
},
error: null,
})
// Event re-fetch (empty is fine for the test).
enqueue({ data: [], error: null })
const request = createMockRequest('/api/transactions/bulk-book', {
method: 'POST',
body: {
tx_ids: [TX1, TX2],
existing_journal_entry_id: JE,
},
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{
data: { mode: string; journal_entry_id: string; linked_tx_count: number }
}>(response)
expect(status).toBe(200)
expect(body.data.mode).toBe('link_existing')
expect(body.data.journal_entry_id).toBe(JE)
expect(body.data.linked_tx_count).toBe(2)
})
it('create-new path fetches template, expands per mode, and calls RPC', async () => {
// Template fetch.
enqueue({
data: {
id: TPL,
name: 'Försäljning 25%',
lines: [
{ account: '1930', label: 'Bank', side: 'debit', type: 'settlement' },
{ account: '3001', label: 'Försäljning', side: 'credit', type: 'business', ratio: 0.8 },
{ account: '2611', label: 'Utg moms 25%', side: 'credit', type: 'vat', vat_rate: 0.25 },
],
is_active: true,
},
error: null,
})
// Tx fetch: 2 incomes totalling 300.
enqueue({
data: [
{ id: TX1, amount: 100, currency: 'SEK', description: 'Swish 1', date: '2026-06-05' },
{ id: TX2, amount: 200, currency: 'SEK', description: 'Swish 2', date: '2026-06-05' },
],
error: null,
})
// applyTemplate stub — return a balanced 3-line set per call.
vi.mocked(applyTemplate).mockImplementation((_lines, total) => [
{ account_number: '1930', debit_amount: String(total), credit_amount: '', line_description: 'Bank' },
{ account_number: '3001', debit_amount: '', credit_amount: String(total * 0.8), line_description: 'Försäljning' },
{ account_number: '2611', debit_amount: '', credit_amount: String(total * 0.2), line_description: 'Utg moms 25%' },
])
// RPC returns happy path.
enqueue({
data: {
ok: true,
mode: 'create_new',
journal_entry_id: JE,
voucher_series: 'A',
voucher_number: 13,
linked_tx_count: 2,
tx_sum: 300,
},
error: null,
})
// Event re-fetch.
enqueue({ data: [], error: null })
const request = createMockRequest('/api/transactions/bulk-book', {
method: 'POST',
body: {
tx_ids: [TX1, TX2],
template_id: TPL,
mode: 'one_line_per_tx',
entry_description: 'Samlingsverifikation 2026-06-05',
},
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{
data: { mode: string; journal_entry_id: string }
}>(response)
expect(status).toBe(200)
expect(body.data.mode).toBe('create_new')
expect(body.data.journal_entry_id).toBe(JE)
// Template expansion was invoked once per tx in one_line_per_tx mode.
expect(vi.mocked(applyTemplate)).toHaveBeenCalledTimes(2)
})
it('maps RPC structured failure code to errorResponseFromCode', async () => {
enqueue({
data: { ok: false, code: 'BULK_BOOK_DATE_MISMATCH', details: { expected: '2026-06-05', got: '2026-06-06' } },
error: null,
})
const request = createMockRequest('/api/transactions/bulk-book', {
method: 'POST',
body: { tx_ids: [TX1, TX2], existing_journal_entry_id: JE },
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('BULK_BOOK_DATE_MISMATCH')
})
})