From f243fdd4e3204c35712efb2d28db54de50979972 Mon Sep 17 00:00:00 2001
From: Stephanos A
Date: Tue, 14 Jul 2026 20:51:40 +0300
Subject: [PATCH] Currency and converted amount for non ETB
---
.../src/modules/bookings/bookings.dto.ts | 2 +-
.../src/modules/bookings/bookings.service.ts | 47 ++++--
.../backoffice/src/app/bookings/page.tsx | 2 +-
.../src/app/booking/confirmation/page.tsx | 13 +-
.../portal/src/app/booking/detail/page.tsx | 31 +++-
.../portal/src/app/booking/lookup/page.tsx | 7 +-
.../portal/src/app/booking/payment/page.tsx | 6 +-
.../portal/src/app/booking/results/page.tsx | 8 +-
.../portal/src/app/booking/review/page.tsx | 155 ++++++++++--------
.../portal/src/app/booking/seats/page.tsx | 14 +-
.../portal/src/lib/generate-voucher.ts | 11 +-
11 files changed, 180 insertions(+), 116 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 d50f48592..4b355c1fe 100644
--- a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts
@@ -145,7 +145,7 @@ export class CreateBookingDto {
@ApiPropertyOptional({ description: 'Package price tier ID — required when packageId is provided' })
@IsOptional() @IsString() priceTierId?: string;
- @ApiPropertyOptional({ description: 'Total amount in minor units (ETB) as computed and displayed on the review page. When provided, this overrides the fare engine total — use to pass the exact berth-specific amount the user saw.' })
+ @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;
@ApiPropertyOptional({ description: 'Promo code for discount (applies to combined fare for round-trip)' })
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 7f09ffa49..4e4bb706f 100644
--- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
@@ -837,16 +837,30 @@ 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);
- const resolvedTotalMinor = dto.reviewedTotalMinor ??
- (allFaresProvided
- ? passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0)
- : fareCalculation.totalMinor);
- this.logger.log(`createOneWayBooking: resolvedTotalMinor=${resolvedTotalMinor} (reviewedTotalMinor=${dto.reviewedTotalMinor} allFaresProvided=${allFaresProvided} fareEngine=${fareCalculation.totalMinor})`);
-
- let displayTotalMinor = resolvedTotalMinor;
- if (displayCurrency !== Currency.ETB) {
- displayTotalMinor = await this.currencyService.convertAmount(resolvedTotalMinor, Currency.ETB, displayCurrency);
+ // 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.
+ 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;
+ }
+ } 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;
+ } else {
+ resolvedTotalMinor = fareCalculation.totalMinor;
+ displayTotalMinor = displayCurrency !== Currency.ETB
+ ? await this.currencyService.convertAmount(resolvedTotalMinor, Currency.ETB, displayCurrency)
+ : resolvedTotalMinor;
}
+ this.logger.log(`createOneWayBooking: resolvedTotalMinor=${resolvedTotalMinor} displayTotalMinor=${displayTotalMinor} displayCurrency=${displayCurrency} (reviewedTotalMinor=${dto.reviewedTotalMinor} allFaresProvided=${allFaresProvided} fareEngine=${fareCalculation.totalMinor})`);
const booking = await this.prisma.booking.create({
data: {
@@ -857,7 +871,7 @@ export class BookingsService {
destinationStationId: dto.destinationStationId,
status: 'PENDING_PAYMENT',
bookingType: 'ONE_WAY',
- totalMinor: resolvedTotalMinor,
+ totalMinor: resolvedTotalMinor / 100,
adultCount,
childCount,
displayCurrency,
@@ -1011,11 +1025,14 @@ export class BookingsService {
const rtSeatedPassengers = passengersData.filter(p => p.outboundSeatId);
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) {
+ if (displayCurrency !== Currency.ETB) {
+ displayTotalMinor = dto.reviewedTotalMinor;
+ totalMinor = await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB);
+ } else {
+ totalMinor = dto.reviewedTotalMinor;
+ displayTotalMinor = dto.reviewedTotalMinor;
+ }
} else if (allRTFaresProvided && !dto.packageId) {
totalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
if (displayCurrency !== Currency.ETB) {
diff --git a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx
index 4ab5cb9b7..1a80a2aef 100644
--- a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx
@@ -270,7 +270,7 @@ function BookingsPageContent() {
render: (booking: any) => (
{booking.paymentIntent?.status || 'PENDING'}
-
{formatCurrency(booking.totalMinor, booking.currency)}
+
{formatCurrency(booking.displayTotalMinor ?? booking.totalMinor, booking.displayCurrency ?? booking.currency ?? 'ETB')}
),
},
diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx
index f4e67656d..f0e221f48 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx
@@ -126,7 +126,10 @@ export default function ConfirmationPage() {
const settledAmountMinor = _booking?.payment?.amountMinor;
const settledCurrency = _booking?.payment?.currency;
const hasSettledAmount = settledAmountMinor != null && !!settledCurrency;
- const voucherCurrency = hasSettledAmount ? settledCurrency! : "ETB";
+ // Derive display currency from nationality (same logic as review/payment pages)
+ const nat = (searchCriteria?.nationality ?? '').toUpperCase();
+ const passengerDisplayCurrency = nat === 'DJIBOUTIAN' ? 'DJF' : nat === 'ETHIOPIAN' ? 'ETB' : 'USD';
+ const voucherCurrency = hasSettledAmount ? settledCurrency! : passengerDisplayCurrency;
const createdAt = _booking?.createdAt || new Date().toISOString();
const status = _booking?.status || "CONFIRMED";
@@ -530,15 +533,15 @@ export default function ConfirmationPage() {
// The server-confirmed settled amount is authoritative — prefer it over
// any client-side session state, which can go stale (e.g. after a refresh).
if (_booking?.payment?.amountMinor != null) {
- return `${_booking.payment.currency || "ETB"} ${_booking.payment.amountMinor}`;
+ return `${_booking.payment.currency || 'ETB'} ${(_booking.payment.amountMinor / 100).toFixed(2)}`;
}
if (reviewedTotalMinor != null)
- return `ETB ${(reviewedTotalMinor / 100).toFixed(2)}`;
+ return `${paidCurrency || 'ETB'} ${(reviewedTotalMinor / 100).toFixed(2)}`;
if (paidAmountMinor != null)
- return `${paidCurrency} ${(paidAmountMinor / 100).toFixed(2)}`;
+ return `${paidCurrency || 'ETB'} ${(paidAmountMinor / 100).toFixed(2)}`;
if (_booking?.totalMinor != null)
return `ETB ${(_booking.totalMinor / 100).toFixed(2)}`;
- return "ETB 0.00";
+ return 'ETB 0.00';
})()}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx
index addaf685d..025c212bf 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx
@@ -29,9 +29,11 @@ import {
} from "@/utils/manage-booking-return";
import QRCode from "qrcode.react";
-// Same convention as /booking/payment — payment methods are ETB-settled by default;
-// a method only needs a currency conversion when its own currency differs.
-const displayCurrency = "ETB" as const;
+// Derive display currency from the booking record's own displayCurrency field
+// (set at booking creation from the passenger's nationality). Falls back to ETB.
+function getBookingDisplayCurrency(booking: any): string {
+ return booking?.displayCurrency || 'ETB';
+}
const getIconForMethod = (methodType: string) => {
if (methodType.includes("CARD")) return CreditCard;
@@ -126,8 +128,10 @@ function BookingDetailContent() {
const selectedPaymentMethod =
(paymentMethods || []).find((m: any) => m.type === selectedMethod) || null;
+ const displayCurrency = getBookingDisplayCurrency(booking);
+
// Same conversion logic as /booking/payment: only hit the booking-amount-changer API
- // when the selected method actually settles in a different currency than ETB.
+ // when the selected method actually settles in a different currency than the booking's display currency.
const isConversionNeeded =
!!selectedMethodCurrency && selectedMethodCurrency !== displayCurrency;
const amountCurrency = isConversionNeeded
@@ -152,7 +156,7 @@ function BookingDetailContent() {
? bookingAmountData != null
? bookingAmountData.amount
: null
- : (booking?.totalMinor ?? 0) / 100;
+ : (booking?.displayTotalMinor ?? booking?.totalMinor ?? 0) / 100;
const confirmedCurrency = isConversionNeeded
? bookingAmountData?.currency || amountCurrency
: displayCurrency;
@@ -399,6 +403,15 @@ function BookingDetailContent() {
}));
})();
+ // Scale per-passenger ETB fareMinor to the booking's display currency using the
+ // ratio of displayTotalMinor / totalMinor. Falls back to 1 (ETB) when not available.
+ const fareScaleFactor = (() => {
+ const etbTotal = booking?.totalMinor;
+ const displayTotal = booking?.displayTotalMinor;
+ if (!etbTotal || !displayTotal || etbTotal === displayTotal) return 1;
+ return displayTotal / etbTotal;
+ })();
+
// Order summary card — mirrors /booking/payment's OrderSummary: fare breakdown per
// passenger, Total with a loading spinner while a currency conversion is in flight, and
// a note confirming what will actually be charged once a payment method is selected.
@@ -439,7 +452,7 @@ function BookingDetailContent() {
)}
- {formatFare(passenger.fareMinor ?? 0, displayCurrency)}
+ {formatFare(Math.round((passenger.fareMinor ?? 0) * fareScaleFactor), displayCurrency)}
{isRoundTripBooking && !isFreeChild && (
@@ -448,7 +461,7 @@ function BookingDetailContent() {
Outbound
{formatFare(
- passenger.outboundFareMinor ?? 0,
+ Math.round((passenger.outboundFareMinor ?? 0) * fareScaleFactor),
displayCurrency,
)}
@@ -457,7 +470,7 @@ function BookingDetailContent() {
Return
{formatFare(
- passenger.returnFareMinor ?? 0,
+ Math.round((passenger.returnFareMinor ?? 0) * fareScaleFactor),
displayCurrency,
)}
@@ -937,7 +950,7 @@ function BookingDetailContent() {
Total paid:{" "}
{booking?.payment?.amountMinor != null
- ? `${booking.payment.currency || "ETB"} ${booking.payment.amountMinor}`
+ ? `${booking.payment.currency || 'ETB'} ${(booking.payment.amountMinor / 100).toFixed(2)}`
: `ETB ${((booking?.totalMinor ?? 0) / 100).toFixed(2)}`}
{booking?.payment?.method && (
diff --git a/apps/edr-passenger-web/portal/src/app/booking/lookup/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/lookup/page.tsx
index 3f371280f..123cd5710 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/lookup/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/lookup/page.tsx
@@ -15,6 +15,8 @@ interface BookingListItem {
status: string;
totalMinor: number;
currency: string;
+ displayCurrency?: string | null;
+ displayTotalMinor?: number | null;
adultCount: number;
childCount: number;
bookingType: string;
@@ -200,7 +202,8 @@ export default function BookingLookupPage() {
{phoneResults.map((b) => {
const statusInfo = STATUS_LABELS[b.status] ?? { label: b.status, className: "bg-gray-100 text-gray-700" };
- const amountEtb = (b.totalMinor / 100).toLocaleString("en-ET", { minimumFractionDigits: 2 });
+ const displayCurrency = b.displayCurrency || 'ETB';
+ const displayAmount = ((b.displayTotalMinor ?? b.totalMinor) / 100).toLocaleString("en-ET", { minimumFractionDigits: 2 });
return (