From f243fdd4e3204c35712efb2d28db54de50979972 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Tue, 14 Jul 2026 20:51:40 +0300 Subject: [PATCH 1/2] 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 ( - {/* TODO: re-enable once auth is integrated - */}
diff --git a/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx b/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx index 81a455401..0acb5d9ea 100644 --- a/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx +++ b/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx @@ -192,9 +192,7 @@ export default function AppSidebar() { )}
) : ( - // TODO: Sign in / Register temporarily disabled — re-enable later. - null - /*
+
Register -
*/ +
)} diff --git a/apps/edr-passenger-web/portal/src/components/BottomTabBar.tsx b/apps/edr-passenger-web/portal/src/components/BottomTabBar.tsx index 9a1d71f15..ee4cdccad 100644 --- a/apps/edr-passenger-web/portal/src/components/BottomTabBar.tsx +++ b/apps/edr-passenger-web/portal/src/components/BottomTabBar.tsx @@ -1,10 +1,9 @@ 'use client'; -// NOTE: User icon + useAuthStore are unused while the Sign in / Account tab is -// temporarily disabled below. Re-add them when that tab is restored. -import { Home, Phone, Ticket } from 'lucide-react'; +import { Home, Phone, Ticket, User } from 'lucide-react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; +import { useAuthStore } from '@/lib/auth-store'; // The linear, one-screen-at-a-time booking flow — each of these pages already // has its own sticky mobile CTA bar (and the mobile step strip at the top), @@ -22,6 +21,7 @@ const LINEAR_FLOW_PREFIXES = [ export default function BottomTabBar() { const pathname = usePathname() ?? ''; + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); const isInLinearFlow = LINEAR_FLOW_PREFIXES.some((p) => pathname.startsWith(p)); if (isInLinearFlow) return null; @@ -30,13 +30,12 @@ export default function BottomTabBar() { { href: '/', label: 'Home', icon: Home, match: (p: string) => p === '/' }, { href: '/booking/lookup', label: 'Bookings', icon: Ticket, match: (p: string) => p.startsWith('/booking/lookup') || p.startsWith('/booking/detail') }, { href: '/contact', label: 'Contact', icon: Phone, match: (p: string) => p.startsWith('/contact') }, - // TODO: Sign in / Account tab temporarily disabled — re-enable later. - // { - // href: isAuthenticated ? '/profile' : '/login', - // label: isAuthenticated ? 'Account' : 'Sign in', - // icon: User, - // match: (p: string) => p.startsWith('/profile') || p.startsWith('/login') || p.startsWith('/register'), - // }, + { + href: isAuthenticated ? '/profile' : '/login', + label: isAuthenticated ? 'Account' : 'Sign in', + icon: User, + match: (p: string) => p.startsWith('/profile') || p.startsWith('/login') || p.startsWith('/register'), + }, ]; return (