Files
accounted/lib/invoices/__tests__/voucher-matching.pg.test.ts
T
MattssonandClaude Fable 5 cd344b6dbb fix(db): enforce balance check on directly inserted posted journal entries (v2) (#1439)
* fix(db): enforce balance check on directly inserted posted journal entries

check_balance_on_post only fires on the draft-to-posted UPDATE transition,
so any code path that INSERTs a row with status 'posted' directly skipped
balance validation entirely. The invariant sum(debit) = sum(credit) on
every posted entry was DB-enforced only for the engine's commit lifecycle.

Add check_balance_on_posted_insert, a deferred constraint trigger on
AFTER INSERT WHEN (NEW.status = 'posted') reusing the existing
check_journal_entry_balance() function, which already handles the
journal_entries INSERT context via NEW.id/NEW.status. Deferred semantics
let an atomic transaction insert header and lines together; zero-line and
unbalanced posted inserts are rejected at constraint evaluation. All
existing checks stay intact; this only adds coverage.

The one first-party posted-INSERT path outside an RPC, the sandbox seed,
now books through the bookkeeping engine (createJournalEntry) instead of
raw inserts. SIE import already inserts header and lines in a single
transaction via its structured RPC and passes unchanged.

pg tests cover the new path (zero-line rejected, unbalanced rejected at
SET CONSTRAINTS IMMEDIATE, balanced same-transaction insert accepted) and
existing posted-entry fixtures move to a transactional
insertPostedJournalEntry helper so they stay valid setup.

Fixes #327

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tests): insert list-filters pg fixtures in one transaction

The list-filters suite (landed via a sibling merge) inserted posted
headers with getPool().query, where each query autocommits: the deferred
check_balance_on_posted_insert constraint fired at the header's own
commit with zero lines and correctly rejected the fixture. Header and
balanced lines now share one BEGIN/COMMIT so the constraint evaluates
the complete entry, mirroring the insertPostedJournalEntry helper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(seed): insert journal headers as drafts, post after lines land

check_balance_on_posted_insert (renamed to apply-time version
20260806130000) rejects a posted header whose transaction has no lines.
PostgREST autocommits each request, so every seed path that inserted
posted headers first would die with "has zero total": the sandbox seed
(ledger history, invoice vouchers, salary vouchers), seed-demo-account
and seed-export-data. All now insert draft headers, insert lines, then
flip to posted so check_balance_on_post validates the finished
verifikat. The sandbox seed keeps its documented no-events design.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(db): preserve a preset committed_at on draft-to-posted transition

set_committed_at() stamped now() unconditionally, so the seed flows that
post backdated drafts lost their historical booking timestamps and every
demo verifikat read as booked today (CodeRabbit finding on PR 1439).
Stamp only when committed_at is NULL: the engine path (drafts carry no
committed_at) behaves exactly as before and a posted entry still always
has a committed_at; an explicitly supplied value now survives posting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(db): preserve preset committed_at only for trusted roles

The IS NULL guard alone (20260806150000, never shipped; replaced by
20260806160000) let any RLS-permitted member backdate committed_at
through PostgREST by presetting it on a draft and posting, which the
Swedish accounting review flagged: committed_at is what the BFL 5 kap
timeliness checks and behandlingshistorik treat as the genuine
transition time. Preset values now survive posting only for
service_role/postgres/supabase_admin; authenticated and anon writers
always get the now() stamp. Consequence: the sandbox seed (runs as the
requesting user) gets committed_at = posting time, accepted and
documented in the route; the demo scripts run as service_role and keep
their backdated history. pg tests cover all four paths, with the upper
timestamp bound CodeRabbit asked for.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(db): restore superseded migration so the preview tracker stays consistent

The preview branch had already applied 20260806150000 when the previous
commit deleted the file, orphaning the preview's migration tracker
("Remote migration versions not found in local migrations directory").
Restored with a header explaining it is superseded in the same deploy by
20260806160000, so the unguarded semantics are never live on their own.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(db): decide committed_at trust by JWT claims, not current_user

The Swedish review found the current_user guard bypassable:
commit_journal_entry is SECURITY DEFINER and granted to authenticated,
so inside it current_user is the function owner and a member could
preset a backdated committed_at on a direct-inserted draft and launder
it through the RPC. The guard now reads the JWT claims role (same
primitive as the RPC's own tenant guard): preset values survive only
for service_role or claim-less backend connections; authenticated and
anon callers are always stamped now(), on both the direct UPDATE and
the RPC path (new pg test). Both migration files now carry the
identical final body so no unguarded intermediate exists as a
standalone applyable unit. Behandlingshistorik logging of trusted
overrides is follow-up #1444.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 23:00:05 +02:00

642 lines
26 KiB
TypeScript

/**
* pg-real test for the link-invoice-voucher feature's DB-side guards.
*
* Covers what the TypeScript service can't verify on its own:
* - The partial unique index idx_invoice_payments_je_inv_unique blocks
* linking the same voucher to the same invoice twice while still
* allowing the voucher to settle other invoices.
* - The link_invoice_voucher operation_type passes the pending_operations
* CHECK constraint.
* - Invoice + invoice_payments writes survive RLS for the owning user and
* are rejected for a different user.
*
* Asserts behaviour the migration 20260528120000 introduced.
*/
import { describe, it, expect } from 'vitest'
import { randomUUID } from 'node:crypto'
import { getClient, getPool } from '@/tests/pg/setup'
import {
insertAuthUser,
insertCompany,
insertCompanyMember,
insertFiscalPeriod,
} from '@/tests/pg/fixtures'
async function seedCustomer(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)
VALUES ($1, $2, $3, 'Test Kund AB', 'swedish_business')`,
[id, params.userId, params.companyId],
)
return id
}
async function seedInvoice(params: {
userId: string
companyId: string
customerId: string
total?: number
status?: 'sent' | 'overdue' | 'partially_paid'
}): Promise<string> {
const id = randomUUID()
const total = params.total ?? 1000
await getPool().query(
`INSERT INTO public.invoices
(id, user_id, company_id, customer_id, invoice_number, invoice_date, due_date,
currency, subtotal, vat_amount, total, vat_treatment, vat_rate, status,
paid_amount, remaining_amount)
VALUES ($1, $2, $3, $4, $5, '2026-04-01', '2026-05-01', 'SEK',
$6, 0, $6, 'standard_25', 25, $7, 0, $6)`,
[id, params.userId, params.companyId, params.customerId, `F-${id.slice(0, 8)}`, total, params.status ?? 'sent'],
)
return id
}
async function seedPostedVoucher(params: {
userId: string
companyId: string
fiscalPeriodId: string
amount?: number
}): Promise<string> {
const id = randomUUID()
const amount = params.amount ?? 1000
const client = await getClient()
try {
await client.query('BEGIN')
await client.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, $5, 'A', '2026-05-05', 'Inbetalning', 'manual', 'posted')`,
[id, params.userId, params.companyId, params.fiscalPeriodId, Math.floor(Math.random() * 100000)],
)
await client.query(
`INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount)
VALUES ($1, '1930', $2, 0),
($1, '1510', 0, $2)`,
[id, amount],
)
await client.query('COMMIT')
} catch (error) {
await client.query('ROLLBACK').catch(() => {})
throw error
} finally {
client.release()
}
return id
}
/**
* Seed a posted voucher with one debit line and one credit line (balanced).
* Lets kontantmetoden tests build a cash-receipt verifikat (debit 1930 /
* credit 3001: no 1510) and a non-matching one (debit 1510 / credit 3001).
*/
async function seedVoucherDebitCredit(params: {
userId: string
companyId: string
fiscalPeriodId: string
amount?: number
debitAccount: string
creditAccount: string
}): Promise<string> {
const id = randomUUID()
const amount = params.amount ?? 1000
const client = await getClient()
try {
await client.query('BEGIN')
await client.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, $5, 'A', '2026-05-05', 'Inbetalning', 'manual', 'posted')`,
[id, params.userId, params.companyId, params.fiscalPeriodId, Math.floor(Math.random() * 100000)],
)
await client.query(
`INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount)
VALUES ($1, $2, $3, 0),
($1, $4, 0, $3)`,
[id, params.debitAccount, amount, params.creditAccount],
)
await client.query('COMMIT')
} catch (error) {
await client.query('ROLLBACK').catch(() => {})
throw error
} finally {
client.release()
}
return id
}
/** Flip a company onto kontantmetoden so the link RPC keys on the 19xx debit. */
async function setCashMethod(companyId: string): Promise<void> {
await getPool().query(
`INSERT INTO public.company_settings (company_id, accounting_method)
VALUES ($1, 'cash')
ON CONFLICT (company_id) DO UPDATE SET accounting_method = 'cash'`,
[companyId],
)
}
describe('link_invoice_voucher pg-real guards', () => {
it('partial unique index blocks linking the same voucher to the same invoice twice', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId })
const fiscalPeriodId = await insertFiscalPeriod({ userId, companyId })
const customerId = await seedCustomer({ userId, companyId })
const invoiceId = await seedInvoice({ userId, companyId, customerId })
const voucherId = await seedPostedVoucher({ userId, companyId, fiscalPeriodId })
// First link: should succeed.
await getPool().query(
`INSERT INTO public.invoice_payments
(user_id, company_id, invoice_id, payment_date, amount, currency, journal_entry_id)
VALUES ($1, $2, $3, '2026-05-05', 1000, 'SEK', $4)`,
[userId, companyId, invoiceId, voucherId],
)
// Second identical link: should be rejected by the partial unique index.
await expect(
getPool().query(
`INSERT INTO public.invoice_payments
(user_id, company_id, invoice_id, payment_date, amount, currency, journal_entry_id)
VALUES ($1, $2, $3, '2026-05-05', 1000, 'SEK', $4)`,
[userId, companyId, invoiceId, voucherId],
),
).rejects.toMatchObject({ code: '23505' })
})
it('one voucher can be linked to multiple distinct invoices', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId })
const fiscalPeriodId = await insertFiscalPeriod({ userId, companyId })
const customerId = await seedCustomer({ userId, companyId })
const invoiceAId = await seedInvoice({ userId, companyId, customerId, total: 500 })
const invoiceBId = await seedInvoice({ userId, companyId, customerId, total: 500 })
const voucherId = await seedPostedVoucher({ userId, companyId, fiscalPeriodId, amount: 1000 })
await getPool().query(
`INSERT INTO public.invoice_payments
(user_id, company_id, invoice_id, payment_date, amount, currency, journal_entry_id)
VALUES ($1, $2, $3, '2026-05-05', 500, 'SEK', $4),
($1, $2, $5, '2026-05-05', 500, 'SEK', $4)`,
[userId, companyId, invoiceAId, voucherId, invoiceBId],
)
const { rows } = await getPool().query<{ count: string }>(
`SELECT COUNT(*) FROM public.invoice_payments WHERE journal_entry_id = $1`,
[voucherId],
)
expect(Number(rows[0].count)).toBe(2)
})
it('partial unique index does NOT collide when journal_entry_id is NULL', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId })
const customerId = await seedCustomer({ userId, companyId })
const invoiceId = await seedInvoice({ userId, companyId, customerId })
// Two transaction-keyed payment rows for the same invoice with NULL JE
// must coexist (until 2026-05-28 partial index, this would have been a
// false positive if the index were unconditional).
const txId1 = randomUUID()
const txId2 = randomUUID()
await getPool().query(
`INSERT INTO public.transactions (id, user_id, company_id, account_id, date, description, amount, currency)
VALUES ($1, $2, $3, $4, '2026-05-05', 'Payment 1', 500, 'SEK'),
($5, $2, $3, $4, '2026-05-06', 'Payment 2', 500, 'SEK')`,
[txId1, userId, companyId, randomUUID(), txId2],
).catch(async () => {
// transactions table also requires account_id pointing at bank_connections;
// skip seeding txs if FK doesn't allow NULL, and assert against the
// invoice_payments table directly.
})
// Insert two rows with no journal_entry_id and no transaction_id: the
// partial index excludes them and the (transaction_id, invoice_id) unique
// index allows NULL transaction_id duplicates.
await getPool().query(
`INSERT INTO public.invoice_payments
(user_id, company_id, invoice_id, payment_date, amount, currency)
VALUES ($1, $2, $3, '2026-05-05', 500, 'SEK'),
($1, $2, $3, '2026-05-06', 500, 'SEK')`,
[userId, companyId, invoiceId],
)
const { rows } = await getPool().query<{ count: string }>(
`SELECT COUNT(*) FROM public.invoice_payments WHERE invoice_id = $1`,
[invoiceId],
)
expect(Number(rows[0].count)).toBe(2)
})
it('link_invoice_voucher passes the operation_type CHECK constraint', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId })
const fiscalPeriodId = await insertFiscalPeriod({ userId, companyId })
const customerId = await seedCustomer({ userId, companyId })
const invoiceId = await seedInvoice({ userId, companyId, customerId })
const voucherId = await seedPostedVoucher({ userId, companyId, fiscalPeriodId })
const opId = randomUUID()
await getPool().query(
`INSERT INTO public.pending_operations
(id, user_id, company_id, operation_type, title, params, preview_data, status, risk_level)
VALUES ($1, $2, $3, 'link_invoice_voucher', 'test', $4::jsonb, $5::jsonb, 'pending', 'medium')`,
[
opId,
userId,
companyId,
JSON.stringify({ invoice_id: invoiceId, journal_entry_id: voucherId }),
JSON.stringify({ voucher_label: 'A-1' }),
],
)
const { rows } = await getPool().query<{ status: string }>(
`SELECT status FROM public.pending_operations WHERE id = $1`,
[opId],
)
expect(rows[0]?.status).toBe('pending')
})
it('linking a voucher whose period is locked does NOT trigger enforce_period_lock', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId })
// Period must be open while we seed the voucher: enforce_period_lock
// fires on INSERT, so close it only after the JE rows exist.
const fiscalPeriodId = await insertFiscalPeriod({ userId, companyId })
const customerId = await seedCustomer({ userId, companyId })
const invoiceId = await seedInvoice({ userId, companyId, customerId })
const voucherId = await seedPostedVoucher({ userId, companyId, fiscalPeriodId })
await getPool().query(
`UPDATE public.fiscal_periods
SET is_closed = true, closed_at = now()
WHERE id = $1`,
[fiscalPeriodId],
)
// No journal_entries write happens in the link flow: only
// invoice_payments + invoices, neither of which is gated by
// enforce_period_lock. The insert below must succeed even though the
// voucher's fiscal period is closed.
await getPool().query(
`INSERT INTO public.invoice_payments
(user_id, company_id, invoice_id, payment_date, amount, currency, journal_entry_id)
VALUES ($1, $2, $3, '2026-05-05', 1000, 'SEK', $4)`,
[userId, companyId, invoiceId, voucherId],
)
const { rows } = await getPool().query<{ count: string }>(
`SELECT COUNT(*) FROM public.invoice_payments WHERE invoice_id = $1`,
[invoiceId],
)
expect(Number(rows[0].count)).toBe(1)
})
})
// ============================================================
// link_invoice_to_voucher RPC: atomic customer link (audit C2)
// Mirrors the supplier-side link_supplier_invoice_to_voucher tests: the RPC
// must lock the invoice FOR UPDATE, validate, and apply UPDATE + INSERT in a
// single transaction so concurrent linkers serialize instead of clobbering
// each other (the old TS path's stale-snapshot manual rollback).
// ============================================================
type RpcResult = {
ok: boolean
code?: string
invoice_status?: string
paid_amount?: number
remaining_amount?: number
payment_amount?: number
details?: Record<string, unknown>
}
async function callLinkRpc(args: {
invoiceId: string
voucherId: string
userId: string
companyId: string
}): Promise<RpcResult> {
const { rows } = await getPool().query<{ result: RpcResult }>(
`SELECT public.link_invoice_to_voucher($1, $2, $3, $4, NULL) AS result`,
[args.invoiceId, args.voucherId, args.userId, args.companyId],
)
return rows[0].result
}
describe('link_invoice_to_voucher RPC (atomic link: audit C2)', () => {
it('links a full payment: invoice advanced + payment row, one transaction', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId })
const fiscalPeriodId = await insertFiscalPeriod({ userId, companyId })
const customerId = await seedCustomer({ userId, companyId })
const invoiceId = await seedInvoice({ userId, companyId, customerId, total: 1000 })
const voucherId = await seedPostedVoucher({ userId, companyId, fiscalPeriodId, amount: 1000 })
const result = await callLinkRpc({ invoiceId, voucherId, userId, companyId })
expect(result.ok).toBe(true)
expect(result.invoice_status).toBe('paid')
expect(Number(result.paid_amount)).toBe(1000)
expect(Number(result.remaining_amount)).toBe(0)
const { rows: inv } = await getPool().query(
`SELECT status, paid_amount, remaining_amount,
paid_at = TIMESTAMPTZ '2026-05-05 12:00:00+00' AS paid_at_matches
FROM public.invoices WHERE id = $1`,
[invoiceId],
)
expect(inv[0].status).toBe('paid')
expect(Number(inv[0].paid_amount)).toBe(1000)
expect(Number(inv[0].remaining_amount)).toBe(0)
expect(inv[0].paid_at_matches).toBe(true)
const { rows: pay } = await getPool().query(
`SELECT amount, payment_date = DATE '2026-05-05' AS payment_date_matches
FROM public.invoice_payments WHERE invoice_id = $1 AND journal_entry_id = $2`,
[invoiceId, voucherId],
)
expect(pay).toHaveLength(1)
expect(Number(pay[0].amount)).toBe(1000)
expect(pay[0].payment_date_matches).toBe(true)
})
it('links a partial payment as partially_paid with the right remaining', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId })
const fiscalPeriodId = await insertFiscalPeriod({ userId, companyId })
const customerId = await seedCustomer({ userId, companyId })
const invoiceId = await seedInvoice({ userId, companyId, customerId, total: 1000 })
const voucherId = await seedPostedVoucher({ userId, companyId, fiscalPeriodId, amount: 400 })
const result = await callLinkRpc({ invoiceId, voucherId, userId, companyId })
expect(result.ok).toBe(true)
expect(result.invoice_status).toBe('partially_paid')
expect(Number(result.paid_amount)).toBe(400)
expect(Number(result.remaining_amount)).toBe(600)
})
it('rejects overpayment and leaves the invoice completely untouched', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId })
const fiscalPeriodId = await insertFiscalPeriod({ userId, companyId })
const customerId = await seedCustomer({ userId, companyId })
const invoiceId = await seedInvoice({ userId, companyId, customerId, total: 1000 })
const voucherId = await seedPostedVoucher({ userId, companyId, fiscalPeriodId, amount: 1500 })
const result = await callLinkRpc({ invoiceId, voucherId, userId, companyId })
expect(result.ok).toBe(false)
expect(result.code).toBe('LINK_VOUCHER_AMOUNT_EXCEEDS_REMAINING')
const { rows: inv } = await getPool().query(
`SELECT status, paid_amount, remaining_amount FROM public.invoices WHERE id = $1`,
[invoiceId],
)
expect(inv[0].status).toBe('sent')
expect(Number(inv[0].paid_amount)).toBe(0)
expect(Number(inv[0].remaining_amount)).toBe(1000)
const { rows: pay } = await getPool().query(
`SELECT COUNT(*) AS count FROM public.invoice_payments WHERE invoice_id = $1`,
[invoiceId],
)
expect(Number(pay[0].count)).toBe(0)
})
it('rejects re-linking the same voucher to the same invoice (ALREADY_LINKED)', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId })
const fiscalPeriodId = await insertFiscalPeriod({ userId, companyId })
const customerId = await seedCustomer({ userId, companyId })
const invoiceId = await seedInvoice({ userId, companyId, customerId, total: 2000 })
const voucherId = await seedPostedVoucher({ userId, companyId, fiscalPeriodId, amount: 1000 })
const first = await callLinkRpc({ invoiceId, voucherId, userId, companyId })
expect(first.ok).toBe(true)
const second = await callLinkRpc({ invoiceId, voucherId, userId, companyId })
expect(second.ok).toBe(false)
expect(second.code).toBe('LINK_VOUCHER_ALREADY_LINKED')
const { rows: pay } = await getPool().query(
`SELECT COUNT(*) AS count FROM public.invoice_payments WHERE invoice_id = $1`,
[invoiceId],
)
expect(Number(pay[0].count)).toBe(1)
})
it('REGRESSION (the C2 race): two concurrent full-payment links: exactly one wins', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId })
const fiscalPeriodId = await insertFiscalPeriod({ userId, companyId })
const customerId = await seedCustomer({ userId, companyId })
const invoiceId = await seedInvoice({ userId, companyId, customerId, total: 1000 })
const voucherA = await seedPostedVoucher({ userId, companyId, fiscalPeriodId, amount: 1000 })
const voucherB = await seedPostedVoucher({ userId, companyId, fiscalPeriodId, amount: 1000 })
// Two different vouchers, each covering the full remaining, racing on the
// same invoice from separate pool connections. The FOR UPDATE lock must
// serialize them: the loser sees remaining = 0 and gets FULLY_PAID. Under
// the old TS path both could pass validation and the invoice ended up
// with paid_amount > total (the audit C2 corruption).
const [r1, r2] = await Promise.all([
callLinkRpc({ invoiceId, voucherId: voucherA, userId, companyId }),
callLinkRpc({ invoiceId, voucherId: voucherB, userId, companyId }),
])
const winners = [r1, r2].filter((r) => r.ok)
const losers = [r1, r2].filter((r) => !r.ok)
expect(winners).toHaveLength(1)
expect(losers).toHaveLength(1)
expect(losers[0].code).toBe('LINK_VOUCHER_INVOICE_FULLY_PAID')
const { rows: inv } = await getPool().query(
`SELECT status, paid_amount, remaining_amount FROM public.invoices WHERE id = $1`,
[invoiceId],
)
expect(inv[0].status).toBe('paid')
expect(Number(inv[0].paid_amount)).toBe(1000) // never 2000
expect(Number(inv[0].remaining_amount)).toBe(0)
const { rows: pay } = await getPool().query(
`SELECT COUNT(*) AS count FROM public.invoice_payments WHERE invoice_id = $1`,
[invoiceId],
)
expect(Number(pay[0].count)).toBe(1)
})
})
// ============================================================
// link_invoice_to_voucher RPC: kontantmetoden branch
// On cash method no 1510 is ever booked (revenue is recognised at payment),
// so the RPC must key on the bank/cash DEBIT (19xx) instead of the AR credit.
// Mirrors the accounting-method branch in lib/invoices/voucher-matching.ts.
// ============================================================
describe('link_invoice_to_voucher RPC (kontantmetoden: 19xx debit)', () => {
it('cash method: links against the 1930 debit of a receipt voucher (no 1510)', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId })
await setCashMethod(companyId)
const fiscalPeriodId = await insertFiscalPeriod({ userId, companyId })
const customerId = await seedCustomer({ userId, companyId })
const invoiceId = await seedInvoice({ userId, companyId, customerId, total: 1000 })
// Cash receipt: debit 1930 (bank) / credit 3001 (revenue): no receivable.
const voucherId = await seedVoucherDebitCredit({
userId,
companyId,
fiscalPeriodId,
amount: 1000,
debitAccount: '1930',
creditAccount: '3001',
})
const result = await callLinkRpc({ invoiceId, voucherId, userId, companyId })
expect(result.ok).toBe(true)
expect(result.invoice_status).toBe('paid')
expect(Number(result.paid_amount)).toBe(1000)
expect(Number(result.remaining_amount)).toBe(0)
const { rows: pay } = await getPool().query(
`SELECT amount FROM public.invoice_payments WHERE invoice_id = $1 AND journal_entry_id = $2`,
[invoiceId, voucherId],
)
expect(pay).toHaveLength(1)
expect(Number(pay[0].amount)).toBe(1000)
})
it('cash method: rejects a voucher with no bank/cash debit (NO_AR_CREDIT)', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId })
await setCashMethod(companyId)
const fiscalPeriodId = await insertFiscalPeriod({ userId, companyId })
const customerId = await seedCustomer({ userId, companyId })
const invoiceId = await seedInvoice({ userId, companyId, customerId, total: 1000 })
// An AR-clearing voucher (debit 1510 / credit 3001): valid on accrual, but
// on cash there is no 19xx debit, so it must not match.
const voucherId = await seedVoucherDebitCredit({
userId,
companyId,
fiscalPeriodId,
amount: 1000,
debitAccount: '1510',
creditAccount: '3001',
})
const result = await callLinkRpc({ invoiceId, voucherId, userId, companyId })
expect(result.ok).toBe(false)
expect(result.code).toBe('LINK_VOUCHER_NO_AR_CREDIT')
const { rows: inv } = await getPool().query(
`SELECT status, paid_amount FROM public.invoices WHERE id = $1`,
[invoiceId],
)
expect(inv[0].status).toBe('sent')
expect(Number(inv[0].paid_amount)).toBe(0)
})
it('accrual default (no settings row): still keys on the 1510 credit', async () => {
// Regression guard: a company with no company_settings row must behave as
// accrual: the 1930-debit / 1510-credit voucher links via its AR credit.
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId })
const fiscalPeriodId = await insertFiscalPeriod({ userId, companyId })
const customerId = await seedCustomer({ userId, companyId })
const invoiceId = await seedInvoice({ userId, companyId, customerId, total: 1000 })
const voucherId = await seedPostedVoucher({ userId, companyId, fiscalPeriodId, amount: 1000 })
const result = await callLinkRpc({ invoiceId, voucherId, userId, companyId })
expect(result.ok).toBe(true)
expect(result.invoice_status).toBe('paid')
})
})
describe('link_supplier_invoice_to_voucher RPC payment date', () => {
it('anchors paid_at at UTC noon on the voucher entry date', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId })
const fiscalPeriodId = await insertFiscalPeriod({ userId, companyId })
const supplierId = 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, 'Leverantör AB', 'swedish_business', 'SE', 30, 'SEK')`,
[supplierId, userId, companyId],
)
const supplierInvoiceId = randomUUID()
const arrivalNumber = Number.parseInt(supplierInvoiceId.slice(0, 8), 16) % 2_000_000_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, '2026-04-01', '2026-05-01', '2026-04-01',
'approved', 'SEK', 1000, 0, 1000, 0, 1000,
'standard_25', false, false)`,
[
supplierInvoiceId,
userId,
companyId,
supplierId,
arrivalNumber,
`LF-${supplierInvoiceId.slice(0, 8)}`,
],
)
const voucherId = await seedVoucherDebitCredit({
userId,
companyId,
fiscalPeriodId,
amount: 1000,
debitAccount: '2440',
creditAccount: '1930',
})
const { rows } = await getPool().query<{ result: RpcResult }>(
`SELECT public.link_supplier_invoice_to_voucher($1, $2, $3, $4, NULL) AS result`,
[supplierInvoiceId, voucherId, userId, companyId],
)
expect(rows[0].result.ok).toBe(true)
expect(rows[0].result.invoice_status).toBe('paid')
const { rows: invoiceRows } = await getPool().query<{ paid_at_matches: boolean }>(
`SELECT paid_at = TIMESTAMPTZ '2026-05-05 12:00:00+00' AS paid_at_matches
FROM public.supplier_invoices WHERE id = $1`,
[supplierInvoiceId],
)
expect(invoiceRows[0].paid_at_matches).toBe(true)
const { rows: paymentRows } = await getPool().query<{ payment_date_matches: boolean }>(
`SELECT payment_date = DATE '2026-05-05' AS payment_date_matches
FROM public.supplier_invoice_payments
WHERE supplier_invoice_id = $1 AND journal_entry_id = $2`,
[supplierInvoiceId, voucherId],
)
expect(paymentRows).toHaveLength(1)
expect(paymentRows[0].payment_date_matches).toBe(true)
})
})