diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts index 73887454c..42d4af0d9 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsArray, ValidateNested, IsOptional, IsInt, IsEnum, IsDate, MaxDate } from 'class-validator'; +import { IsString, IsArray, ValidateNested, IsOptional, IsInt, IsNumber, IsEnum, IsDate, MaxDate } from 'class-validator'; import { Type, Transform } from 'class-transformer'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Currency, IdDocumentType } from '@prisma/client'; @@ -21,8 +21,8 @@ export class PassengerInputDto { @ApiPropertyOptional({ example: 'P1234567', description: 'Passport number for non-Ethiopian passengers (no verification)' }) @IsOptional() @IsString() passportNumber?: string; @ApiPropertyOptional({ example: 'Djibouti', description: 'Passport issuing country for non-Ethiopians' }) @IsOptional() @IsString() passportCountry?: string; @ApiPropertyOptional({ example: 'Ethiopian', description: 'Ethiopian (Verifayda + Telebirr/CBE/eBirr), Djiboutian (Passport + Waafi), Other (Passport + Card)' }) @IsOptional() @IsString() nationality?: string; - @ApiPropertyOptional({ example: 35000, description: 'Actual fare for this passenger in minor units (ETB). When provided, overrides the fare engine calculation — use for berth-specific pricing (Upper/Middle/Lower).' }) @IsOptional() @IsInt() seatFareMinor?: number; - @ApiPropertyOptional({ example: 35000, description: 'Return leg fare for this passenger in minor units (ETB). Used for ROUND_TRIP berth-specific pricing.' }) @IsOptional() @IsInt() returnSeatFareMinor?: number; + @ApiPropertyOptional({ example: 35000, description: 'Actual fare for this passenger in minor units (ETB). When provided, overrides the fare engine calculation — use for berth-specific pricing (Upper/Middle/Lower).' }) @IsOptional() @IsNumber() seatFareMinor?: number; + @ApiPropertyOptional({ example: 35000, description: 'Return leg fare for this passenger in minor units (ETB). Used for ROUND_TRIP berth-specific pricing.' }) @IsOptional() @IsNumber() returnSeatFareMinor?: number; } export class RoundTripPassengerDto { @@ -146,7 +146,7 @@ export class CreateBookingDto { @IsOptional() @IsString() priceTierId?: string; @ApiPropertyOptional({ description: 'Total amount in display-currency minor units as computed and displayed on the review page. When displayCurrency is ETB this equals ETB minor units; for DJF/USD it is the converted display amount. The backend uses this directly as displayTotalMinor and back-converts to ETB for storage.' }) - @IsOptional() @IsInt() reviewedTotalMinor?: number; + @IsOptional() @IsNumber() reviewedTotalMinor?: number; @ApiPropertyOptional({ description: 'Promo code for discount (applies to combined fare for round-trip)' }) @IsOptional() @IsString() promoCode?: string; 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 dea2517c0..cffa01c4b 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -846,23 +846,22 @@ export class BookingsService { // Free children have no seatId and no seatFareMinor — exclude them from the check. const seatedPassengers = passengersData.filter(p => p.seatId); const allFaresProvided = seatedPassengers.length > 0 && seatedPassengers.every(p => p.seatFareMinor != null); - // reviewedTotalMinor is now sent in display-currency minor units from the review page. - // When displayCurrency != ETB, use it directly as displayTotalMinor and back-convert to ETB. + // seatFareMinor values from the client are in display-currency minor units (matching + // displayAmountMinor from search results). reviewedTotalMinor is also display-currency minor. + // In both cases: store as displayTotalMinor as-is, back-convert to ETB for totalMinor. let resolvedTotalMinor: number; let displayTotalMinor: number; if (dto.reviewedTotalMinor != null) { - if (displayCurrency !== Currency.ETB) { - displayTotalMinor = dto.reviewedTotalMinor; - resolvedTotalMinor = await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB); - } else { - resolvedTotalMinor = dto.reviewedTotalMinor; - displayTotalMinor = dto.reviewedTotalMinor; - } + displayTotalMinor = dto.reviewedTotalMinor; + resolvedTotalMinor = displayCurrency !== Currency.ETB + ? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB) + : dto.reviewedTotalMinor; } else if (allFaresProvided) { - resolvedTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0); - displayTotalMinor = displayCurrency !== Currency.ETB - ? await this.currencyService.convertAmount(resolvedTotalMinor, Currency.ETB, displayCurrency) - : resolvedTotalMinor; + // seatFareMinor is in display currency — sum is already the display total + displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0); + resolvedTotalMinor = displayCurrency !== Currency.ETB + ? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB) + : displayTotalMinor; } else { resolvedTotalMinor = fareCalculation.totalMinor; displayTotalMinor = displayCurrency !== Currency.ETB @@ -880,7 +879,7 @@ export class BookingsService { destinationStationId: dto.destinationStationId, status: 'PENDING_PAYMENT', bookingType: 'ONE_WAY', - totalMinor: resolvedTotalMinor / 100, + totalMinor: resolvedTotalMinor, adultCount, childCount, displayCurrency, @@ -1001,10 +1000,10 @@ export class BookingsService { const taxesMinor = 0; const displayCurrency = dto.displayCurrency || resolveCurrencyFromNationality(passengersData[0]?.nationality); - let displayTotalMinor = totalMinor; - if (displayCurrency !== Currency.ETB) { - displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency); - } + // displayTotalMinor will be overridden below when reviewedTotalMinor is provided. + let displayTotalMinor = displayCurrency !== Currency.ETB + ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) + : totalMinor; // Track per-seat fare. Use client-supplied seatFareMinor/returnSeatFareMinor when // present (berth-specific pricing). Fall back to fare engine values. @@ -1036,20 +1035,16 @@ export class BookingsService { const allRTFaresProvided = rtSeatedPassengers.length > 0 && rtSeatedPassengers.every(p => p.seatFareMinor != null && p.returnSeatFareMinor != null); if (dto.reviewedTotalMinor != null) { - if (displayCurrency !== Currency.ETB) { - displayTotalMinor = dto.reviewedTotalMinor; - totalMinor = await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB); - } else { - totalMinor = dto.reviewedTotalMinor; - displayTotalMinor = dto.reviewedTotalMinor; - } + displayTotalMinor = dto.reviewedTotalMinor; + totalMinor = displayCurrency !== Currency.ETB + ? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB) + : dto.reviewedTotalMinor; } else if (allRTFaresProvided && !dto.packageId) { - totalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0); - if (displayCurrency !== Currency.ETB) { - displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency); - } else { - displayTotalMinor = totalMinor; - } + // seatFareMinor/returnSeatFareMinor are in display currency — sum is already the display total + displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0); + totalMinor = displayCurrency !== Currency.ETB + ? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB) + : displayTotalMinor; } const booking = await this.prisma.booking.create({ diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts index f5a5559bb..f89b5228d 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsArray, ValidateNested, IsOptional, IsEnum, IsDateString, IsBoolean, IsInt } from 'class-validator'; +import { IsString, IsArray, ValidateNested, IsOptional, IsEnum, IsDateString, IsBoolean, IsInt, IsNumber } from 'class-validator'; import { Type } from 'class-transformer'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Currency, IdDocumentType } from '@prisma/client'; @@ -158,7 +158,7 @@ export class CreateGuestBookingDto { @IsOptional() @IsString() priceTierId?: string; @ApiPropertyOptional({ description: 'Total amount in minor units (ETB) as computed and displayed on the review page. When provided, overrides the fare engine total — use to pass the exact berth-specific amount the user saw.' }) - @IsOptional() @IsInt() reviewedTotalMinor?: number; + @IsOptional() @IsNumber() reviewedTotalMinor?: number; } export class SavedPassengerProfileDto { diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts index 321e2269b..259beb404 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts @@ -221,20 +221,28 @@ export class GuestBookingService { return { ...p, fareMinor }; }); - // Use reviewedTotalMinor from frontend as authoritative total when provided. - // Fall back to per-seat sum when all seated passengers supplied seatFareMinor. + // reviewedTotalMinor and seatFareMinor are both in display-currency minor units. + // Store as displayTotalMinor as-is; back-convert to ETB for totalMinor. + const displayCurrency = dto.displayCurrency || Currency.ETB; const seatedPassengers = passengersData.filter(p => p.seatId); const allFaresProvided = seatedPassengers.length > 0 && seatedPassengers.every(p => p.seatFareMinor != null); - const resolvedTotalMinor = dto.reviewedTotalMinor ?? - (allFaresProvided - ? passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0) - : Math.max(0, totalBaseFareMinor - discountMinor)); - const displayCurrency = dto.displayCurrency || Currency.ETB; - let displayTotalMinor = resolvedTotalMinor; - if (displayCurrency !== Currency.ETB) { - displayTotalMinor = await this.currencyService.convertAmount(resolvedTotalMinor, Currency.ETB, displayCurrency); + let displayTotalMinor: number; + let resolvedTotalMinor: number; + if (dto.reviewedTotalMinor != null) { + displayTotalMinor = dto.reviewedTotalMinor; + } else if (allFaresProvided) { + displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0); + } else { + // fare engine returns ETB — convert forward to display currency + const etbTotal = Math.max(0, totalBaseFareMinor - discountMinor); + displayTotalMinor = displayCurrency !== Currency.ETB + ? await this.currencyService.convertAmount(etbTotal, Currency.ETB, displayCurrency) + : etbTotal; } + resolvedTotalMinor = displayCurrency !== Currency.ETB + ? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB) + : displayTotalMinor; // Resolve or create the guest Passenger record const firstPassenger = passengersData[0]; @@ -501,16 +509,17 @@ export class GuestBookingService { const rtSeatedPassengers = passengersData.filter(p => p.seatId); const allRTFaresProvided = rtSeatedPassengers.length > 0 && rtSeatedPassengers.every(p => p.seatFareMinor != null && p.returnSeatFareMinor != null); - if (dto.reviewedTotalMinor) { - totalMinor = dto.reviewedTotalMinor; - displayTotalMinor = displayCurrency !== Currency.ETB - ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) - : totalMinor; + if (dto.reviewedTotalMinor != null) { + displayTotalMinor = dto.reviewedTotalMinor; + totalMinor = displayCurrency !== Currency.ETB + ? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB) + : displayTotalMinor; } else if (allRTFaresProvided && !isPackageRoundTrip) { - totalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0); - displayTotalMinor = displayCurrency !== Currency.ETB - ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) - : totalMinor; + // seatFareMinor/returnSeatFareMinor are display-currency — sum is already display total + displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0); + totalMinor = displayCurrency !== Currency.ETB + ? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB) + : displayTotalMinor; } // Create or resolve guest passenger (same as one-way) diff --git a/apps/edr-passenger-api/src/modules/currency/currency.service.ts b/apps/edr-passenger-api/src/modules/currency/currency.service.ts index 57276b116..95ecc2cd7 100644 --- a/apps/edr-passenger-api/src/modules/currency/currency.service.ts +++ b/apps/edr-passenger-api/src/modules/currency/currency.service.ts @@ -83,16 +83,31 @@ export class CurrencyService { toCurrency: Currency, ): Promise { if (fromCurrency === toCurrency) return 1; - const exchangeRate = await this.prisma.currencyExchangeRate.findFirst({ + + // Direct rate + const direct = await this.prisma.currencyExchangeRate.findFirst({ where: { fromCurrency, toCurrency }, orderBy: { effectiveDate: 'desc' }, }); - if (!exchangeRate) { - throw new BadRequestException( - `No exchange rate configured for ${fromCurrency}->${toCurrency}`, - ); + if (direct) return Number(direct.rate); + + // Inverse rate + const inverse = await this.prisma.currencyExchangeRate.findFirst({ + where: { fromCurrency: toCurrency, toCurrency: fromCurrency }, + orderBy: { effectiveDate: 'desc' }, + }); + if (inverse) return 1 / Number(inverse.rate); + + // Bridge via ETB (e.g. DJF→USD = (DJF→ETB) × (ETB→USD)) + if (fromCurrency !== Currency.ETB && toCurrency !== Currency.ETB) { + const toEtb = await this.getRateOrThrow(fromCurrency, Currency.ETB); + const etbToTarget = await this.getRateOrThrow(Currency.ETB, toCurrency); + return toEtb * etbToTarget; } - return Number(exchangeRate.rate); + + throw new BadRequestException( + `No exchange rate configured for ${fromCurrency}->${toCurrency}`, + ); } private roundTo(value: number, decimals: number): number { @@ -110,7 +125,7 @@ export class CurrencyService { } const rate = await this.getExchangeRate(fromCurrency, toCurrency); - return Math.round(amountMinor * rate); + return amountMinor * rate; } async getExchangeRate( diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts index f917a9590..50cd99ec4 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -230,7 +230,7 @@ export class PaymentsController { @ApiOperation({ summary: "Get booking amount in a specific currency", description: - "Returns the booking total converted from ETB to the requested currency using the latest exchange rate. " + + "Returns the booking total converted from the booking's stored currency to the requested currency using the latest exchange rate. " + "If currency is ETB the stored amount is returned as-is (no conversion). " + "Amounts are returned in major currency units (e.g. 162.50 DJF, not centimes).", }) diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts index bc5faee95..5271acef4 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts @@ -38,6 +38,9 @@ describe("PaymentsService", () => { paymentMethod: { findUnique: jest.fn(), }, + currencyExchangeRate: { + findFirst: jest.fn(), + }, walletAccount: { findUnique: jest.fn(), update: jest.fn(), @@ -338,6 +341,38 @@ describe("PaymentsService", () => { }); }); + describe("getBookingAmountByCurrency", () => { + it("should convert from the booking currency to the requested currency", async () => { + mockPrisma.booking.findUnique.mockResolvedValue({ + id: "booking-1", + totalMinor: 100000, + bookingType: "ONE_WAY", + packageId: null, + priceTierId: null, + currency: "USD", + displayCurrency: "USD", + displayTotalMinor: 125000, + }); + mockPrisma.currencyExchangeRate.findFirst.mockResolvedValue({ rate: 2.5 }); + + const result = await service.getBookingAmountByCurrency("booking-1", "DJF"); + + expect(result).toEqual({ + booking_id: "booking-1", + currency: "DJF", + amount: 3125, + }); + expect(mockPrisma.currencyExchangeRate.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + fromCurrency: "USD", + toCurrency: "DJF", + }), + }), + ); + }); + }); + describe("getIntentByBookingId", () => { it("should return the cached local intent when the payment service has none", async () => { const mockIntent = { 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 fb70b2d76..495edc17c 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -234,19 +234,36 @@ export class PaymentsService { ); // The selected method's settlement currency lives in the PaymentMethod table (WAAFI/DMONEY - // settle in DJF, CARD in USD, Ethiopian wallets in ETB). Convert the ETB booking total into - // that currency here so the payment microservice stays currency-agnostic and charges it as-is. + // settle in DJF, CARD in USD, Ethiopian wallets in ETB). When the booking's displayCurrency + // already matches the charge currency, use displayTotalMinor directly — the rate is already + // baked in at booking creation time. Only fall back to ETB→target conversion when they differ. const paymentMethod = await this.prisma.paymentMethod.findUnique({ where: { type: method }, }); const chargeCurrency = ( paymentMethod?.currency ?? booking.currency ).toUpperCase(); - const chargeAmount = await this.currencyService.convertMinorToChargeMajor( - booking.totalMinor, - booking.currency, - chargeCurrency, - ); + + const bookingDisplayCurrency = ((booking as any).displayCurrency ?? 'ETB').toUpperCase(); + const bookingDisplayTotalMinor = (booking as any).displayTotalMinor as number | null; + + let chargeAmount: number; + if ( + chargeCurrency === bookingDisplayCurrency && + chargeCurrency !== 'ETB' && + bookingDisplayTotalMinor != null + ) { + // Display currency matches charge currency — use the pre-converted amount directly. + chargeAmount = this.currencyService.displayMinorToChargeMajor(bookingDisplayTotalMinor, chargeCurrency); + } else if (chargeCurrency === 'ETB') { + chargeAmount = this.currencyService.displayMinorToChargeMajor(booking.totalMinor, 'ETB'); + } else { + // Booking is in ETB — convert to the provider's settlement currency. + chargeAmount = await this.currencyService.convertEtbMinorToChargeMajor( + booking.totalMinor, + chargeCurrency, + ); + } const snapshot = await this.paymentClient.initiate({ service: PaymentServiceEnum.PASSENGER, @@ -658,28 +675,54 @@ 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, bookingType: true, packageId: true, priceTierId: true }, + select: { + id: true, + totalMinor: true, + bookingType: true, + packageId: true, + priceTierId: true, + currency: true, + displayCurrency: true, + displayTotalMinor: true, + }, }); if (!booking) throw new NotFoundException('Booking not found'); const correctTotalMinor = await this.resolveBookingTotal(booking as any); const requestedCurrency = currency.toUpperCase(); - const amountInETB = correctTotalMinor / 100; - if (requestedCurrency === 'ETB') { - return { booking_id: bookingId, currency: 'ETB', amount: amountInETB }; + // Source of truth: displayTotalMinor in displayCurrency when available, + // otherwise totalMinor in ETB (bookings with no display currency override). + const sourceCurrency = (booking.displayCurrency ?? 'ETB').toUpperCase(); + const sourceMinor = booking.displayTotalMinor ?? correctTotalMinor; + + // Same currency — return directly, no conversion needed. + if (requestedCurrency === sourceCurrency) { + return { booking_id: bookingId, currency: requestedCurrency, amount: sourceMinor / 100 }; } const exchangeRate = await this.prisma.currencyExchangeRate.findFirst({ - where: { fromCurrency: 'ETB' as any, toCurrency: requestedCurrency as any }, + where: { fromCurrency: sourceCurrency as any, toCurrency: requestedCurrency as any }, orderBy: { effectiveDate: 'desc' }, }); - if (!exchangeRate) { - throw new NotFoundException(`Exchange rate not found for ETB → ${requestedCurrency}`); - } - const rate = Number(exchangeRate.rate); - const converted = parseFloat((amountInETB * rate).toFixed(2)); + let rate: number; + if (exchangeRate) { + rate = Number(exchangeRate.rate); + } else { + // Try inverse rate + const inverseRate = await this.prisma.currencyExchangeRate.findFirst({ + where: { fromCurrency: requestedCurrency as any, toCurrency: sourceCurrency as any }, + orderBy: { effectiveDate: 'desc' }, + }); + if (inverseRate) { + rate = 1 / Number(inverseRate.rate); + } else { + // Bridge via ETB (e.g. DJF→USD = (DJF→ETB) × (ETB→USD)) + rate = await this.currencyService.getRateOrThrow(sourceCurrency as any, requestedCurrency as any); + } + } + const converted = (sourceMinor / 100) * rate; return { booking_id: bookingId, currency: requestedCurrency, amount: converted }; } 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 e56680d11..4333e95e9 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 @@ -34,7 +34,6 @@ export default function PaymentPage() { const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, passengers, searchCriteria, packageName, reviewedTotalMinor, reviewedPassengerFares } = useBookingStore(); const { setPaymentIntent, updateStatus, setCurrency, setPaidAmount } = usePaymentStore(); const [selectedMethod, setSelectedMethod] = useState(null); - const [selectedMethodCurrency, setSelectedMethodCurrency] = useState(null); const [isProcessing, setIsProcessing] = useState(false); const [paymentError, setPaymentError] = useState(null); // CAC Bank OTP debit: on Pay, collect the payer's mobile in a modal, then the SMS'd OTP. @@ -47,40 +46,40 @@ export default function PaymentPage() { const [otpError, setOtpError] = useState(null); const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP'; - const isPackage = !!packageName; - // Use the same display currency as the review page (derived from nationality) + // Use the same display currency as the review page — stored on the schedule at search time. + const scheduleCurrency = isRoundTrip + ? outboundSchedule?.displayCurrency + : selectedSchedule?.displayCurrency; const nat = (searchCriteria?.nationality ?? '').toUpperCase(); - const displayCurrency = nat === 'DJIBOUTIAN' ? 'DJF' : nat === 'ETHIOPIAN' ? 'ETB' : 'USD'; + const displayCurrency = scheduleCurrency || (nat === 'DJIBOUTIAN' ? 'DJF' : nat === 'ETHIOPIAN' ? 'ETB' : 'USD'); const { data: paymentMethods = [], isLoading: loadingMethods, error } = useQuery({ - queryKey: ['paymentMethods', displayCurrency], + queryKey: ['paymentMethods'], queryFn: async () => { - const response = await apiClient.get(`/payments/methods?currency=${displayCurrency}`); + const response = await apiClient.get(`/payments/methods`); return Array.isArray(response) ? response : []; }, }); const selectedPaymentMethod = paymentMethods.find(m => m.type === selectedMethod) || null; - // A payment method only needs a currency conversion when its own currency differs from - // the default booking currency (e.g. Waafi settles in USD) — otherwise the reviewed ETB - // total already shown on the review page is exact and there's nothing to convert. - const isConversionNeeded = !!selectedMethodCurrency && selectedMethodCurrency !== displayCurrency; - const amountCurrency = isConversionNeeded ? selectedMethodCurrency! : displayCurrency; + // Derive charge currency directly from the selected method — no separate state that can lag. + const amountCurrency = (selectedPaymentMethod?.currency || 'ETB').toUpperCase(); - // Fetch the converted booking amount from the booking-amount-changer API whenever a - // currency-specific payment method is selected. - const { data: bookingAmountData, isLoading: loadingAmount } = useQuery<{ amount: number; currency: string; booking_id: string }>({ + const { data: bookingAmountData, isFetching: fetchingAmount } = useQuery<{ amount: number; currency: string; booking_id: string }>({ queryKey: ['bookingAmount', bookingId, amountCurrency], queryFn: async () => { - const url = `/payments/booking-amount?bookingId=${bookingId}¤cy=${amountCurrency}`; - const response: any = await apiClient.get(url); + const response: any = await apiClient.get(`/payments/booking-amount?bookingId=${bookingId}¤cy=${amountCurrency}`); return response; }, - enabled: !!bookingId && isConversionNeeded, + enabled: !!bookingId && !!selectedMethod, + staleTime: 30_000, }); + // Data is only usable when it belongs to the currently-selected method's currency. + const dataReady = !fetchingAmount && bookingAmountData != null && bookingAmountData.currency.toUpperCase() === amountCurrency.toUpperCase(); + // Per-leg subtotals for the journey header — sum each paying passenger's reviewed fare // split equally across both legs. This guarantees leg totals are consistent with the // per-passenger breakdown rows and the overall reviewed total. @@ -91,39 +90,33 @@ export default function PaymentPage() { ? (reviewedPassengerFares ?? []).reduce((sum, f) => sum + (f.isFree ? 0 : (f.inboundFareMinor ?? Math.round(f.fareMinor / 2))), 0) : 0; - // reviewedPassengerFares / reviewedTotalMinor are the single source of truth for display - // in the booking's default currency (ETB) — they were computed and shown to the user on - // the review page. But once a payment method with its own currency is selected (e.g. - // Waafi/USD), the converted amount from the booking-amount API takes over so the user - // sees the actual amount they'll be charged in that currency. + // reviewedTotalMinor is in display-currency minor units — matches what was shown on the review page. + // When a method with a different currency is selected, bookingAmountData gives the converted charge amount. + // When the method's currency matches displayCurrency (or no method selected), use reviewedTotal directly. const reviewedTotal = reviewedTotalMinor ?? (reviewedPassengerFares?.reduce((s, f) => s + f.fareMinor, 0) ?? null); - const totalAmountDisplay = isConversionNeeded - ? (bookingAmountData != null ? bookingAmountData.amount : null) - : (reviewedTotal != null ? reviewedTotal / 100 : (bookingAmountData != null ? bookingAmountData.amount : null)); - const totalAmount = isConversionNeeded - ? (bookingAmountData != null ? Math.round(bookingAmountData.amount * 100) : (reviewedTotal ?? 0)) - : (reviewedTotal ?? (bookingAmountData != null ? Math.round(bookingAmountData.amount * 100) : 0)); - const confirmedCurrency = isConversionNeeded ? (bookingAmountData?.currency || amountCurrency) : displayCurrency; - // Show loading spinner while the converted amount is still in flight for a - // currency-specific method; ETB methods always have the reviewed total instantly. - const awaitingAmount = !isPackage && isConversionNeeded && loadingAmount && totalAmountDisplay === null; + // When a method is selected: show spinner until dataReady, then show converted amount. + // When no method is selected: show the reviewed total in displayCurrency. + const totalAmountDisplay = selectedMethod + ? (dataReady ? bookingAmountData!.amount : null) + : (reviewedTotal != null ? reviewedTotal / 100 : null); + const totalAmount = selectedMethod && dataReady + ? Math.round(bookingAmountData!.amount * 100) + : (reviewedTotal ?? 0); + const confirmedCurrency = selectedMethod + ? (dataReady ? bookingAmountData!.currency : amountCurrency) + : displayCurrency; + const awaitingAmount = !!selectedMethod && !dataReady; useEffect(() => { - // Once a currency-specific payment method's converted amount has loaded, that's the - // real charge amount and currency — store it as the paid amount. Otherwise fall back - // to the reviewed ETB total shown on the review page. - if (isConversionNeeded && bookingAmountData != null) { - setCurrency(confirmedCurrency as 'ETB' | 'DJF' | 'USD'); - setPaidAmount(Math.round(bookingAmountData.amount * 100)); - } else if (reviewedTotal != null) { - setCurrency('ETB'); + if (selectedMethod && dataReady) { + setCurrency(bookingAmountData!.currency as 'ETB' | 'DJF' | 'USD'); + setPaidAmount(Math.round(bookingAmountData!.amount * 100)); + } else if (!selectedMethod && reviewedTotal != null) { + setCurrency(displayCurrency as 'ETB' | 'DJF' | 'USD'); setPaidAmount(reviewedTotal); - } else if (bookingAmountData != null) { - setCurrency(confirmedCurrency as 'ETB' | 'DJF' | 'USD'); - setPaidAmount(Math.round(bookingAmountData.amount * 100)); } - }, [isConversionNeeded, bookingAmountData, confirmedCurrency, reviewedTotal, setCurrency, setPaidAmount]); + }, [selectedMethod, dataReady, bookingAmountData, reviewedTotal, displayCurrency, setCurrency, setPaidAmount]); const paymentMutation = useMutation({ mutationFn: async (data: any) => { @@ -603,7 +596,7 @@ export default function PaymentPage() { return (