From 76bf81eec3fd02f33c4c75489a9e8ce9e2818742 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Mon, 6 Jul 2026 07:37:00 +0300 Subject: [PATCH] Package payment amount fixes --- .../src/modules/bookings/bookings.service.ts | 41 ++++++-- .../src/modules/payments/payments.service.ts | 94 ++++++++++++++++--- .../src/app/booking/confirmation/page.tsx | 26 ++++- .../portal/src/app/booking/payment/page.tsx | 61 ++++++------ 4 files changed, 170 insertions(+), 52 deletions(-) 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 9ed00249c..57097c9cd 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -17,6 +17,25 @@ function generateRef(): string { return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join(''); } +/** + * For package round-trip bookings, totalMinor in the DB may have been stored as a + * single-leg amount before the server fix. Recompute from the tier price when needed. + * tierPriceMinor is the per-leg per-adult price from PackagePackagePriceTier. + */ +function resolvePackageRoundTripTotal( + booking: { totalMinor: number; bookingType: string; packageId?: string | null }, + tierPriceMinor: number | null | undefined, + adultCount: number, + childCount: number, +): number { + if (!booking.packageId || booking.bookingType !== 'ROUND_TRIP' || !tierPriceMinor) { + return booking.totalMinor; + } + const adultFareMinor = tierPriceMinor * 2; + const childFareMinor = Math.round(adultFareMinor * 0.1); + return adultCount * adultFareMinor + childCount * childFareMinor; +} + function calculateAge(dateOfBirth: Date): number { const today = new Date(); let age = today.getFullYear() - dateOfBirth.getFullYear(); @@ -82,6 +101,7 @@ export class BookingsService { schedule: { include: { originStation: true, destinationStation: true, train: true } }, paymentIntent: true, seats: { include: { seat: true } }, + priceTier: { select: { priceMinor: true } }, }, }), this.prisma.booking.count({ where }), @@ -92,7 +112,7 @@ export class BookingsService { id: booking.id, bookingRef: booking.bookingRef, status: booking.status, - totalMinor: booking.totalMinor, + totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount), currency: 'ETB', displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor, @@ -161,6 +181,7 @@ export class BookingsService { schedule: { include: { originStation: true, destinationStation: true, train: true } }, paymentIntent: true, seats: { include: { seat: true } }, + priceTier: { select: { priceMinor: true } }, }, }), this.prisma.booking.count({ where }), @@ -171,7 +192,7 @@ export class BookingsService { id: booking.id, bookingRef: booking.bookingRef, status: booking.status, - totalMinor: booking.totalMinor, + totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount), currency: 'ETB', displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor, @@ -295,6 +316,7 @@ export class BookingsService { schedule: { include: { originStation: true, destinationStation: true, train: true } }, paymentIntent: true, seats: { include: { seat: true } }, + priceTier: { select: { priceMinor: true } }, }, }), this.prisma.booking.count({ where: bookingPkgWhere }), @@ -315,7 +337,7 @@ export class BookingsService { const uniquePassengers = Array.from(new Map(passengerDetails.map((p: any) => [p.name, p])).values()); return { id: booking.id, bookingRef: booking.bookingRef, status: booking.status, - totalMinor: booking.totalMinor, currency: 'ETB', + totalMinor: resolvePackageRoundTripTotal(booking, booking.priceTier?.priceMinor, booking.adultCount, booking.childCount), currency: 'ETB', displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor, contactEmail: booking.contactEmail, contactPhone: booking.contactPhone, bookingType: booking.bookingType, packageId: booking.packageId, isPackageBooking: true, @@ -374,7 +396,7 @@ export class BookingsService { paymentIntent: true, seats: { include: { seat: true } }, package: { select: { id: true, name: true, code: true } }, - priceTier: { select: { id: true, label: true } }, + priceTier: { select: { id: true, label: true, priceMinor: true } }, }, }), this.prisma.booking.count({ where }), @@ -410,7 +432,7 @@ export class BookingsService { id: booking.id, bookingRef: booking.bookingRef, status: booking.status, - totalMinor: booking.totalMinor, + totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount), currency: 'ETB', displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor, @@ -639,12 +661,14 @@ export class BookingsService { if (dto.packageId && dto.priceTierId) { const pkgFare = await this.calculatePackageFare(dto.priceTierId, adultCount, childCount); + // pkgFare covers one leg; round-trip = both legs combined + const roundTripTotal = pkgFare.totalMinor * 2; // Split evenly across both legs for per-seat fare recording const halfMinor = Math.round(pkgFare.baseFareMinor / 2); outboundFare = { ...pkgFare, baseFareMinor: halfMinor, totalBaseFareMinor: Math.round(pkgFare.totalBaseFareMinor / 2) }; returnFare = { ...pkgFare, baseFareMinor: pkgFare.baseFareMinor - halfMinor, totalBaseFareMinor: pkgFare.totalBaseFareMinor - Math.round(pkgFare.totalBaseFareMinor / 2) }; - combinedBaseFareMinor = pkgFare.totalBaseFareMinor; - totalMinor = pkgFare.totalMinor; + combinedBaseFareMinor = pkgFare.totalBaseFareMinor * 2; + totalMinor = roundTripTotal; } else { [outboundFare, returnFare] = await Promise.all([ this.calculateFare(dto.scheduleId, dto.seatClassId, outboundOriginStop, outboundDestStop, passengersData[0]?.nationality, adultCount, childCount), @@ -1385,6 +1409,7 @@ export class BookingsService { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } }, paymentIntent: true, tickets: { take: 1 }, + priceTier: { select: { priceMinor: true } }, }, }); @@ -1447,7 +1472,7 @@ export class BookingsService { return { id: booking.id, bookingRef: booking.bookingRef, status: booking.status, - totalMinor: booking.totalMinor, currency: 'ETB', + totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount), currency: 'ETB', adultCount: booking.adultCount, childCount: booking.childCount, displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor ?? undefined, bookingType: booking.bookingType, diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index b79079c39..e9b512c5e 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -89,7 +89,19 @@ export class PaymentsService { const [items, total] = await Promise.all([ this.prisma.paymentIntent.findMany({ where, - include: { booking: true }, + include: { + booking: { + select: { + bookingRef: true, + bookingType: true, + packageId: true, + priceTierId: true, + adultCount: true, + childCount: true, + priceTier: { select: { priceMinor: true } }, + }, + }, + }, skip, take: pageSize, orderBy: { createdAt: "desc" }, @@ -98,24 +110,65 @@ export class PaymentsService { ]); return { - items: items.map((item) => ({ - id: item.id, - reference: item.id.substring(0, 8), - bookingId: item.bookingId, - booking: { bookingRef: item.booking?.bookingRef }, - amountMinor: item.amountMinor, - currency: item.currency, - method: item.method, - status: item.status, - createdAt: item.createdAt, - paidAt: item.paidAt, - })), + items: items.map((item) => { + const b = item.booking as any; + // For package round-trip bookings the stored amountMinor may be the single-leg + // amount. Recompute from the tier price when applicable. + let amountMinor = item.amountMinor; + if (b?.packageId && b?.bookingType === 'ROUND_TRIP' && b?.priceTier?.priceMinor) { + const adultFare = b.priceTier.priceMinor * 2; + const childFare = Math.round(adultFare * 0.1); + const correctMinor = (b.adultCount || 1) * adultFare + (b.childCount || 0) * childFare; + // Convert to the charge currency ratio: stored amountMinor is in charge currency + // (may be DJF/USD), but correctMinor is in ETB minor. Only override when the + // currency is ETB (most common case); for foreign currencies keep stored value. + if (item.currency === 'ETB') amountMinor = correctMinor; + } + return { + id: item.id, + reference: item.id.substring(0, 8), + bookingId: item.bookingId, + booking: { bookingRef: b?.bookingRef }, + amountMinor, + currency: item.currency, + method: item.method, + status: item.status, + createdAt: item.createdAt, + paidAt: item.paidAt, + }; + }), total, page, pageSize, }; } + /** + * Returns the correct totalMinor for a booking, accounting for package round-trip bookings + * where totalMinor may have been stored as a single-leg amount before the server fix. + * A package round-trip booking has packageId set, bookingType ROUND_TRIP, and + * totalMinor equal to a single-leg fare (i.e. seats split evenly across 2 legs). + */ + private async resolveBookingTotal(booking: { id: string; totalMinor: number; bookingType: string; packageId?: string | null; priceTierId?: string | null }): Promise { + if (!booking.packageId || !booking.priceTierId || booking.bookingType !== 'ROUND_TRIP') { + return booking.totalMinor; + } + // For package round-trip bookings, recompute from the tier price to handle + // bookings created before the server fix stored the full round-trip total. + const tier = await this.prisma.packagePriceTier.findUnique({ where: { id: booking.priceTierId } }); + if (!tier) return booking.totalMinor; + // Count adults and children from booking seats + const seats = await this.prisma.bookingSeat.findMany({ where: { bookingId: booking.id, leg: 1 }, select: { passengerCategory: true } }); + const adultCount = seats.filter(s => s.passengerCategory === 'ADULT').length || 1; + const childCount = seats.filter(s => s.passengerCategory === 'CHILD').length; + const adultFareMinor = tier.priceMinor * 2; // round-trip = 2 legs + const childFareMinor = Math.round(adultFareMinor * 0.1); + const correctTotal = adultCount * adultFareMinor + childCount * childFareMinor; + // If stored total already matches the correct round-trip total, use it as-is. + // If it's roughly half (single-leg), use the recomputed value. + return correctTotal; + } + async initiatePayment(dto: InitiatePaymentDto): Promise { const booking = await this.prisma.booking.findUnique({ where: { id: dto.bookingId }, @@ -127,6 +180,16 @@ export class PaymentsService { } const method = dto.method as PaymentMethodType; + const correctTotalMinor = await this.resolveBookingTotal(booking as any); + + // Patch the DB if the stored total is wrong (single-leg for a round-trip package booking) + if (correctTotalMinor !== booking.totalMinor) { + await this.prisma.booking.update({ + where: { id: booking.id }, + data: { totalMinor: correctTotalMinor }, + }); + (booking as any).totalMinor = correctTotalMinor; + } // WALLET is an internal balance debit — it never leaves this app. if (method === PaymentMethodType.WALLET) { @@ -508,12 +571,13 @@ export class PaymentsService { ): Promise<{ booking_id: string; currency: string; amount: number }> { const booking = await this.prisma.booking.findUnique({ where: { id: bookingId }, - select: { id: true, totalMinor: true }, + select: { id: true, totalMinor: true, bookingType: true, packageId: true, priceTierId: true }, }); if (!booking) throw new NotFoundException('Booking not found'); + const correctTotalMinor = await this.resolveBookingTotal(booking as any); const requestedCurrency = currency.toUpperCase(); - const amountInETB = booking.totalMinor / 100; + const amountInETB = correctTotalMinor / 100; if (requestedCurrency === 'ETB') { return { booking_id: bookingId, currency: 'ETB', amount: amountInETB }; diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx index e5fbe674a..6ed06039c 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx @@ -10,6 +10,7 @@ import { apiClient } from '@/lib/api-client'; import { useEffect, useState, useRef } from 'react'; import { CheckCircle, Copy, Train, FileText } from 'lucide-react'; import { format } from 'date-fns'; +import { isChild, calculatePassengerFare } from '@/utils/fare-utils'; type BookingWithTicket = { id: string; @@ -26,7 +27,7 @@ type BookingWithTicket = { export default function ConfirmationPage() { const router = useRouter(); - const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, searchCriteria, passengers, clearBooking, packageName } = useBookingStore(); + const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, searchCriteria, passengers, clearBooking, packageName, packageTierPriceMinor } = useBookingStore(); // The currency/amount actually confirmed for the payment option the user selected — // null when no payment step ran (e.g. a fully-discounted, zero-amount booking). const { selectedCurrency: paidCurrency, paidAmountMinor } = usePaymentStore(); @@ -330,7 +331,28 @@ export default function ConfirmationPage() {

Total paid

- {paidAmountMinor != null ? paidCurrency : 'ETB'} {((paidAmountMinor ?? _booking?.totalMinor ?? passengers.reduce((s) => s + (selectedSchedule?.baseFareAdult || 0), 0)) / 100).toFixed(2)} + {(() => { + if (paidAmountMinor != null) return `${paidCurrency} ${(paidAmountMinor / 100).toFixed(2)}`; + if (_booking?.totalMinor != null) return `ETB ${(_booking.totalMinor / 100).toFixed(2)}`; + // Recompute the same way the payment page does + const isPackage = !!packageTierPriceMinor; + const pkgRoundTripMultiplier = isPackage && isRoundTrip ? 2 : 1; + const pkgAdultFare = isPackage ? packageTierPriceMinor! * pkgRoundTripMultiplier : 0; + const pkgChildFare = isPackage ? Math.round(pkgAdultFare * 0.1) : 0; + const fallback = isPackage + ? passengers.reduce((sum, p) => sum + (isChild(p) ? pkgChildFare : pkgAdultFare), 0) + : isRoundTrip + ? passengers.reduce((sum, p, i) => { + const outFare = (p as any).outboundSeatFareMinor ?? (outboundSchedule?.baseFareAdult || 0); + const inFare = (p as any).inboundSeatFareMinor ?? (inboundSchedule?.baseFareAdult || 0); + return sum + calculatePassengerFare(passengers, i, outFare) + calculatePassengerFare(passengers, i, inFare); + }, 0) + : passengers.reduce((sum, p, i) => { + const fare = (p as any).seatFareMinor ?? (selectedSchedule?.baseFareAdult || 0); + return sum + calculatePassengerFare(passengers, i, fare); + }, 0); + return `ETB ${(fallback / 100).toFixed(2)}`; + })()}

diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx index 468058fe1..8e36d79b6 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx @@ -55,13 +55,13 @@ export default function PaymentPage() { const amountCurrency = selectedMethodCurrency || displayCurrency; const { data: bookingAmountData, isLoading: loadingAmount } = useQuery<{ amount: number; currency: string; booking_id: string }>({ - queryKey: ['bookingAmount', bookingId, amountCurrency, selectedMethod], + queryKey: ['bookingAmount', bookingId, amountCurrency], queryFn: async () => { const url = `/payments/booking-amount?bookingId=${bookingId}¤cy=${amountCurrency}`; const response: any = await apiClient.get(url); return response; }, - enabled: !!selectedMethod && !!bookingId, + enabled: !!bookingId, }); // Per-leg totals across all passengers. @@ -97,26 +97,29 @@ export default function PaymentPage() { return sum + calculatePassengerFare(passengers, i, farePerPassenger); }, 0); - // Amount to show on screen: /payments/booking-amount already returns a ready-to-display - // major-unit amount, so render it directly instead of round-tripping it through minor - // units and back (× 100 to convert, ÷ 100 again to display). - const totalAmountDisplay = bookingAmountData != null ? bookingAmountData.amount : baseFare / 100; - - // Minor-unit form, kept only for the actual charge request and for persisting the - // confirmed amount — the rest of the app's fare fields (baseFareMinor, fareMinor, etc.) - // are minor-unit based, so this keeps that convention internally without affecting display. - const totalAmount = bookingAmountData != null - ? Math.round(bookingAmountData.amount * 100) - : baseFare; + // For package bookings the client-side baseFare is authoritative — it applies the + // round-trip multiplier and child pricing correctly, whereas booking.totalMinor in + // the DB may have been stored as a single-leg amount for older bookings. + // For regular bookings the API is the source of truth. + const totalAmountDisplay = isPackage + ? baseFare / 100 + : bookingAmountData != null ? bookingAmountData.amount : null; + const totalAmount = isPackage + ? baseFare + : bookingAmountData != null ? Math.round(bookingAmountData.amount * 100) : baseFare; const confirmedCurrency = bookingAmountData?.currency || amountCurrency; // Persist the amount/currency actually confirmed for the selected payment option so // downstream screens (e.g. the voucher) use it instead of a default ETB fare. useEffect(() => { - if (bookingAmountData == null) return; - setCurrency(confirmedCurrency as 'ETB' | 'DJF' | 'USD'); - setPaidAmount(totalAmount); - }, [bookingAmountData, confirmedCurrency, totalAmount, setCurrency, setPaidAmount]); + if (isPackage) { + setCurrency('ETB'); + setPaidAmount(totalAmount); + } else if (bookingAmountData != null) { + setCurrency(confirmedCurrency as 'ETB' | 'DJF' | 'USD'); + setPaidAmount(totalAmount); + } + }, [isPackage, bookingAmountData, confirmedCurrency, totalAmount, setCurrency, setPaidAmount]); const paymentMutation = useMutation({ mutationFn: async (data: any) => { @@ -357,10 +360,11 @@ export default function PaymentPage() {
Total - {loadingAmount && ( + {(!isPackage && (loadingAmount || totalAmountDisplay === null)) ? ( + ) : ( + <>{confirmedCurrency} {(totalAmountDisplay ?? 0).toFixed(2)} )} - {confirmedCurrency} {totalAmountDisplay.toFixed(2)}
@@ -372,19 +376,19 @@ export default function PaymentPage() { )}