Files
accounted/tests/pg/match-batch-allocate.pg.test.ts
T
Jakob Wennberg 62135c0c02 fix(invoices): settle öresavrundning in batch match and close stuck partially_paid invoices (#1829)
A whole-krona payment (Bankgiro/Swish/kort) of an öre-bearing invoice
left the invoice hanging: the match_batch_allocate RPC (samlingsbetalning
dialog + MCP path) had no öre handling, so a sub-krona overshoot was
rejected as BATCH_OVERSHOOT and a sub-krona shortfall parked the invoice
in partially_paid forever. Invoices already stuck that way had no exit:
the mark-paid dialog proposed clearing the full total (rejected with
MATCH_AMOUNT_EXCEEDS_REMAINING) and the route refused partially_paid
outright.

Fix, both halves:

1. New migration 20260824120000 replaces match_batch_allocate with the
   same öresavrundning band every single-payment path already uses
   (ORE_ROUNDING_SETTLEMENT_MAX = 1.00 kr, lib/money.ts): overshoot
   rejected only at >= 1 kr; a 0 < |remaining - allocation| < 1 kr diff
   clears the FULL remaining off 1510/2440, books the residual to 3740
   with correct polarity per side, records the full remaining as paid
   and flips the status to paid. >= 1 kr diffs keep today's behaviour.

2. proposePaymentLines is remaining-aware: a partially_paid SEK accrual
   invoice gets a proposal clearing the actual remaining, and a
   sub-krona remaining gets a bank-less Dr 3740 / Cr 1510 write-off so
   one click closes a stuck invoice. The mark-paid route and the
   invoice-detail button now accept partially_paid (the settle layer's
   CAS guard always did).

Forward-only: already-stuck invoices are not auto-repaired; they are
closed via the new dialog proposal.


Claude-Session: https://claude.ai/code/session_01SyDuePXxUFowaPBKpAv8SF

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 13:19:32 +02:00

1052 lines
42 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 type { PoolClient } from 'pg'
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 (latest body:
* 20260824120000_match_batch_allocate_ore_settlement):
* - 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)
* for a >= 1 kr excess; a sub-krona excess is öresavrundning (#1717).
* - Sub-krona |remaining - allocation| settles the invoice in full with a
* 3740 line carrying the residual (#1717).
* - 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
}
let arrivalSeq = 0
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 hardcode a unique value: time component for
// cross-run uniqueness, counter for within-run uniqueness. The previous
// Date.now()+random scheme collided in CI (same ms + overlapping random
// ranges → duplicate key on idx_supplier_invoices_company_arrival_number).
const arrivalNumber = (Date.now() % 1_000_000) * 1000 + arrivalSeq++
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 insertCustomer(params: {
userId: string
companyId: string
}): Promise<string> {
const id = 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')`,
[id, params.userId, params.companyId],
)
return id
}
async function insertCustomerInvoice(params: {
userId: string
companyId: string
customerId: string
total: number
status?: string
}): Promise<string> {
const id = 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, $5, '2026-06-01', '2026-07-01', $6, 'SEK',
$7, 0, $7, 0, $7, 'standard_25')`,
[
id,
params.userId,
params.companyId,
params.customerId,
`F-${id.slice(0, 8)}`,
params.status ?? 'sent',
params.total,
],
)
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)`,
[txId, JSON.stringify(allocations), 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
paid_at_matches: boolean
}>(
`SELECT status, paid_amount, remaining_amount,
paid_at = TIMESTAMPTZ '2026-06-05 12:00:00+00' AS paid_at_matches
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)
expect(inv1.rows[0]!.paid_at_matches).toBe(true)
// Verify 3 supplier_invoice_payments rows all reference the same JE.
const payments = await client.query<{
journal_entry_id: string
supplier_invoice_id: string
payment_date_matches: boolean
}>(
`SELECT journal_entry_id, supplier_invoice_id,
payment_date = DATE '2026-06-05' AS payment_date_matches
FROM public.supplier_invoice_payments WHERE transaction_id = $1`,
[txId],
)
expect(payments.rows).toHaveLength(3)
expect(payments.rows.every((payment) => payment.payment_date_matches)).toBe(true)
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('anchors customer paid_at at UTC noon on the bank transaction date', async () => {
const { userId, companyId } = await seedTenant()
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, $5, '2026-06-01', '2026-07-01', 'sent', 'SEK',
1000, 0, 1000, 0, 1000, 'standard_25')`,
[invoiceId, userId, companyId, customerId, `F-${invoiceId.slice(0, 8)}`],
)
const txId = await insertTransaction({
userId,
companyId,
amount: 1000,
date: '2026-06-07',
})
await withUserContext(userId, async (client) => {
const response = await client.query<{ match_batch_allocate: RpcResult }>(
`SELECT match_batch_allocate($1, $2::jsonb, $3)`,
[
txId,
JSON.stringify([{ kind: 'customer_invoice', invoice_id: invoiceId, amount: 1000 }]),
companyId,
],
)
expect(response.rows[0]!.match_batch_allocate.ok).toBe(true)
const invoice = await client.query<{ paid_at_matches: boolean }>(
`SELECT paid_at = TIMESTAMPTZ '2026-06-07 12:00:00+00' AS paid_at_matches
FROM public.invoices WHERE id = $1`,
[invoiceId],
)
expect(invoice.rows[0]!.paid_at_matches).toBe(true)
const payment = await client.query<{ payment_date_matches: boolean }>(
`SELECT payment_date = DATE '2026-06-07' AS payment_date_matches
FROM public.invoice_payments
WHERE invoice_id = $1 AND transaction_id = $2`,
[invoiceId, txId],
)
expect(payment.rows).toHaveLength(1)
expect(payment.rows[0]!.payment_date_matches).toBe(true)
})
})
// #1717 deliberately relaxed this guard: a SUB-krona excess is
// öresavrundning (absorbed to 3740, see the öre suite below); only an
// excess of >= 1.00 kr is still a real overshoot.
it('rejects with BATCH_OVERSHOOT when allocation exceeds invoice remaining by >= 1 kr', 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)`,
[txId, JSON.stringify(allocations), 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_OVERSHOOT at exactly 1 kr excess (band boundary)', 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: -1001 })
await withUserContext(userId, async (client) => {
const r = await client.query<{ match_batch_allocate: RpcResult }>(
`SELECT match_batch_allocate($1, $2::jsonb, $3)`,
[
txId,
JSON.stringify([{ kind: 'supplier_invoice', supplier_invoice_id: si, amount: 1001 }]),
companyId,
],
)
const result = r.rows[0]!.match_batch_allocate
expect(result.ok).toBe(false)
expect(result.code).toBe('BATCH_OVERSHOOT')
})
})
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)`,
[
txId,
JSON.stringify([{ kind: 'supplier_invoice', supplier_invoice_id: si, amount: 1000 }]),
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)`,
[txId, JSON.stringify(allocations), 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)`,
[
txId,
JSON.stringify([{ kind: 'supplier_invoice', supplier_invoice_id: si, amount: 1000 }]),
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)`,
[txId, JSON.stringify(allocations), 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)`,
[
txId,
JSON.stringify([
{ kind: 'supplier_invoice', supplier_invoice_id: si, amount: 1000 },
{ kind: 'customer_invoice', invoice_id: invoiceId, amount: 1000 },
]),
companyId,
],
)
const result = r.rows[0]!.match_batch_allocate
expect(result.ok).toBe(false)
expect(result.code).toBe('BATCH_MIXED_KINDS_UNSUPPORTED')
})
})
// PR #607: cross-currency happy path. One USD supplier invoice paid by
// a single SEK bank transaction. The RPC must book the AP line at the
// invoice's original SEK value (booked_sek = remaining × exchange_rate)
// and post the difference between booked_sek and the actual bank
// withdrawal to 7960 (loss) or 3960 (gain). Bank line is the full tx_abs.
it('books cross-currency supplier invoice with FX diff line and tx_abs bank line', async () => {
const { userId, companyId } = await seedTenant()
const supplier = await insertSupplier({ userId, companyId })
// USD invoice for $100, booked at 10.0 SEK/USD = 1000 SEK on 2440 at
// creation time. (We use the standard insertSupplierInvoice and patch
// the currency/exchange_rate after so we don't have to thread params
// through the helper.)
const si = await insertSupplierInvoice({
userId, companyId, supplierId: supplier, total: 100,
})
await getPool().query(
`UPDATE public.supplier_invoices
SET currency = 'USD', exchange_rate = 10.0, remaining_amount = 100
WHERE id = $1`,
[si],
)
// Bank actually withdrew 1050 SEK: rate moved to ~10.5 SEK/USD on
// payment day. Loss of 50 SEK lands on 7960.
const txId = await insertTransaction({
userId, companyId, amount: -1050, date: '2026-06-05', currency: 'SEK',
})
await withUserContext(userId, async (client) => {
const r = await client.query<{ match_batch_allocate: RpcResult }>(
`SELECT match_batch_allocate($1, $2::jsonb, $3)`,
[
txId,
JSON.stringify([
{ kind: 'supplier_invoice', supplier_invoice_id: si, amount: 1050 },
]),
companyId,
],
)
const result = r.rows[0]!.match_batch_allocate
expect(result.ok).toBe(true)
expect(result.allocations).toHaveLength(1)
expect(result.allocations![0]!.cross_currency).toBe(true)
expect(result.allocations![0]!.status).toBe('paid')
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],
)
// Expected lines:
// Dr 2440 1000 (booked SEK at original rate)
// Dr 7960 50 (FX loss = bank tx: booked SEK)
// Cr 1930 1050 (actual bank withdrawal)
expect(lines.rows).toHaveLength(3)
const ap = lines.rows.find((l) => l.account_number === '2440')!
expect(Number(ap.debit_amount)).toBe(1000)
expect(Number(ap.credit_amount)).toBe(0)
const fxLoss = lines.rows.find((l) => l.account_number === '7960')!
expect(Number(fxLoss.debit_amount)).toBe(50)
expect(Number(fxLoss.credit_amount)).toBe(0)
const bank = lines.rows.find((l) => l.account_number === '1930')!
expect(Number(bank.debit_amount)).toBe(0)
expect(Number(bank.credit_amount)).toBe(1050)
// Round-1 fix: bank line credit must equal tx_abs, not the AR/AP
// total. With FX diff lines this distinction matters: verify it.
expect(Number(bank.credit_amount)).toBe(1050)
// Supplier invoice settled in full and stored in invoice currency.
const inv = await client.query<{
status: string; paid_amount: string; remaining_amount: string
}>(
`SELECT status, paid_amount, remaining_amount FROM public.supplier_invoices WHERE id = $1`,
[si],
)
expect(inv.rows[0]!.status).toBe('paid')
expect(Number(inv.rows[0]!.paid_amount)).toBe(100) // USD value, not SEK
expect(Number(inv.rows[0]!.remaining_amount)).toBe(0)
// Round-3: payment row stores the effective payment-day rate
// (v_alloc_amount / v_inv_remaining = 1050/100 = 10.5) alongside
// the invoicing rate (10.0). swedish-compliance traceability fix.
const pay = await client.query<{
exchange_rate: string | null; payment_exchange_rate: string | null
}>(
`SELECT exchange_rate, payment_exchange_rate
FROM public.supplier_invoice_payments
WHERE supplier_invoice_id = $1`,
[si],
)
expect(Number(pay.rows[0]!.exchange_rate)).toBe(10) // invoicing rate
expect(Number(pay.rows[0]!.payment_exchange_rate)).toBe(10.5) // payment-day rate
// Sum of debits = sum of credits (balanced verifikat).
const balance = await client.query<{ debits: string; credits: string }>(
`SELECT
COALESCE(SUM(debit_amount), 0) AS debits,
COALESCE(SUM(credit_amount), 0) AS credits
FROM public.journal_entry_lines
WHERE journal_entry_id = $1`,
[result.journal_entry_id],
)
expect(Number(balance.rows[0]!.debits)).toBe(Number(balance.rows[0]!.credits))
})
})
// PR #607 round-1: strict undershoot rejection. The RPC previously
// accepted sum(allocations) < tx_abs and silently underbooked the bank
// line, breaking reconciliation. Now it must reject with
// BATCH_AMOUNT_BELOW_TX.
it('rejects BATCH_AMOUNT_BELOW_TX when allocations sum below tx_abs', 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: -1500 })
await withUserContext(userId, async (client) => {
const r = await client.query<{ match_batch_allocate: RpcResult }>(
`SELECT match_batch_allocate($1, $2::jsonb, $3)`,
[
txId,
JSON.stringify([
{ kind: 'supplier_invoice', supplier_invoice_id: si, amount: 1000 },
]),
companyId,
],
)
const result = r.rows[0]!.match_batch_allocate
expect(result.ok).toBe(false)
expect(result.code).toBe('BATCH_AMOUNT_BELOW_TX')
expect(result.details).toMatchObject({ allocated: 1000, tx_amount_abs: 1500 })
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()
})
})
// #1717: öresavrundning. A whole-krona settlement of an öre-bearing
// remaining (|diff| < 1 kr) must settle the invoice in FULL: the AR/AP leg
// clears the whole remaining, 3740 carries the residual, status flips to
// 'paid'. A diff of >= 1 kr keeps the legacy behaviour (real partial /
// BATCH_OVERSHOOT above).
describe('öresavrundning settlement (#1717)', () => {
async function fetchLines(client: PoolClient, journalEntryId: string) {
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`,
[journalEntryId],
)
return lines.rows
}
function assertBalanced(rows: Array<{ debit_amount: string; credit_amount: string }>) {
const debits = rows.reduce((s, l) => s + Number(l.debit_amount), 0)
const credits = rows.reduce((s, l) => s + Number(l.credit_amount), 0)
expect(Math.round(debits * 100)).toBe(Math.round(credits * 100))
}
it('customer invoice paid a whole krona short settles to paid with Dr 3740', async () => {
const { userId, companyId } = await seedTenant()
const customerId = await insertCustomer({ userId, companyId })
const invoiceId = await insertCustomerInvoice({
userId, companyId, customerId, total: 1000.4,
})
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)`,
[
txId,
JSON.stringify([{ kind: 'customer_invoice', invoice_id: invoiceId, amount: 1000 }]),
companyId,
],
)
const result = r.rows[0]!.match_batch_allocate
expect(result.ok).toBe(true)
expect(result.allocations![0]!.status).toBe('paid')
expect(result.allocations![0]!.paid_amount).toBe(1000.4)
expect(result.allocations![0]!.remaining_amount).toBe(0)
const rows = await fetchLines(client, result.journal_entry_id!)
// Cr 1510 1000.40 (full remaining), Dr 3740 0.40, Dr 1930 1000.
expect(rows).toHaveLength(3)
const ar = rows.find((l) => l.account_number === '1510')!
expect(Number(ar.credit_amount)).toBe(1000.4)
const ore = rows.find((l) => l.account_number === '3740')!
expect(Number(ore.debit_amount)).toBe(0.4)
expect(Number(ore.credit_amount)).toBe(0)
const bank = rows.find((l) => l.account_number === '1930')!
expect(Number(bank.debit_amount)).toBe(1000)
assertBalanced(rows)
const inv = await client.query<{
status: string; paid_amount: string; remaining_amount: string; paid_at: string | null
}>(
`SELECT status, paid_amount, remaining_amount, paid_at FROM public.invoices WHERE id = $1`,
[invoiceId],
)
expect(inv.rows[0]!.status).toBe('paid')
expect(Number(inv.rows[0]!.paid_amount)).toBe(1000.4)
expect(Number(inv.rows[0]!.remaining_amount)).toBe(0)
expect(inv.rows[0]!.paid_at).not.toBeNull()
// The payment row records the FULL remaining (planInvoicePayment
// convention), not the raw bank allocation.
const pay = await client.query<{ amount: string }>(
`SELECT amount FROM public.invoice_payments WHERE invoice_id = $1`,
[invoiceId],
)
expect(Number(pay.rows[0]!.amount)).toBe(1000.4)
})
})
it('customer invoice over-paid by öre is accepted with Cr 3740', async () => {
const { userId, companyId } = await seedTenant()
const customerId = await insertCustomer({ userId, companyId })
const invoiceId = await insertCustomerInvoice({
userId, companyId, customerId, total: 1000.6,
})
const txId = await insertTransaction({ userId, companyId, amount: 1001 })
await withUserContext(userId, async (client) => {
const r = await client.query<{ match_batch_allocate: RpcResult }>(
`SELECT match_batch_allocate($1, $2::jsonb, $3)`,
[
txId,
JSON.stringify([{ kind: 'customer_invoice', invoice_id: invoiceId, amount: 1001 }]),
companyId,
],
)
const result = r.rows[0]!.match_batch_allocate
expect(result.ok).toBe(true)
expect(result.allocations![0]!.status).toBe('paid')
const rows = await fetchLines(client, result.journal_entry_id!)
// Cr 1510 1000.60, Cr 3740 0.40 (vinst), Dr 1930 1001.
expect(rows).toHaveLength(3)
expect(Number(rows.find((l) => l.account_number === '1510')!.credit_amount)).toBe(1000.6)
const ore = rows.find((l) => l.account_number === '3740')!
expect(Number(ore.credit_amount)).toBe(0.4)
expect(Number(ore.debit_amount)).toBe(0)
expect(Number(rows.find((l) => l.account_number === '1930')!.debit_amount)).toBe(1001)
assertBalanced(rows)
const inv = await client.query<{ status: string; paid_amount: string }>(
`SELECT status, paid_amount FROM public.invoices WHERE id = $1`,
[invoiceId],
)
expect(inv.rows[0]!.status).toBe('paid')
expect(Number(inv.rows[0]!.paid_amount)).toBe(1000.6)
})
})
it('supplier invoice paid öre short settles to paid with Cr 3740 (mirror polarity)', async () => {
const { userId, companyId } = await seedTenant()
const supplier = await insertSupplier({ userId, companyId })
const si = await insertSupplierInvoice({
userId, companyId, supplierId: supplier, total: 2000.3,
})
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)`,
[
txId,
JSON.stringify([{ kind: 'supplier_invoice', supplier_invoice_id: si, amount: 2000 }]),
companyId,
],
)
const result = r.rows[0]!.match_batch_allocate
expect(result.ok).toBe(true)
expect(result.allocations![0]!.status).toBe('paid')
const rows = await fetchLines(client, result.journal_entry_id!)
// Dr 2440 2000.30 (full remaining), Cr 3740 0.30 (vinst: paid less
// than owed), Cr 1930 2000.
expect(rows).toHaveLength(3)
expect(Number(rows.find((l) => l.account_number === '2440')!.debit_amount)).toBe(2000.3)
const ore = rows.find((l) => l.account_number === '3740')!
expect(Number(ore.credit_amount)).toBe(0.3)
expect(Number(ore.debit_amount)).toBe(0)
expect(Number(rows.find((l) => l.account_number === '1930')!.credit_amount)).toBe(2000)
assertBalanced(rows)
const inv = await client.query<{
status: string; paid_amount: string; remaining_amount: string
}>(
`SELECT status, paid_amount, remaining_amount FROM public.supplier_invoices WHERE id = $1`,
[si],
)
expect(inv.rows[0]!.status).toBe('paid')
expect(Number(inv.rows[0]!.paid_amount)).toBe(2000.3)
expect(Number(inv.rows[0]!.remaining_amount)).toBe(0)
})
})
it('supplier invoice over-paid by öre gets Dr 3740 (förlust)', async () => {
const { userId, companyId } = await seedTenant()
const supplier = await insertSupplier({ userId, companyId })
const si = await insertSupplierInvoice({
userId, companyId, supplierId: supplier, total: 1999.6,
})
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)`,
[
txId,
JSON.stringify([{ kind: 'supplier_invoice', supplier_invoice_id: si, amount: 2000 }]),
companyId,
],
)
const result = r.rows[0]!.match_batch_allocate
expect(result.ok).toBe(true)
expect(result.allocations![0]!.status).toBe('paid')
const rows = await fetchLines(client, result.journal_entry_id!)
expect(rows).toHaveLength(3)
expect(Number(rows.find((l) => l.account_number === '2440')!.debit_amount)).toBe(1999.6)
const ore = rows.find((l) => l.account_number === '3740')!
expect(Number(ore.debit_amount)).toBe(0.4)
expect(Number(ore.credit_amount)).toBe(0)
assertBalanced(rows)
})
})
it('a shortfall of exactly 1 kr stays a real partial (no 3740)', 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: -999 })
await withUserContext(userId, async (client) => {
const r = await client.query<{ match_batch_allocate: RpcResult }>(
`SELECT match_batch_allocate($1, $2::jsonb, $3)`,
[
txId,
JSON.stringify([{ kind: 'supplier_invoice', supplier_invoice_id: si, amount: 999 }]),
companyId,
],
)
const result = r.rows[0]!.match_batch_allocate
expect(result.ok).toBe(true)
expect(result.allocations![0]!.status).toBe('partially_paid')
const rows = await fetchLines(client, result.journal_entry_id!)
expect(rows).toHaveLength(2)
expect(rows.some((l) => l.account_number === '3740')).toBe(false)
expect(Number(rows.find((l) => l.account_number === '2440')!.debit_amount)).toBe(999)
const inv = await client.query<{
status: string; paid_amount: string; remaining_amount: string
}>(
`SELECT status, paid_amount, remaining_amount FROM public.supplier_invoices WHERE id = $1`,
[si],
)
expect(inv.rows[0]!.status).toBe('partially_paid')
expect(Number(inv.rows[0]!.paid_amount)).toBe(999)
expect(Number(inv.rows[0]!.remaining_amount)).toBe(1)
})
})
it('samlingsbetalning: whole-krona clump over two öre-bearing invoices settles both', async () => {
const { userId, companyId } = await seedTenant()
const customerId = await insertCustomer({ userId, companyId })
// 500.25 + 499.75 = 1000.00 owed; the batch dialog splits a 1000 kr
// payment 500/500, leaving +0.25 short on one and 0.25 over on the
// other: both absorb to 3740 and both invoices reach 'paid'.
const inv1 = await insertCustomerInvoice({
userId, companyId, customerId, total: 500.25,
})
const inv2 = await insertCustomerInvoice({
userId, companyId, customerId, total: 499.75,
})
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)`,
[
txId,
JSON.stringify([
{ kind: 'customer_invoice', invoice_id: inv1, amount: 500 },
{ kind: 'customer_invoice', invoice_id: inv2, amount: 500 },
]),
companyId,
],
)
const result = r.rows[0]!.match_batch_allocate
expect(result.ok).toBe(true)
expect(result.allocations).toHaveLength(2)
for (const alloc of result.allocations!) {
expect(alloc.status).toBe('paid')
expect(alloc.remaining_amount).toBe(0)
}
const rows = await fetchLines(client, result.journal_entry_id!)
// 2 × Cr 1510 (full remainings) + Dr 3740 0.25 + Cr 3740 0.25
// + Dr 1930 1000.
expect(rows).toHaveLength(5)
const arSum = rows
.filter((l) => l.account_number === '1510')
.reduce((s, l) => s + Number(l.credit_amount), 0)
expect(Math.round(arSum * 100)).toBe(100000)
const oreRows = rows.filter((l) => l.account_number === '3740')
expect(oreRows).toHaveLength(2)
assertBalanced(rows)
const invoices = await client.query<{ status: string }>(
`SELECT status FROM public.invoices WHERE id IN ($1, $2)`,
[inv1, inv2],
)
expect(invoices.rows.every((row) => row.status === 'paid')).toBe(true)
})
})
})
})