Invoice correctness bundle: voucher-link race, agent send guards, payment-reversal restore (audit C2/C17, F-2026080) (#666)

* fix(invoices): atomic link_invoice_to_voucher RPC — close the customer voucher-link race (audit C2)

linkInvoiceToVoucher() did UPDATE-then-INSERT with a manual rollback restoring a STALE pre-link snapshot: under concurrent linking on the same invoice, A's failed insert could overwrite B's successful link while B's payment row remained — corrupting paid_amount/AR. Mirrors the supplier-side link_supplier_invoice_to_voucher fix (PR #602).

- New SECURITY DEFINER RPC locks the invoice FOR UPDATE, re-validates (status, posted voucher, 151x AR credit, currency, overshoot, already-linked) and applies UPDATE + INSERT in one PG transaction. Inherits the supplier RPC's remaining-amount fix (trust stored remaining_amount even at 0 — the TS '> 0' guard let rounding drift slip past FULLY_PAID). Hardened per audit A5: REVOKE from PUBLIC/anon, GRANT to authenticated + service_role.
- linkInvoiceToVoucher() now delegates to the RPC — same signature, same LINK_VOUCHER_* codes, so all callers (route, pending-op executor, MCP) are unchanged. Keeps the invoice.paid event (now emitted with the post-link row, mirroring the supplier wrapper) and the best-effort bank auto-reconcile.
- pg-real tests: full/partial link, overshoot leaves the invoice untouched, ALREADY_LINKED, and the race regression (two concurrent full links -> exactly one wins, paid_amount never exceeds total, exactly one payment row). Verified locally against supabase/postgres:15.8.1.060 with all 334 migrations replayed: 10/10 pass. Two unrelated pg tests fail locally with AND without this change (pre-existing env sensitivity; green in CI).
- Unit tests re-mocked to the RPC-wrapper contract.

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

* fix(invoices): agent send path — block cancelled invoices + preflight PDF render (audit C17)

commitSendInvoice (the agent/MCP path) was missing two guards the send route has:

- No cancelled guard: a cancelled invoice passed the already-sent check, got re-rendered and EMAILED (a 'MAKULERAD' PDF delivered as if live), and the unguarded status flip silently re-activated it to 'sent'. Now rejected with the registry's INVOICE_SEND_CANCELLED message (400), mirroring the route.
- No preflight render: the executor assigned the F-series number BEFORE rendering, so a render failure left a numbered-but-never-issued invoice (an F-series gap if the draft is abandoned). Now mirrors the route: on fresh allocation, render with an 'F-PREVIEW' placeholder first and reject with INVOICE_SEND_PDF_RENDER_FAILED before any number is consumed; retries with an existing number skip the preflight.

Items/credit-note lookup moved above the preflight (it needs them); the real render and everything downstream are unchanged.

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

* fix(bookkeeping): payment reversal restores invoice state and releases bank line (F-2026080)

Reversing a payment voucher left the customer invoice deadlocked: status
stayed 'paid' while remaining_amount stayed stale (= total), and the bank
transaction kept pointing at the reversed JE so the line could neither be
re-matched nor deleted.

- Customer branch now recomputes remaining_amount from total (the supplier
  branch already did) and clamps paid_amount at 0.
- Both branches delete the payment row(s) tied to the reversed voucher so a
  re-match doesn't double-count or trip the unique indexes.
- New releaseLinkedTransactions() detaches bank transactions from the
  reversed JE (by journal_entry_id and by captured payment transaction ids),
  clearing the link/categorization columns so the line returns to the inbox.

Covers every standalone storno path (reverse route, MCP reverse tool,
delete-last-voucher); the match-invoice route already handled its own case.

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

* fix(transactions): match-invoice preview double-subtracted VAT on per-item path (F-2026080)

InvoiceItem.line_total is the NET line amount (it sums to invoice.subtotal,
each line's vat_amount = line_total * rate), but the preview's per-item rate
aggregation computed sub = line_total - vat_amount, double-subtracting VAT
and producing an unbalanced previewed verifikat (revenue credit too low
against the 1930 debit). The commit path (generatePerRateLines) was already
correct; only the preview disagreed.

Regression test mirrors the F-2026080 invoice: multi-item 25% SEK cash entry
must balance, with 3001 = subtotal and 2611 = vat_amount.

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

* fix(bookkeeping): address PR #666 review — supplier cash reversal, RPC tenant guard, CI fixes

Review feedback fixes:

- Supplier cash-payment reversal (Greptile): the supplier branch required a
  payment row before restoring status/amounts, so reversing a
  supplier_invoice_cash_payment (which books no payment row) left the invoice
  deadlocked at paid/remaining=0 — the same bug the customer branch fixed.
  Mirror the customer fallback (revert full paid_amount when no row exists).

- Payment-row lookups now filter by invoice id + company_id: a batch voucher
  (match_batch_allocate) carries one payment row per invoice under the same
  journal_entry_id, so the unfiltered .single() errored out and silently
  yielded null.

- Tenant guard on the voucher-link write RPCs (compliance V8.2.1, audit A5):
  link_invoice_to_voucher and link_supplier_invoice_to_voucher are SECURITY
  DEFINER + authenticated-executable, so any signed-in user could mutate
  another tenant's invoices via PostgREST. New migration applies the PR #625
  claims-based membership guard to both, caps p_notes at the Zod layer's
  2000 chars, and gives the supplier RPC the explicit REVOKE/GRANT it never
  had (was default PUBLIC execute). Covered by a new pg-real test.

- releaseLinkedTransactions now logs Supabase errors (compliance V16.1) —
  a failed release leaves a bank line stuck on a reversed JE and must be
  observable.

CI fixes:

- naive-ore-round ratchet (core-only): payment-sync.ts converted to
  roundOre() from @/lib/money (-4 occurrences vs baseline).
- match-batch-allocate.pg.test.ts flake (pg-real): Date.now()+random arrival
  numbers collided in CI; now time-component + monotonic counter.

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

* fix(bookkeeping): address PR #666 review round 2 — payment attribution, batch-scoped deletes, send guard

- RPC payment attribution (GDPR Art.32): user-session callers can no longer
  attribute invoice_payments / supplier_invoice_payments rows to an arbitrary
  user via p_user_id — the JWT sub is authoritative when role is
  anon/authenticated. service_role / direct callers keep p_user_id verbatim
  (their scoping happens in TS). pg-real test asserts the spoofed id is
  ignored.

- Payment-row deletes scoped to the source invoice (SOC 2 CC6.3): a batch
  voucher carries sibling payment rows for other invoices whose status this
  sync doesn't restore; deleting them desynced paid_amount from the rows.

- releaseLinkedTransactions success audit log: transactions has no
  write_audit_log trigger, so clearing the link/categorization columns now
  logs the affected transaction ids for incident reconstruction.

- commitSendInvoice guard extended with partially_paid/credited (ASVS V2.3):
  both imply the invoice was already issued; the status flip would have
  regressed them to 'sent'.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-06-04 15:32:39 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 3e42fc6f32
commit f538401988
12 changed files with 1622 additions and 154 deletions
@@ -141,4 +141,65 @@ describe('GET /api/transactions/[id]/match-invoice/preview', () => {
expect(body.fx_conversion.required).toBe(false)
expect(mockFetchExchangeRate).not.toHaveBeenCalled()
})
// Regression for the F-2026080 bug: the cash-entry preview double-subtracted
// VAT (sub = line_total - vat_amount) even though line_total is ALREADY the
// net line amount, producing 3001=3127.5 against a 1930 debit of 5212.5 — an
// unbalanced verifikat. The existing 'same-currency full payment' test above
// uses an itemless invoice, so it only hits the fallback branch and never the
// buggy per-item loop. This invoice mirrors F-2026080 exactly.
it('multi-item SEK cash entry balances (line_total is net, not gross)', async () => {
const tx = makeTransaction({
id: 'tx-3',
amount: 5213,
currency: 'SEK',
date: '2026-05-18',
invoice_id: null,
})
const invoice = {
...makeInvoice({
id: VALID_UUID,
status: 'sent',
currency: 'SEK',
total: 5212.5,
subtotal: 4170,
vat_amount: 1042.5,
remaining_amount: 5212.5,
paid_amount: 0,
}),
// line_total is NET (excludes VAT); each vat_amount = line_total * 0.25.
items: [
{ vat_rate: 25, line_total: 420, vat_amount: 105 },
{ vat_rate: 25, line_total: 1500, vat_amount: 375 },
{ vat_rate: 25, line_total: 2250, vat_amount: 562.5 },
],
}
enqueue({ data: tx, error: null })
enqueue({ data: invoice, error: null })
enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null })
const request = createMockRequest('/api/transactions/tx-3/match-invoice/preview', {
searchParams: { invoice_id: VALID_UUID },
})
const response = await GET(request, createMockRouteParams({ id: 'tx-3' }))
const { status, body } = await parseJsonResponse<{
entry_type: string
lines: Array<{ account_number: string; debit_amount: number; credit_amount: number }>
}>(response)
expect(status).toBe(200)
expect(body.entry_type).toBe('cash')
const totalDebit = body.lines.reduce((s, l) => s + l.debit_amount, 0)
const totalCredit = body.lines.reduce((s, l) => s + l.credit_amount, 0)
// The crux: the previewed verifikat must balance.
expect(Math.round((totalDebit - totalCredit) * 100)).toBe(0)
const revenue = body.lines.find((l) => l.account_number === '3001')
const vat = body.lines.find((l) => l.account_number === '2611')
const bank = body.lines.find((l) => l.account_number === '1930')
expect(revenue?.credit_amount).toBe(4170) // net subtotal, NOT 3127.5
expect(vat?.credit_amount).toBe(1042.5)
expect(bank?.debit_amount).toBe(5212.5)
})
})
@@ -18,6 +18,7 @@ import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
import { roundOre } from '@/lib/money'
import { getRevenueAccount, getOutputVatAccount } from '@/lib/bookkeeping/invoice-entries'
import { buildInvoicePaymentClearingLines } from '@/lib/bookkeeping/invoice-payment-lines'
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
@@ -185,15 +186,18 @@ export const GET = withRouteContext(
const isForeign = inv.currency !== 'SEK'
// Per-item rate aggregation (matches generatePerRateLines semantics).
// InvoiceItem.line_total is the gross-net-line; the subtotal contribution
// is line_total minus that line's vat_amount.
// InvoiceItem.line_total is the NET line amount (EXCLUDES VAT) — it sums
// to invoice.subtotal, and each line's vat_amount = line_total * rate. The
// commit path (generatePerRateLines) credits revenue with line_total
// directly; subtracting vat here double-subtracts VAT and unbalances the
// previewed verifikat against the 1930 debit (inv.total).
const byRate = new Map<number, { subtotal: number; vat: number }>()
if (items.length > 0) {
for (const it of items) {
const rate = it.vat_rate ?? 25
const itemVat = resolveSekAmount(it.vat_amount, null, inv.currency, inv.exchange_rate)
const itemTotal = resolveSekAmount(it.line_total, null, inv.currency, inv.exchange_rate)
const sub = Math.round((itemTotal - itemVat) * 100) / 100
const sub = roundOre(itemTotal)
const bucket = byRate.get(rate) ?? { subtotal: 0, vat: 0 }
bucket.subtotal += sub
bucket.vat += itemVat
+214 -6
View File
@@ -3,6 +3,46 @@ import { isPaymentSourceType, syncInvoiceStatusFromPaymentEntry } from '@/lib/bo
import { createQueuedMockSupabase } from '@/tests/helpers'
import type { JournalEntry } from '@/types'
/**
* A Supabase mock that records the table + method + args of every chained call
* (the shared createQueuedMockSupabase only records `from()` table names). Lets
* us assert on the actual UPDATE/DELETE payloads, which is what the reversal
* restore (remaining_amount reset, payment-row delete, tx release) hinges on.
*/
type RecordedCall = {
table: string
ops: Array<{ method: string; args: unknown[] }>
}
function createRecordingSupabase(queue: Array<{ data?: unknown; error?: unknown }>) {
const calls: RecordedCall[] = []
let i = 0
const from = vi.fn((table: string) => {
const result = queue[i++] ?? { data: null, error: null }
const rec: RecordedCall = { table, ops: [] }
calls.push(rec)
const chain: unknown = new Proxy(
{},
{
get(_t, prop) {
if (prop === 'then') return (resolve: (v: unknown) => void) => resolve(result)
return (...args: unknown[]) => {
rec.ops.push({ method: String(prop), args })
return chain
}
},
},
)
return chain
})
const updatePayload = (table: string): Record<string, unknown> | undefined => {
const rec = calls.find((c) => c.table === table && c.ops.some((o) => o.method === 'update'))
return rec?.ops.find((o) => o.method === 'update')?.args[0] as Record<string, unknown> | undefined
}
const tablesUpdated = (table: string) => calls.filter((c) => c.table === table && c.ops.some((o) => o.method === 'update'))
const wasDeleted = (table: string) => calls.some((c) => c.table === table && c.ops.some((o) => o.method === 'delete'))
return { supabase: { from } as never, calls, updatePayload, tablesUpdated, wasDeleted }
}
describe('isPaymentSourceType', () => {
it.each([
'invoice_paid',
@@ -67,10 +107,15 @@ describe('syncInvoiceStatusFromPaymentEntry', () => {
await syncInvoiceStatusFromPaymentEntry(supabase as never, 'co-1', entry())
const fromCalls = (supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
// After the status update the helper now also deletes the stale payment row
// and releases any linked bank transaction back to the inbox.
expect(fromCalls).toEqual([
'supplier_invoice_payments',
'supplier_invoices',
'supplier_invoices',
'supplier_invoice_payments', // select amount
'supplier_invoices', // select
'supplier_invoices', // update status/paid/remaining
'supplier_invoice_payments', // select transaction_id
'supplier_invoice_payments', // delete payment row
'transactions', // release linked bank line
])
})
@@ -85,8 +130,9 @@ describe('syncInvoiceStatusFromPaymentEntry', () => {
await syncInvoiceStatusFromPaymentEntry(supabase as never, 'co-1', entry())
// Test passes if the queries fire in the expected order without error
expect((supabase.from as ReturnType<typeof vi.fn>).mock.calls.length).toBe(3)
// select payment, select invoice, update invoice, select payment tx,
// delete payment row, release linked transaction.
expect((supabase.from as ReturnType<typeof vi.fn>).mock.calls.length).toBe(6)
})
it('routes customer invoice entries through the invoices table', async () => {
@@ -104,7 +150,14 @@ describe('syncInvoiceStatusFromPaymentEntry', () => {
)
const fromCalls = (supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
expect(fromCalls).toEqual(['invoice_payments', 'invoices', 'invoices'])
expect(fromCalls).toEqual([
'invoice_payments', // select amount
'invoices', // select
'invoices', // update status/paid/remaining
'invoice_payments', // select transaction_id
'invoice_payments', // delete payment row
'transactions', // release linked bank line
])
})
it('handles invoice_cash_payment the same way as invoice_paid', async () => {
@@ -156,4 +209,159 @@ describe('syncInvoiceStatusFromPaymentEntry', () => {
syncInvoiceStatusFromPaymentEntry(supabase as never, 'co-1', entry())
).resolves.toBeUndefined()
})
// Regression for the stuck-invoice deadlock (F-2026080): reversing a cash
// payment left the invoice at status='paid' / remaining_amount=total because
// the customer branch never reset remaining_amount. The cash path has no
// invoice_payments row, so the full paid_amount is reverted.
it('customer cash-payment reversal resets paid_amount, remaining_amount and status', async () => {
const { supabase, updatePayload, wasDeleted } = createRecordingSupabase([
{ data: null }, // invoice_payments select amount → none (cash entry)
{ data: { paid_amount: 5212.5, total: 5212.5, due_date: '2099-12-31' } }, // invoices select
{ data: null }, // invoices update
{ data: [] }, // invoice_payments select transaction_id
{ data: null }, // invoice_payments delete
{ data: null }, // transactions update
])
await syncInvoiceStatusFromPaymentEntry(
supabase,
'co-1',
entry({ source_type: 'invoice_cash_payment', source_id: 'invoice-1' }),
)
expect(updatePayload('invoices')).toEqual({
status: 'sent',
paid_at: null,
paid_amount: 0,
remaining_amount: 5212.5,
})
expect(wasDeleted('invoice_payments')).toBe(true)
})
// Partial reversal (clearing entry with a payment row): only the reversed
// amount comes off, remaining = total - newPaid, status stays partially_paid.
it('customer partial reversal keeps remaining_amount = total - newPaid', async () => {
const { supabase, updatePayload } = createRecordingSupabase([
{ data: { amount: 500 } }, // invoice_payments select amount
{ data: { paid_amount: 1500, total: 2000, due_date: '2099-12-31' } }, // invoices select
{ data: null }, // invoices update
{ data: [] }, // invoice_payments select transaction_id
{ data: null }, // invoice_payments delete
{ data: null }, // transactions update
])
await syncInvoiceStatusFromPaymentEntry(
supabase,
'co-1',
entry({ source_type: 'invoice_paid', source_id: 'invoice-1' }),
)
expect(updatePayload('invoices')).toEqual({
status: 'partially_paid',
paid_at: null,
paid_amount: 1000,
remaining_amount: 1000,
})
})
// The bank line that paid the (now reversed) voucher must be detached so it
// returns to the inbox and is re-matchable — cleared both by journal_entry_id
// and by the transaction id captured from the payment row.
it('releases the linked bank transaction (clears journal_entry_id, invoice_id, category)', async () => {
const { supabase, tablesUpdated } = createRecordingSupabase([
{ data: null }, // invoice_payments select amount
{ data: { paid_amount: 5212.5, total: 5212.5, due_date: '2099-12-31' } }, // invoices select
{ data: null }, // invoices update
{ data: [{ transaction_id: 'tx-9' }] }, // invoice_payments select transaction_id
{ data: null }, // invoice_payments delete
{ data: null }, // transactions update by journal_entry_id
{ data: null }, // transactions update by id
])
await syncInvoiceStatusFromPaymentEntry(
supabase,
'co-1',
entry({ source_type: 'invoice_cash_payment', source_id: 'invoice-1' }),
)
const txUpdates = tablesUpdated('transactions')
// Once by journal_entry_id, once by the captured payment transaction_id.
expect(txUpdates.length).toBe(2)
const resetPayload = txUpdates[0].ops.find((o) => o.method === 'update')?.args[0]
expect(resetPayload).toEqual({
journal_entry_id: null,
invoice_id: null,
is_business: null,
category: null,
})
// Second update targets the captured tx id.
const byId = txUpdates[1].ops.find((o) => o.method === 'in')
expect(byId?.args).toEqual(['id', ['tx-9']])
})
// Supplier-side parity: remaining_amount was already reset; now the payment
// row is deleted and the bank line released too.
it('supplier reversal deletes the payment row and releases the bank line', async () => {
const { supabase, updatePayload, wasDeleted, tablesUpdated } = createRecordingSupabase([
{ data: { amount: 1000 } }, // supplier_invoice_payments select amount
{ data: { paid_amount: 1000, total_amount: 1000, due_date: '2099-12-31' } }, // supplier_invoices select
{ data: null }, // supplier_invoices update
{ data: [{ transaction_id: 'tx-7' }] }, // supplier_invoice_payments select transaction_id
{ data: null }, // supplier_invoice_payments delete
{ data: null }, // transactions update by journal_entry_id
{ data: null }, // transactions update by id
])
await syncInvoiceStatusFromPaymentEntry(
supabase,
'co-1',
entry({ source_type: 'supplier_invoice_paid', source_id: 'supplier-invoice-1' }),
)
expect(updatePayload('supplier_invoices')).toMatchObject({
status: 'approved',
paid_amount: 0,
remaining_amount: 1000, // total_amount - 0 paid = full amount owed again
})
expect(wasDeleted('supplier_invoice_payments')).toBe(true)
const resetPayload = tablesUpdated('transactions')[0].ops.find((o) => o.method === 'update')?.args[0]
expect(resetPayload).toEqual({
journal_entry_id: null,
supplier_invoice_id: null,
is_business: null,
category: null,
})
})
// Regression for the Greptile finding on PR #666: the supplier branch
// required a payment row before restoring status/amounts, so reversing a
// supplier_invoice_cash_payment (which books NO payment row — cash entries
// are only ever full payments) deleted nothing visible but left the invoice
// permanently at status='paid' / remaining_amount=0 — the same deadlock the
// customer branch fix closed.
it('supplier cash-payment reversal restores status without a payment row', async () => {
const { supabase, updatePayload } = createRecordingSupabase([
{ data: null }, // supplier_invoice_payments select amount → none (cash entry)
{ data: { paid_amount: 1000, total_amount: 1000, due_date: '2099-12-31' } }, // supplier_invoices select
{ data: null }, // supplier_invoices update
{ data: [] }, // supplier_invoice_payments select transaction_id
{ data: null }, // supplier_invoice_payments delete
{ data: null }, // transactions update by journal_entry_id
])
await syncInvoiceStatusFromPaymentEntry(
supabase,
'co-1',
entry({ source_type: 'supplier_invoice_cash_payment', source_id: 'supplier-invoice-1' }),
)
expect(updatePayload('supplier_invoices')).toMatchObject({
status: 'approved',
paid_amount: 0,
remaining_amount: 1000,
paid_at: null,
payment_journal_entry_id: null,
})
})
})
+174 -6
View File
@@ -1,6 +1,10 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { createLogger } from '@/lib/logger'
import { roundOre } from '@/lib/money'
import type { JournalEntry } from '@/types'
const log = createLogger('payment-sync')
export const PAYMENT_SOURCE_TYPES = [
'invoice_paid',
'invoice_cash_payment',
@@ -32,10 +36,15 @@ export async function syncInvoiceStatusFromPaymentEntry(
const entryId = entry.id
if (entry.source_type.startsWith('supplier_invoice')) {
// Scope to THIS invoice's payment row: a batch voucher (match_batch_allocate)
// carries one payment row per invoice under the same journal_entry_id, so an
// unfiltered .single() errors out on multi-row and silently yields null.
const { data: payment } = await supabase
.from('supplier_invoice_payments')
.select('amount')
.eq('journal_entry_id', entryId)
.eq('supplier_invoice_id', entry.source_id)
.eq('company_id', companyId)
.single()
const { data: supplierInvoice } = await supabase
@@ -45,9 +54,15 @@ export async function syncInvoiceStatusFromPaymentEntry(
.eq('company_id', companyId)
.single()
if (supplierInvoice && payment) {
const newPaidAmount = Math.round((supplierInvoice.paid_amount - payment.amount) * 100) / 100
const newRemaining = Math.round((supplierInvoice.total_amount - Math.max(0, newPaidAmount)) * 100) / 100
if (supplierInvoice) {
// Same fallback semantics as the customer branch below: a cash payment
// (supplier_invoice_cash_payment) books no payment row and is only ever
// a FULL payment, so reverting the whole paid_amount is correct. The
// old `&& payment` guard skipped the restore entirely for cash
// reversals, leaving the supplier invoice deadlocked on 'paid'.
const paymentAmount = payment?.amount ?? supplierInvoice.paid_amount
const newPaidAmount = roundOre(supplierInvoice.paid_amount - paymentAmount)
const newRemaining = roundOre(supplierInvoice.total_amount - Math.max(0, newPaidAmount))
let newStatus: string
if (newPaidAmount > 0) {
newStatus = 'partially_paid'
@@ -69,23 +84,69 @@ export async function syncInvoiceStatusFromPaymentEntry(
.eq('id', entry.source_id)
.eq('company_id', companyId)
}
// Remove THIS invoice's payment row tied to the reversed voucher so a
// re-match of the same bank line doesn't double-count or trip the unique
// index on supplier_invoice_payments. Scoped to the source invoice — a
// batch voucher carries sibling rows for other invoices whose status this
// call does not restore, so deleting them here would desync paid_amount
// from the payment rows (PR #666 review, SOC 2 CC6.3). Capture the linked
// transaction id first so the bank line can be released back to the inbox.
const { data: spRows } = await supabase
.from('supplier_invoice_payments')
.select('transaction_id')
.eq('journal_entry_id', entryId)
.eq('supplier_invoice_id', entry.source_id)
.eq('company_id', companyId)
await supabase
.from('supplier_invoice_payments')
.delete()
.eq('journal_entry_id', entryId)
.eq('supplier_invoice_id', entry.source_id)
.eq('company_id', companyId)
await releaseLinkedTransactions(
supabase,
companyId,
entryId,
(spRows ?? []).map((r) => (r as { transaction_id: string | null }).transaction_id),
'supplier_invoice_id',
)
} else {
// Scoped like the supplier branch: filter by invoice_id + company_id so a
// batch voucher's sibling payment rows don't break the .single().
const { data: payment } = await supabase
.from('invoice_payments')
.select('amount')
.eq('journal_entry_id', entryId)
.eq('invoice_id', entry.source_id)
.eq('company_id', companyId)
.single()
const { data: customerInvoice } = await supabase
.from('invoices')
.select('paid_amount, due_date')
.select('paid_amount, total, due_date')
.eq('id', entry.source_id)
.eq('company_id', companyId)
.single()
if (customerInvoice) {
// For a partial reversal we take the exact amount from the payment row.
// The fallback (full paid_amount) only applies when no payment row exists
// — true for invoice_cash_payment, which is only ever booked on a FULL
// payment, so reverting the whole paid_amount is correct there. Guarding
// this keeps a future partial-cash path from over-reverting.
const paymentAmount = payment?.amount ?? customerInvoice.paid_amount
const newPaidAmount = Math.round((customerInvoice.paid_amount - paymentAmount) * 100) / 100
const newPaidAmount = roundOre(customerInvoice.paid_amount - paymentAmount)
const safePaidAmount = Math.max(0, newPaidAmount)
// The supplier branch already resets remaining_amount; the customer branch
// never did, leaving it stale (= total) after a reversal so the invoice
// showed fully unpaid yet stuck on 'paid'. Recompute from total. (The
// .in('status', …) guard below can leave status/remaining un-updated if
// the invoice isn't paid/partially_paid — only reachable on a non-storno
// path; the payment-row delete + tx release still run, freeing the line.)
const newRemaining = roundOre(customerInvoice.total - safePaidAmount)
const revertStatus = newPaidAmount > 0
? 'partially_paid'
: customerInvoice.due_date && new Date(customerInvoice.due_date) < new Date()
@@ -97,11 +158,118 @@ export async function syncInvoiceStatusFromPaymentEntry(
.update({
status: revertStatus,
paid_at: null,
paid_amount: Math.max(0, newPaidAmount),
paid_amount: safePaidAmount,
remaining_amount: newRemaining,
})
.eq('id', entry.source_id)
.eq('company_id', companyId)
.in('status', ['paid', 'partially_paid'])
}
// Remove THIS invoice's payment row tied to the reversed voucher so a
// re-match of the same bank line doesn't trip the (transaction_id,
// invoice_id) / (journal_entry_id, invoice_id) unique indexes on
// invoice_payments. Scoped to the source invoice — see the supplier
// branch comment for the batch-voucher rationale.
const { data: ipRows } = await supabase
.from('invoice_payments')
.select('transaction_id')
.eq('journal_entry_id', entryId)
.eq('invoice_id', entry.source_id)
.eq('company_id', companyId)
await supabase
.from('invoice_payments')
.delete()
.eq('journal_entry_id', entryId)
.eq('invoice_id', entry.source_id)
.eq('company_id', companyId)
await releaseLinkedTransactions(
supabase,
companyId,
entryId,
(ipRows ?? []).map((r) => (r as { transaction_id: string | null }).transaction_id),
'invoice_id',
)
}
}
/**
* Detach any bank transactions still pointing at a reversed payment voucher so
* the bank line returns to the inbox and becomes re-matchable. Without this, a
* standalone storno (the reverse route / MCP reverse tool / delete-last-voucher)
* leaves transactions.journal_entry_id pointing at a reversed JE — the match
* POST refuses (invoice no longer matchable once we also fix its status) and the
* line can't be re-booked or deleted. The match-invoice route already clears the
* tx when IT stornos a conflicting auto-categorization JE; this covers every
* other reversal path.
*
* Clears by journal_entry_id (covers the link even when the payment row was
* missing) and by the captured payment-row transaction ids (covers a partial
* match that cleared journal_entry_id but left invoice_id/category set). Only
* the link/categorization columns are reset; the transaction row is preserved.
*/
async function releaseLinkedTransactions(
supabase: SupabaseClient,
companyId: string,
entryId: string,
paymentTransactionIds: Array<string | null>,
invoiceColumn: 'invoice_id' | 'supplier_invoice_id',
): Promise<void> {
const resetFields = {
journal_entry_id: null,
[invoiceColumn]: null,
is_business: null,
category: null,
}
const { data: releasedByEntry, error: byEntryError } = await supabase
.from('transactions')
.update(resetFields)
.eq('company_id', companyId)
.eq('journal_entry_id', entryId)
.select('id')
if (byEntryError) {
// Best-effort like the rest of the sync — the storno itself already
// committed — but a failed release leaves the bank line stuck on a
// reversed JE, so it must be observable.
log.error('Failed to release transactions by journal_entry_id', byEntryError, {
companyId,
journalEntryId: entryId,
})
} else if (releasedByEntry && releasedByEntry.length > 0) {
// transactions has no write_audit_log trigger, so the clearing of the
// link/categorization columns is logged here for incident reconstruction.
log.info('Released bank transactions from reversed payment voucher', {
companyId,
journalEntryId: entryId,
invoiceColumn,
transactionIds: releasedByEntry.map((r) => (r as { id: string }).id),
})
}
const txIds = paymentTransactionIds.filter((id): id is string => !!id)
if (txIds.length > 0) {
const { data: releasedById, error: byIdError } = await supabase
.from('transactions')
.update(resetFields)
.eq('company_id', companyId)
.in('id', txIds)
.select('id')
if (byIdError) {
log.error('Failed to release transactions by payment transaction ids', byIdError, {
companyId,
journalEntryId: entryId,
transactionIds: txIds,
})
} else if (releasedById && releasedById.length > 0) {
log.info('Released payment-linked bank transactions from reversed voucher', {
companyId,
journalEntryId: entryId,
invoiceColumn,
transactionIds: releasedById.map((r) => (r as { id: string }).id),
})
}
}
}
@@ -242,3 +242,175 @@ describe('link_invoice_voucher pg-real guards', () => {
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 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)
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('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)
})
})
+41 -12
View File
@@ -6,7 +6,6 @@ import {
} from '../voucher-matching'
import {
makeInvoice,
makeCustomer,
createQueuedMockSupabase,
} from '@/tests/helpers'
import { eventBus } from '@/lib/events/bus'
@@ -329,31 +328,61 @@ describe('linkInvoiceToVoucher', () => {
eventBus.clear()
})
it('rejects when the invoice is not in a payable status', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: { ...makeInvoice({ status: 'paid' }), customer: makeCustomer() },
// linkInvoiceToVoucher now delegates validation + writes to the atomic
// link_invoice_to_voucher RPC (audit C2) — the wrapper's job is calling it
// with the right args and mapping the jsonb result/transport errors through.
// Guard behaviour itself is covered by voucher-matching.pg.test.ts against
// the real RPC.
it('passes a guard rejection from the RPC through unchanged', async () => {
const rpc = vi.fn().mockResolvedValue({
data: { ok: false, code: 'LINK_VOUCHER_INVOICE_FULLY_PAID', details: { status: 'paid' } },
error: null,
})
const result = await linkInvoiceToVoucher(
supabase as never,
{ rpc } as never,
'user-1',
'company-1',
{ invoiceId: 'inv-1', journalEntryId: 'je-1' },
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('LINK_VOUCHER_INVOICE_FULLY_PAID')
if (!result.ok) {
expect(result.code).toBe('LINK_VOUCHER_INVOICE_FULLY_PAID')
expect(result.details).toEqual({ status: 'paid' })
}
expect(rpc).toHaveBeenCalledWith('link_invoice_to_voucher', {
p_invoice_id: 'inv-1',
p_journal_entry_id: 'je-1',
p_user_id: 'user-1',
p_company_id: 'company-1',
p_notes: null,
})
})
it('rejects when the invoice is missing', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: { message: 'not found' } })
it('maps an RPC transport error to LINK_VOUCHER_DB_ERROR', async () => {
const rpc = vi.fn().mockResolvedValue({ data: null, error: { message: 'connection reset' } })
const result = await linkInvoiceToVoucher(
supabase as never,
{ rpc } as never,
'user-1',
'company-1',
{ invoiceId: 'inv-1', journalEntryId: 'je-1' },
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('LINK_VOUCHER_INVOICE_NOT_FOUND')
if (!result.ok) {
expect(result.code).toBe('LINK_VOUCHER_DB_ERROR')
expect(result.details).toEqual({ reason: 'connection reset' })
}
})
it('maps an empty RPC response to LINK_VOUCHER_DB_ERROR', async () => {
const rpc = vi.fn().mockResolvedValue({ data: null, error: null })
const result = await linkInvoiceToVoucher(
{ rpc } as never,
'user-1',
'company-1',
{ invoiceId: 'inv-1', journalEntryId: 'je-1' },
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('LINK_VOUCHER_DB_ERROR')
})
})
+79 -116
View File
@@ -467,6 +467,26 @@ export interface LinkInvoiceToVoucherResult {
reconciledTransactionId: string | null
}
/** jsonb payload returned by the link_invoice_to_voucher RPC on success. */
interface RpcLinkInvoiceOk {
ok: true
payment_id: string
invoice_status: 'paid' | 'partially_paid'
paid_amount: number
remaining_amount: number
payment_amount: number
journal_entry_id: string
currency: string
payment_date: string
}
/** jsonb payload returned by the link_invoice_to_voucher RPC on guard failure. */
interface RpcLinkInvoiceErr {
ok: false
code: VoucherLinkErrorCode
details?: Record<string, unknown>
}
/**
* Atomically link an existing posted verifikat to an invoice. Inserts an
* invoice_payments row, advances the invoice's paid_amount/remaining_amount,
@@ -487,124 +507,67 @@ export async function linkInvoiceToVoucher(
| { ok: true; result: LinkInvoiceToVoucherResult }
| { ok: false; code: VoucherLinkErrorCode; details?: Record<string, unknown> }
> {
const { data: invoice, error: invoiceError } = await supabase
// All validation + writes happen inside link_invoice_to_voucher (PL/pgSQL).
// The function locks the invoice row FOR UPDATE, re-validates the voucher,
// and applies the invoices UPDATE + invoice_payments INSERT in a single PG
// transaction, so concurrent linkers serialize and a failure on either write
// rolls back automatically. The previous TS implementation did
// UPDATE-then-INSERT with a manual rollback that restored from a STALE
// pre-link snapshot — under concurrent linking it could clobber a sibling's
// successful write while leaving its payment row in place (audit C2; mirrors
// the supplier-side link_supplier_invoice_to_voucher fix from PR #602).
const { data: rpcData, error: rpcError } = await supabase.rpc('link_invoice_to_voucher', {
p_invoice_id: params.invoiceId,
p_journal_entry_id: params.journalEntryId,
p_user_id: userId,
p_company_id: companyId,
p_notes: params.notes ?? null,
})
if (rpcError) {
log.error('link_invoice_to_voucher RPC error', {
companyId,
userId,
invoiceId: params.invoiceId,
journalEntryId: params.journalEntryId,
message: rpcError.message,
})
return { ok: false, code: 'LINK_VOUCHER_DB_ERROR', details: { reason: rpcError.message } }
}
const rpc = rpcData as RpcLinkInvoiceOk | RpcLinkInvoiceErr | null
if (!rpc) {
return { ok: false, code: 'LINK_VOUCHER_DB_ERROR', details: { reason: 'empty RPC response' } }
}
if (!rpc.ok) {
return { ok: false, code: rpc.code, details: rpc.details }
}
// Fetch the now-updated invoice (with customer) for event emission — the RPC
// committed before this read, so the row reflects post-link state. Mirrors
// the supplier-side wrapper.
const { data: invoice } = await supabase
.from('invoices')
.select('*, customer:customers(*)')
.eq('id', params.invoiceId)
.eq('company_id', companyId)
.single()
if (invoiceError || !invoice) {
return { ok: false, code: 'LINK_VOUCHER_INVOICE_NOT_FOUND', details: { invoice_id: params.invoiceId } }
}
.maybeSingle()
if (!['sent', 'overdue', 'partially_paid'].includes(invoice.status)) {
return { ok: false, code: 'LINK_VOUCHER_INVOICE_FULLY_PAID', details: { status: invoice.status } }
}
const validation = await validateVoucherForInvoiceLink(
supabase,
companyId,
invoice as Invoice & { customer?: Customer },
params.journalEntryId
)
if (!validation.ok) return validation
const now = new Date().toISOString()
const newPaidAmount = round2((invoice.paid_amount ?? 0) + validation.paymentAmount)
const newRemaining = validation.remainingAfter
const newStatus: 'paid' | 'partially_paid' = validation.isFullyPaid ? 'paid' : 'partially_paid'
const { data: updatedRows, error: updateInvError } = await supabase
.from('invoices')
.update({
status: newStatus,
paid_at: validation.isFullyPaid ? now : invoice.paid_at,
paid_amount: newPaidAmount,
remaining_amount: newRemaining,
})
.eq('id', params.invoiceId)
.eq('company_id', companyId)
.in('status', ['sent', 'overdue', 'partially_paid'])
.select('id')
if (updateInvError) {
// Real DB failure (RLS, network, constraint) — distinct from "voucher not
// found" so the pending-op dispatcher retries instead of auto-rejecting.
return { ok: false, code: 'LINK_VOUCHER_DB_ERROR', details: { reason: updateInvError.message } }
}
if (!updatedRows || updatedRows.length === 0) {
return { ok: false, code: 'LINK_VOUCHER_INVOICE_FULLY_PAID' }
}
const { data: payment, error: insertError } = await supabase
.from('invoice_payments')
.insert({
user_id: userId,
company_id: companyId,
invoice_id: params.invoiceId,
payment_date: validation.voucher.entry_date,
amount: validation.paymentAmount,
currency: invoice.currency,
exchange_rate: invoice.exchange_rate,
journal_entry_id: params.journalEntryId,
transaction_id: null,
notes: params.notes ?? null,
})
.select('id')
.single()
if (insertError) {
// Roll back the invoice update so we don't leave the row in a half-linked
// state. The partial unique index raises 23505 if another linker won the
// race between validation and insert.
const { error: rollbackError } = await supabase
.from('invoices')
.update({
status: invoice.status,
paid_at: invoice.paid_at,
paid_amount: invoice.paid_amount,
remaining_amount: invoice.remaining_amount,
})
.eq('id', params.invoiceId)
.eq('company_id', companyId)
if (rollbackError) {
// Rollback failed — the invoice is stuck advanced with no payment row.
// Surface loudly so ops can reconcile manually; the insert error code
// below still goes back to the caller for the original failure cause.
log.error('voucher link rollback failed — invoice left in advanced state without payment row', {
companyId,
userId,
invoiceId: params.invoiceId,
journalEntryId: params.journalEntryId,
insertError: insertError.message,
rollbackError: rollbackError.message,
if (invoice) {
try {
await eventBus.emit({
type: 'invoice.paid',
payload: {
invoice: invoice as Invoice,
paymentAmount: rpc.payment_amount,
paymentDate: rpc.payment_date,
userId,
companyId,
},
})
} catch {
/* non-critical */
}
if (insertError.code === '23505') {
return { ok: false, code: 'LINK_VOUCHER_ALREADY_LINKED' }
}
return {
ok: false,
code: 'LINK_VOUCHER_DB_ERROR',
details: { reason: insertError.message },
}
}
try {
await eventBus.emit({
type: 'invoice.paid',
payload: {
invoice: invoice as Invoice,
paymentAmount: validation.paymentAmount,
paymentDate: validation.voucher.entry_date,
userId,
companyId,
},
})
} catch {
/* non-critical */
}
// Close the loop on the bank feed: the invoice→voucher link above only
@@ -634,11 +597,11 @@ export async function linkInvoiceToVoucher(
return {
ok: true,
result: {
paymentId: (payment as { id: string }).id,
invoiceStatus: newStatus,
paidAmount: newPaidAmount,
remainingAmount: newRemaining,
paymentAmount: validation.paymentAmount,
paymentId: rpc.payment_id,
invoiceStatus: rpc.invoice_status,
paidAmount: rpc.paid_amount,
remainingAmount: rpc.remaining_amount,
paymentAmount: rpc.payment_amount,
journalEntryId: params.journalEntryId,
reconciledTransactionId,
},
+56 -7
View File
@@ -718,9 +718,23 @@ async function commitSendInvoice(
.single()
if (invoiceError || !invoice) return { error: 'Invoice not found', status: 404 }
if (invoice.status === 'sent' || invoice.status === 'paid' || invoice.status === 'overdue') {
// partially_paid/credited imply the invoice was already issued too — the
// status flip below would regress them to 'sent' (PR #666 review, ASVS V2.3).
if (['sent', 'paid', 'overdue', 'partially_paid', 'credited'].includes(invoice.status)) {
return { error: 'Invoice has already been sent', status: 409 }
}
// A cancelled invoice keeps its F-series number for ML 17 kap 24§ compliance
// but is not a valid faktura — sending it would silently re-activate it (the
// status flip below has no guard) and deliver a "MAKULERAD" PDF as if live.
// Mirrors the send route's guard (audit C17 — this agent path lacked it).
if (invoice.status === 'cancelled') {
return {
error:
getErrorEntry('INVOICE_SEND_CANCELLED')?.message_sv ??
'Makulerade fakturor kan inte skickas. Skapa en ny faktura istället.',
status: 400,
}
}
const customer = invoice.customer as Customer
if (!customer.email) return { error: 'Customer has no email address', status: 400 }
@@ -730,12 +744,6 @@ async function commitSendInvoice(
if (companyError || !company) return { error: 'Company settings missing', status: 500 }
try {
await ensureInvoiceNumber(supabase, companyId, invoice as Invoice)
} catch (err) {
return { error: `Failed to assign invoice number: ${err instanceof Error ? err.message : 'unknown'}`, status: 500 }
}
const items = (invoice.items as InvoiceItem[]).sort(
(a: InvoiceItem, b: InvoiceItem) => a.sort_order - b.sort_order
)
@@ -747,6 +755,47 @@ async function commitSendInvoice(
if (orig) originalInvoiceNumber = orig.invoice_number
}
// Preflight render: validate the PDF pipeline BEFORE consuming an F-series
// number, so a render failure can't leave a numbered-but-never-issued
// invoice (an F-series gap if the draft is later abandoned). Skipped when
// the row is already numbered (retry path) — we'd render twice for no gain.
// Mirrors the send route (audit C17 — this agent path assigned the number
// first and rendered unguarded).
const isFreshAllocation = !invoice.invoice_number
if (isFreshAllocation) {
try {
const preflight = prepareInvoicePdfRender(company as CompanySettings)
await renderToBuffer(
InvoicePDF({
invoice: { ...(invoice as Invoice), invoice_number: 'F-PREVIEW' },
customer,
items,
company: company as CompanySettings,
originalInvoiceNumber,
branding: preflight.branding,
})
)
} catch (err) {
log.error('preflight PDF render failed before invoice number assignment (agent send)', err as Error, {
companyId,
userId,
invoiceId,
})
return {
error:
getErrorEntry('INVOICE_SEND_PDF_RENDER_FAILED')?.message_sv ??
'Fakturans PDF kunde inte skapas. Kontrollera fakturarader och kunduppgifter och försök igen.',
status: 500,
}
}
}
try {
await ensureInvoiceNumber(supabase, companyId, invoice as Invoice)
} catch (err) {
return { error: `Failed to assign invoice number: ${err instanceof Error ? err.message : 'unknown'}`, status: 500 }
}
// Override `status` to 'sent' on the in-memory copy. The DB flip happens
// after email delivery (line ~625); rendering with the stale 'draft' status
// would stamp the customer's PDF with "UTKAST – inte en giltig faktura".
@@ -0,0 +1,202 @@
-- Audit C2 fix — atomic customer-invoice voucher linking RPC.
--
-- Mirrors link_supplier_invoice_to_voucher (PR #602, migrations
-- 20260529130000 + 20260529140000): the TS-side linkInvoiceToVoucher()
-- updated the invoices row first, then inserted the invoice_payments row,
-- with a manual rollback that restored from a STALE pre-link snapshot. Under
-- concurrent linking against the same invoice (A starts on `sent`, B
-- completes to `paid`, A's insert fails and A's rollback overwrites B's
-- `paid` back to `sent`) the rollback could clobber a sibling's successful
-- write while leaving its payment row in place. This RPC moves validation +
-- both writes into a single Postgres transaction with the invoice row locked
-- FOR UPDATE, so concurrent linkers serialize and PG's own rollback handles
-- the failure path.
--
-- Also inherits the supplier RPC's remaining-amount fix: trust the stored
-- remaining_amount whenever it is non-NULL (even 0) and only fall back to
-- total - paid_amount when NULL. The TS computeRemaining()'s "> 0" guard let
-- rounding drift on a fully-paid invoice slip past the FULLY_PAID check.
--
-- AR matching mirrors lib/invoices/voucher-matching.ts: credit lines on the
-- 151x range (AR_ACCOUNT_PREFIX '151' — 1510 Kundfordringar et al). Error
-- codes are the existing LINK_VOUCHER_* set so callers map unchanged.
CREATE OR REPLACE FUNCTION public.link_invoice_to_voucher(
p_invoice_id uuid,
p_journal_entry_id uuid,
p_user_id uuid,
p_company_id uuid,
p_notes text DEFAULT NULL
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_invoice RECORD;
v_voucher RECORD;
v_ar_credit_total numeric := 0;
v_line_currency text;
v_remaining numeric;
v_payment_amount numeric;
v_new_paid numeric;
v_new_remaining numeric;
v_new_status text;
v_is_fully_paid boolean;
v_now timestamptz := now();
v_payment_id uuid;
BEGIN
-- 1. Lock the invoice for the duration of this transaction. FOR UPDATE so a
-- concurrent linker has to wait until we commit (or roll back).
SELECT * INTO v_invoice
FROM public.invoices
WHERE id = p_invoice_id AND company_id = p_company_id
FOR UPDATE;
IF NOT FOUND THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_VOUCHER_INVOICE_NOT_FOUND');
END IF;
IF v_invoice.status NOT IN ('sent', 'overdue', 'partially_paid') THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'LINK_VOUCHER_INVOICE_FULLY_PAID',
'details', jsonb_build_object('status', v_invoice.status)
);
END IF;
v_remaining := COALESCE(v_invoice.remaining_amount,
v_invoice.total - COALESCE(v_invoice.paid_amount, 0));
IF v_remaining <= 0.005 THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_VOUCHER_INVOICE_FULLY_PAID');
END IF;
-- 2. Resolve the voucher.
SELECT * INTO v_voucher
FROM public.journal_entries
WHERE id = p_journal_entry_id AND company_id = p_company_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_VOUCHER_VOUCHER_NOT_FOUND');
END IF;
IF v_voucher.status <> 'posted' THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'LINK_VOUCHER_NOT_POSTED',
'details', jsonb_build_object('status', v_voucher.status)
);
END IF;
IF v_voucher.source_type IN ('opening_balance', 'storno') THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'LINK_VOUCHER_NO_AR_CREDIT',
'details', jsonb_build_object('source_type', v_voucher.source_type)
);
END IF;
-- 3. Sum AR credit across the voucher's 151x lines.
SELECT COALESCE(SUM(credit_amount), 0), MAX(currency)
INTO v_ar_credit_total, v_line_currency
FROM public.journal_entry_lines
WHERE journal_entry_id = p_journal_entry_id
AND account_number LIKE '151%'
AND credit_amount > 0;
v_ar_credit_total := ROUND(v_ar_credit_total * 100) / 100;
IF v_ar_credit_total <= 0 THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_VOUCHER_NO_AR_CREDIT');
END IF;
IF COALESCE(v_line_currency, v_invoice.currency) IS DISTINCT FROM v_invoice.currency THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'LINK_VOUCHER_CURRENCY_MISMATCH',
'details', jsonb_build_object(
'invoice_currency', v_invoice.currency,
'line_currency', v_line_currency
)
);
END IF;
IF v_ar_credit_total > v_remaining + 0.005 THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'LINK_VOUCHER_AMOUNT_EXCEEDS_REMAINING',
'details', jsonb_build_object(
'ar_credit', v_ar_credit_total,
'remaining', ROUND(v_remaining * 100) / 100
)
);
END IF;
-- 4. Reject re-link of the same voucher to the same invoice. Authoritative
-- under the FOR UPDATE lock; the partial unique index
-- idx_invoice_payments_je_inv_unique stays as the last line of defence
-- for non-RPC writers.
IF EXISTS (
SELECT 1 FROM public.invoice_payments
WHERE company_id = p_company_id
AND invoice_id = p_invoice_id
AND journal_entry_id = p_journal_entry_id
) THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_VOUCHER_ALREADY_LINKED');
END IF;
-- 5. Compute the advance.
v_payment_amount := LEAST(v_ar_credit_total, ROUND(v_remaining * 100) / 100);
v_new_remaining := GREATEST(0,
ROUND((v_remaining - v_payment_amount) * 100) / 100
);
v_new_paid := ROUND((COALESCE(v_invoice.paid_amount, 0) + v_payment_amount) * 100) / 100;
v_is_fully_paid := v_new_remaining <= 0.005;
v_new_status := CASE WHEN v_is_fully_paid THEN 'paid' ELSE 'partially_paid' END;
-- 6. Apply both writes. The RPC body is one transaction; a failure on the
-- INSERT triggers PG's own rollback of the UPDATE — no manual rollback
-- path needed.
UPDATE public.invoices
SET status = v_new_status,
paid_at = CASE WHEN v_is_fully_paid THEN v_now ELSE paid_at END,
paid_amount = v_new_paid,
remaining_amount = v_new_remaining,
updated_at = v_now
WHERE id = p_invoice_id;
INSERT INTO public.invoice_payments (
user_id, company_id, invoice_id, payment_date, amount, currency,
exchange_rate, journal_entry_id, transaction_id, notes
) VALUES (
p_user_id, p_company_id, p_invoice_id, v_voucher.entry_date,
v_payment_amount, v_invoice.currency, v_invoice.exchange_rate,
p_journal_entry_id, NULL, p_notes
)
RETURNING id INTO v_payment_id;
RETURN jsonb_build_object(
'ok', true,
'payment_id', v_payment_id,
'invoice_status', v_new_status,
'paid_amount', v_new_paid,
'remaining_amount', v_new_remaining,
'payment_amount', v_payment_amount,
'journal_entry_id', p_journal_entry_id,
'currency', v_invoice.currency,
'payment_date', v_voucher.entry_date
);
END;
$$;
-- Write-RPC hardening (audit A5 direction — PR #625 guarded the read RPCs):
-- never callable anonymously. `authenticated` covers user-session clients;
-- `service_role` covers the MCP / API-key paths (createServiceClientNoCookies).
REVOKE ALL ON FUNCTION public.link_invoice_to_voucher(uuid, uuid, uuid, uuid, text) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.link_invoice_to_voucher(uuid, uuid, uuid, uuid, text) TO authenticated, service_role;
COMMENT ON FUNCTION public.link_invoice_to_voucher(uuid, uuid, uuid, uuid, text) IS
'Atomically link an existing posted verifikat as payment for a customer invoice. Locks the invoice row, validates the voucher credits 151x, advances paid_amount/remaining_amount/status, and inserts an invoice_payments row in one PG transaction. Returns jsonb { ok, ..., payment_id } on success or { ok: false, code, details } on guard failure.';
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,385 @@
-- Tenant guard for the voucher-link write RPCs (PR #666 review follow-up,
-- OWASP ASVS V8.2.1 / audit A5 direction).
--
-- link_invoice_to_voucher (20260614120000) and link_supplier_invoice_to_voucher
-- (20260529130000/140000) are SECURITY DEFINER and EXECUTE-able by
-- `authenticated`, so any signed-in user could call them via PostgREST with
-- ANOTHER company's p_company_id and mutate that tenant's invoices + payment
-- rows. PR #625 closed the same hole on the GL read RPCs; this applies the
-- identical claims-based guard to both write RPCs: anon/authenticated callers
-- must be a member of p_company_id (user_company_ids()), while service_role and
-- direct/superuser access (no JWT role — migrations, pg-real harness, MCP /
-- API-key paths whose company scoping happens in TS) bypass.
--
-- Guard failures return the existing *_INVOICE_NOT_FOUND codes so a probing
-- caller cannot distinguish "wrong tenant" from "no such invoice".
--
-- Also hardens p_notes with the same 2000-char cap the Zod layer
-- (LinkInvoiceToVoucherSchema / LinkSupplierInvoiceToVoucherSchema) enforces on
-- the API path — direct PostgREST callers could otherwise insert unbounded text.
--
-- And pins payment-row attribution (GDPR Art. 32): for user-session callers the
-- JWT sub is authoritative for invoice_payments.user_id — a direct PostgREST
-- caller could otherwise attribute financial records to an arbitrary user via
-- p_user_id. service_role / direct callers keep p_user_id verbatim (their
-- company + user scoping happens in the TS layer).
--
-- Both function bodies are otherwise identical to their previous versions.
CREATE OR REPLACE FUNCTION public.link_invoice_to_voucher(
p_invoice_id uuid,
p_journal_entry_id uuid,
p_user_id uuid,
p_company_id uuid,
p_notes text DEFAULT NULL
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_invoice RECORD;
v_voucher RECORD;
v_ar_credit_total numeric := 0;
v_line_currency text;
v_remaining numeric;
v_payment_amount numeric;
v_new_paid numeric;
v_new_remaining numeric;
v_new_status text;
v_is_fully_paid boolean;
v_now timestamptz := now();
v_payment_id uuid;
v_jwt_role text := coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', '');
v_acting_user uuid := p_user_id;
BEGIN
-- 0. Tenant guard (mirrors 20260611140000): anon/authenticated may only act
-- on their own companies; service_role / direct access bypasses.
IF v_jwt_role IN ('anon', 'authenticated') THEN
IF p_company_id NOT IN (SELECT public.user_company_ids()) THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_VOUCHER_INVOICE_NOT_FOUND');
END IF;
-- Attribution: the JWT sub is authoritative for user-session callers —
-- p_user_id cannot point the payment row at someone else.
v_acting_user := coalesce(
(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub')::uuid,
p_user_id
);
END IF;
IF p_notes IS NOT NULL AND char_length(p_notes) > 2000 THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'LINK_VOUCHER_NOTES_TOO_LONG',
'details', jsonb_build_object('max_length', 2000, 'length', char_length(p_notes))
);
END IF;
-- 1. Lock the invoice for the duration of this transaction. FOR UPDATE so a
-- concurrent linker has to wait until we commit (or roll back).
SELECT * INTO v_invoice
FROM public.invoices
WHERE id = p_invoice_id AND company_id = p_company_id
FOR UPDATE;
IF NOT FOUND THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_VOUCHER_INVOICE_NOT_FOUND');
END IF;
IF v_invoice.status NOT IN ('sent', 'overdue', 'partially_paid') THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'LINK_VOUCHER_INVOICE_FULLY_PAID',
'details', jsonb_build_object('status', v_invoice.status)
);
END IF;
v_remaining := COALESCE(v_invoice.remaining_amount,
v_invoice.total - COALESCE(v_invoice.paid_amount, 0));
IF v_remaining <= 0.005 THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_VOUCHER_INVOICE_FULLY_PAID');
END IF;
-- 2. Resolve the voucher.
SELECT * INTO v_voucher
FROM public.journal_entries
WHERE id = p_journal_entry_id AND company_id = p_company_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_VOUCHER_VOUCHER_NOT_FOUND');
END IF;
IF v_voucher.status <> 'posted' THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'LINK_VOUCHER_NOT_POSTED',
'details', jsonb_build_object('status', v_voucher.status)
);
END IF;
IF v_voucher.source_type IN ('opening_balance', 'storno') THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'LINK_VOUCHER_NO_AR_CREDIT',
'details', jsonb_build_object('source_type', v_voucher.source_type)
);
END IF;
-- 3. Sum AR credit across the voucher's 151x lines.
SELECT COALESCE(SUM(credit_amount), 0), MAX(currency)
INTO v_ar_credit_total, v_line_currency
FROM public.journal_entry_lines
WHERE journal_entry_id = p_journal_entry_id
AND account_number LIKE '151%'
AND credit_amount > 0;
v_ar_credit_total := ROUND(v_ar_credit_total * 100) / 100;
IF v_ar_credit_total <= 0 THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_VOUCHER_NO_AR_CREDIT');
END IF;
IF COALESCE(v_line_currency, v_invoice.currency) IS DISTINCT FROM v_invoice.currency THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'LINK_VOUCHER_CURRENCY_MISMATCH',
'details', jsonb_build_object(
'invoice_currency', v_invoice.currency,
'line_currency', v_line_currency
)
);
END IF;
IF v_ar_credit_total > v_remaining + 0.005 THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'LINK_VOUCHER_AMOUNT_EXCEEDS_REMAINING',
'details', jsonb_build_object(
'ar_credit', v_ar_credit_total,
'remaining', ROUND(v_remaining * 100) / 100
)
);
END IF;
-- 4. Reject re-link of the same voucher to the same invoice. Authoritative
-- under the FOR UPDATE lock; the partial unique index
-- idx_invoice_payments_je_inv_unique stays as the last line of defence
-- for non-RPC writers.
IF EXISTS (
SELECT 1 FROM public.invoice_payments
WHERE company_id = p_company_id
AND invoice_id = p_invoice_id
AND journal_entry_id = p_journal_entry_id
) THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_VOUCHER_ALREADY_LINKED');
END IF;
-- 5. Compute the advance.
v_payment_amount := LEAST(v_ar_credit_total, ROUND(v_remaining * 100) / 100);
v_new_remaining := GREATEST(0,
ROUND((v_remaining - v_payment_amount) * 100) / 100
);
v_new_paid := ROUND((COALESCE(v_invoice.paid_amount, 0) + v_payment_amount) * 100) / 100;
v_is_fully_paid := v_new_remaining <= 0.005;
v_new_status := CASE WHEN v_is_fully_paid THEN 'paid' ELSE 'partially_paid' END;
-- 6. Apply both writes. The RPC body is one transaction; a failure on the
-- INSERT triggers PG's own rollback of the UPDATE — no manual rollback
-- path needed.
UPDATE public.invoices
SET status = v_new_status,
paid_at = CASE WHEN v_is_fully_paid THEN v_now ELSE paid_at END,
paid_amount = v_new_paid,
remaining_amount = v_new_remaining,
updated_at = v_now
WHERE id = p_invoice_id;
INSERT INTO public.invoice_payments (
user_id, company_id, invoice_id, payment_date, amount, currency,
exchange_rate, journal_entry_id, transaction_id, notes
) VALUES (
v_acting_user, p_company_id, p_invoice_id, v_voucher.entry_date,
v_payment_amount, v_invoice.currency, v_invoice.exchange_rate,
p_journal_entry_id, NULL, p_notes
)
RETURNING id INTO v_payment_id;
RETURN jsonb_build_object(
'ok', true,
'payment_id', v_payment_id,
'invoice_status', v_new_status,
'paid_amount', v_new_paid,
'remaining_amount', v_new_remaining,
'payment_amount', v_payment_amount,
'journal_entry_id', p_journal_entry_id,
'currency', v_invoice.currency,
'payment_date', v_voucher.entry_date
);
END;
$$;
CREATE OR REPLACE FUNCTION public.link_supplier_invoice_to_voucher(
p_supplier_invoice_id uuid,
p_journal_entry_id uuid,
p_user_id uuid,
p_company_id uuid,
p_notes text DEFAULT NULL
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_invoice RECORD;
v_voucher RECORD;
v_ap_debit_total numeric := 0;
v_line_currency text;
v_remaining numeric;
v_payment_amount numeric;
v_new_paid numeric;
v_new_remaining numeric;
v_new_status text;
v_is_fully_paid boolean;
v_now timestamptz := now();
v_payment_id uuid;
v_jwt_role text := coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', '');
v_acting_user uuid := p_user_id;
BEGIN
-- Tenant guard (mirrors 20260611140000): anon/authenticated may only act on
-- their own companies; service_role / direct access bypasses.
IF v_jwt_role IN ('anon', 'authenticated') THEN
IF p_company_id NOT IN (SELECT public.user_company_ids()) THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_INVOICE_NOT_FOUND');
END IF;
-- Attribution: the JWT sub is authoritative for user-session callers —
-- p_user_id cannot point the payment row at someone else.
v_acting_user := coalesce(
(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub')::uuid,
p_user_id
);
END IF;
IF p_notes IS NOT NULL AND char_length(p_notes) > 2000 THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'LINK_SI_VOUCHER_NOTES_TOO_LONG',
'details', jsonb_build_object('max_length', 2000, 'length', char_length(p_notes))
);
END IF;
SELECT * INTO v_invoice
FROM public.supplier_invoices
WHERE id = p_supplier_invoice_id AND company_id = p_company_id
FOR UPDATE;
IF NOT FOUND THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_INVOICE_NOT_FOUND');
END IF;
IF v_invoice.status NOT IN ('registered', 'approved', 'overdue', 'partially_paid') THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_INVOICE_FULLY_PAID',
'details', jsonb_build_object('status', v_invoice.status));
END IF;
v_remaining := COALESCE(v_invoice.remaining_amount, v_invoice.total - COALESCE(v_invoice.paid_amount, 0));
IF v_remaining <= 0.005 THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_INVOICE_FULLY_PAID');
END IF;
SELECT * INTO v_voucher
FROM public.journal_entries
WHERE id = p_journal_entry_id AND company_id = p_company_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_VOUCHER_NOT_FOUND');
END IF;
IF v_voucher.status <> 'posted' THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_NOT_POSTED',
'details', jsonb_build_object('status', v_voucher.status));
END IF;
IF v_voucher.source_type IN ('opening_balance', 'storno') THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_NO_AP_DEBIT',
'details', jsonb_build_object('source_type', v_voucher.source_type));
END IF;
-- Sum AP debit across the full 244x range (was: account_number = '2440').
SELECT COALESCE(SUM(debit_amount), 0), MAX(currency)
INTO v_ap_debit_total, v_line_currency
FROM public.journal_entry_lines
WHERE journal_entry_id = p_journal_entry_id
AND account_number LIKE '244%'
AND debit_amount > 0;
v_ap_debit_total := ROUND(v_ap_debit_total * 100) / 100;
IF v_ap_debit_total <= 0 THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_NO_AP_DEBIT');
END IF;
IF COALESCE(v_line_currency, v_invoice.currency) IS DISTINCT FROM v_invoice.currency THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_CURRENCY_MISMATCH',
'details', jsonb_build_object('invoice_currency', v_invoice.currency, 'line_currency', v_line_currency));
END IF;
IF v_ap_debit_total > v_remaining + 0.005 THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_AMOUNT_EXCEEDS_REMAINING',
'details', jsonb_build_object('ap_debit', v_ap_debit_total, 'remaining', ROUND(v_remaining * 100) / 100));
END IF;
IF EXISTS (
SELECT 1 FROM public.supplier_invoice_payments
WHERE company_id = p_company_id
AND supplier_invoice_id = p_supplier_invoice_id
AND journal_entry_id = p_journal_entry_id
) THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_ALREADY_LINKED');
END IF;
v_payment_amount := LEAST(v_ap_debit_total, ROUND(v_remaining * 100) / 100);
v_new_remaining := GREATEST(0, ROUND((v_remaining - v_payment_amount) * 100) / 100);
v_new_paid := ROUND((COALESCE(v_invoice.paid_amount, 0) + v_payment_amount) * 100) / 100;
v_is_fully_paid := v_new_remaining <= 0.005;
v_new_status := CASE WHEN v_is_fully_paid THEN 'paid' ELSE 'partially_paid' END;
UPDATE public.supplier_invoices
SET status = v_new_status,
paid_at = CASE WHEN v_is_fully_paid THEN v_now ELSE paid_at END,
paid_amount = v_new_paid,
remaining_amount = v_new_remaining,
updated_at = v_now
WHERE id = p_supplier_invoice_id;
INSERT INTO public.supplier_invoice_payments (
user_id, company_id, supplier_invoice_id, payment_date, amount, currency,
journal_entry_id, transaction_id, notes
) VALUES (
v_acting_user, p_company_id, p_supplier_invoice_id, v_voucher.entry_date,
v_payment_amount, v_invoice.currency, p_journal_entry_id, NULL, p_notes
)
RETURNING id INTO v_payment_id;
RETURN jsonb_build_object(
'ok', true,
'payment_id', v_payment_id,
'invoice_status', v_new_status,
'paid_amount', v_new_paid,
'remaining_amount', v_new_remaining,
'payment_amount', v_payment_amount,
'journal_entry_id', p_journal_entry_id,
'currency', v_invoice.currency
);
END;
$$;
-- The supplier RPC predates the write-RPC grant hardening and still carried the
-- Postgres default (EXECUTE to PUBLIC). Align it with link_invoice_to_voucher:
-- `authenticated` covers user-session clients; `service_role` covers the MCP /
-- API-key paths (createServiceClientNoCookies).
REVOKE ALL ON FUNCTION public.link_supplier_invoice_to_voucher(uuid, uuid, uuid, uuid, text) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.link_supplier_invoice_to_voucher(uuid, uuid, uuid, uuid, text) TO authenticated, service_role;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,223 @@
/**
* pg-real test for the voucher-link write-RPC tenant guard
* (20260615120000_link_voucher_rpcs_tenant_guard.sql).
*
* link_invoice_to_voucher and link_supplier_invoice_to_voucher are SECURITY
* DEFINER and EXECUTE-able by authenticated, so without the guard any
* authenticated user could call them directly with another company's id and
* mutate that tenant's invoices + payment rows. The guard enforces membership
* for anon/authenticated while leaving service_role and direct/superuser access
* (this harness, migrations) untouched — same pattern as the GL read-RPC guard
* (tests/pg/gl_lines_rpc_tenant_guard.pg.test.ts).
*/
import { describe, it, expect } from 'vitest'
import { randomUUID } from 'node:crypto'
import { getPool, withUserContext } from './setup'
import { seedCompany } from './fixtures'
let arrivalSeq = 0
async function seedCustomerInvoice(params: {
userId: string
companyId: string
total?: number
}): Promise<string> {
const customerId = 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')`,
[customerId, params.userId, params.companyId],
)
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, 'sent', 0, $6)`,
[id, params.userId, params.companyId, customerId, `F-${id.slice(0, 8)}`, total],
)
return id
}
async function seedSupplierInvoice(params: {
userId: string
companyId: string
total?: number
}): Promise<string> {
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, params.userId, params.companyId],
)
const id = randomUUID()
const total = params.total ?? 1000
// Time component for cross-run uniqueness, counter for within-run uniqueness.
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, '2026-04-01', '2026-05-01', '2026-04-01', 'approved', 'SEK',
$7, 0, $7, 0, $7, 'standard_25', false, false)`,
[id, params.userId, params.companyId, supplierId, arrivalNumber, `LF-${arrivalNumber}`, total],
)
return id
}
/** Posted voucher crediting 1510 (AR settle) or debiting 244x (AP settle). */
async function seedPostedVoucher(params: {
userId: string
companyId: string
fiscalPeriodId: string
side: 'ar' | 'ap'
amount?: number
}): Promise<string> {
const id = randomUUID()
const amount = params.amount ?? 1000
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, $5, 'A', '2026-05-05', 'Betalning', 'manual', 'posted')`,
[id, params.userId, params.companyId, params.fiscalPeriodId, Math.floor(Math.random() * 100000)],
)
if (params.side === 'ar') {
await getPool().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],
)
} else {
await getPool().query(
`INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount)
VALUES ($1, '2440', $2, 0),
($1, '1930', 0, $2)`,
[id, amount],
)
}
return id
}
const LINK_INVOICE = `SELECT public.link_invoice_to_voucher($1, $2, $3, $4, $5) AS result`
const LINK_SUPPLIER = `SELECT public.link_supplier_invoice_to_voucher($1, $2, $3, $4, $5) AS result`
describe('voucher-link write RPCs — tenant-isolation guard', () => {
it('blocks an authenticated non-member from linking another company\'s invoice', async () => {
const a = await seedCompany()
const b = await seedCompany()
const invoiceId = await seedCustomerInvoice({ userId: a.userId, companyId: a.companyId })
const voucherId = await seedPostedVoucher({
userId: a.userId,
companyId: a.companyId,
fiscalPeriodId: a.fiscalPeriodId,
side: 'ar',
})
// Member of company B probing company A: guard fires before any data
// access and answers indistinguishably from a missing invoice.
await withUserContext(b.userId, async (client) => {
const res = await client.query(LINK_INVOICE, [invoiceId, voucherId, b.userId, a.companyId, null])
expect(res.rows[0].result).toMatchObject({ ok: false, code: 'LINK_VOUCHER_INVOICE_NOT_FOUND' })
})
// Nothing was mutated on the cross-tenant attempt.
const inv = await getPool().query(`SELECT status, paid_amount FROM public.invoices WHERE id = $1`, [invoiceId])
expect(inv.rows[0]).toMatchObject({ status: 'sent' })
const payments = await getPool().query(
`SELECT id FROM public.invoice_payments WHERE invoice_id = $1`,
[invoiceId],
)
expect(payments.rows).toHaveLength(0)
// A member of company A still links successfully through the same path —
// the guard must not break legitimate user-session calls. The spoofed
// p_user_id is ignored for user-session callers: the JWT sub is
// authoritative for payment-row attribution (GDPR Art. 32). Asserted
// inside the context — withUserContext rolls its transaction back.
const spoofedUserId = randomUUID()
await withUserContext(a.userId, async (client) => {
const res = await client.query(LINK_INVOICE, [invoiceId, voucherId, spoofedUserId, a.companyId, null])
expect(res.rows[0].result).toMatchObject({ ok: true, invoice_status: 'paid' })
const payment = await client.query(
`SELECT user_id FROM public.invoice_payments WHERE invoice_id = $1`,
[invoiceId],
)
expect(payment.rows).toHaveLength(1)
expect(payment.rows[0].user_id).toBe(a.userId)
})
})
it('blocks an authenticated non-member from linking another company\'s supplier invoice', async () => {
const a = await seedCompany()
const b = await seedCompany()
const supplierInvoiceId = await seedSupplierInvoice({ userId: a.userId, companyId: a.companyId })
const voucherId = await seedPostedVoucher({
userId: a.userId,
companyId: a.companyId,
fiscalPeriodId: a.fiscalPeriodId,
side: 'ap',
})
await withUserContext(b.userId, async (client) => {
const res = await client.query(LINK_SUPPLIER, [supplierInvoiceId, voucherId, b.userId, a.companyId, null])
expect(res.rows[0].result).toMatchObject({ ok: false, code: 'LINK_SI_VOUCHER_INVOICE_NOT_FOUND' })
})
const inv = await getPool().query(
`SELECT status FROM public.supplier_invoices WHERE id = $1`,
[supplierInvoiceId],
)
expect(inv.rows[0]).toMatchObject({ status: 'approved' })
await withUserContext(a.userId, async (client) => {
const res = await client.query(LINK_SUPPLIER, [supplierInvoiceId, voucherId, a.userId, a.companyId, null])
expect(res.rows[0].result).toMatchObject({ ok: true, invoice_status: 'paid' })
})
})
it('direct/superuser access (no JWT role) bypasses the guard', async () => {
const a = await seedCompany()
const invoiceId = await seedCustomerInvoice({ userId: a.userId, companyId: a.companyId })
const voucherId = await seedPostedVoucher({
userId: a.userId,
companyId: a.companyId,
fiscalPeriodId: a.fiscalPeriodId,
side: 'ar',
})
// The bare pool has no request.jwt.claims — the trusted bypass that the
// harness, migrations and service-role API paths rely on.
const res = await getPool().query(LINK_INVOICE, [invoiceId, voucherId, a.userId, a.companyId, null])
expect(res.rows[0].result).toMatchObject({ ok: true, invoice_status: 'paid' })
})
it('rejects notes longer than the 2000-char Zod cap for all callers', async () => {
const a = await seedCompany()
const invoiceId = await seedCustomerInvoice({ userId: a.userId, companyId: a.companyId })
const voucherId = await seedPostedVoucher({
userId: a.userId,
companyId: a.companyId,
fiscalPeriodId: a.fiscalPeriodId,
side: 'ar',
})
const res = await getPool().query(LINK_INVOICE, [
invoiceId,
voucherId,
a.userId,
a.companyId,
'x'.repeat(2001),
])
expect(res.rows[0].result).toMatchObject({ ok: false, code: 'LINK_VOUCHER_NOTES_TOO_LONG' })
})
})
+8 -4
View File
@@ -38,6 +38,8 @@ async function insertSupplier(params: {
return id
}
let arrivalSeq = 0
async function insertSupplierInvoice(params: {
userId: string
companyId: string
@@ -48,10 +50,12 @@ async function insertSupplierInvoice(params: {
dueDate?: string
}): Promise<string> {
const id = randomUUID()
// Arrival numbers are generated per-company by get_next_arrival_number,
// but for an isolated test we can hardcode a unique value via current time
// millis modulo a wide range. The unique constraint allows that.
const arrivalNumber = (Date.now() % 1_000_000_000) + Math.floor(Math.random() * 10_000)
// 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,