feat(transactions): split-payment allocator — 1 tx → N invoices (#603)

* fix(category-mapping): use leaf BAS accounts instead of group codes

3900, 5800, 6200 are BAS gruppkonton (header codes) and shouldn't carry
postings. Switched the default mappings to the matching leaf accounts:

  - income_other:     3900 -> 3999 (Övriga rörelseintäkter)
  - expense_travel:   5800 -> 5890 (Övriga resekostnader)
  - expense_telecom:  6200 -> 6230 (Datakommunikation)

The fallback for income_other inside getCategoryAccountMapping was also
hardcoded to '3900'; updated to '3999' for consistency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(transactions): split-payment allocator — 1 tx → N invoices

Closes one of the two flows that motivated PR #602's foundation:
allocating a single bank transaction across multiple customer OR
multiple supplier invoices, with one combined verifikat
(samlingsverifikation per BFL 5 kap 6§ st 3).

## Backend (Phase 3a)

- **PL/pgSQL RPC** match_batch_allocate (~400 lines): locks the tx +
  each target invoice with SELECT … FOR UPDATE in id order, validates
  status/currency/remaining/direction before any write, builds the
  combined verifikat via commit_journal_entry (atomically assigns
  voucher_number + flips draft→posted), inserts N rows in
  invoice_payments or supplier_invoice_payments pointing at the same
  JE, advances paid_amount/remaining_amount/status per invoice. Returns
  { ok, journal_entry_id, voucher_number, allocations: [...] } on
  success or { ok: false, code, details } on guard failure. Mixed
  customer+supplier kinds are rejected (v1 scope).

- **Endpoint** POST /api/transactions/[id]/match-batch — thin wrapper
  around the RPC. Validates body via MatchBatchSchema (zod
  discriminatedUnion + superRefine to catch mixed-kinds at the schema
  layer). On RPC success, emits one invoice.match_confirmed or
  supplier_invoice.match_confirmed event per allocation so existing
  subscribers (reminders, automations, processing-history) keep
  working. Maps the structured RPC error envelope to
  errorResponseFromCode.

- **16 new BATCH_* error codes** (sv+en): BATCH_TX_NOT_FOUND,
  BATCH_TX_ALREADY_BOOKED, BATCH_OVERSHOOT, BATCH_AMOUNT_EXCEEDS_TX,
  BATCH_MIXED_KINDS_UNSUPPORTED, BATCH_DIRECTION_MISMATCH,
  BATCH_CURRENCY_MISMATCH, BATCH_PERIOD_LOCKED, BATCH_RPC_FAILED, etc.

## UI (Phase 5a)

- **MatchAllocationDialog** (components/transactions/) — direction-
  aware (positive tx → customer invoices, negative → supplier). Search
  + selectable list of open invoices. Per-row amount input with default
  = min(invoice.remaining, tx_remaining_budget). Live tally with
  green-check balanced state, red overshoot warning, gray leftover
  note. Confirm button disabled on overshoot. POSTs to /match-batch
  and on 200 triggers the same exit animation as single-tx match.

- **Inbox row** gains a second outline icon button (Split icon) next
  to the existing 1:1 match button, gated by the same
  showInvoiceMatchButton predicate. Tooltip explains the direction-
  aware split. Opens MatchAllocationDialog.

- **i18n** strings under tx_match_allocation namespace in sv.json
  and en.json (32 keys each).

## Tests

- tests/pg/match-batch-allocate.pg.test.ts — 5 pg-real tests covering
  combined verifikat shape, overshoot guard, already-booked tx,
  direction mismatch, mixed-kinds rejection.
- app/api/transactions/[id]/match-batch/__tests__/route.test.ts — 5
  unit tests covering schema validation, mixed-kinds, happy path,
  structured-error mapping, raw-error → BATCH_RPC_FAILED.

63 unit tests pass across the touched paths. The RPC migration was
already applied to remote in an earlier Phase 3a session (idempotent
CREATE OR REPLACE FUNCTION; the next replay is a no-op).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(match-batch): PR #603 review round 1 + CI fixes

Closes both CI failures and the three real review findings.

## CI fixes

- **pg-real failure**: the RPC declared
  `v_journal_entry_id uuid := uuid_generate_v4()` which fails in the CI
  Postgres image (uuid-ossp extension is off). Switched to
  `gen_random_uuid()` — the codebase standard already used by
  supplier_invoices, invoice_inbox, etc.
- **core-only failure**: my earlier BAS leaf-account commit
  (3900→3999, 5800→5890, 6200→6230) didn't update the matching
  `lib/bookkeeping/__tests__/category-mapping.test.ts` expectations,
  and `getDefaultAccountForCategory`'s fallback for `income_*` was
  still hardcoded to '3900'. Updated both.

## Review findings (greptile)

- **P1 deadlock-stable locking** (`match_batch_allocate.sql:11`): the
  validation `FOR UPDATE` loop ran in caller-supplied array order. Two
  concurrent calls with overlapping invoice sets in opposite orders
  could deadlock and one would abort with `BATCH_RPC_FAILED`. Now
  all three loops (validate, build lines, advance invoices) iterate
  via `SELECT … FROM jsonb_array_elements(…) ORDER BY
  COALESCE(invoice_id, supplier_invoice_id)`, giving a stable global
  lock order regardless of how the caller ordered the JSON array.

- **P1 duplicate-allocation detection** (`match_batch_allocate.sql:163`):
  the same invoice_id listed twice would pass the per-row overshoot
  guard (both iterations read the original `remaining_amount`) and
  the write loop would insert two `invoice_payments` rows for the
  same invoice. Added a `v_seen_ids text[]` check in the validation
  loop and a new `BATCH_DUPLICATE_ALLOCATION` error code (sv + en).
  The dialog already prevents this UI-side via `if (prev[candidate.id]
  return prev` — the RPC guard is the defense-in-depth layer.

- **P2 zod `.positive()`** (`schemas.ts:544`): allocation amount was
  `nonNegativeAmount` (allowing 0), passing schema validation only to
  be rejected by the RPC with `BATCH_INVALID_AMOUNT`. Now
  `z.number().positive(…)` so 0-amount entries fail at the schema
  layer with a per-field path, cleaner 400.

- **P2 strict `> 0` direction check** (`MatchAllocationDialog.tsx:82`):
  used `amount >= 0` to pick customer-side, but a zero-amount tx would
  load customer candidates only to hit `BATCH_TX_ZERO_AMOUNT` at
  submit time after the user has filled in allocations. Switched to
  `> 0` so 0-amount tx never reaches the dialog at all (it's rejected
  by the RPC immediately).

The fourth Greptile comment (the schema P2 about amount validation)
overlaps with the third; addressed in the same edit.

## Verification

- 112 unit tests pass across touched paths
- ESLint clean
- New pg-real test `tests/pg/match-batch-allocate.pg.test.ts` covers
  the dedupe scenario (same supplier invoice listed twice with summing
  amounts that individually pass per-row overshoot)
- RPC patch applied to remote via Supabase MCP

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(match-batch): PR #603 review round 2 — compliance hardening

Addresses the actionable findings from compliance-swarm and
Swedish-accounting-compliance reviews. Six small RPC changes + two
TS-side guards, all bundled in one follow-up migration.

## Security

- **(GDPR Art.5(1)(f) / ISO A.8.2) Caller verification**: SECURITY
  DEFINER bypasses RLS, and the prior RPC accepted any
  (p_user_id, p_company_id) pair from the route. Now the function
  rejects with new `BATCH_UNAUTHORIZED` (sv+en, HTTP 403) if
  `auth.uid()` is not a member of `p_company_id`. Pattern lifted from
  `harden_invoice_number_rpcs` (#20260510140000).
- **(OWASP V4.2) Allocation cap**: `MatchBatchSchema.allocations` now
  carries `.max(100)` to prevent DoS via unbounded FOR UPDATE locks.

## Swedish accounting correctness

- **source_type per direction**: was hardcoded to `'invoice_paid'` for
  both customer + supplier batches, mis-routing behandlingshistorik
  filters. Customer batches keep `'invoice_paid'`, supplier batches now
  write `'supplier_invoice_paid'`.
- **Fiscal-period determinism**: `LIMIT 1` on the period lookup was
  non-deterministic on overlap (e.g. corrected broken year). Added
  `ORDER BY period_start DESC` so the most recent matching period
  wins.
- **Tolerance harmonisation**: cross-allocation sum used `+0.01`
  tolerance while per-row used `+0.005`. Both now `+0.005` so a
  multi-row batch can't drift ~0.01 SEK while each row passes
  individually.
- **`transactions.category` no longer overwritten**: was forced to
  `'income_services'` (→ BAS 3001 at 25% VAT) for any customer batch,
  misrepresenting reduced-rate / export / EU-service invoices. The
  category is only meaningful 1:1 with a single invoice; batches now
  leave it as-is, mirroring the supplier-side `ELSE category` branch.

## Tests

- `tests/pg/match-batch-allocate.pg.test.ts` now wraps every RPC call
  in `withUserContext(userId)` so `auth.uid()` resolves to the seeded
  owner. Without this the new membership check would have failed all
  existing tests.
- New pg-real test: `rejects with BATCH_UNAUTHORIZED when caller is
  not a member of the company` — outsider user gets explicit refusal.
- New happy-path assertion: `source_type = 'supplier_invoice_paid'`
  on the combined verifikat for supplier batches.

15 unit tests pass on the touched paths. RPC patch applied to remote
via Supabase MCP. Out-of-scope mcp-server changes still parked locally.

Skipped findings (documented in PR comment thread):
  - V8.2.1 ownership pre-check at route layer (RPC enforces it)
  - V4.5 / Art.5(1)(b) narrower API response and event payload —
    typed contracts require the full shapes
  - V2.4 rate-limiting — system-level, applies to all match endpoints
  - A.8.28 client-side RLS reliance — documented architectural choice
  - Direction pre-check at API layer (RPC catches with cleaner code)
  - V16 + Art.32 + Art.5(1)(b) low-severity logging nits

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 13:45:41 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 7bcd46d503
commit 7eb8715417
15 changed files with 2844 additions and 12 deletions
@@ -0,0 +1,157 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import {
createMockRequest,
createMockRouteParams,
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 }),
}))
import { POST } from '../route'
const TX_UUID = '11111111-1111-4111-8111-111111111111'
const INV_UUID = '22222222-2222-4222-8222-222222222222'
const SI_UUID = '33333333-3333-4333-8333-333333333333'
describe('POST /api/transactions/[id]/match-batch', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
reset()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
it('returns 400 when allocations is missing', async () => {
const request = createMockRequest(`/api/transactions/${TX_UUID}/match-batch`, {
method: 'POST',
body: {},
})
const response = await POST(request, createMockRouteParams({ id: TX_UUID }))
expect(response.status).toBe(400)
})
it('returns 400 when allocations mix customer and supplier kinds', async () => {
const request = createMockRequest(`/api/transactions/${TX_UUID}/match-batch`, {
method: 'POST',
body: {
allocations: [
{ kind: 'customer_invoice', invoice_id: INV_UUID, amount: 500 },
{ kind: 'supplier_invoice', supplier_invoice_id: SI_UUID, amount: 500 },
],
},
})
const response = await POST(request, createMockRouteParams({ id: TX_UUID }))
expect(response.status).toBe(400)
})
it('returns 200 with the RPC result on the happy path', async () => {
// RPC returns success envelope
enqueue({
data: {
ok: true,
journal_entry_id: 'je-batch-1',
voucher_series: 'A',
voucher_number: 12,
tx_id: TX_UUID,
allocations: [
{
kind: 'customer_invoice',
invoice_id: INV_UUID,
payment_id: 'ip-1',
status: 'paid',
paid_amount: 1000,
remaining_amount: 0,
amount: 1000,
},
],
total_allocated: 1000,
leftover: 0,
},
error: null,
})
// tx fetch for event payload
enqueue({ data: { id: TX_UUID, amount: 1000, currency: 'SEK' }, error: null })
// invoice fetch for event payload
enqueue({ data: { id: INV_UUID, currency: 'SEK', status: 'paid' }, error: null })
const request = createMockRequest(`/api/transactions/${TX_UUID}/match-batch`, {
method: 'POST',
body: {
allocations: [{ kind: 'customer_invoice', invoice_id: INV_UUID, amount: 1000 }],
},
})
const response = await POST(request, createMockRouteParams({ id: TX_UUID }))
const { status, body } = await parseJsonResponse<{
data: {
journal_entry_id: string
voucher_number: number
allocations: Array<{ payment_id: string }>
total_allocated: number
}
}>(response)
expect(status).toBe(200)
expect(body.data.journal_entry_id).toBe('je-batch-1')
expect(body.data.voucher_number).toBe(12)
expect(body.data.allocations).toHaveLength(1)
expect(body.data.total_allocated).toBe(1000)
})
it('maps an RPC structured failure to errorResponseFromCode', async () => {
enqueue({
data: {
ok: false,
code: 'BATCH_OVERSHOOT',
details: { invoice_id: INV_UUID, requested: 2000, remaining: 1000 },
},
error: null,
})
const request = createMockRequest(`/api/transactions/${TX_UUID}/match-batch`, {
method: 'POST',
body: {
allocations: [{ kind: 'customer_invoice', invoice_id: INV_UUID, amount: 2000 }],
},
})
const response = await POST(request, createMockRouteParams({ id: TX_UUID }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('BATCH_OVERSHOOT')
})
it('maps a raw RPC error to BATCH_RPC_FAILED', async () => {
enqueue({ data: null, error: { message: 'connection dropped' } })
const request = createMockRequest(`/api/transactions/${TX_UUID}/match-batch`, {
method: 'POST',
body: {
allocations: [{ kind: 'customer_invoice', invoice_id: INV_UUID, amount: 1000 }],
},
})
const response = await POST(request, createMockRouteParams({ id: TX_UUID }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(500)
expect(body.error.code).toBe('BATCH_RPC_FAILED')
})
})
@@ -0,0 +1,162 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { MatchBatchSchema } from '@/lib/api/schemas'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { eventBus } from '@/lib/events/bus'
import { ensureInitialized } from '@/lib/init'
import type { Invoice, SupplierInvoice, Transaction } from '@/types'
ensureInitialized()
interface RpcAllocationResult {
kind: 'customer_invoice' | 'supplier_invoice'
invoice_id?: string
supplier_invoice_id?: string
payment_id: string
status: 'paid' | 'partially_paid'
paid_amount: number
remaining_amount: number
amount: number
}
interface RpcOk {
ok: true
journal_entry_id: string
voucher_series: string
voucher_number: number
tx_id: string
allocations: RpcAllocationResult[]
total_allocated: number
leftover: number
}
interface RpcErr {
ok: false
code: string
details?: Record<string, unknown>
}
/**
* POST /api/transactions/[id]/match-batch
*
* Allocate one bank transaction across N customer OR N supplier invoices.
* Builds a single combined verifikat (samlingsverifikation) and inserts N
* payment rows via the match_batch_allocate PL/pgSQL RPC.
*
* The RPC is the atomicity boundary; this route is a thin wrapper that:
* 1. Validates the request body via MatchBatchSchema.
* 2. Invokes the RPC.
* 3. Maps the structured RPC error (jsonb { ok: false, code }) to an
* errorResponseFromCode call.
* 4. On success, refetches the per-allocation invoice/supplier_invoice rows
* to emit the same per-allocation events the legacy single-tx routes
* emit (invoice.match_confirmed, invoice.paid, supplier_invoice.*).
* Event emission is best-effort — a failure here does not roll back
* the booking; the RPC commit is the source of truth.
*/
export const POST = withRouteContext(
'transaction.match_batch',
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id: transactionId } = await params
const { user, supabase, companyId, log, requestId } = ctx
const validation = await validateBody(request, MatchBatchSchema, {
log,
operation: 'transaction.match_batch',
})
if (!validation.success) return validation.response
const txLog = log.child({ transactionId })
const { data, error } = await supabase.rpc('match_batch_allocate', {
p_tx_id: transactionId,
p_allocations: validation.data.allocations,
p_user_id: user.id,
p_company_id: companyId,
})
if (error) {
txLog.error('match_batch_allocate RPC error', error)
return errorResponseFromCode('BATCH_RPC_FAILED', txLog, {
requestId,
details: { message: error.message },
})
}
const result = data as RpcOk | RpcErr | null
if (!result || !result.ok) {
const code = (result as RpcErr | null)?.code ?? 'BATCH_RPC_FAILED'
const details = (result as RpcErr | null)?.details
return errorResponseFromCode(code, txLog, { requestId, details })
}
// Re-fetch the transaction row for event payloads (the RPC has already
// updated it). Lookup is non-critical — events fail open on miss.
const { data: tx } = await supabase
.from('transactions')
.select('*')
.eq('id', transactionId)
.eq('company_id', companyId)
.maybeSingle()
// Emit one event per allocation so existing subscribers (reminder
// cancellation, automation, processing-history) keep working without a
// new event channel. Loop sequentially so a single failure logs cleanly.
for (const alloc of result.allocations) {
try {
if (alloc.kind === 'customer_invoice' && alloc.invoice_id) {
const { data: invoice } = await supabase
.from('invoices')
.select('*')
.eq('id', alloc.invoice_id)
.eq('company_id', companyId)
.maybeSingle()
if (invoice && tx) {
await eventBus.emit({
type: 'invoice.match_confirmed',
payload: {
invoice: invoice as Invoice,
transaction: tx as Transaction,
userId: user.id,
companyId,
},
})
}
} else if (alloc.kind === 'supplier_invoice' && alloc.supplier_invoice_id) {
const { data: supplierInvoice } = await supabase
.from('supplier_invoices')
.select('*')
.eq('id', alloc.supplier_invoice_id)
.eq('company_id', companyId)
.maybeSingle()
if (supplierInvoice && tx) {
await eventBus.emit({
type: 'supplier_invoice.match_confirmed',
payload: {
supplierInvoice: supplierInvoice as SupplierInvoice,
transaction: tx as Transaction,
userId: user.id,
companyId,
},
})
}
}
} catch (err) {
txLog.warn('match_batch event emission failed', err as Error)
}
}
return NextResponse.json({
data: {
journal_entry_id: result.journal_entry_id,
voucher_series: result.voucher_series,
voucher_number: result.voucher_number,
allocations: result.allocations,
total_allocated: result.total_allocated,
leftover: result.leftover,
},
})
},
{ requireWrite: true },
)