Files
accounted/tests/pg/match-batch-allocate.pg.test.ts
T
Jakob WennbergandClaude Opus 4.7 7eb8715417 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>
2026-05-29 13:45:41 +02:00

463 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import {
insertAuthUser,
insertCompany,
insertCompanyMember,
insertFiscalPeriod,
} from '@/tests/pg/fixtures'
import { getPool, withUserContext } from '@/tests/pg/setup'
/**
* Covers 20260529120100_match_batch_allocate:
* - 1 bank tx → N supplier invoices: builds ONE combined verifikat with
* N × Dr 2440 + 1 × Cr 1930, inserts N supplier_invoice_payments rows
* all pointing at the same JE.
* - Per-invoice paid_amount/remaining_amount/status advance correctly.
* - Overshoot guard returns BATCH_OVERSHOOT cleanly (no partial state).
* - Already-booked tx rejection.
* - Direction mismatch rejection.
* - Mixed customer + supplier kinds rejection.
*
* These tests bypass RLS by writing through the superuser pool — they
* exercise the RPC logic + DB constraints, not the policy layer.
*/
async function insertSupplier(params: {
userId: string
companyId: string
name?: string
}): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.suppliers
(id, user_id, company_id, name, supplier_type, country, default_payment_terms, default_currency)
VALUES ($1, $2, $3, $4, 'swedish_business', 'SE', 30, 'SEK')`,
[id, params.userId, params.companyId, params.name ?? 'Leverantör AB'],
)
return id
}
async function insertSupplierInvoice(params: {
userId: string
companyId: string
supplierId: string
total: number
status?: string
invoiceDate?: string
dueDate?: string
}): Promise<string> {
const id = randomUUID()
// Arrival numbers are generated per-company by get_next_arrival_number,
// but for an isolated test we can hardcode a unique value via current time
// millis modulo a wide range. The unique constraint allows that.
const arrivalNumber = (Date.now() % 1_000_000_000) + Math.floor(Math.random() * 10_000)
await getPool().query(
`INSERT INTO public.supplier_invoices
(id, user_id, company_id, supplier_id, arrival_number, supplier_invoice_number,
invoice_date, due_date, received_date, status, currency,
subtotal, vat_amount, total, paid_amount, remaining_amount,
vat_treatment, reverse_charge, is_credit_note)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $7, $9, 'SEK',
$10, 0, $10, 0, $10, 'standard_25', false, false)`,
[
id,
params.userId,
params.companyId,
params.supplierId,
arrivalNumber,
`LF-${arrivalNumber}`,
params.invoiceDate ?? '2026-06-01',
params.dueDate ?? '2026-07-01',
params.status ?? 'approved',
params.total,
],
)
return id
}
async function insertTransaction(params: {
userId: string
companyId: string
amount: number
date?: string
currency?: string
}): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.transactions
(id, user_id, company_id, date, description, amount, currency, category)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'uncategorized')`,
[
id,
params.userId,
params.companyId,
params.date ?? '2026-06-05',
'Bank transfer',
params.amount,
params.currency ?? 'SEK',
],
)
return id
}
async function seedTenant(opts: { isClosed?: boolean } = {}) {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId, role: 'owner' })
const fiscalPeriodId = await insertFiscalPeriod({
userId,
companyId,
periodStart: '2026-01-01',
periodEnd: '2026-12-31',
isClosed: opts.isClosed,
})
return { userId, companyId, fiscalPeriodId }
}
interface RpcResult {
ok: boolean
code?: string
details?: Record<string, unknown>
journal_entry_id?: string
voucher_number?: number
allocations?: Array<{
kind: string
supplier_invoice_id?: string
invoice_id?: string
payment_id: string
status: string
paid_amount: number
remaining_amount: number
amount: number
}>
total_allocated?: number
leftover?: number
}
describe('match_batch_allocate', () => {
it('builds a single combined verifikat for 1 tx → 3 supplier invoices', async () => {
const { userId, companyId } = await seedTenant()
const supplier = await insertSupplier({ userId, companyId })
const si1 = await insertSupplierInvoice({
userId, companyId, supplierId: supplier, total: 2000,
})
const si2 = await insertSupplierInvoice({
userId, companyId, supplierId: supplier, total: 3000,
})
const si3 = await insertSupplierInvoice({
userId, companyId, supplierId: supplier, total: 1500,
})
const txId = await insertTransaction({
userId, companyId, amount: -6500, date: '2026-06-05',
})
const allocations = [
{ kind: 'supplier_invoice', supplier_invoice_id: si1, amount: 2000 },
{ kind: 'supplier_invoice', supplier_invoice_id: si2, amount: 3000 },
{ kind: 'supplier_invoice', supplier_invoice_id: si3, amount: 1500 },
]
// withUserContext sets request.jwt.claim.sub so the RPC's auth.uid()
// membership check (PR #603 round 2) resolves the seeded owner.
// ALL assertions about post-RPC state must run inside this block since
// it rolls back at the end.
await withUserContext(userId, async (client) => {
const r = await client.query<{ match_batch_allocate: RpcResult }>(
`SELECT match_batch_allocate($1, $2::jsonb, $3, $4)`,
[txId, JSON.stringify(allocations), userId, companyId],
)
const result = r.rows[0]!.match_batch_allocate
expect(result.ok).toBe(true)
expect(result.journal_entry_id).toBeTruthy()
expect(result.voucher_number).toBeGreaterThan(0)
expect(result.total_allocated).toBe(6500)
expect(result.leftover).toBe(0)
expect(result.allocations).toHaveLength(3)
// Verify one verifikat with N+1 lines (3 × Dr 2440 + 1 × Cr 1930).
const lines = await client.query<{
account_number: string
debit_amount: string
credit_amount: string
}>(
`SELECT account_number, debit_amount, credit_amount
FROM public.journal_entry_lines
WHERE journal_entry_id = $1
ORDER BY sort_order`,
[result.journal_entry_id],
)
expect(lines.rows).toHaveLength(4)
const apLines = lines.rows.filter((l) => l.account_number === '2440')
const bankLines = lines.rows.filter((l) => l.account_number === '1930')
expect(apLines).toHaveLength(3)
expect(bankLines).toHaveLength(1)
expect(Number(bankLines[0]!.credit_amount)).toBe(6500)
const apSum = apLines.reduce((s, l) => s + Number(l.debit_amount), 0)
expect(apSum).toBe(6500)
// Verify all 3 supplier invoices flipped to 'paid'.
const inv1 = await client.query<{ status: string; paid_amount: string; remaining_amount: string }>(
`SELECT status, paid_amount, remaining_amount FROM public.supplier_invoices WHERE id = $1`,
[si1],
)
expect(inv1.rows[0]!.status).toBe('paid')
expect(Number(inv1.rows[0]!.paid_amount)).toBe(2000)
expect(Number(inv1.rows[0]!.remaining_amount)).toBe(0)
// Verify 3 supplier_invoice_payments rows all reference the same JE.
const payments = await client.query<{ journal_entry_id: string; supplier_invoice_id: string }>(
`SELECT journal_entry_id, supplier_invoice_id
FROM public.supplier_invoice_payments WHERE transaction_id = $1`,
[txId],
)
expect(payments.rows).toHaveLength(3)
const jeIds = new Set(payments.rows.map((p) => p.journal_entry_id))
expect(jeIds.size).toBe(1)
expect(jeIds.has(result.journal_entry_id!)).toBe(true)
// Verify tx.journal_entry_id is set + supplier_invoice_id left NULL (multi).
const txRow = await client.query<{
journal_entry_id: string | null
supplier_invoice_id: string | null
is_business: boolean
}>(
`SELECT journal_entry_id, supplier_invoice_id, is_business
FROM public.transactions WHERE id = $1`,
[txId],
)
expect(txRow.rows[0]!.journal_entry_id).toBe(result.journal_entry_id)
expect(txRow.rows[0]!.supplier_invoice_id).toBeNull()
expect(txRow.rows[0]!.is_business).toBe(true)
// Verify samlingsverifikat carries the supplier-side source_type
// (PR #603 compliance fix — was previously 'invoice_paid' for both
// directions which mis-routed behandlingshistorik filters).
const je = await client.query<{ source_type: string }>(
`SELECT source_type FROM public.journal_entries WHERE id = $1`,
[result.journal_entry_id],
)
expect(je.rows[0]!.source_type).toBe('supplier_invoice_paid')
})
})
it('rejects with BATCH_OVERSHOOT when allocation exceeds invoice remaining', async () => {
const { userId, companyId } = await seedTenant()
const supplier = await insertSupplier({ userId, companyId })
const si = await insertSupplierInvoice({
userId, companyId, supplierId: supplier, total: 1000,
})
const txId = await insertTransaction({ userId, companyId, amount: -5000 })
const allocations = [
{ kind: 'supplier_invoice', supplier_invoice_id: si, amount: 5000 },
]
await withUserContext(userId, async (client) => {
const r = await client.query<{ match_batch_allocate: RpcResult }>(
`SELECT match_batch_allocate($1, $2::jsonb, $3, $4)`,
[txId, JSON.stringify(allocations), userId, companyId],
)
const result = r.rows[0]!.match_batch_allocate
expect(result.ok).toBe(false)
expect(result.code).toBe('BATCH_OVERSHOOT')
expect(result.details).toMatchObject({ supplier_invoice_id: si, requested: 5000 })
// No journal entry should have been created.
const txRow = await client.query<{ journal_entry_id: string | null }>(
`SELECT journal_entry_id FROM public.transactions WHERE id = $1`,
[txId],
)
expect(txRow.rows[0]!.journal_entry_id).toBeNull()
const inv = await client.query<{ paid_amount: string; remaining_amount: string }>(
`SELECT paid_amount, remaining_amount FROM public.supplier_invoices WHERE id = $1`,
[si],
)
expect(Number(inv.rows[0]!.paid_amount)).toBe(0)
expect(Number(inv.rows[0]!.remaining_amount)).toBe(1000)
})
})
it('rejects with BATCH_UNAUTHORIZED when caller is not a member of the company', async () => {
const { userId, companyId } = await seedTenant()
const supplier = await insertSupplier({ userId, companyId })
const si = await insertSupplierInvoice({
userId, companyId, supplierId: supplier, total: 1000,
})
const txId = await insertTransaction({ userId, companyId, amount: -1000 })
// Different user — never added to company_members for companyId. The
// SECURITY DEFINER check (PR #603 compliance) refuses any access.
const outsiderId = await insertAuthUser()
await withUserContext(outsiderId, async (client) => {
const r = await client.query<{ match_batch_allocate: RpcResult }>(
`SELECT match_batch_allocate($1, $2::jsonb, $3, $4)`,
[
txId,
JSON.stringify([{ kind: 'supplier_invoice', supplier_invoice_id: si, amount: 1000 }]),
outsiderId,
companyId,
],
)
const result = r.rows[0]!.match_batch_allocate
expect(result.ok).toBe(false)
expect(result.code).toBe('BATCH_UNAUTHORIZED')
})
})
it('rejects with BATCH_TX_ALREADY_BOOKED when tx already has a JE', async () => {
const { userId, companyId, fiscalPeriodId } = await seedTenant()
const supplier = await insertSupplier({ userId, companyId })
const si = await insertSupplierInvoice({
userId, companyId, supplierId: supplier, total: 1000,
})
// Pre-book the tx by linking it to a manual posted JE.
const existingJeId = randomUUID()
await getPool().query(
`INSERT INTO public.journal_entries
(id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
entry_date, description, source_type, status)
VALUES ($1, $2, $3, $4, 1, 'A', '2026-06-05', 'Manual', 'manual', 'draft')`,
[existingJeId, userId, companyId, fiscalPeriodId],
)
await getPool().query(
`INSERT INTO public.journal_entry_lines (journal_entry_id, account_number, debit_amount, credit_amount)
VALUES ($1, '1930', 0, 1000), ($1, '4010', 1000, 0)`,
[existingJeId],
)
await getPool().query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [existingJeId])
const txId = await insertTransaction({ userId, companyId, amount: -1000 })
await getPool().query(
`UPDATE public.transactions SET journal_entry_id = $1 WHERE id = $2`,
[existingJeId, txId],
)
const allocations = [
{ kind: 'supplier_invoice', supplier_invoice_id: si, amount: 1000 },
]
await withUserContext(userId, async (client) => {
const r = await client.query<{ match_batch_allocate: RpcResult }>(
`SELECT match_batch_allocate($1, $2::jsonb, $3, $4)`,
[txId, JSON.stringify(allocations), userId, companyId],
)
const result = r.rows[0]!.match_batch_allocate
expect(result.ok).toBe(false)
expect(result.code).toBe('BATCH_TX_ALREADY_BOOKED')
})
})
it('rejects with BATCH_DIRECTION_MISMATCH for supplier allocation against income tx', async () => {
const { userId, companyId } = await seedTenant()
const supplier = await insertSupplier({ userId, companyId })
const si = await insertSupplierInvoice({
userId, companyId, supplierId: supplier, total: 1000,
})
// Positive tx (income) — wrong direction for supplier_invoice allocation.
const txId = await insertTransaction({ userId, companyId, amount: 1000 })
await withUserContext(userId, async (client) => {
const r = await client.query<{ match_batch_allocate: RpcResult }>(
`SELECT match_batch_allocate($1, $2::jsonb, $3, $4)`,
[
txId,
JSON.stringify([{ kind: 'supplier_invoice', supplier_invoice_id: si, amount: 1000 }]),
userId,
companyId,
],
)
const result = r.rows[0]!.match_batch_allocate
expect(result.ok).toBe(false)
expect(result.code).toBe('BATCH_DIRECTION_MISMATCH')
})
})
it('rejects BATCH_DUPLICATE_ALLOCATION when the same supplier invoice appears twice', async () => {
const { userId, companyId } = await seedTenant()
const supplier = await insertSupplier({ userId, companyId })
const si = await insertSupplierInvoice({
userId, companyId, supplierId: supplier, total: 1000,
})
const txId = await insertTransaction({ userId, companyId, amount: -800 })
// Same supplier_invoice_id listed twice. Per-allocation amounts (400 each)
// do not individually overshoot the 1 000 remaining, but their sum would
// insert two payment rows for one invoice. The dedupe guard catches
// this in the validation loop before any write.
const allocations = [
{ kind: 'supplier_invoice', supplier_invoice_id: si, amount: 400 },
{ kind: 'supplier_invoice', supplier_invoice_id: si, amount: 400 },
]
await withUserContext(userId, async (client) => {
const r = await client.query<{ match_batch_allocate: RpcResult }>(
`SELECT match_batch_allocate($1, $2::jsonb, $3, $4)`,
[txId, JSON.stringify(allocations), userId, companyId],
)
const result = r.rows[0]!.match_batch_allocate
expect(result.ok).toBe(false)
expect(result.code).toBe('BATCH_DUPLICATE_ALLOCATION')
expect(result.details?.id).toBe(si)
})
})
it('rejects BATCH_MIXED_KINDS_UNSUPPORTED on customer + supplier in same batch', async () => {
const { userId, companyId } = await seedTenant()
const supplier = await insertSupplier({ userId, companyId })
const si = await insertSupplierInvoice({
userId, companyId, supplierId: supplier, total: 1000,
})
// Insert a customer + invoice for the customer-side allocation.
const customerId = randomUUID()
await getPool().query(
`INSERT INTO public.customers
(id, user_id, company_id, name, customer_type, country)
VALUES ($1, $2, $3, 'Kund AB', 'swedish_business', 'SE')`,
[customerId, userId, companyId],
)
const invoiceId = randomUUID()
await getPool().query(
`INSERT INTO public.invoices
(id, user_id, company_id, customer_id, invoice_number, invoice_date, due_date, status,
currency, subtotal, vat_amount, total, paid_amount, remaining_amount, vat_treatment)
VALUES ($1, $2, $3, $4, 'F-001', '2026-06-01', '2026-07-01', 'sent', 'SEK',
1000, 0, 1000, 0, 1000, 'standard_25')`,
[invoiceId, userId, companyId, customerId],
)
// Negative tx — direction makes both sides individually plausible, but
// we reject mixed kinds outright. Actually negative=supplier and we need
// either income or expense; the mixed check fires before the direction
// check, so the result code is MIXED_KINDS regardless.
const txId = await insertTransaction({ userId, companyId, amount: -2000 })
await withUserContext(userId, async (client) => {
const r = await client.query<{ match_batch_allocate: RpcResult }>(
`SELECT match_batch_allocate($1, $2::jsonb, $3, $4)`,
[
txId,
JSON.stringify([
{ kind: 'supplier_invoice', supplier_invoice_id: si, amount: 1000 },
{ kind: 'customer_invoice', invoice_id: invoiceId, amount: 1000 },
]),
userId,
companyId,
],
)
const result = r.rows[0]!.match_batch_allocate
expect(result.ok).toBe(false)
expect(result.code).toBe('BATCH_MIXED_KINDS_UNSUPPORTED')
})
})
})