fix(audit): H12 — consistent refund sign so refunds never inflate revenue
createPayment/updatePayment now store refunds as a negative magnitude, and every financial reader (sumPaymentsInRange, getRevenueByMonth, getCashFlow) subtracts refund magnitude regardless of stored sign — fixing both new rows and legacy positive-stored refunds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,19 @@ import { NotFoundError, ValidationError } from '@/lib/errors';
|
||||
import { emitToRoom } from '@/lib/socket/server';
|
||||
import type { CreatePaymentInput, UpdatePaymentInput } from '@/lib/validators/payments';
|
||||
|
||||
/**
|
||||
* Normalize a payment amount's sign by type (audit H12). Refunds are stored
|
||||
* as a negative magnitude so every aggregate that sums payments nets them out
|
||||
* regardless of what sign the rep entered; all other types pass through
|
||||
* verbatim. Preserves the numeric-string column format.
|
||||
*/
|
||||
function normalizeRefundSign(paymentType: string, amount: string): string {
|
||||
if (paymentType !== 'refund') return amount;
|
||||
const n = Number(amount);
|
||||
if (!Number.isFinite(n)) return amount;
|
||||
return (-Math.abs(n)).toString();
|
||||
}
|
||||
|
||||
// ─── Reads ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** All payments for a single interest, newest received first. */
|
||||
@@ -98,6 +111,12 @@ export async function createPayment(portId: string, data: CreatePaymentInput, me
|
||||
throw new ValidationError('amount must be a non-zero numeric value');
|
||||
}
|
||||
|
||||
// Sign convention (audit H12): refunds are always stored as a NEGATIVE
|
||||
// magnitude so every summation path nets them out consistently. The regex
|
||||
// validator accepts either sign, so normalize here regardless of what the
|
||||
// rep typed (a positive "200" refund becomes "-200").
|
||||
const normalizedAmount = normalizeRefundSign(data.paymentType, data.amount);
|
||||
|
||||
const [row] = await db
|
||||
.insert(payments)
|
||||
.values({
|
||||
@@ -105,7 +124,7 @@ export async function createPayment(portId: string, data: CreatePaymentInput, me
|
||||
interestId: data.interestId,
|
||||
clientId: interest.clientId,
|
||||
paymentType: data.paymentType,
|
||||
amount: data.amount,
|
||||
amount: normalizedAmount,
|
||||
currency: data.currency,
|
||||
receivedAt: new Date(data.receivedAt),
|
||||
receiptFileId: data.receiptFileId ?? null,
|
||||
@@ -123,7 +142,7 @@ export async function createPayment(portId: string, data: CreatePaymentInput, me
|
||||
newValue: {
|
||||
interestId: data.interestId,
|
||||
paymentType: data.paymentType,
|
||||
amount: data.amount,
|
||||
amount: normalizedAmount,
|
||||
currency: data.currency,
|
||||
},
|
||||
ipAddress: meta.ipAddress,
|
||||
@@ -191,6 +210,16 @@ export async function updatePayment(
|
||||
if (data.receiptFileId !== undefined) next.receiptFileId = data.receiptFileId;
|
||||
if (data.notes !== undefined) next.notes = data.notes;
|
||||
|
||||
// Re-apply the refund sign convention (audit H12) whenever the effective
|
||||
// type or amount changes. The effective type is the incoming one if present,
|
||||
// otherwise the row's existing type — so flipping a row to/from 'refund'
|
||||
// re-signs the stored amount even when the amount field itself is untouched.
|
||||
if (data.paymentType !== undefined || data.amount !== undefined) {
|
||||
const effectiveType = data.paymentType ?? existing.paymentType;
|
||||
const effectiveAmount = data.amount ?? existing.amount;
|
||||
next.amount = normalizeRefundSign(effectiveType, effectiveAmount);
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(payments)
|
||||
.set(next)
|
||||
|
||||
Reference in New Issue
Block a user