* 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>
463 lines
18 KiB
TypeScript
463 lines
18 KiB
TypeScript
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')
|
||
})
|
||
})
|
||
})
|