mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 21:08:12 +00:00
Currency and converted amount for non ETB
This commit is contained in:
@@ -145,7 +145,7 @@ export class CreateBookingDto {
|
|||||||
@ApiPropertyOptional({ description: 'Package price tier ID — required when packageId is provided' })
|
@ApiPropertyOptional({ description: 'Package price tier ID — required when packageId is provided' })
|
||||||
@IsOptional() @IsString() priceTierId?: string;
|
@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;
|
@IsOptional() @IsInt() reviewedTotalMinor?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Promo code for discount (applies to combined fare for round-trip)' })
|
@ApiPropertyOptional({ description: 'Promo code for discount (applies to combined fare for round-trip)' })
|
||||||
|
|||||||
@@ -837,16 +837,30 @@ export class BookingsService {
|
|||||||
// Free children have no seatId and no seatFareMinor — exclude them from the check.
|
// Free children have no seatId and no seatFareMinor — exclude them from the check.
|
||||||
const seatedPassengers = passengersData.filter(p => p.seatId);
|
const seatedPassengers = passengersData.filter(p => p.seatId);
|
||||||
const allFaresProvided = seatedPassengers.length > 0 && seatedPassengers.every(p => p.seatFareMinor != null);
|
const allFaresProvided = seatedPassengers.length > 0 && seatedPassengers.every(p => p.seatFareMinor != null);
|
||||||
const resolvedTotalMinor = dto.reviewedTotalMinor ??
|
// reviewedTotalMinor is now sent in display-currency minor units from the review page.
|
||||||
(allFaresProvided
|
// When displayCurrency != ETB, use it directly as displayTotalMinor and back-convert to ETB.
|
||||||
? passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0)
|
let resolvedTotalMinor: number;
|
||||||
: fareCalculation.totalMinor);
|
let displayTotalMinor: number;
|
||||||
this.logger.log(`createOneWayBooking: resolvedTotalMinor=${resolvedTotalMinor} (reviewedTotalMinor=${dto.reviewedTotalMinor} allFaresProvided=${allFaresProvided} fareEngine=${fareCalculation.totalMinor})`);
|
if (dto.reviewedTotalMinor != null) {
|
||||||
|
if (displayCurrency !== Currency.ETB) {
|
||||||
let displayTotalMinor = resolvedTotalMinor;
|
displayTotalMinor = dto.reviewedTotalMinor;
|
||||||
if (displayCurrency !== Currency.ETB) {
|
resolvedTotalMinor = await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB);
|
||||||
displayTotalMinor = await this.currencyService.convertAmount(resolvedTotalMinor, Currency.ETB, displayCurrency);
|
} 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({
|
const booking = await this.prisma.booking.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -857,7 +871,7 @@ export class BookingsService {
|
|||||||
destinationStationId: dto.destinationStationId,
|
destinationStationId: dto.destinationStationId,
|
||||||
status: 'PENDING_PAYMENT',
|
status: 'PENDING_PAYMENT',
|
||||||
bookingType: 'ONE_WAY',
|
bookingType: 'ONE_WAY',
|
||||||
totalMinor: resolvedTotalMinor,
|
totalMinor: resolvedTotalMinor / 100,
|
||||||
adultCount,
|
adultCount,
|
||||||
childCount,
|
childCount,
|
||||||
displayCurrency,
|
displayCurrency,
|
||||||
@@ -1011,11 +1025,14 @@ export class BookingsService {
|
|||||||
const rtSeatedPassengers = passengersData.filter(p => p.outboundSeatId);
|
const rtSeatedPassengers = passengersData.filter(p => p.outboundSeatId);
|
||||||
const allRTFaresProvided = rtSeatedPassengers.length > 0 &&
|
const allRTFaresProvided = rtSeatedPassengers.length > 0 &&
|
||||||
rtSeatedPassengers.every(p => p.seatFareMinor != null && p.returnSeatFareMinor != null);
|
rtSeatedPassengers.every(p => p.seatFareMinor != null && p.returnSeatFareMinor != null);
|
||||||
if (dto.reviewedTotalMinor) {
|
if (dto.reviewedTotalMinor != null) {
|
||||||
totalMinor = dto.reviewedTotalMinor;
|
if (displayCurrency !== Currency.ETB) {
|
||||||
displayTotalMinor = displayCurrency !== Currency.ETB
|
displayTotalMinor = dto.reviewedTotalMinor;
|
||||||
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
totalMinor = await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB);
|
||||||
: totalMinor;
|
} else {
|
||||||
|
totalMinor = dto.reviewedTotalMinor;
|
||||||
|
displayTotalMinor = dto.reviewedTotalMinor;
|
||||||
|
}
|
||||||
} else if (allRTFaresProvided && !dto.packageId) {
|
} else if (allRTFaresProvided && !dto.packageId) {
|
||||||
totalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
|
totalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
|
||||||
if (displayCurrency !== Currency.ETB) {
|
if (displayCurrency !== Currency.ETB) {
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ function BookingsPageContent() {
|
|||||||
render: (booking: any) => (
|
render: (booking: any) => (
|
||||||
<div>
|
<div>
|
||||||
<Badge variant="status" status={booking.paymentIntent?.status || 'PENDING'}>{booking.paymentIntent?.status || 'PENDING'}</Badge>
|
<Badge variant="status" status={booking.paymentIntent?.status || 'PENDING'}>{booking.paymentIntent?.status || 'PENDING'}</Badge>
|
||||||
<div className="text-sm text-muted-foreground">{formatCurrency(booking.totalMinor, booking.currency)}</div>
|
<div className="text-sm text-muted-foreground">{formatCurrency(booking.displayTotalMinor ?? booking.totalMinor, booking.displayCurrency ?? booking.currency ?? 'ETB')}</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -126,7 +126,10 @@ export default function ConfirmationPage() {
|
|||||||
const settledAmountMinor = _booking?.payment?.amountMinor;
|
const settledAmountMinor = _booking?.payment?.amountMinor;
|
||||||
const settledCurrency = _booking?.payment?.currency;
|
const settledCurrency = _booking?.payment?.currency;
|
||||||
const hasSettledAmount = settledAmountMinor != null && !!settledCurrency;
|
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 createdAt = _booking?.createdAt || new Date().toISOString();
|
||||||
const status = _booking?.status || "CONFIRMED";
|
const status = _booking?.status || "CONFIRMED";
|
||||||
|
|
||||||
@@ -530,15 +533,15 @@ export default function ConfirmationPage() {
|
|||||||
// The server-confirmed settled amount is authoritative — prefer it over
|
// The server-confirmed settled amount is authoritative — prefer it over
|
||||||
// any client-side session state, which can go stale (e.g. after a refresh).
|
// any client-side session state, which can go stale (e.g. after a refresh).
|
||||||
if (_booking?.payment?.amountMinor != null) {
|
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)
|
if (reviewedTotalMinor != null)
|
||||||
return `ETB ${(reviewedTotalMinor / 100).toFixed(2)}`;
|
return `${paidCurrency || 'ETB'} ${(reviewedTotalMinor / 100).toFixed(2)}`;
|
||||||
if (paidAmountMinor != null)
|
if (paidAmountMinor != null)
|
||||||
return `${paidCurrency} ${(paidAmountMinor / 100).toFixed(2)}`;
|
return `${paidCurrency || 'ETB'} ${(paidAmountMinor / 100).toFixed(2)}`;
|
||||||
if (_booking?.totalMinor != null)
|
if (_booking?.totalMinor != null)
|
||||||
return `ETB ${(_booking.totalMinor / 100).toFixed(2)}`;
|
return `ETB ${(_booking.totalMinor / 100).toFixed(2)}`;
|
||||||
return "ETB 0.00";
|
return 'ETB 0.00';
|
||||||
})()}
|
})()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,9 +29,11 @@ import {
|
|||||||
} from "@/utils/manage-booking-return";
|
} from "@/utils/manage-booking-return";
|
||||||
import QRCode from "qrcode.react";
|
import QRCode from "qrcode.react";
|
||||||
|
|
||||||
// Same convention as /booking/payment — payment methods are ETB-settled by default;
|
// Derive display currency from the booking record's own displayCurrency field
|
||||||
// a method only needs a currency conversion when its own currency differs.
|
// (set at booking creation from the passenger's nationality). Falls back to ETB.
|
||||||
const displayCurrency = "ETB" as const;
|
function getBookingDisplayCurrency(booking: any): string {
|
||||||
|
return booking?.displayCurrency || 'ETB';
|
||||||
|
}
|
||||||
|
|
||||||
const getIconForMethod = (methodType: string) => {
|
const getIconForMethod = (methodType: string) => {
|
||||||
if (methodType.includes("CARD")) return CreditCard;
|
if (methodType.includes("CARD")) return CreditCard;
|
||||||
@@ -126,8 +128,10 @@ function BookingDetailContent() {
|
|||||||
const selectedPaymentMethod =
|
const selectedPaymentMethod =
|
||||||
(paymentMethods || []).find((m: any) => m.type === selectedMethod) || null;
|
(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
|
// 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 =
|
const isConversionNeeded =
|
||||||
!!selectedMethodCurrency && selectedMethodCurrency !== displayCurrency;
|
!!selectedMethodCurrency && selectedMethodCurrency !== displayCurrency;
|
||||||
const amountCurrency = isConversionNeeded
|
const amountCurrency = isConversionNeeded
|
||||||
@@ -152,7 +156,7 @@ function BookingDetailContent() {
|
|||||||
? bookingAmountData != null
|
? bookingAmountData != null
|
||||||
? bookingAmountData.amount
|
? bookingAmountData.amount
|
||||||
: null
|
: null
|
||||||
: (booking?.totalMinor ?? 0) / 100;
|
: (booking?.displayTotalMinor ?? booking?.totalMinor ?? 0) / 100;
|
||||||
const confirmedCurrency = isConversionNeeded
|
const confirmedCurrency = isConversionNeeded
|
||||||
? bookingAmountData?.currency || amountCurrency
|
? bookingAmountData?.currency || amountCurrency
|
||||||
: displayCurrency;
|
: 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
|
// 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
|
// 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.
|
// a note confirming what will actually be charged once a payment method is selected.
|
||||||
@@ -439,7 +452,7 @@ function BookingDetailContent() {
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
{formatFare(passenger.fareMinor ?? 0, displayCurrency)}
|
{formatFare(Math.round((passenger.fareMinor ?? 0) * fareScaleFactor), displayCurrency)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{isRoundTripBooking && !isFreeChild && (
|
{isRoundTripBooking && !isFreeChild && (
|
||||||
@@ -448,7 +461,7 @@ function BookingDetailContent() {
|
|||||||
<span>Outbound</span>
|
<span>Outbound</span>
|
||||||
<span>
|
<span>
|
||||||
{formatFare(
|
{formatFare(
|
||||||
passenger.outboundFareMinor ?? 0,
|
Math.round((passenger.outboundFareMinor ?? 0) * fareScaleFactor),
|
||||||
displayCurrency,
|
displayCurrency,
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
@@ -457,7 +470,7 @@ function BookingDetailContent() {
|
|||||||
<span>Return</span>
|
<span>Return</span>
|
||||||
<span>
|
<span>
|
||||||
{formatFare(
|
{formatFare(
|
||||||
passenger.returnFareMinor ?? 0,
|
Math.round((passenger.returnFareMinor ?? 0) * fareScaleFactor),
|
||||||
displayCurrency,
|
displayCurrency,
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
@@ -937,7 +950,7 @@ function BookingDetailContent() {
|
|||||||
Total paid:{" "}
|
Total paid:{" "}
|
||||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
{booking?.payment?.amountMinor != null
|
{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)}`}
|
: `ETB ${((booking?.totalMinor ?? 0) / 100).toFixed(2)}`}
|
||||||
</span>
|
</span>
|
||||||
{booking?.payment?.method && (
|
{booking?.payment?.method && (
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ interface BookingListItem {
|
|||||||
status: string;
|
status: string;
|
||||||
totalMinor: number;
|
totalMinor: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
|
displayCurrency?: string | null;
|
||||||
|
displayTotalMinor?: number | null;
|
||||||
adultCount: number;
|
adultCount: number;
|
||||||
childCount: number;
|
childCount: number;
|
||||||
bookingType: string;
|
bookingType: string;
|
||||||
@@ -200,7 +202,8 @@ export default function BookingLookupPage() {
|
|||||||
</p>
|
</p>
|
||||||
{phoneResults.map((b) => {
|
{phoneResults.map((b) => {
|
||||||
const statusInfo = STATUS_LABELS[b.status] ?? { label: b.status, className: "bg-gray-100 text-gray-700" };
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
key={b.id}
|
key={b.id}
|
||||||
@@ -229,7 +232,7 @@ export default function BookingLookupPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-end gap-1 flex-shrink-0">
|
<div className="flex flex-col items-end gap-1 flex-shrink-0">
|
||||||
<span className="text-sm font-semibold text-gray-900 dark:text-white whitespace-nowrap">
|
<span className="text-sm font-semibold text-gray-900 dark:text-white whitespace-nowrap">
|
||||||
{amountEtb} ETB
|
{displayAmount} {displayCurrency}
|
||||||
</span>
|
</span>
|
||||||
<ChevronRight className="w-4 h-4 text-gray-400 group-hover:text-[rgb(20,113,76)] transition-colors" />
|
<ChevronRight className="w-4 h-4 text-gray-400 group-hover:text-[rgb(20,113,76)] transition-colors" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -49,7 +49,9 @@ export default function PaymentPage() {
|
|||||||
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
|
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
|
||||||
const isPackage = !!packageName;
|
const isPackage = !!packageName;
|
||||||
|
|
||||||
const displayCurrency = 'ETB' as const;
|
// Use the same display currency as the review page (derived from nationality)
|
||||||
|
const nat = (searchCriteria?.nationality ?? '').toUpperCase();
|
||||||
|
const displayCurrency = nat === 'DJIBOUTIAN' ? 'DJF' : nat === 'ETHIOPIAN' ? 'ETB' : 'USD';
|
||||||
|
|
||||||
const { data: paymentMethods = [], isLoading: loadingMethods, error } = useQuery<PaymentMethod[]>({
|
const { data: paymentMethods = [], isLoading: loadingMethods, error } = useQuery<PaymentMethod[]>({
|
||||||
queryKey: ['paymentMethods', displayCurrency],
|
queryKey: ['paymentMethods', displayCurrency],
|
||||||
@@ -314,7 +316,7 @@ export default function PaymentPage() {
|
|||||||
{fare !== undefined && (
|
{fare !== undefined && (
|
||||||
<div className="mt-3 pt-2 border-t border-gray-100 dark:border-gray-800 flex justify-between text-sm">
|
<div className="mt-3 pt-2 border-t border-gray-100 dark:border-gray-800 flex justify-between text-sm">
|
||||||
<span className="text-gray-500 dark:text-gray-400">{label} fare</span>
|
<span className="text-gray-500 dark:text-gray-400">{label} fare</span>
|
||||||
<span className="font-semibold text-gray-900 dark:text-gray-100">{displayCurrency} {(fare / 100).toFixed(2)}</span>
|
<span className="font-semibold text-gray-900 dark:text-gray-100">{confirmedCurrency} {(fare / 100).toFixed(2)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import {
|
|||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { formatTime, getTimePeriod, toZonedDate } from "@/utils/format";
|
import { formatTime, getTimePeriod, toZonedDate } from "@/utils/format";
|
||||||
import { formatFare } from "@/utils/fare-utils";
|
import { formatFare } from "@/utils/fare-utils";
|
||||||
import { useCurrencySymbol } from "@/lib/useCurrencies";
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
export default function ResultsPage() {
|
export default function ResultsPage() {
|
||||||
@@ -97,7 +96,6 @@ export default function ResultsPage() {
|
|||||||
|
|
||||||
const nat = (searchData.nationality ?? '').toUpperCase();
|
const nat = (searchData.nationality ?? '').toUpperCase();
|
||||||
const displayCurrencyCode = nat === 'DJIBOUTIAN' ? 'DJF' : nat === 'ETHIOPIAN' ? 'ETB' : 'USD';
|
const displayCurrencyCode = nat === 'DJIBOUTIAN' ? 'DJF' : nat === 'ETHIOPIAN' ? 'ETB' : 'USD';
|
||||||
const displayCurrencySymbol = useCurrencySymbol(displayCurrencyCode);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (searchParams.get("origin")) {
|
if (searchParams.get("origin")) {
|
||||||
@@ -419,7 +417,7 @@ export default function ResultsPage() {
|
|||||||
...coachType.classes.map((c: any) => c.displayAmountMinor ?? c.baseFareMinor),
|
...coachType.classes.map((c: any) => c.displayAmountMinor ?? c.baseFareMinor),
|
||||||
)
|
)
|
||||||
: 0;
|
: 0;
|
||||||
const coachCurrency = displayCurrencySymbol;
|
const coachCurrency = displayCurrencyCode;
|
||||||
const CoachIcon = getCoachIcon(coachType.coachTypeName);
|
const CoachIcon = getCoachIcon(coachType.coachTypeName);
|
||||||
|
|
||||||
const selectThisCoach = () =>
|
const selectThisCoach = () =>
|
||||||
@@ -540,7 +538,7 @@ export default function ResultsPage() {
|
|||||||
<span className="text-base font-bold tabular-nums text-gray-900 dark:text-white">
|
<span className="text-base font-bold tabular-nums text-gray-900 dark:text-white">
|
||||||
{((cls.displayAmountMinor ?? cls.baseFareMinor) / 100).toFixed(2)}
|
{((cls.displayAmountMinor ?? cls.baseFareMinor) / 100).toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
|
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium ml-1">
|
||||||
{coachCurrency}
|
{coachCurrency}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -616,7 +614,7 @@ export default function ResultsPage() {
|
|||||||
// Calculate lowest fare and display currency from coach types / faresByClass.
|
// Calculate lowest fare and display currency from coach types / faresByClass.
|
||||||
// Prefer displayAmountMinor (passenger's own currency) over baseFareMinor (ETB internal).
|
// Prefer displayAmountMinor (passenger's own currency) over baseFareMinor (ETB internal).
|
||||||
let lowestFare = null;
|
let lowestFare = null;
|
||||||
const displayCurrency = displayCurrencySymbol;
|
const displayCurrency = displayCurrencyCode;
|
||||||
if (schedule.coachTypes?.length) {
|
if (schedule.coachTypes?.length) {
|
||||||
const allClasses = schedule.coachTypes.flatMap((ct) => ct.classes);
|
const allClasses = schedule.coachTypes.flatMap((ct) => ct.classes);
|
||||||
const allFares = allClasses
|
const allFares = allClasses
|
||||||
|
|||||||
@@ -7,10 +7,9 @@ import { useMutation } from '@tanstack/react-query';
|
|||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { formatTime, getTimePeriod, toZonedDate } from '@/utils/format';
|
import { formatTime, getTimePeriod, toZonedDate } from '@/utils/format';
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { ChevronLeft } from 'lucide-react';
|
import { ChevronLeft } from 'lucide-react';
|
||||||
import { isChild, isFirstChild, formatFare } from '@/utils/fare-utils';
|
import { isChild, isFirstChild, formatFare } from '@/utils/fare-utils';
|
||||||
import { useCurrencySymbol } from '@/lib/useCurrencies';
|
|
||||||
|
|
||||||
// Helper function to decode JWT token and extract passengerId
|
// Helper function to decode JWT token and extract passengerId
|
||||||
function getPassengerIdFromToken(token: string): string | null {
|
function getPassengerIdFromToken(token: string): string | null {
|
||||||
@@ -60,7 +59,6 @@ export default function ReviewPage() {
|
|||||||
// Derive display currency from nationality so fares show in the passenger's home currency.
|
// Derive display currency from nationality so fares show in the passenger's home currency.
|
||||||
const nat = (searchCriteria?.nationality ?? '').toUpperCase();
|
const nat = (searchCriteria?.nationality ?? '').toUpperCase();
|
||||||
const displayCurrencyCode = nat === 'DJIBOUTIAN' ? 'DJF' : nat === 'ETHIOPIAN' ? 'ETB' : 'USD';
|
const displayCurrencyCode = nat === 'DJIBOUTIAN' ? 'DJF' : nat === 'ETHIOPIAN' ? 'ETB' : 'USD';
|
||||||
const displayCurrencySymbol = useCurrencySymbol(displayCurrencyCode);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!seatHold?.expiresAt) return;
|
if (!seatHold?.expiresAt) return;
|
||||||
@@ -152,7 +150,8 @@ export default function ReviewPage() {
|
|||||||
}, [selectedSchedule?.id, outboundSchedule?.id, inboundSchedule?.id, passengers, isRoundTrip]);
|
}, [selectedSchedule?.id, outboundSchedule?.id, inboundSchedule?.id, passengers, isRoundTrip]);
|
||||||
|
|
||||||
const isPackageBooking = packageTierPriceMinor !== null || !!packageName;
|
const isPackageBooking = packageTierPriceMinor !== null || !!packageName;
|
||||||
const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length;
|
|
||||||
|
const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length;
|
||||||
const pkgAdultFare = isPackageBooking && packageTierPriceMinor != null ? packageTierPriceMinor * 2 : 0;
|
const pkgAdultFare = isPackageBooking && packageTierPriceMinor != null ? packageTierPriceMinor * 2 : 0;
|
||||||
const pkgChildFare = pkgAdultFare;
|
const pkgChildFare = pkgAdultFare;
|
||||||
|
|
||||||
@@ -166,7 +165,15 @@ export default function ReviewPage() {
|
|||||||
return childIndex < adultPassengerCount;
|
return childIndex < adultPassengerCount;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPassengerSeatFare = (p: any): number | null => {
|
// Returns the fare for a passenger in the display currency.
|
||||||
|
// Prefers displayFareMinor (converted) from the fare breakdown API when available.
|
||||||
|
// Falls back to raw ETB seat fares (which are always in minor units).
|
||||||
|
const getPassengerSeatFare = (p: any, index?: number): number | null => {
|
||||||
|
if (!isPackageBooking && fareBreakdown?.passengers && index != null) {
|
||||||
|
const line = fareBreakdown.passengers[index];
|
||||||
|
const displayFare = line?.displayFareMinor ?? line?.fareMinor;
|
||||||
|
if (displayFare != null) return displayFare;
|
||||||
|
}
|
||||||
if (isRoundTrip) {
|
if (isRoundTrip) {
|
||||||
if (p.outboundSeatFareMinor == null && p.inboundSeatFareMinor == null) return null;
|
if (p.outboundSeatFareMinor == null && p.inboundSeatFareMinor == null) return null;
|
||||||
if (isPackageBooking) return (p.outboundSeatFareMinor ?? 0) * 2;
|
if (isPackageBooking) return (p.outboundSeatFareMinor ?? 0) * 2;
|
||||||
@@ -445,13 +452,14 @@ export default function ReviewPage() {
|
|||||||
const isFreeChild = isPackageBooking
|
const isFreeChild = isPackageBooking
|
||||||
? (isChildPassenger && (i - adultPassengerCount) < adultPassengerCount)
|
? (isChildPassenger && (i - adultPassengerCount) < adultPassengerCount)
|
||||||
: (line?.isFree ?? (isChild(p) && isFirstChild(passengers, i)));
|
: (line?.isFree ?? (isChild(p) && isFirstChild(passengers, i)));
|
||||||
const seatFare = getPassengerSeatFare(p);
|
const seatFare = getPassengerSeatFare(p, i);
|
||||||
const pkgFallback = isChildPassenger ? pkgChildFare : pkgAdultFare;
|
const pkgFallback = isChildPassenger ? pkgChildFare : pkgAdultFare;
|
||||||
const fareMinor = isPackageBooking
|
const fareMinor = isPackageBooking
|
||||||
? (isFreeChild ? 0 : (seatFare ?? pkgFallback))
|
? (isFreeChild ? 0 : (seatFare ?? pkgFallback))
|
||||||
: (isFreeChild ? 0 : (seatFare ?? line?.fareMinor ?? 0));
|
: (isFreeChild ? 0 : (seatFare ?? line?.fareMinor ?? 0));
|
||||||
const outboundFareMinor = isRoundTrip ? ((p as any).outboundSeatFareMinor ?? undefined) : undefined;
|
const halfFare = seatFare != null ? Math.round(seatFare / 2) : undefined;
|
||||||
const inboundFareMinor = isRoundTrip ? ((p as any).inboundSeatFareMinor ?? undefined) : undefined;
|
const outboundFareMinor = isRoundTrip ? (halfFare ?? (p as any).outboundSeatFareMinor ?? undefined) : undefined;
|
||||||
|
const inboundFareMinor = isRoundTrip ? (halfFare ?? (p as any).inboundSeatFareMinor ?? undefined) : undefined;
|
||||||
return { fareMinor, isFree: isFreeChild, outboundFareMinor, inboundFareMinor };
|
return { fareMinor, isFree: isFreeChild, outboundFareMinor, inboundFareMinor };
|
||||||
});
|
});
|
||||||
setReviewedTotal(computedTotal, passengerFares);
|
setReviewedTotal(computedTotal, passengerFares);
|
||||||
@@ -482,55 +490,60 @@ export default function ReviewPage() {
|
|||||||
}
|
}
|
||||||
}, [isRoundTrip, selectedSchedule, outboundSchedule, inboundSchedule, passengers.length, createBookingMutation.isPending, createBookingMutation.isSuccess, router]);
|
}, [isRoundTrip, selectedSchedule, outboundSchedule, inboundSchedule, passengers.length, createBookingMutation.isPending, createBookingMutation.isSuccess, router]);
|
||||||
|
|
||||||
const fetchFareBreakdown = useCallback(async (scheduleId: string, originStationId: string, destinationStationId: string) => {
|
const fareBreakdownFetchedRef = useRef(false);
|
||||||
// Package bookings use the stored tier price — no fare calculation needed
|
const scheduleId = isRoundTrip ? outboundSchedule?.id : selectedSchedule?.id;
|
||||||
if (isPackageBooking) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const seatClasses: any[] = await apiClient.get('/seat-classes');
|
|
||||||
const scheduleSeatClassName = isRoundTrip
|
|
||||||
? (outboundSchedule as any)?.seatClassName
|
|
||||||
: (selectedSchedule as any)?.seatClassName;
|
|
||||||
const fallbackSeatClassId = seatClasses.find((sc: any) => sc.name === scheduleSeatClassName)?.id || seatClasses[0]?.id;
|
|
||||||
if (!fallbackSeatClassId) return;
|
|
||||||
|
|
||||||
const resolveSeatClassId = (p: any): string => {
|
|
||||||
const bedPosition: string | undefined = isRoundTrip ? (p as any).outboundBedPosition : (p as any).bedPosition;
|
|
||||||
if (!bedPosition) return fallbackSeatClassId;
|
|
||||||
const match = seatClasses.find((sc: any) => sc.name?.toLowerCase().includes(bedPosition));
|
|
||||||
return match?.id || fallbackSeatClassId;
|
|
||||||
};
|
|
||||||
|
|
||||||
const passengersParam = JSON.stringify(
|
|
||||||
passengers.map(p => ({
|
|
||||||
passengerName: p.name,
|
|
||||||
dateOfBirth: p.dateOfBirth,
|
|
||||||
seatClassId: resolveSeatClassId(p),
|
|
||||||
nationality: p.nationality,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
scheduleId,
|
|
||||||
originStationId,
|
|
||||||
destinationStationId,
|
|
||||||
passengers: passengersParam,
|
|
||||||
displayCurrency: displayCurrencyCode,
|
|
||||||
...(searchCriteria?.promoCode ? { promoCode: searchCriteria.promoCode } : {}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result: any = await apiClient.get(`/search/fare-breakdown?${params}`);
|
|
||||||
setFareBreakdown(result);
|
|
||||||
} catch (err) {
|
|
||||||
}
|
|
||||||
}, [isPackageBooking, passengers, selectedSchedule, outboundSchedule, isRoundTrip, searchCriteria, displayCurrencyCode]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (fareBreakdownFetchedRef.current) return;
|
||||||
if (!searchCriteria?.originStationId || !searchCriteria?.destinationStationId) return;
|
if (!searchCriteria?.originStationId || !searchCriteria?.destinationStationId) return;
|
||||||
const scheduleId = isRoundTrip ? outboundSchedule?.id : selectedSchedule?.id;
|
|
||||||
if (!scheduleId) return;
|
if (!scheduleId) return;
|
||||||
fetchFareBreakdown(scheduleId, searchCriteria.originStationId, searchCriteria.destinationStationId);
|
if (isPackageBooking) return;
|
||||||
}, [fetchFareBreakdown, isRoundTrip, outboundSchedule?.id, selectedSchedule?.id, searchCriteria?.originStationId, searchCriteria?.destinationStationId]);
|
|
||||||
|
fareBreakdownFetchedRef.current = true;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const seatClasses: any[] = await apiClient.get('/seat-classes');
|
||||||
|
const scheduleSeatClassName = isRoundTrip
|
||||||
|
? (outboundSchedule as any)?.seatClassName
|
||||||
|
: (selectedSchedule as any)?.seatClassName;
|
||||||
|
const fallbackSeatClassId = seatClasses.find((sc: any) => sc.name === scheduleSeatClassName)?.id || seatClasses[0]?.id;
|
||||||
|
if (!fallbackSeatClassId) return;
|
||||||
|
|
||||||
|
const resolveSeatClassId = (p: any): string => {
|
||||||
|
const bedPosition: string | undefined = isRoundTrip ? (p as any).outboundBedPosition : (p as any).bedPosition;
|
||||||
|
if (!bedPosition) return fallbackSeatClassId;
|
||||||
|
const match = seatClasses.find((sc: any) => sc.name?.toLowerCase().includes(bedPosition));
|
||||||
|
return match?.id || fallbackSeatClassId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const passengersParam = JSON.stringify(
|
||||||
|
passengers.map(p => ({
|
||||||
|
passengerName: p.name,
|
||||||
|
dateOfBirth: p.dateOfBirth,
|
||||||
|
seatClassId: resolveSeatClassId(p),
|
||||||
|
nationality: p.nationality,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
scheduleId,
|
||||||
|
originStationId: searchCriteria.originStationId,
|
||||||
|
destinationStationId: searchCriteria.destinationStationId,
|
||||||
|
passengers: passengersParam,
|
||||||
|
displayCurrency: displayCurrencyCode,
|
||||||
|
...(searchCriteria.promoCode ? { promoCode: searchCriteria.promoCode } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result: any = await apiClient.get(`/search/fare-breakdown?${params}`);
|
||||||
|
setFareBreakdown(result);
|
||||||
|
} catch (err) {
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
// Intentionally re-runs until store is hydrated (scheduleId/originStationId become
|
||||||
|
// available), then the ref guard ensures it only fetches once.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [searchCriteria?.originStationId, searchCriteria?.destinationStationId, scheduleId]);
|
||||||
|
|
||||||
// For package bookings, passengers are initialized without dateOfBirth so isChild() is
|
// For package bookings, passengers are initialized without dateOfBirth so isChild() is
|
||||||
// unreliable. Use the stored adultCount from searchCriteria to determine category by index.
|
// unreliable. Use the stored adultCount from searchCriteria to determine category by index.
|
||||||
@@ -539,7 +552,7 @@ export default function ReviewPage() {
|
|||||||
const isChild_ = isPackageChild(i);
|
const isChild_ = isPackageChild(i);
|
||||||
const isFreeChild = isChild_ && (i - adultPassengerCount) < adultPassengerCount;
|
const isFreeChild = isChild_ && (i - adultPassengerCount) < adultPassengerCount;
|
||||||
if (isFreeChild) return sum;
|
if (isFreeChild) return sum;
|
||||||
const seatFare = getPassengerSeatFare(p);
|
const seatFare = getPassengerSeatFare(p, i);
|
||||||
const pkgFallback = isChild_ ? pkgChildFare : pkgAdultFare;
|
const pkgFallback = isChild_ ? pkgChildFare : pkgAdultFare;
|
||||||
return sum + (seatFare ?? pkgFallback);
|
return sum + (seatFare ?? pkgFallback);
|
||||||
}, 0)
|
}, 0)
|
||||||
@@ -548,8 +561,9 @@ export default function ReviewPage() {
|
|||||||
const line = fareBreakdown?.passengers?.[i];
|
const line = fareBreakdown?.passengers?.[i];
|
||||||
const isFreeChild = line?.isFree ?? (isChildPassenger && isFirstChild(passengers, i));
|
const isFreeChild = line?.isFree ?? (isChildPassenger && isFirstChild(passengers, i));
|
||||||
if (isFreeChild) return sum;
|
if (isFreeChild) return sum;
|
||||||
const seatFare = getPassengerSeatFare(p);
|
const seatFare = getPassengerSeatFare(p, i);
|
||||||
return sum + (seatFare ?? line?.fareMinor ?? 0);
|
const displayFare = line?.displayFareMinor ?? line?.fareMinor;
|
||||||
|
return sum + (seatFare ?? displayFare ?? 0);
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
// Keep computedTotal in sync so handleConfirm can persist it to the store
|
// Keep computedTotal in sync so handleConfirm can persist it to the store
|
||||||
@@ -568,17 +582,26 @@ export default function ReviewPage() {
|
|||||||
? isPkgFreeChild(i)
|
? isPkgFreeChild(i)
|
||||||
: (line?.isFree ?? (isChild(p) && isFirstChild(passengers, i)));
|
: (line?.isFree ?? (isChild(p) && isFirstChild(passengers, i)));
|
||||||
|
|
||||||
// Per-leg fares for round trips
|
// Per-leg fares for round trips — use converted amounts from fareBreakdown when available
|
||||||
|
const displayFare = line?.displayFareMinor ?? line?.fareMinor;
|
||||||
const outboundFare: number | null = isRoundTrip
|
const outboundFare: number | null = isRoundTrip
|
||||||
? (isPackageBooking ? (packageTierPriceMinor ?? null) : ((p as any).outboundSeatFareMinor ?? null))
|
? (isPackageBooking
|
||||||
|
? (packageTierPriceMinor ?? null)
|
||||||
|
: (displayFare != null
|
||||||
|
? Math.round(displayFare / 2)
|
||||||
|
: ((p as any).outboundSeatFareMinor ?? null)))
|
||||||
: null;
|
: null;
|
||||||
const inboundFare: number | null = isRoundTrip
|
const inboundFare: number | null = isRoundTrip
|
||||||
? (isPackageBooking ? (packageTierPriceMinor ?? null) : ((p as any).inboundSeatFareMinor ?? null))
|
? (isPackageBooking
|
||||||
|
? (packageTierPriceMinor ?? null)
|
||||||
|
: (displayFare != null
|
||||||
|
? Math.round(displayFare / 2)
|
||||||
|
: ((p as any).inboundSeatFareMinor ?? null)))
|
||||||
: null;
|
: null;
|
||||||
const seatFare = getPassengerSeatFare(p);
|
const seatFare = getPassengerSeatFare(p, i);
|
||||||
const passengerTotal = isPackageBooking
|
const passengerTotal = isPackageBooking
|
||||||
? (isFreeChild ? 0 : (seatFare ?? (isChildPassenger ? pkgChildFare : pkgAdultFare)))
|
? (isFreeChild ? 0 : (seatFare ?? (isChildPassenger ? pkgChildFare : pkgAdultFare)))
|
||||||
: (isFreeChild ? 0 : (seatFare ?? line?.fareMinor ?? 0));
|
: (isFreeChild ? 0 : (seatFare ?? displayFare ?? 0));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={i} className="border-b border-gray-100 dark:border-gray-800 pb-2 last:border-0">
|
<div key={i} className="border-b border-gray-100 dark:border-gray-800 pb-2 last:border-0">
|
||||||
@@ -594,7 +617,7 @@ export default function ReviewPage() {
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
{formatFare(passengerTotal, displayCurrencySymbol)}
|
{formatFare(passengerTotal, displayCurrencyCode)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/* Round-trip: show outbound + inbound breakdown */}
|
{/* Round-trip: show outbound + inbound breakdown */}
|
||||||
@@ -602,11 +625,11 @@ export default function ReviewPage() {
|
|||||||
<div className="mt-1 space-y-0.5 pl-2">
|
<div className="mt-1 space-y-0.5 pl-2">
|
||||||
<div className="flex justify-between text-xs text-gray-500 dark:text-gray-400">
|
<div className="flex justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||||
<span>↗ Outbound</span>
|
<span>↗ Outbound</span>
|
||||||
<span>{outboundFare != null ? formatFare(outboundFare, displayCurrencySymbol) : '—'}</span>
|
<span>{outboundFare != null ? formatFare(outboundFare, displayCurrencyCode) : '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-xs text-gray-500 dark:text-gray-400">
|
<div className="flex justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||||
<span>↙ Return</span>
|
<span>↙ Return</span>
|
||||||
<span>{inboundFare != null ? formatFare(inboundFare, displayCurrencySymbol) : '—'}</span>
|
<span>{inboundFare != null ? formatFare(inboundFare, displayCurrencyCode) : '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -615,7 +638,7 @@ export default function ReviewPage() {
|
|||||||
})}
|
})}
|
||||||
<div className="flex justify-between items-center pt-1 border-t border-gray-200 dark:border-gray-700">
|
<div className="flex justify-between items-center pt-1 border-t border-gray-200 dark:border-gray-700">
|
||||||
<span className="font-bold text-gray-900 dark:text-gray-100">Total</span>
|
<span className="font-bold text-gray-900 dark:text-gray-100">Total</span>
|
||||||
<span className="text-xl font-bold text-primary">{formatFare(total, displayCurrencySymbol)}</span>
|
<span className="text-xl font-bold text-primary">{formatFare(total, displayCurrencyCode)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action buttons — visible only in desktop sidebar */}
|
{/* Action buttons — visible only in desktop sidebar */}
|
||||||
@@ -963,7 +986,7 @@ export default function ReviewPage() {
|
|||||||
<div className="lg:hidden fixed bottom-0 inset-x-0 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 px-4 py-3 z-40 shadow-lg">
|
<div className="lg:hidden fixed bottom-0 inset-x-0 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 px-4 py-3 z-40 shadow-lg">
|
||||||
<div className="flex items-center justify-between mb-2.5">
|
<div className="flex items-center justify-between mb-2.5">
|
||||||
<span className="text-sm text-gray-600 dark:text-gray-400">Total</span>
|
<span className="text-sm text-gray-600 dark:text-gray-400">Total</span>
|
||||||
<span className="text-lg font-bold text-primary">{formatFare(total, displayCurrencySymbol)}</span>
|
<span className="text-lg font-bold text-primary">{formatFare(total, displayCurrencyCode)}</span>
|
||||||
</div>
|
</div>
|
||||||
{createBookingMutation.isError && (
|
{createBookingMutation.isError && (
|
||||||
<p className="text-red-600 dark:text-red-400 text-xs mb-2">
|
<p className="text-red-600 dark:text-red-400 text-xs mb-2">
|
||||||
|
|||||||
@@ -415,7 +415,7 @@ export default function SeatsPage() {
|
|||||||
if (!coachTypeId) return null;
|
if (!coachTypeId) return null;
|
||||||
const types = (currentSchedule as any)?.coachTypes || [];
|
const types = (currentSchedule as any)?.coachTypes || [];
|
||||||
const match = types.find((ct: any) => ct.coachTypeId === coachTypeId || ct.coachId === coachTypeId);
|
const match = types.find((ct: any) => ct.coachTypeId === coachTypeId || ct.coachId === coachTypeId);
|
||||||
const fares = (match?.classes || []).map((c: any) => c.baseFareMinor).filter((f: number) => f > 0);
|
const fares = (match?.classes || []).map((c: any) => c.displayAmountMinor ?? c.baseFareMinor).filter((f: number) => f > 0);
|
||||||
return fares.length ? Math.min(...fares) : null;
|
return fares.length ? Math.min(...fares) : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -437,10 +437,10 @@ export default function SeatsPage() {
|
|||||||
const match = currentCoachTypeClasses.find((c: any) =>
|
const match = currentCoachTypeClasses.find((c: any) =>
|
||||||
c.name?.toLowerCase().includes(seat.bedPosition),
|
c.name?.toLowerCase().includes(seat.bedPosition),
|
||||||
);
|
);
|
||||||
if (match) return match.baseFareMinor;
|
if (match) return match.displayAmountMinor ?? match.baseFareMinor;
|
||||||
}
|
}
|
||||||
const regular = currentCoachTypeClasses.find((c: any) => /regular/i.test(c.name || ""));
|
const regular = currentCoachTypeClasses.find((c: any) => /regular/i.test(c.name || ""));
|
||||||
return (regular || currentCoachTypeClasses[0])?.baseFareMinor ?? null;
|
return (regular || currentCoachTypeClasses[0])?.displayAmountMinor ?? (regular || currentCoachTypeClasses[0])?.baseFareMinor ?? null;
|
||||||
},
|
},
|
||||||
[currentCoachTypeClasses],
|
[currentCoachTypeClasses],
|
||||||
);
|
);
|
||||||
@@ -573,7 +573,7 @@ export default function SeatsPage() {
|
|||||||
setModalState({
|
setModalState({
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
title: "Fare Will Change",
|
title: "Fare Will Change",
|
||||||
message: `Switching to ${coach.label} (${matchedType.coachTypeName || coach.typeName || ""}) changes the fare to ETB ${(newFare / 100 * (isRoundTrip ? 2 : 1)).toFixed(2)} per adult (currently ETB ${(currentFare / 100 * (isRoundTrip ? 2 : 1)).toFixed(2)}). Continue?`,
|
message: `Switching to ${coach.label} (${matchedType.coachTypeName || coach.typeName || ""}) changes the fare to ${currentSchedule?.displayCurrency || 'ETB'} ${(newFare / 100 * (isRoundTrip ? 2 : 1)).toFixed(2)} per adult (currently ${currentSchedule?.displayCurrency || 'ETB'} ${(currentFare / 100 * (isRoundTrip ? 2 : 1)).toFixed(2)}). Continue?`,
|
||||||
type: "warning",
|
type: "warning",
|
||||||
showCancel: true,
|
showCancel: true,
|
||||||
confirmText: "Switch Coach",
|
confirmText: "Switch Coach",
|
||||||
@@ -588,7 +588,7 @@ export default function SeatsPage() {
|
|||||||
isOpen: true,
|
isOpen: true,
|
||||||
title: "Switch Coach Type",
|
title: "Switch Coach Type",
|
||||||
message: `Switch to ${coach.label}${coachTypeName ? ` (${coachTypeName})` : ""}?${
|
message: `Switch to ${coach.label}${coachTypeName ? ` (${coachTypeName})` : ""}?${
|
||||||
newFare != null ? ` Fare: ETB ${(newFare / 100 * (isRoundTrip ? 2 : 1)).toFixed(2)} per adult.` : " This will have a fare change."
|
newFare != null ? ` Fare: ${currentSchedule?.displayCurrency || 'ETB'} ${(newFare / 100 * (isRoundTrip ? 2 : 1)).toFixed(2)} per adult.` : " This will have a fare change."
|
||||||
}`,
|
}`,
|
||||||
type: "info",
|
type: "info",
|
||||||
showCancel: true,
|
showCancel: true,
|
||||||
@@ -926,7 +926,7 @@ export default function SeatsPage() {
|
|||||||
setModalState({
|
setModalState({
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
title: "Fare Will Change",
|
title: "Fare Will Change",
|
||||||
message: `${positionLabel} ${seatLabel} costs ETB ${(newFare / 100 * legMultiplier).toFixed(2)}, different from ${referenceLabel} (ETB ${(referenceFare / 100 * legMultiplier).toFixed(2)}). Continue with this selection?`,
|
message: `${positionLabel} ${seatLabel} costs ${currentSchedule?.displayCurrency || 'ETB'} ${(newFare / 100 * legMultiplier).toFixed(2)}, different from ${referenceLabel} (${currentSchedule?.displayCurrency || 'ETB'} ${(referenceFare / 100 * legMultiplier).toFixed(2)}). Continue with this selection?`,
|
||||||
type: "warning",
|
type: "warning",
|
||||||
showCancel: true,
|
showCancel: true,
|
||||||
confirmText: "Continue",
|
confirmText: "Continue",
|
||||||
@@ -1981,7 +1981,7 @@ export default function SeatsPage() {
|
|||||||
</span>
|
</span>
|
||||||
{assignedSeat && seatFare != null && (
|
{assignedSeat && seatFare != null && (
|
||||||
<span className="text-[11px] text-gray-500 dark:text-gray-400">
|
<span className="text-[11px] text-gray-500 dark:text-gray-400">
|
||||||
ETB {(seatFare / 100 * (isPackageBooking ? 2 : 1)).toFixed(2)}
|
{currentSchedule?.displayCurrency || 'ETB'} {(seatFare / 100 * (isPackageBooking ? 2 : 1)).toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -452,7 +452,9 @@ interface VoucherData {
|
|||||||
schedule: VoucherSchedule;
|
schedule: VoucherSchedule;
|
||||||
returnSchedule?: VoucherSchedule | null;
|
returnSchedule?: VoucherSchedule | null;
|
||||||
totalMinor: number;
|
totalMinor: number;
|
||||||
currency: string;
|
displayTotalMinor?: number;
|
||||||
|
currency?: string;
|
||||||
|
displayCurrency?: string;
|
||||||
bookingType: string;
|
bookingType: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
// One ticket per passenger per leg (round trips have a separate ticket/barcode for the
|
// One ticket per passenger per leg (round trips have a separate ticket/barcode for the
|
||||||
@@ -473,7 +475,10 @@ export const generateVoucherPDF = async (booking: VoucherData): Promise<void> =>
|
|||||||
const settledAmountMinor = booking.payment?.amountMinor;
|
const settledAmountMinor = booking.payment?.amountMinor;
|
||||||
const settledCurrency = booking.payment?.currency;
|
const settledCurrency = booking.payment?.currency;
|
||||||
const useSettledAmount = settledAmountMinor != null && !!settledCurrency;
|
const useSettledAmount = settledAmountMinor != null && !!settledCurrency;
|
||||||
const voucherCurrency = useSettledAmount ? settledCurrency! : booking.currency;
|
// Prefer displayCurrency (passenger's home currency) over the internal ETB currency field.
|
||||||
|
const voucherCurrency = useSettledAmount ? settledCurrency! : (booking.displayCurrency || booking.currency || 'ETB');
|
||||||
|
// Use displayTotalMinor when available so the voucher shows the passenger's currency amount.
|
||||||
|
const voucherFareMinor = useSettledAmount ? settledAmountMinor! : (booking.displayTotalMinor ?? booking.totalMinor);
|
||||||
|
|
||||||
const isRoundTrip = booking.bookingType === 'ROUND_TRIP' && !!booking.returnSchedule;
|
const isRoundTrip = booking.bookingType === 'ROUND_TRIP' && !!booking.returnSchedule;
|
||||||
|
|
||||||
@@ -519,7 +524,7 @@ export const generateVoucherPDF = async (booking: VoucherData): Promise<void> =>
|
|||||||
seatNumber: isRoundTrip ? undefined : p.outboundSeat?.number,
|
seatNumber: isRoundTrip ? undefined : p.outboundSeat?.number,
|
||||||
outboundSeatNumber: isRoundTrip ? p.outboundSeat?.number : undefined,
|
outboundSeatNumber: isRoundTrip ? p.outboundSeat?.number : undefined,
|
||||||
inboundSeatNumber: isRoundTrip ? p.returnSeat?.number : undefined,
|
inboundSeatNumber: isRoundTrip ? p.returnSeat?.number : undefined,
|
||||||
fareMinor: useSettledAmount ? settledAmountMinor! : booking.totalMinor,
|
fareMinor: voucherFareMinor,
|
||||||
currency: voucherCurrency,
|
currency: voucherCurrency,
|
||||||
fareIsMajorUnits: useSettledAmount,
|
fareIsMajorUnits: useSettledAmount,
|
||||||
createdAt: booking.createdAt,
|
createdAt: booking.createdAt,
|
||||||
|
|||||||
Reference in New Issue
Block a user