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:
2026-06-02 12:24:51 +02:00
parent 77829485a7
commit 808e80744b
2 changed files with 60 additions and 10 deletions

View File

@@ -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)