From 64b362ff51227d7fed8a9d1c9ae5136b49b5ef3e Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Wed, 15 Jul 2026 08:13:23 +0300 Subject: [PATCH 1/7] Fix expired seat --- .../src/modules/seats/seats.service.ts | 87 +++++++++++++++---- .../segments/enhanced-seats.service.ts | 72 ++++++++++++--- 2 files changed, 132 insertions(+), 27 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index 3d485d22c..ab0791352 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -1,4 +1,4 @@ -import { Injectable, ConflictException, NotFoundException, BadRequestException } from '@nestjs/common'; +import { Injectable, ConflictException, NotFoundException, BadRequestException, Logger } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; import { HoldSeatsDto, JourneyDirection } from './seats.dto'; import { Cron, CronExpression } from '@nestjs/schedule'; @@ -7,6 +7,8 @@ import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config @Injectable() export class SeatsService { + private readonly logger = new Logger(SeatsService.name); + constructor( private prisma: PrismaService, private segmentsService: SegmentsService, @@ -891,30 +893,83 @@ export class SeatsService { ); } + // Runs every minute, but is also safe to call on-demand (e.g. right after a hold's + // TTL is read back to the client) — expiresAt/now are both absolute UTC instants + // (Date objects, not wall-clock strings), so this is correct regardless of the + // server's or a client's local timezone; there's no wall-clock parsing involved. @Cron(CronExpression.EVERY_MINUTE) async expireHolds() { + try { + const result = await this.expireHoldsCore(); + if (result.expiredHolds > 0) { + this.logger.log( + `Expired ${result.expiredHolds} hold(s): released ${result.releasedSeatIds.length} seat(s), ` + + `skipped ${result.skippedSeatIds.length} still held by another active hold on the same schedule`, + ); + } + } catch (error) { + // A failed run must not crash the process or silently go unnoticed — the next + // scheduled run one minute later will retry the same (still-expired) holds, + // since nothing here is deleted/updated until the queries above succeed. + this.logger.error('Failed to expire seat holds', error instanceof Error ? error.stack : error); + } + } + + async expireHoldsCore(now: Date = new Date()): Promise<{ + expiredHolds: number; + releasedSeatIds: string[]; + skippedSeatIds: string[]; + }> { const expired = await this.prisma.seatHold.findMany({ - where: { expiresAt: { lt: new Date() } }, - select: { id: true, seatIds: true }, + where: { expiresAt: { lt: now } }, + select: { id: true, scheduleId: true, seatIds: true }, }); - if (expired.length === 0) return; + if (expired.length === 0) { + return { expiredHolds: 0, releasedSeatIds: [], skippedSeatIds: [] }; + } - const expiredSeatIds = expired.flatMap(h => h.seatIds as string[]); - - // Only reset seats that have no remaining active holds - const stillHeld = await this.prisma.seatHold.findMany({ - where: { expiresAt: { gte: new Date() }, seatIds: { hasSome: expiredSeatIds } }, - select: { seatIds: true }, + // Still-active holds — scoped per (scheduleId, seatId), not just seatId. The same + // physical Seat row is reused across every recurring date a coach runs, so the + // same seatId legitimately appears in unrelated holds for other schedules; without + // this scoping, an unrelated active hold on a DIFFERENT schedule would wrongly + // block release of a seat whose hold expired on THIS schedule, leaving it stuck at + // status 'HELD' indefinitely. + const activeHolds = await this.prisma.seatHold.findMany({ + where: { expiresAt: { gte: now } }, + select: { scheduleId: true, seatIds: true }, }); - const stillHeldIds = new Set(stillHeld.flatMap(h => h.seatIds as string[])); - const toRelease = expiredSeatIds.filter(id => !stillHeldIds.has(id)); + const stillHeldKeys = new Set( + activeHolds.flatMap(h => (h.seatIds as string[]).map(seatId => `${h.scheduleId}:${seatId}`)), + ); - if (toRelease.length > 0) { + const releasedSeatIds = new Set(); + const skippedSeatIds = new Set(); + for (const hold of expired) { + for (const seatId of hold.seatIds as string[]) { + if (stillHeldKeys.has(`${hold.scheduleId}:${seatId}`)) { + skippedSeatIds.add(seatId); + } else { + releasedSeatIds.add(seatId); + } + } + } + + if (releasedSeatIds.size > 0) { await this.prisma.seat.updateMany({ - where: { id: { in: toRelease }, status: 'HELD' }, - data: { status: 'AVAILABLE' }, + where: { id: { in: Array.from(releasedSeatIds) }, status: 'HELD' }, + // heldUntil is cleared alongside status — leaving a stale (past) heldUntil on an + // AVAILABLE seat is stale data that any future code reading heldUntil directly + // (instead of re-deriving availability live) would misinterpret. + data: { status: 'AVAILABLE', heldUntil: null }, }); } - await this.prisma.seatHold.deleteMany({ where: { expiresAt: { lt: new Date() } } }); + + await this.prisma.seatHold.deleteMany({ where: { expiresAt: { lt: now } } }); + + return { + expiredHolds: expired.length, + releasedSeatIds: Array.from(releasedSeatIds), + skippedSeatIds: Array.from(skippedSeatIds), + }; } } diff --git a/apps/edr-passenger-api/src/modules/segments/enhanced-seats.service.ts b/apps/edr-passenger-api/src/modules/segments/enhanced-seats.service.ts index 406c9e61b..bd99f1ceb 100644 --- a/apps/edr-passenger-api/src/modules/segments/enhanced-seats.service.ts +++ b/apps/edr-passenger-api/src/modules/segments/enhanced-seats.service.ts @@ -1,4 +1,4 @@ -import { Injectable, BadRequestException, ConflictException } from '@nestjs/common'; +import { Injectable, BadRequestException, ConflictException, Logger } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; import { SegmentsService, Segment } from '../segments/segments.service'; import { EventEmitter2 } from '@nestjs/event-emitter'; @@ -18,6 +18,8 @@ export interface BookingConfirmRequest { @Injectable() export class EnhancedSeatsService { + private readonly logger = new Logger(EnhancedSeatsService.name); + constructor( private prisma: PrismaService, private segmentsService: SegmentsService, @@ -57,6 +59,15 @@ export class EnhancedSeatsService { }, }); + // Mirrors SeatsService.holdSeats() — without this, a seat held through this path + // reads back as status 'AVAILABLE' in the DB despite being actively held, which is + // wrong for any consumer that trusts `status` directly instead of re-deriving + // availability live from SeatHold. + await tx.seat.updateMany({ + where: { id: { in: request.seatIds } }, + data: { status: 'HELD', heldUntil: expiresAt }, + }); + this.eventEmitter.emit('seats.held', { holdId: seatHold.id, scheduleId: request.scheduleId, seatIds: request.seatIds, segments }); return { holdId: seatHold.id, expiresAt, segments, seats: request.seatIds }; }); @@ -157,19 +168,58 @@ export class EnhancedSeatsService { }); } - async expireHolds() { - return this.prisma.$transaction(async (tx) => { - const expiredHolds = await tx.seatHold.findMany({ where: { expiresAt: { lt: new Date() } } }); - const expiredSeatIds = expiredHolds.flatMap(h => h.seatIds); + // now/expiresAt are absolute UTC instants (Date objects), not wall-clock strings, so + // this comparison is correct regardless of the server's local timezone. + async expireHolds(now: Date = new Date()) { + try { + const result = await this.prisma.$transaction(async (tx) => { + const expiredHolds = await tx.seatHold.findMany({ where: { expiresAt: { lt: now } } }); + if (expiredHolds.length === 0) { + return { expiredHolds: 0, releasedSeats: [] as string[] }; + } - if (expiredSeatIds.length > 0) { - await tx.seat.updateMany({ where: { id: { in: expiredSeatIds } }, data: { status: 'AVAILABLE', heldUntil: null } }); - await tx.seatHold.deleteMany({ where: { expiresAt: { lt: new Date() } } }); - this.eventEmitter.emit('holds.expired', { expiredHolds: expiredHolds.length, releasedSeats: expiredSeatIds }); + // Still-active holds — scoped per (scheduleId, seatId). The same physical Seat + // row is reused across every recurring date a coach runs, so the same seatId can + // legitimately appear in an unrelated hold for a different schedule; without this + // scoping, that unrelated hold would wrongly be treated as covering THIS + // schedule's seat too, and a seat still genuinely held (same schedule, a newer + // non-expired hold) could be released out from under it. + const activeHolds = await tx.seatHold.findMany({ where: { expiresAt: { gte: now } } }); + const stillHeldKeys = new Set( + activeHolds.flatMap(h => h.seatIds.map(seatId => `${h.scheduleId}:${seatId}`)), + ); + + const releasedSeatIds = new Set(); + for (const hold of expiredHolds) { + for (const seatId of hold.seatIds) { + if (!stillHeldKeys.has(`${hold.scheduleId}:${seatId}`)) releasedSeatIds.add(seatId); + } + } + + if (releasedSeatIds.size > 0) { + await tx.seat.updateMany({ + where: { id: { in: Array.from(releasedSeatIds) } }, + data: { status: 'AVAILABLE', heldUntil: null }, + }); + } + await tx.seatHold.deleteMany({ where: { expiresAt: { lt: now } } }); + + return { expiredHolds: expiredHolds.length, releasedSeats: Array.from(releasedSeatIds) }; + }); + + if (result.expiredHolds > 0) { + this.logger.log(`Expired ${result.expiredHolds} hold(s), released ${result.releasedSeats.length} seat(s)`); + this.eventEmitter.emit('holds.expired', { expiredHolds: result.expiredHolds, releasedSeats: result.releasedSeats }); } - return { expiredHolds: expiredHolds.length, releasedSeats: expiredSeatIds }; - }); + return result; + } catch (error) { + // A failed run must not go unnoticed — nothing is deleted/updated until the + // transaction commits, so the next caller/scheduled run simply retries the same + // still-expired holds. + this.logger.error('Failed to expire seat holds', error instanceof Error ? error.stack : error); + return { expiredHolds: 0, releasedSeats: [] as string[] }; + } } async getSeatAvailability(scheduleId: string, originStationId: string, destinationStationId: string) { From 6a9227b0f6d4c0ec3af6c8bbadbe19581847cc52 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Wed, 15 Jul 2026 09:15:49 +0300 Subject: [PATCH 2/7] Extend seat hold time if booking created --- .../common/utils/payment-deadline.utils.ts | 22 +++++++++ .../src/modules/seats/seats.service.ts | 46 ++++++++++++++++++- .../src/modules/tasks/tasks.service.ts | 30 ++++++------ 3 files changed, 82 insertions(+), 16 deletions(-) create mode 100644 apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts diff --git a/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts b/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts new file mode 100644 index 000000000..1d4c286be --- /dev/null +++ b/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts @@ -0,0 +1,22 @@ +/** + * Single source of truth for how long a PENDING_PAYMENT booking has to be paid for, + * shared by TasksService (which auto-cancels bookings past this deadline) and + * SeatsService (which extends the seat hold to cover exactly this window when a + * booking/PNR is created — without this, the seat hold reverted to its original + * short seat-selection TTL and could expire mid-payment, letting a second customer + * grab the same seat). + */ + +/** Maximum time (hours) a passenger has to pay after booking. */ +export const MAX_PAYMENT_HOURS = 2; +/** Minutes before departure: cutoff for new bookings and payment deadline. */ +export const CUTOFF_MINUTES = 30; + +/** + * payment_deadline = MIN(booking_time + 2h, departure_time - 30min) + */ +export function computePaymentDeadline(createdAt: Date, departureAt: Date): Date { + const maxDeadline = new Date(createdAt.getTime() + MAX_PAYMENT_HOURS * 60 * 60 * 1000); + const cutoffDeadline = new Date(departureAt.getTime() - CUTOFF_MINUTES * 60 * 1000); + return maxDeadline < cutoffDeadline ? maxDeadline : cutoffDeadline; +} diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index 1d17e9826..b2bff14b6 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -5,6 +5,7 @@ import { Cron, CronExpression } from '@nestjs/schedule'; import { SegmentsService } from '../segments/segments.service'; import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service'; import { AuditService } from '../../common/audit.service'; +import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils'; @Injectable() export class SeatsService { @@ -625,7 +626,50 @@ export class SeatsService { return { released: true, holdId }; } - async confirmSeats(_seatIds: string[]) {} + // Called right after a booking (PNR) is created, and again on successful payment. + // Extends the SeatHold(s) covering these seats to the booking's actual payment + // deadline — the same MIN(createdAt + 2h, departureAt - 30min) window TasksService + // uses to auto-cancel unpaid bookings — instead of leaving them on the original + // short seat-selection hold (5 min by default). Without this, the hold could expire + // while the customer was still on the payment page, and a second customer could + // hold/book the exact same seat out from under them. + async confirmSeats(seatIds: string[], now: Date = new Date()): Promise { + if (seatIds.length === 0) return; + + const holds = await this.prisma.seatHold.findMany({ + where: { seatIds: { hasSome: seatIds } }, + select: { id: true, scheduleId: true, expiresAt: true }, + }); + if (holds.length === 0) return; + + const scheduleIds = Array.from(new Set(holds.map(h => h.scheduleId))); + const schedules = await this.prisma.trainSchedule.findMany({ + where: { id: { in: scheduleIds } }, + select: { id: true, departureAt: true }, + }); + const departureById = new Map(schedules.map(s => [s.id, s.departureAt])); + + let extended = 0; + await Promise.all( + holds.map(async (hold) => { + const departureAt = departureById.get(hold.scheduleId); + if (!departureAt) return; + const deadline = computePaymentDeadline(now, departureAt); + // Only ever extend forward — never shorten a hold that's already valid longer + // than the payment deadline would give it (e.g. a second confirmSeats call on + // the same booking, or a hold that was already extended). + if (deadline <= hold.expiresAt) return; + await this.prisma.seatHold.update({ where: { id: hold.id }, data: { expiresAt: deadline } }); + extended++; + }), + ); + + if (extended > 0) { + this.logger.log( + `Extended ${extended} seat hold(s) covering ${seatIds.length} seat(s) to their booking's payment deadline`, + ); + } + } // Delete the Journey (and its JourneySegments) scoped to this booking. async releaseSeats(bookingId: string) { diff --git a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts index fb9957b92..4fb3a4f0f 100644 --- a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts +++ b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts @@ -3,11 +3,7 @@ import { Cron } from '@nestjs/schedule'; import { PrismaService } from '../../common/prisma.service'; import { SmsClientService } from '../notifications/sms-client.service'; import { CurrencyService } from '../currency/currency.service'; - -/** Maximum time (hours) a passenger has to pay after booking. */ -const MAX_PAYMENT_HOURS = 2; -/** Minutes before departure: cutoff for new bookings and payment deadline. */ -const CUTOFF_MINUTES = 30; +import { MAX_PAYMENT_HOURS, CUTOFF_MINUTES, computePaymentDeadline } from '../../common/utils/payment-deadline.utils'; // Retention windows const OTP_RETENTION_HOURS = 1; @@ -16,15 +12,6 @@ const AUDIT_LOG_RETENTION_DAYS = 365; const WEBHOOK_EVENT_RETENTION_DAYS = 90; const GATE_LOG_RETENTION_DAYS = 180; -/** - * payment_deadline = MIN(booking_time + 2h, departure_time - 30min) - */ -function computePaymentDeadline(createdAt: Date, departureAt: Date): Date { - const maxDeadline = new Date(createdAt.getTime() + MAX_PAYMENT_HOURS * 60 * 60 * 1000); - const cutoffDeadline = new Date(departureAt.getTime() - CUTOFF_MINUTES * 60 * 1000); - return maxDeadline < cutoffDeadline ? maxDeadline : cutoffDeadline; -} - function fmtTime(d: Date): string { return d.toLocaleTimeString('en-GB', { hour: '2-digit', @@ -192,6 +179,7 @@ export class TasksService { }, }, paymentIntent: { select: { method: true } }, + seats: { select: { seatId: true } }, }, }); @@ -205,9 +193,21 @@ export class TasksService { const paymentDeadline = computePaymentDeadline(createdAt, dep); if (now < paymentDeadline) continue; - // 1. Release held seats (Journey rows are the occupancy source of truth) + // 1a. Release held seats (Journey rows are the occupancy source of truth once paid) await this.prisma.journey.deleteMany({ where: { bookingId: booking.id } as any }); + // 1b. Also release the SeatHold(s) covering this booking's seats — SeatsService + // extends these to the payment deadline when the booking is created, so without + // this they'd otherwise keep the seat locked for up to MAX_PAYMENT_HOURS even + // though the booking is now cancelled. Scoped to this booking's own schedule, + // since the same physical Seat row is reused across other recurring dates. + const seatIds = booking.seats.map(s => s.seatId); + if (seatIds.length > 0) { + await this.prisma.seatHold.deleteMany({ + where: { scheduleId: booking.scheduleId, seatIds: { hasSome: seatIds } }, + }); + } + // 2. Audit record (no refund — payment was never completed) await this.prisma.bookingCancellation.create({ data: { From e2529b9317a6cf93083795738f7349ff5cc04fcb Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Wed, 15 Jul 2026 10:27:17 +0300 Subject: [PATCH 3/7] Payment amount for non ETB and exchange fixes --- .../src/modules/bookings/bookings.dto.ts | 8 +- .../src/modules/bookings/bookings.service.ts | 57 ++++++------- .../src/modules/bookings/guest-booking.dto.ts | 4 +- .../modules/bookings/guest-booking.service.ts | 47 ++++++----- .../src/modules/currency/currency.service.ts | 29 +++++-- .../modules/payments/payments.controller.ts | 2 +- .../modules/payments/payments.service.spec.ts | 35 ++++++++ .../src/modules/payments/payments.service.ts | 76 +++++++++++++---- .../portal/src/app/booking/payment/page.tsx | 83 +++++++++---------- 9 files changed, 216 insertions(+), 125 deletions(-) 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 806ab423e..198571572 100644 --- a/apps/edr-passenger-api/src/modules/currency/currency.service.ts +++ b/apps/edr-passenger-api/src/modules/currency/currency.service.ts @@ -78,16 +78,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 { @@ -105,7 +120,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 5ef160135..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,18 +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.convertEtbMinorToChargeMajor( - booking.totalMinor, - 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, @@ -657,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 ( + {/* span wrapper so the tooltip still fires on the disabled button */} + + + +