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:
co-authored by
Claude Opus 4.8
parent
0b86901a2b
commit
5777f51940
@@ -30,6 +30,7 @@ import { reverseEntry, createJournalEntry, findFiscalPeriod } from '@/lib/bookke
|
||||
import { AccountsNotInChartError, isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { logMatchEvent } from '@/lib/invoices/match-log'
|
||||
import { planInvoicePayment } from '@/lib/invoices/apply-invoice-payment'
|
||||
import { detectDuplicatePaymentVoucher } from '@/lib/invoices/duplicate-payment-detection'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import type { EntityType, Invoice, Transaction } from '@/types'
|
||||
@@ -265,6 +266,22 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
})
|
||||
}
|
||||
|
||||
const paidAmount = transaction.amount
|
||||
|
||||
// Overshoot guard + paid/remaining math — shared with the dashboard and
|
||||
// agent (commit) paths via planInvoicePayment. Without this, the public API
|
||||
// silently overpaid an invoice (recording paid_amount > total, over-crediting
|
||||
// AR). Runs BEFORE the storno + strict-mode JE creation, so a rejected match
|
||||
// touches no state.
|
||||
const payment = planInvoicePayment(invoice, paidAmount)
|
||||
if (!payment.ok) {
|
||||
return v1ErrorResponseFromCode('MATCH_AMOUNT_EXCEEDS_REMAINING', txLog, {
|
||||
requestId: ctx.requestId,
|
||||
details: payment.details,
|
||||
})
|
||||
}
|
||||
const { newPaidAmount, newRemaining, isFullyPaid, newStatus } = payment.plan
|
||||
|
||||
if (transaction.journal_entry_id) {
|
||||
try {
|
||||
await reverseEntry(ctx.supabase, ctx.companyId!, ctx.userId, transaction.journal_entry_id)
|
||||
@@ -288,17 +305,6 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
}
|
||||
|
||||
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 ctx.supabase
|
||||
.from('company_settings')
|
||||
|
||||
Reference in New Issue
Block a user