diff --git a/src/lib/services/payments.service.ts b/src/lib/services/payments.service.ts index dbc4f17a..6398cc57 100644 --- a/src/lib/services/payments.service.ts +++ b/src/lib/services/payments.service.ts @@ -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) diff --git a/src/lib/services/reports/financial.service.ts b/src/lib/services/reports/financial.service.ts index 6dd7100b..23b61bc2 100644 --- a/src/lib/services/reports/financial.service.ts +++ b/src/lib/services/reports/financial.service.ts @@ -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