From e2c2d677bc94330f7530f148ba0c8e00abe102df Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Fri, 28 Aug 2026 15:29:47 +0300 Subject: [PATCH] fix: ( bookings ) report the full amount paid after a reschedule --- .../src/modules/bookings/bookings.service.ts | 13 +++ .../bookings/payment-breakdown.util.ts | 101 ++++++++++++++++++ .../portal/src/lib/generate-voucher.ts | 69 +++++++++++- 3 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 apps/edr-passenger-api/src/modules/bookings/payment-breakdown.util.ts diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index fc3328605..dd4258438 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -7,6 +7,7 @@ import { TicketsService } from '../tickets/tickets.service'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { CreateBookingDto } from './bookings.dto'; import { assertIdentitiesNotAlreadyBooked, resolveIdentityRef } from './booking-identity.util'; +import { buildPaymentBreakdown } from './payment-breakdown.util'; import { Cron, CronExpression } from '@nestjs/schedule'; import { VerifaydaService } from '../verifayda/verifayda.service'; import { CurrencyService } from '../currency/currency.service'; @@ -2037,6 +2038,8 @@ export class BookingsService { returnSchedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } }, paymentIntent: true, tickets: true, + // Money collected after the original payment (reschedule fees, underpayments). + supplementaryCharges: { orderBy: { createdAt: 'asc' } }, priceTier: { select: { priceMinor: true } }, }, }); @@ -2130,6 +2133,8 @@ export class BookingsService { returnSchedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } }, paymentIntent: true, tickets: true, + // Money collected after the original payment (reschedule fees, underpayments). + supplementaryCharges: { orderBy: { createdAt: 'asc' } }, priceTier: { select: { priceMinor: true } }, }, }); @@ -2208,12 +2213,20 @@ export class BookingsService { }, }; }), + // `amountMinor` stays exactly as it was — the original intent, in the major units that + // column actually stores — so existing callers keep working. Everything collected since + // (reschedule fees and fare differences) lives in `breakdown`, whose `totalPaidMinor` is + // the number to show as "Total paid". See payment-breakdown.util.ts. payment: (booking as any).paymentIntent ? { method: (booking as any).paymentIntent.method, status: (booking as any).paymentIntent.status, amountMinor: (booking as any).paymentIntent.amountMinor, currency: (booking as any).paymentIntent.currency, + breakdown: buildPaymentBreakdown( + (booking as any).paymentIntent, + (booking as any).supplementaryCharges ?? [], + ), } : undefined, // One ticket per passenger per leg (round trips have a separate ticket — and diff --git a/apps/edr-passenger-api/src/modules/bookings/payment-breakdown.util.ts b/apps/edr-passenger-api/src/modules/bookings/payment-breakdown.util.ts new file mode 100644 index 000000000..0980fcc97 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/bookings/payment-breakdown.util.ts @@ -0,0 +1,101 @@ +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 = { + 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), + }; +} diff --git a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts index de306da8f..c0e1eacbe 100644 --- a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts +++ b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts @@ -40,6 +40,8 @@ interface PassengerVoucherData { // payment.amountMinor straight from the API) and must NOT be divided by 100 — as // opposed to the normal case where fareMinor is genuine minor units (cents). fareIsMajorUnits?: boolean; + /** Itemises the total when a booking was paid more than once (e.g. after a reschedule). */ + paymentLines?: Array<{ label: string; amountMinor: number; currency: string; status: string; settled: boolean }>; } // ─── palette ─────────────────────────────────────────────────────────────── @@ -365,6 +367,43 @@ function drawFareSummary(doc: jsPDF, fareMinor: number, currency: string, y: num return y + cardH + 6; } +function drawPaymentBreakdown( + doc: jsPDF, + lines: Array<{ label: string; amountMinor: number; currency: string; status: string; settled: boolean }>, + y: number, + margin: number, + pageWidth: number, +): number { + if (lines.length < 2) return y; + + const rowH = 6; + const padX = 7; + const headerH = 7; + const cardH = headerH + lines.length * rowH + 3; + + doc.setDrawColor(...HAIRLINE); + doc.setLineWidth(0.2); + doc.roundedRect(margin, y, pageWidth - margin * 2, cardH, 3, 3, 'S'); + + doc.setFontSize(7.5); doc.setFont('helvetica', 'bold'); doc.setTextColor(...MUTED); + doc.text('PAYMENT BREAKDOWN', margin + padX, y + 5, { charSpace: 0.2 }); + + lines.forEach((line, i) => { + const rowY = y + headerH + i * rowH; + doc.setFontSize(8.5); doc.setFont('helvetica', 'normal'); doc.setTextColor(...BODY); + // No status suffix: the caller passes settled lines only, so every row here is paid. + doc.text(line.label, margin + padX, rowY + 4); + doc.setFont('helvetica', 'bold'); doc.setTextColor(...INK); + doc.text( + `${line.currency} ${(line.amountMinor / 100).toFixed(2)}`, + pageWidth - margin - padX, + rowY + 4, + { align: 'right' }, + ); + }); + + return y + cardH + 6; +} // ─── instructions ────────────────────────────────────────────────────────── @@ -425,6 +464,7 @@ async function drawPassengerVoucherPage(doc: jsPDF, data: PassengerVoucherData): y = drawPassengerDetails(doc, data, y, margin, pageW); y = drawFareSummary(doc, data.fareMinor, data.currency, y, margin, pageW, data.fareIsMajorUnits); + y = drawPaymentBreakdown(doc, data.paymentLines ?? [], y, margin, pageW); y = drawInstructions(doc, y, margin, pageW); drawFooter(doc, data.createdAt, y); } @@ -474,15 +514,32 @@ interface VoucherData { tickets?: Array<{ passengerName?: string; leg?: number; barcodePayload?: string }>; // The actual settled amount/currency for this booking's payment — preferred over the // ETB booking total once available, since it reflects what was really charged. - payment?: { amountMinor?: number; currency?: string }; + payment?: { + amountMinor?: number; + currency?: string; + /** Every line collected for this booking — see the API's payment-breakdown.util.ts. */ + breakdown?: { + lines: Array<{ label: string; amountMinor: number; currency: string; status: string; settled: boolean }>; + totalPaidMinor: number | null; + totalPaidCurrency: string | null; + }; + }; } export const generateVoucherPDF = async (booking: VoucherData): Promise => { // Currency always comes straight from the booking/payment data, never hardcoded — the // settled payment currency when a payment has settled, otherwise the booking's own // display currency (falling back to the internal ETB currency field). - const settledAmountMinor = booking.payment?.amountMinor; - const settledCurrency = booking.payment?.currency; + // `payment.amountMinor` is the ORIGINAL intent only — it never moves when a booking is + // rescheduled, so a voucher built from it reports the pre-reschedule amount forever (a 51.85 + // booking moved to a 1752.34 journey still printed 51.85). `payment.breakdown` counts every + // line collected, including the reschedule fee and fare difference. It arrives in true minor + // units, so it is divided down to the major-unit basis the rest of this function expects. + const breakdown = booking.payment?.breakdown; + const settledAmountMinor = + breakdown?.totalPaidMinor != null ? breakdown.totalPaidMinor / 100 : booking.payment?.amountMinor; + const settledCurrency = + (breakdown?.totalPaidMinor != null ? breakdown.totalPaidCurrency : booking.payment?.currency) ?? undefined; const useSettledAmount = settledAmountMinor != null && !!settledCurrency; // Prefer displayCurrency (passenger's home currency) over the internal ETB currency field. const voucherCurrency = useSettledAmount ? settledCurrency! : (booking.displayCurrency || booking.currency || 'ETB'); @@ -541,7 +598,10 @@ export const generateVoucherPDF = async (booking: VoucherData): Promise => // This passenger's own fare, scaled onto the same currency/amount basis as the rest of // the voucher — not the booking's overall total, and not an equal share of it. - const passengerFareMinor = Math.round(p.fareMinor * fareScaleFactor); + const scaledFare = p.fareMinor * fareScaleFactor; + const passengerFareMinor = useSettledAmount + ? Math.round(scaledFare * 100) / 100 + : Math.round(scaledFare); await generatePassengerVoucherPDF({ bookingRef: booking.bookingRef, @@ -560,6 +620,7 @@ export const generateVoucherPDF = async (booking: VoucherData): Promise => fareMinor: passengerFareMinor, currency: voucherCurrency, fareIsMajorUnits: useSettledAmount, + paymentLines: grouped.size === 1 ? breakdown?.lines.filter((l) => l.settled) : undefined, createdAt: booking.createdAt, }); }