diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInfoCard.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInfoCard.tsx
index e28513821..6bda9e88d 100644
--- a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInfoCard.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInfoCard.tsx
@@ -1,5 +1,5 @@
import { useState } from 'react';
-import { Badge, Button, Card, Divider, Group, Stack, Text } from '@mantine/core';
+import { Badge, Button, Card, Divider, Group, Stack, Text, Tooltip } from '@mantine/core';
import { PackagePlus, Train as TrainIcon, Warehouse as WarehouseIcon } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
@@ -126,14 +126,24 @@ export function WarehouseInfoCard({ bookingId, bookingReference }: WarehouseInfo
>
)}
- }
- onClick={() => setModalOpen(true)}
- fullWidth
+
- Receive At Warehouse
-
+ {/* span wrapper so the tooltip still fires on the disabled button */}
+
+ }
+ onClick={() => setModalOpen(true)}
+ fullWidth
+ disabled={Boolean(latest)}
+ >
+ {latest ? 'Received At Warehouse' : 'Receive At Warehouse'}
+
+
+
onToggleSelect?.(item.id)}
/>
)}
- {item.bookingId ? (
-
+ {item.bookingReference || item.booking?.reference || item.bookingId ? (
+
- {item.bookingId.slice(0, 8)}...
+ {item.bookingReference ?? item.booking?.reference ?? `${item.bookingId?.slice(0, 8)}...`}
) : (
diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts
index 7a88b136e..348646dbb 100644
--- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts
+++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts
@@ -217,6 +217,10 @@ export interface WarehouseInventoryItem {
yard?: WarehouseYard | null;
zone?: WarehouseZone | null;
booking?: InventoryBookingRef | null;
+ /** Flat booking summary fields attached by the inventory list (attachBookingSummaries). */
+ bookingReference?: string | null;
+ bookingStatus?: string | null;
+ customerName?: string | null;
}
/** Slim booking shape returned alongside inventory for the loading queue. */
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/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..36d3fbd1d 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,37 @@ 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.convertMinorToChargeMajor(
+ booking.totalMinor,
+ 'ETB',
+ chargeCurrency,
+ );
+ }
const snapshot = await this.paymentClient.initiate({
service: PaymentServiceEnum.PASSENGER,
@@ -658,28 +676,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-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts
index 9f9fb7bdc..340d9fac9 100644
--- a/apps/edr-passenger-api/src/modules/search/search.service.ts
+++ b/apps/edr-passenger-api/src/modules/search/search.service.ts
@@ -704,7 +704,12 @@ export class SearchService {
scheduleId: schedule.id,
});
return {
- seatClassName: fare.seatClassName,
+ // Use the input seat class name (sc.name) so it always matches what
+ // buildCoachTypeDetails looks up via coachType.seatClasses. The fare
+ // engine may resolve a nationality-specific variant (nationalitySeatClass)
+ // whose name differs from sc.name, which would cause the class to be
+ // silently dropped from coachTypes and show N/A on the results page.
+ seatClassName: sc.name,
baseFareMinor: fare.totalMinor,
displayCurrency: fare.billingCurrency as Currency,
displayAmountMinor: fare.totalInBillingCurrency,
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 ad92660a4..b2bff14b6 100644
--- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts
+++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts
@@ -1,13 +1,16 @@
-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';
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 {
+ private readonly logger = new Logger(SeatsService.name);
+
constructor(
private prisma: PrismaService,
private segmentsService: SegmentsService,
@@ -623,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) {
@@ -893,30 +939,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) {
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: {
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 (