Reject overpayment on all invoice-match paths (audit C3) (#647)

* fix(invoices): reject overpayment on all invoice-match paths (audit C3)

The paid/remaining math was copy-pasted across three sites; the dashboard match-invoice route guarded against overpayment but the v1 public API route and the agent/MCP commitMatchTransactionInvoice had drifted WITHOUT it — silently accepting payment > remaining (recording paid_amount > total, over-crediting AR; cleanup needs storno, not edit).

- New lib/invoices/apply-invoice-payment.ts planInvoicePayment(): single source of the paid/remaining/status math + overpayment guard, via canonical roundOre (@/lib/money, guard rail #9). FX-agnostic — caller passes the invoice-currency amount.
- All three sites delegate; the guard runs BEFORE journal-entry creation so a rejected match never burns a voucher number. Dashboard behaviour unchanged (faithful extraction — its existing overpayment test still passes, the equivalence anchor). v1 returns MATCH_AMOUNT_EXCEEDS_REMAINING; commit returns the same registry message at 400.
- Removes 7 hand-rolled Math.round(x*100)/100 sites; antipattern guard ratchets 668 -> 661.
- Unit tests for the helper (overpayment rejection, half-öre tolerance, remaining_amount fallback).

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

* review: run overpayment guard before the storno (PR #647)

greptile: in commit.ts and the v1 route the conflicting-JE storno ran BEFORE the new guard, so a rejected overpayment would still reverse the transaction's prior JE and null its journal_entry_id — a side effect on a rejected match. Move planInvoicePayment above the storno so a rejection leaves the transaction fully untouched. (The dashboard route's pre-existing storno-before-guard ordering is FX-entangled and unchanged here; noted as a follow-up.)

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-03 17:00:22 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 0b86901a2b
commit 5777f51940
6 changed files with 194 additions and 36 deletions
+18 -6
View File
@@ -40,6 +40,7 @@ import {
createSupplierInvoiceRegistrationEntry,
} from '@/lib/bookkeeping/supplier-invoice-entries'
import { linkInvoiceToVoucher } from '@/lib/invoices/voucher-matching'
import { planInvoicePayment } from '@/lib/invoices/apply-invoice-payment'
import { linkSupplierInvoiceToVoucher } from '@/lib/invoices/supplier-voucher-matching'
import { linkTransactionToJournalEntry } from '@/lib/transactions/link-journal-entry'
import { getErrorEntry } from '@/lib/errors/structured-errors'
@@ -899,18 +900,29 @@ async function commitMatchTransactionInvoice(
return { error: 'Invoice is not in a matchable state', status: 409 }
}
// Overshoot guard + paid/remaining math — shared with the dashboard and v1
// routes via planInvoicePayment. This agent/MCP path previously had NO guard,
// so a 1500 payment on a 1000 invoice was silently accepted (paid_amount >
// total, AR over-credited). Runs BEFORE the storno + JE below, so a rejected
// match leaves the transaction untouched and never burns a voucher number.
const paidAmount = transaction.amount
const payment = planInvoicePayment(invoice, paidAmount)
if (!payment.ok) {
return {
error:
getErrorEntry('MATCH_AMOUNT_EXCEEDS_REMAINING')?.message_sv ??
'Transaktionsbeloppet är större än fakturans återstående belopp.',
status: 400,
}
}
const { newPaidAmount, newRemaining, isFullyPaid, newStatus } = payment.plan
if (transaction.journal_entry_id) {
await reverseEntry(supabase, companyId, userId, transaction.journal_entry_id)
await supabase.from('transactions').update({ journal_entry_id: null }).eq('id', transactionId)
}
const now = new Date().toISOString()
const paidAmount = transaction.amount
const newPaidAmount = Math.round(((invoice.paid_amount || 0) + paidAmount) * 100) / 100
const currentRemaining = invoice.remaining_amount ?? (invoice.total - (invoice.paid_amount || 0))
const newRemaining = Math.max(0, Math.round((currentRemaining - paidAmount) * 100) / 100)
const isFullyPaid = newRemaining <= 0
const newStatus = isFullyPaid ? 'paid' : 'partially_paid'
const { data: settings } = await supabase
.from('company_settings').select('accounting_method, entity_type').eq('company_id', companyId).single()