Files
edr-platform/apps/edr-passenger-api/src/modules/bookings/payment-breakdown.util.ts

102 lines
3.3 KiB
TypeScript

export type PaymentLineKind = 'BOOKING' | 'SUPPLEMENTARY';
export interface PaymentLine {
kind: PaymentLineKind;
/** Human label — "Original booking", "Reschedule", "Excess baggage". */
label: string;
/** Raw reason for supplementary lines (RESCHEDULE, UNDERPAYMENT, …); null for the booking line. */
reason: string | null;
/** Always true minor units, whatever the source column stored. */
amountMinor: number;
currency: string;
status: string;
paidAt: string | null;
/** Whether this line represents money actually collected. */
settled: boolean;
}
export interface PaymentBreakdown {
lines: PaymentLine[];
/** Sum of settled lines, or null when they are not all in one currency. */
totalPaidMinor: number | null;
totalPaidCurrency: string | null;
/** True when something is still owed (a charge raised but not yet paid). */
hasOutstanding: boolean;
outstandingMinor: number;
}
/** `PaymentIntent.amountMinor` is a Float in major units — bring it onto the minor-unit scale. */
export function intentAmountToMinor(amount: number | null | undefined): number {
if (amount == null) return 0;
return Math.round(amount * 100);
}
const SUPPLEMENTARY_LABELS: Record<string, string> = {
RESCHEDULE: 'Reschedule',
UNDERPAYMENT: 'Underpayment',
FARE_CORRECTION: 'Fare correction',
EXCESS_BAGGAGE: 'Excess baggage',
};
function labelFor(reason: string): string {
return (
SUPPLEMENTARY_LABELS[reason] ??
// "SOME_OTHER_REASON" → "Some other reason"
reason.charAt(0).toUpperCase() + reason.slice(1).toLowerCase().replace(/_/g, ' ')
);
}
export function buildPaymentBreakdown(
paymentIntent: { status?: string | null; amountMinor?: number | null; currency?: string | null } | null | undefined,
supplementaryCharges: Array<{
reason: string;
amountMinor: number;
currency: string;
status: string;
paidAt: Date | string | null;
}> = [],
): PaymentBreakdown {
const lines: PaymentLine[] = [];
if (paymentIntent) {
lines.push({
kind: 'BOOKING',
label: 'Original booking',
reason: null,
amountMinor: intentAmountToMinor(paymentIntent.amountMinor),
currency: paymentIntent.currency ?? 'ETB',
status: paymentIntent.status ?? 'UNKNOWN',
paidAt: null,
settled: paymentIntent.status === 'SUCCEEDED',
});
}
for (const charge of supplementaryCharges) {
if (charge.status === 'WAIVED' || charge.status === 'EXPIRED') continue;
lines.push({
kind: 'SUPPLEMENTARY',
label: labelFor(charge.reason),
reason: charge.reason,
amountMinor: charge.amountMinor,
currency: charge.currency ?? 'ETB',
status: charge.status,
paidAt: charge.paidAt ? new Date(charge.paidAt).toISOString() : null,
settled: charge.status === 'PAID',
});
}
const settled = lines.filter((l) => l.settled);
const currencies = new Set(settled.map((l) => l.currency));
const singleCurrency = currencies.size === 1 ? [...currencies][0] : null;
const outstanding = lines.filter((l) => l.status === 'PENDING');
return {
lines,
totalPaidMinor: singleCurrency ? settled.reduce((sum, l) => sum + l.amountMinor, 0) : null,
totalPaidCurrency: singleCurrency,
hasOutstanding: outstanding.length > 0,
outstandingMinor: outstanding.reduce((sum, l) => sum + l.amountMinor, 0),
};
}