Files
accounted/tests/pg/transaction_voucher_links.pg.test.ts
T
Jakob WennbergandClaude Opus 4.7 7bcd46d503 feat(transactions): match overshoot guards + supplier voucher linking (#602)
* feat(transactions): match overshoot guards + supplier voucher linking

Three changes that together close the "I can't link a bank transaction
to an already-booked verifikat on the supplier side" gap and fix a
latent data-corruption bug on the per-tx match endpoints.

1. fix: clamp paid_amount on match endpoints when tx > remaining

   /api/transactions/[id]/match-{invoice,supplier-invoice} previously
   used transaction.amount wholesale as the paid amount, pushing
   invoice.paid_amount past invoice.total whenever the bank tx was
   larger than what was owed. Both endpoints now reject with
   MATCH_AMOUNT_EXCEEDS_REMAINING / MATCH_SI_AMOUNT_EXCEEDS_REMAINING
   and a structured { transaction_amount, remaining_amount, excess }
   payload that points the user at the future split-payment flow.
   FX branch already clamps to invoice.remaining_amount and is
   unchanged.

2. feat: supplier-side "link existing verifikat" (mirror of #591)

   lib/invoices/supplier-voucher-matching.ts mirrors the customer
   voucher-matching module: finds posted JEs that debit 2440
   (Leverantörsskulder), validates currency + remaining-amount, and
   atomically links them as supplier_invoice_payments rows. New
   /api/supplier-invoices/[id]/{voucher-candidates,link-to-voucher}
   routes wrap it. LinkVoucherPicker gains a mode='supplier_invoice'
   prop so the same component renders both flows. The supplier-invoice
   mark-paid dialog now uses Tabs ("Ny betalning" / "Befintlig
   verifikation") to match the customer-side UX.

3. infra: transaction_voucher_links junction + denorm guard

   Foundation migration for upcoming multi-tx ↔ multi-voucher flows.
   Adds the junction table (with RLS, updated_at, indexes), a
   block_contradictory_invoice_denorm trigger on transactions that
   refuses to set invoice_id/supplier_invoice_id to a value that
   contradicts an existing payment row, and is_transaction_booked(uuid)
   as a single source of truth for "is this tx anchored?" once
   multi-allocation leaves denorm columns NULL. No application code
   uses these yet — they unlock the batch allocation and bulk-book
   flows in follow-up PRs.

Tests: 98 unit tests pass across the touched paths (match-invoice,
match-supplier-invoice, supplier-voucher-matching, link-to-voucher).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(supplier-invoices): PR review — atomic link RPC, computeRemaining edge case, pg-real tests

Addresses the three real issues raised by Greptile on PR #602.

1. (P1) Atomic supplier voucher linking — new
   link_supplier_invoice_to_voucher PL/pgSQL RPC. The TS-side
   linkSupplierInvoiceToVoucher() previously did UPDATE-then-INSERT with
   a manual unconditional rollback. Under concurrent linking against the
   same invoice, request A's rollback could overwrite a sibling B's
   successful write while leaving B's payment row in place. Moving both
   writes into a single PG transaction (one RPC call) lets PG's own
   rollback handle the failure path correctly. TS wrapper now just
   translates the structured RPC return into the lib's Result type.

2. (P1) pg-real tests — tests/pg/transaction_voucher_links.pg.test.ts.
   CLAUDE.md mandates *.pg.test.ts for any PR adding a trigger, RPC, or
   RLS. The Phase 1A foundation migration added all three but had no
   pg-real coverage. Tests now cover:
     - trg_block_contradictory_invoice_denorm refusing contradictory
       UPDATEs on invoice_id and supplier_invoice_id
     - the same trigger PERMITTING a matching UPDATE (no false positives)
     - is_transaction_booked() returning true via journal_entry_id, via
       invoice_payments, and via transaction_voucher_links rows.

3. (P2) computeRemaining edge case — trust remaining_amount whenever
   the column is non-null (including the legitimate 0 for fully-paid
   invoices). The old "> 0" guard fell through to total - paid_amount,
   which under rounding drift could compute a tiny positive residue and
   slip a fully-paid invoice past LINK_SI_VOUCHER_INVOICE_FULLY_PAID.

The fourth Greptile comment (overdue invoices silently get no
candidates) was a misread: 'overdue' IS in the open-state list at
route.ts:35. No code change needed there.

Tests: 100 unit tests pass (16 in the directly-touched paths).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(supplier-invoices): PR review round 2 — broaden AP range, log event failures

Addresses the actionable findings from the compliance-swarm and
Swedish-accounting-compliance bot reviews on PR #602.

1. (swedish-accounting-compliance, high) AP account hardcoded to 2440
   rejected legitimate samlingsverifikationer that debit 2441
   (Leverantörsskulder i utländsk valuta), 2443 (Skuldfakturor), etc.
   BAS 2026 reserves the full 2440–2449 range for Leverantörsskulder.
   The TS-side AP_ACCOUNT constant becomes AP_ACCOUNT_PREFIX ('244')
   used with .like() and .startsWith(). The PL/pgSQL RPC's
   account_number filter becomes LIKE '244%'. The
   LINK_SI_VOUCHER_NO_AP_DEBIT error message updates to reference the
   244x range with examples.

2. (ISO 27001:2022 A.8.15 / OWASP V16) Empty catch on the
   supplier_invoice.paid event emission now logs with log.warn so a
   failure in the downstream reminder/audit subscriber leaves an
   auditable trail without blocking the response.

3. (GDPR Art.5(1)(c)) Documented design rationale for retaining
   select('*') on the post-link invoice re-fetch: the
   supplier_invoice.paid event payload is typed as
   `supplierInvoice: SupplierInvoice` in lib/events/types.ts, narrowing
   would break the subscriber contract. The event stays in-process
   and consumers legitimately need the full context.

Skipped findings:
  - V8.2.1 ownership concerns: route + RPC already filter by
    company_id from withRouteContext; the RPC's WHERE clause covers it.
  - DELETE policy scoping: matches the gnubok pattern across all
    company-scoped tables — any member with write access manages records.
  - transaction_id = NULL on the voucher-link path: by design — the
    flow has no bank tx (the voucher's 1930 line represents it).
  - Reverse-charge VAT (2614/2647) validation on linked vouchers:
    real concern but invasive change; tracked for follow-up.
  - Storno-chain integrity (linking the original of a storno pair):
    edge case; tracked for follow-up.

Tests: 26 unit tests pass in the directly-touched paths. RPC patch
applied to remote via Supabase MCP.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 12:31:19 +02:00

368 lines
14 KiB
TypeScript

import { randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import {
insertAuthUser,
insertCompany,
insertCompanyMember,
insertFiscalPeriod,
} from '@/tests/pg/fixtures'
import { getPool } from '@/tests/pg/setup'
/**
* Covers 20260529120000_transaction_voucher_links per PR #602 review note:
* - transaction_voucher_links table is RLS-policied + indexed
* - block_contradictory_invoice_denorm trigger refuses an UPDATE that
* would set transactions.invoice_id (or supplier_invoice_id) to a value
* contradicting an existing payment row.
* - is_transaction_booked(uuid) returns true when ANY of:
* transactions.journal_entry_id IS NOT NULL,
* invoice_payments references the tx,
* supplier_invoice_payments references the tx,
* transaction_voucher_links references the tx
* …and false otherwise.
*
* Tests write via the superuser pool (bypass RLS) — the goal is to exercise
* trigger logic + the helper's SQL truth, not the policy layer.
*/
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 insertInvoice(params: {
userId: string
companyId: string
customerId: string
total?: number
}): Promise<string> {
const id = randomUUID()
const invoiceNumber = `F-${Date.now() % 1_000_000}-${Math.floor(Math.random() * 1_000)}`
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',
$6, 0, $6, 0, $6, 'standard_25')`,
[
id,
params.userId,
params.companyId,
params.customerId,
invoiceNumber,
params.total ?? 1000,
],
)
return id
}
async function insertSupplier(params: {
userId: string
companyId: 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, 'Leverantör AB', 'swedish_business', 'SE', 30, 'SEK')`,
[id, params.userId, params.companyId],
)
return id
}
async function insertSupplierInvoice(params: {
userId: string
companyId: string
supplierId: string
total?: number
}): Promise<string> {
const id = randomUUID()
const arrivalNumber = (Date.now() % 1_000_000_000) + Math.floor(Math.random() * 10_000)
await getPool().query(
`INSERT INTO public.supplier_invoices
(id, user_id, company_id, supplier_id, arrival_number, supplier_invoice_number,
invoice_date, due_date, received_date, status, currency,
subtotal, vat_amount, total, paid_amount, remaining_amount,
vat_treatment, reverse_charge, is_credit_note)
VALUES ($1, $2, $3, $4, $5, $6, '2026-06-01', '2026-07-01', '2026-06-02',
'approved', 'SEK', $7, 0, $7, 0, $7, 'standard_25', false, false)`,
[
id,
params.userId,
params.companyId,
params.supplierId,
arrivalNumber,
`LF-${arrivalNumber}`,
params.total ?? 1000,
],
)
return id
}
async function insertTransaction(params: {
userId: string
companyId: string
amount?: number
}): 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, '2026-06-05', 'Bank transfer', $4, 'SEK', 'uncategorized')`,
[id, params.userId, params.companyId, params.amount ?? 1000],
)
return id
}
async function seedTenant() {
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',
})
return { userId, companyId, fiscalPeriodId }
}
describe('block_contradictory_invoice_denorm trigger', () => {
it('blocks an UPDATE that sets transactions.invoice_id to a value contradicting an invoice_payments row', async () => {
const { userId, companyId, fiscalPeriodId } = await seedTenant()
const customerId = await insertCustomer({ userId, companyId })
const invoiceA = await insertInvoice({ userId, companyId, customerId, total: 1000 })
const invoiceB = await insertInvoice({ userId, companyId, customerId, total: 2000 })
const txId = await insertTransaction({ userId, companyId, amount: 1000 })
// Create a posted journal_entries row so we can satisfy invoice_payments.journal_entry_id FK
const jeId = 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', 'Test', 'manual', 'draft')`,
[jeId, userId, companyId, fiscalPeriodId],
)
await getPool().query(
`INSERT INTO public.journal_entry_lines (journal_entry_id, account_number, debit_amount, credit_amount)
VALUES ($1, '1930', 1000, 0), ($1, '1510', 0, 1000)`,
[jeId],
)
await getPool().query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [jeId])
// Insert invoice_payments row linking tx → invoiceA
await getPool().query(
`INSERT INTO public.invoice_payments
(user_id, company_id, invoice_id, payment_date, amount, currency, journal_entry_id, transaction_id)
VALUES ($1, $2, $3, '2026-06-05', 1000, 'SEK', $4, $5)`,
[userId, companyId, invoiceA, jeId, txId],
)
// Now try to set transactions.invoice_id to invoiceB (contradiction)
await expect(
getPool().query(`UPDATE public.transactions SET invoice_id = $1 WHERE id = $2`, [
invoiceB,
txId,
]),
).rejects.toThrow(/contradicts invoice_payments/)
})
it('permits an UPDATE that sets transactions.invoice_id to the same value as the existing payment row', async () => {
const { userId, companyId, fiscalPeriodId } = await seedTenant()
const customerId = await insertCustomer({ userId, companyId })
const invoiceA = await insertInvoice({ userId, companyId, customerId, total: 1000 })
const txId = await insertTransaction({ userId, companyId, amount: 1000 })
const jeId = 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', 'Test', 'manual', 'draft')`,
[jeId, userId, companyId, fiscalPeriodId],
)
await getPool().query(
`INSERT INTO public.journal_entry_lines (journal_entry_id, account_number, debit_amount, credit_amount)
VALUES ($1, '1930', 1000, 0), ($1, '1510', 0, 1000)`,
[jeId],
)
await getPool().query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [jeId])
await getPool().query(
`INSERT INTO public.invoice_payments
(user_id, company_id, invoice_id, payment_date, amount, currency, journal_entry_id, transaction_id)
VALUES ($1, $2, $3, '2026-06-05', 1000, 'SEK', $4, $5)`,
[userId, companyId, invoiceA, jeId, txId],
)
// Setting invoice_id to the SAME id should succeed
await expect(
getPool().query(`UPDATE public.transactions SET invoice_id = $1 WHERE id = $2`, [
invoiceA,
txId,
]),
).resolves.toBeDefined()
})
it('blocks an UPDATE that sets transactions.supplier_invoice_id to a value contradicting a supplier_invoice_payments row', async () => {
const { userId, companyId, fiscalPeriodId } = await seedTenant()
const supplierId = await insertSupplier({ userId, companyId })
const siA = await insertSupplierInvoice({ userId, companyId, supplierId, total: 1000 })
const siB = await insertSupplierInvoice({ userId, companyId, supplierId, total: 2000 })
const txId = await insertTransaction({ userId, companyId, amount: -1000 })
const jeId = 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', 'Test', 'manual', 'draft')`,
[jeId, userId, companyId, fiscalPeriodId],
)
await getPool().query(
`INSERT INTO public.journal_entry_lines (journal_entry_id, account_number, debit_amount, credit_amount)
VALUES ($1, '2440', 1000, 0), ($1, '1930', 0, 1000)`,
[jeId],
)
await getPool().query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [jeId])
await getPool().query(
`INSERT INTO public.supplier_invoice_payments
(user_id, company_id, supplier_invoice_id, payment_date, amount, currency,
journal_entry_id, transaction_id)
VALUES ($1, $2, $3, '2026-06-05', 1000, 'SEK', $4, $5)`,
[userId, companyId, siA, jeId, txId],
)
await expect(
getPool().query(`UPDATE public.transactions SET supplier_invoice_id = $1 WHERE id = $2`, [
siB,
txId,
]),
).rejects.toThrow(/contradicts supplier_invoice_payments/)
})
})
describe('is_transaction_booked', () => {
it('returns false for a fresh, unbooked transaction', async () => {
const { userId, companyId } = await seedTenant()
const txId = await insertTransaction({ userId, companyId })
const r = await getPool().query<{ is_transaction_booked: boolean }>(
`SELECT is_transaction_booked($1)`,
[txId],
)
expect(r.rows[0]!.is_transaction_booked).toBe(false)
})
it('returns true when transactions.journal_entry_id is set', async () => {
const { userId, companyId, fiscalPeriodId } = await seedTenant()
const txId = await insertTransaction({ userId, companyId })
const jeId = 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', 'Test', 'manual', 'draft')`,
[jeId, userId, companyId, fiscalPeriodId],
)
await getPool().query(
`INSERT INTO public.journal_entry_lines (journal_entry_id, account_number, debit_amount, credit_amount)
VALUES ($1, '1930', 1000, 0), ($1, '3001', 0, 1000)`,
[jeId],
)
await getPool().query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [jeId])
await getPool().query(`UPDATE public.transactions SET journal_entry_id = $1 WHERE id = $2`, [
jeId,
txId,
])
const r = await getPool().query<{ is_transaction_booked: boolean }>(
`SELECT is_transaction_booked($1)`,
[txId],
)
expect(r.rows[0]!.is_transaction_booked).toBe(true)
})
it('returns true when only an invoice_payments row references the tx (multi-allocation case)', async () => {
const { userId, companyId, fiscalPeriodId } = await seedTenant()
const customerId = await insertCustomer({ userId, companyId })
const invoiceId = await insertInvoice({ userId, companyId, customerId })
const txId = await insertTransaction({ userId, companyId })
const jeId = 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', 'Test', 'manual', 'draft')`,
[jeId, userId, companyId, fiscalPeriodId],
)
await getPool().query(
`INSERT INTO public.journal_entry_lines (journal_entry_id, account_number, debit_amount, credit_amount)
VALUES ($1, '1930', 1000, 0), ($1, '1510', 0, 1000)`,
[jeId],
)
await getPool().query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [jeId])
// No tx.journal_entry_id set, but a payment row exists referencing tx.
await getPool().query(
`INSERT INTO public.invoice_payments
(user_id, company_id, invoice_id, payment_date, amount, currency, journal_entry_id, transaction_id)
VALUES ($1, $2, $3, '2026-06-05', 1000, 'SEK', $4, $5)`,
[userId, companyId, invoiceId, jeId, txId],
)
const r = await getPool().query<{ is_transaction_booked: boolean }>(
`SELECT is_transaction_booked($1)`,
[txId],
)
expect(r.rows[0]!.is_transaction_booked).toBe(true)
})
it('returns true when only a transaction_voucher_links row references the tx', async () => {
const { userId, companyId, fiscalPeriodId } = await seedTenant()
const txId = await insertTransaction({ userId, companyId })
const jeId = 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', 'Test', 'manual', 'draft')`,
[jeId, userId, companyId, fiscalPeriodId],
)
await getPool().query(
`INSERT INTO public.journal_entry_lines (journal_entry_id, account_number, debit_amount, credit_amount)
VALUES ($1, '1930', 1000, 0), ($1, '3001', 0, 1000)`,
[jeId],
)
await getPool().query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [jeId])
await getPool().query(
`INSERT INTO public.transaction_voucher_links
(user_id, company_id, transaction_id, journal_entry_id, allocated_amount)
VALUES ($1, $2, $3, $4, 1000)`,
[userId, companyId, txId, jeId],
)
const r = await getPool().query<{ is_transaction_booked: boolean }>(
`SELECT is_transaction_booked($1)`,
[txId],
)
expect(r.rows[0]!.is_transaction_booked).toBe(true)
})
})