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)
|
||||
|
||||
@@ -22,8 +22,9 @@ import type { DateRange } from './sales.service';
|
||||
// ─── KPI strip ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface FinancialKpis {
|
||||
/** Σ of every payment received in range (refunds net out — they carry
|
||||
* negative amounts). In port currency. */
|
||||
/** Σ of every payment received in range; refunds net out (their magnitude is
|
||||
* always subtracted regardless of stored sign — audit H12). In port
|
||||
* currency. */
|
||||
revenueCollected: number;
|
||||
depositsCollected: number;
|
||||
balanceCollected: number;
|
||||
@@ -157,10 +158,19 @@ async function sumPaymentsInRange(
|
||||
r.currency ?? targetCurrency,
|
||||
targetCurrency,
|
||||
);
|
||||
acc.total += amount;
|
||||
if (r.paymentType === 'deposit') acc.deposit += amount;
|
||||
else if (r.paymentType === 'balance') acc.balance += amount;
|
||||
else if (r.paymentType === 'refund') acc.refund += amount; // already negative
|
||||
if (r.paymentType === 'refund') {
|
||||
// Refund sign convention (audit H12): treat a refund as a magnitude
|
||||
// deduction regardless of the stored sign. New rows store negatives, but
|
||||
// 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;
|
||||
}
|
||||
@@ -260,7 +270,12 @@ export async function getRevenueByMonth(
|
||||
currency,
|
||||
);
|
||||
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 }));
|
||||
}
|
||||
@@ -365,6 +380,7 @@ export async function getCashFlow(portId: string, range: DateRange): Promise<Cas
|
||||
|
||||
const paymentRows = await db
|
||||
.select({
|
||||
paymentType: payments.paymentType,
|
||||
amount: payments.amount,
|
||||
currency: payments.currency,
|
||||
receivedAt: payments.receivedAt,
|
||||
@@ -381,7 +397,12 @@ export async function getCashFlow(portId: string, range: DateRange): Promise<Cas
|
||||
if (!r.receivedAt) continue;
|
||||
const bucket = byMonth.get(monthKey(new Date(r.receivedAt)));
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user