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 { emitToRoom } from '@/lib/socket/server';
import type { CreatePaymentInput, UpdatePaymentInput } from '@/lib/validators/payments'; 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 ────────────────────────────────────────────────────────────────── // ─── Reads ──────────────────────────────────────────────────────────────────
/** All payments for a single interest, newest received first. */ /** 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'); 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 const [row] = await db
.insert(payments) .insert(payments)
.values({ .values({
@@ -105,7 +124,7 @@ export async function createPayment(portId: string, data: CreatePaymentInput, me
interestId: data.interestId, interestId: data.interestId,
clientId: interest.clientId, clientId: interest.clientId,
paymentType: data.paymentType, paymentType: data.paymentType,
amount: data.amount, amount: normalizedAmount,
currency: data.currency, currency: data.currency,
receivedAt: new Date(data.receivedAt), receivedAt: new Date(data.receivedAt),
receiptFileId: data.receiptFileId ?? null, receiptFileId: data.receiptFileId ?? null,
@@ -123,7 +142,7 @@ export async function createPayment(portId: string, data: CreatePaymentInput, me
newValue: { newValue: {
interestId: data.interestId, interestId: data.interestId,
paymentType: data.paymentType, paymentType: data.paymentType,
amount: data.amount, amount: normalizedAmount,
currency: data.currency, currency: data.currency,
}, },
ipAddress: meta.ipAddress, ipAddress: meta.ipAddress,
@@ -191,6 +210,16 @@ export async function updatePayment(
if (data.receiptFileId !== undefined) next.receiptFileId = data.receiptFileId; if (data.receiptFileId !== undefined) next.receiptFileId = data.receiptFileId;
if (data.notes !== undefined) next.notes = data.notes; 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 const [updated] = await db
.update(payments) .update(payments)
.set(next) .set(next)

View File

@@ -22,8 +22,9 @@ import type { DateRange } from './sales.service';
// ─── KPI strip ─────────────────────────────────────────────────────────────── // ─── KPI strip ───────────────────────────────────────────────────────────────
export interface FinancialKpis { export interface FinancialKpis {
/** Σ of every payment received in range (refunds net out they carry /** Σ of every payment received in range; refunds net out (their magnitude is
* negative amounts). In port currency. */ * always subtracted regardless of stored sign — audit H12). In port
* currency. */
revenueCollected: number; revenueCollected: number;
depositsCollected: number; depositsCollected: number;
balanceCollected: number; balanceCollected: number;
@@ -157,10 +158,19 @@ async function sumPaymentsInRange(
r.currency ?? targetCurrency, r.currency ?? targetCurrency,
targetCurrency, targetCurrency,
); );
acc.total += amount; if (r.paymentType === 'refund') {
if (r.paymentType === 'deposit') acc.deposit += amount; // Refund sign convention (audit H12): treat a refund as a magnitude
else if (r.paymentType === 'balance') acc.balance += amount; // deduction regardless of the stored sign. New rows store negatives, but
else if (r.paymentType === 'refund') acc.refund += amount; // already negative // legacy rows may be positive — `Math.abs` makes both subtract so a
// refund can never INFLATE collected revenue.
const magnitude = Math.abs(amount);
acc.total -= magnitude;
acc.refund -= magnitude;
} else {
acc.total += amount;
if (r.paymentType === 'deposit') acc.deposit += amount;
else if (r.paymentType === 'balance') acc.balance += amount;
}
} }
return acc; return acc;
} }
@@ -260,7 +270,12 @@ export async function getRevenueByMonth(
currency, currency,
); );
if (r.paymentType === 'balance') bucket.balance += amount; if (r.paymentType === 'balance') bucket.balance += amount;
else if (r.paymentType !== 'refund') bucket.deposit += amount; // deposit + other → deposit bar else if (r.paymentType === 'refund')
// Refund sign convention (audit H12): net the refund magnitude out of the
// deposit bar (it previously dropped refunds, overstating monthly
// revenue). `Math.abs` so legacy positive-stored refunds also subtract.
bucket.deposit -= Math.abs(amount);
else bucket.deposit += amount; // deposit + other → deposit bar
} }
return Array.from(byMonth.entries()).map(([month, v]) => ({ month, ...v })); return Array.from(byMonth.entries()).map(([month, v]) => ({ month, ...v }));
} }
@@ -365,6 +380,7 @@ export async function getCashFlow(portId: string, range: DateRange): Promise<Cas
const paymentRows = await db const paymentRows = await db
.select({ .select({
paymentType: payments.paymentType,
amount: payments.amount, amount: payments.amount,
currency: payments.currency, currency: payments.currency,
receivedAt: payments.receivedAt, receivedAt: payments.receivedAt,
@@ -381,7 +397,12 @@ export async function getCashFlow(portId: string, range: DateRange): Promise<Cas
if (!r.receivedAt) continue; if (!r.receivedAt) continue;
const bucket = byMonth.get(monthKey(new Date(r.receivedAt))); const bucket = byMonth.get(monthKey(new Date(r.receivedAt)));
if (!bucket) continue; if (!bucket) continue;
bucket.inflow += await normalizeAmount(Number(r.amount ?? 0), r.currency ?? currency, currency); const amount = await normalizeAmount(Number(r.amount ?? 0), r.currency ?? currency, currency);
// Refund sign convention (audit H12): a refund is cash leaving, so deduct
// its magnitude from monthly inflow regardless of the stored sign (legacy
// rows may be positive, new rows negative). Without this a positive-stored
// refund would overstate inflow.
bucket.inflow += r.paymentType === 'refund' ? -Math.abs(amount) : amount;
} }
const expenseRows = await db const expenseRows = await db