diff --git a/app/api/transactions/[id]/match-invoice/preview/__tests__/route.test.ts b/app/api/transactions/[id]/match-invoice/preview/__tests__/route.test.ts index c8fd883c..e483f46d 100644 --- a/app/api/transactions/[id]/match-invoice/preview/__tests__/route.test.ts +++ b/app/api/transactions/[id]/match-invoice/preview/__tests__/route.test.ts @@ -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) + }) }) diff --git a/app/api/transactions/[id]/match-invoice/preview/route.ts b/app/api/transactions/[id]/match-invoice/preview/route.ts index a8770c95..1b3aeeb4 100644 --- a/app/api/transactions/[id]/match-invoice/preview/route.ts +++ b/app/api/transactions/[id]/match-invoice/preview/route.ts @@ -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() 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 diff --git a/lib/bookkeeping/__tests__/payment-sync.test.ts b/lib/bookkeeping/__tests__/payment-sync.test.ts index 85d1be81..febf3857 100644 --- a/lib/bookkeeping/__tests__/payment-sync.test.ts +++ b/lib/bookkeeping/__tests__/payment-sync.test.ts @@ -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 | 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 | 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).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).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).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).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, + }) + }) }) diff --git a/lib/bookkeeping/payment-sync.ts b/lib/bookkeeping/payment-sync.ts index 5d557376..7cf3aaa1 100644 --- a/lib/bookkeeping/payment-sync.ts +++ b/lib/bookkeeping/payment-sync.ts @@ -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, + invoiceColumn: 'invoice_id' | 'supplier_invoice_id', +): Promise { + 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), + }) + } } } diff --git a/lib/invoices/__tests__/voucher-matching.pg.test.ts b/lib/invoices/__tests__/voucher-matching.pg.test.ts index 558200b2..00eb0822 100644 --- a/lib/invoices/__tests__/voucher-matching.pg.test.ts +++ b/lib/invoices/__tests__/voucher-matching.pg.test.ts @@ -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 +} + +async function callLinkRpc(args: { + invoiceId: string + voucherId: string + userId: string + companyId: string +}): Promise { + 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) + }) +}) diff --git a/lib/invoices/__tests__/voucher-matching.test.ts b/lib/invoices/__tests__/voucher-matching.test.ts index 27cad353..9af9793e 100644 --- a/lib/invoices/__tests__/voucher-matching.test.ts +++ b/lib/invoices/__tests__/voucher-matching.test.ts @@ -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') }) }) diff --git a/lib/invoices/voucher-matching.ts b/lib/invoices/voucher-matching.ts index aea8be31..d1cd7655 100644 --- a/lib/invoices/voucher-matching.ts +++ b/lib/invoices/voucher-matching.ts @@ -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 +} + /** * 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 } > { - 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, }, diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index d2f56a3f..4cf37d8c 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -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". diff --git a/supabase/migrations/20260614120000_link_invoice_to_voucher_rpc.sql b/supabase/migrations/20260614120000_link_invoice_to_voucher_rpc.sql new file mode 100644 index 00000000..f195d214 --- /dev/null +++ b/supabase/migrations/20260614120000_link_invoice_to_voucher_rpc.sql @@ -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'; diff --git a/supabase/migrations/20260615120000_link_voucher_rpcs_tenant_guard.sql b/supabase/migrations/20260615120000_link_voucher_rpcs_tenant_guard.sql new file mode 100644 index 00000000..6fef922c --- /dev/null +++ b/supabase/migrations/20260615120000_link_voucher_rpcs_tenant_guard.sql @@ -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'; diff --git a/tests/pg/link-voucher-rpcs-tenant-guard.pg.test.ts b/tests/pg/link-voucher-rpcs-tenant-guard.pg.test.ts new file mode 100644 index 00000000..a083fbcc --- /dev/null +++ b/tests/pg/link-voucher-rpcs-tenant-guard.pg.test.ts @@ -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 { + 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 { + 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 { + 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' }) + }) +}) diff --git a/tests/pg/match-batch-allocate.pg.test.ts b/tests/pg/match-batch-allocate.pg.test.ts index d0484f74..04a4a343 100644 --- a/tests/pg/match-batch-allocate.pg.test.ts +++ b/tests/pg/match-batch-allocate.pg.test.ts @@ -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 { 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,