62135c0c02
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>
1052 lines
42 KiB
TypeScript
1052 lines
42 KiB
TypeScript
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)
|
||
})
|
||
})
|
||
})
|
||
})
|