* 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>
642 lines
26 KiB
TypeScript
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)
|
|
})
|
|
})
|