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 { 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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user