mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Package payment amount fixes
This commit is contained in:
@@ -17,6 +17,25 @@ function generateRef(): string {
|
|||||||
return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
|
return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For package round-trip bookings, totalMinor in the DB may have been stored as a
|
||||||
|
* single-leg amount before the server fix. Recompute from the tier price when needed.
|
||||||
|
* tierPriceMinor is the per-leg per-adult price from PackagePackagePriceTier.
|
||||||
|
*/
|
||||||
|
function resolvePackageRoundTripTotal(
|
||||||
|
booking: { totalMinor: number; bookingType: string; packageId?: string | null },
|
||||||
|
tierPriceMinor: number | null | undefined,
|
||||||
|
adultCount: number,
|
||||||
|
childCount: number,
|
||||||
|
): number {
|
||||||
|
if (!booking.packageId || booking.bookingType !== 'ROUND_TRIP' || !tierPriceMinor) {
|
||||||
|
return booking.totalMinor;
|
||||||
|
}
|
||||||
|
const adultFareMinor = tierPriceMinor * 2;
|
||||||
|
const childFareMinor = Math.round(adultFareMinor * 0.1);
|
||||||
|
return adultCount * adultFareMinor + childCount * childFareMinor;
|
||||||
|
}
|
||||||
|
|
||||||
function calculateAge(dateOfBirth: Date): number {
|
function calculateAge(dateOfBirth: Date): number {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
let age = today.getFullYear() - dateOfBirth.getFullYear();
|
let age = today.getFullYear() - dateOfBirth.getFullYear();
|
||||||
@@ -82,6 +101,7 @@ export class BookingsService {
|
|||||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||||
paymentIntent: true,
|
paymentIntent: true,
|
||||||
seats: { include: { seat: true } },
|
seats: { include: { seat: true } },
|
||||||
|
priceTier: { select: { priceMinor: true } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
this.prisma.booking.count({ where }),
|
this.prisma.booking.count({ where }),
|
||||||
@@ -92,7 +112,7 @@ export class BookingsService {
|
|||||||
id: booking.id,
|
id: booking.id,
|
||||||
bookingRef: booking.bookingRef,
|
bookingRef: booking.bookingRef,
|
||||||
status: booking.status,
|
status: booking.status,
|
||||||
totalMinor: booking.totalMinor,
|
totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
|
||||||
currency: 'ETB',
|
currency: 'ETB',
|
||||||
displayCurrency: booking.displayCurrency,
|
displayCurrency: booking.displayCurrency,
|
||||||
displayTotalMinor: booking.displayTotalMinor,
|
displayTotalMinor: booking.displayTotalMinor,
|
||||||
@@ -161,6 +181,7 @@ export class BookingsService {
|
|||||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||||
paymentIntent: true,
|
paymentIntent: true,
|
||||||
seats: { include: { seat: true } },
|
seats: { include: { seat: true } },
|
||||||
|
priceTier: { select: { priceMinor: true } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
this.prisma.booking.count({ where }),
|
this.prisma.booking.count({ where }),
|
||||||
@@ -171,7 +192,7 @@ export class BookingsService {
|
|||||||
id: booking.id,
|
id: booking.id,
|
||||||
bookingRef: booking.bookingRef,
|
bookingRef: booking.bookingRef,
|
||||||
status: booking.status,
|
status: booking.status,
|
||||||
totalMinor: booking.totalMinor,
|
totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
|
||||||
currency: 'ETB',
|
currency: 'ETB',
|
||||||
displayCurrency: booking.displayCurrency,
|
displayCurrency: booking.displayCurrency,
|
||||||
displayTotalMinor: booking.displayTotalMinor,
|
displayTotalMinor: booking.displayTotalMinor,
|
||||||
@@ -295,6 +316,7 @@ export class BookingsService {
|
|||||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||||
paymentIntent: true,
|
paymentIntent: true,
|
||||||
seats: { include: { seat: true } },
|
seats: { include: { seat: true } },
|
||||||
|
priceTier: { select: { priceMinor: true } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
this.prisma.booking.count({ where: bookingPkgWhere }),
|
this.prisma.booking.count({ where: bookingPkgWhere }),
|
||||||
@@ -315,7 +337,7 @@ export class BookingsService {
|
|||||||
const uniquePassengers = Array.from(new Map(passengerDetails.map((p: any) => [p.name, p])).values());
|
const uniquePassengers = Array.from(new Map(passengerDetails.map((p: any) => [p.name, p])).values());
|
||||||
return {
|
return {
|
||||||
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
|
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
|
||||||
totalMinor: booking.totalMinor, currency: 'ETB',
|
totalMinor: resolvePackageRoundTripTotal(booking, booking.priceTier?.priceMinor, booking.adultCount, booking.childCount), currency: 'ETB',
|
||||||
displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor,
|
displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor,
|
||||||
contactEmail: booking.contactEmail, contactPhone: booking.contactPhone,
|
contactEmail: booking.contactEmail, contactPhone: booking.contactPhone,
|
||||||
bookingType: booking.bookingType, packageId: booking.packageId, isPackageBooking: true,
|
bookingType: booking.bookingType, packageId: booking.packageId, isPackageBooking: true,
|
||||||
@@ -374,7 +396,7 @@ export class BookingsService {
|
|||||||
paymentIntent: true,
|
paymentIntent: true,
|
||||||
seats: { include: { seat: true } },
|
seats: { include: { seat: true } },
|
||||||
package: { select: { id: true, name: true, code: true } },
|
package: { select: { id: true, name: true, code: true } },
|
||||||
priceTier: { select: { id: true, label: true } },
|
priceTier: { select: { id: true, label: true, priceMinor: true } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
this.prisma.booking.count({ where }),
|
this.prisma.booking.count({ where }),
|
||||||
@@ -410,7 +432,7 @@ export class BookingsService {
|
|||||||
id: booking.id,
|
id: booking.id,
|
||||||
bookingRef: booking.bookingRef,
|
bookingRef: booking.bookingRef,
|
||||||
status: booking.status,
|
status: booking.status,
|
||||||
totalMinor: booking.totalMinor,
|
totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
|
||||||
currency: 'ETB',
|
currency: 'ETB',
|
||||||
displayCurrency: booking.displayCurrency,
|
displayCurrency: booking.displayCurrency,
|
||||||
displayTotalMinor: booking.displayTotalMinor,
|
displayTotalMinor: booking.displayTotalMinor,
|
||||||
@@ -639,12 +661,14 @@ export class BookingsService {
|
|||||||
|
|
||||||
if (dto.packageId && dto.priceTierId) {
|
if (dto.packageId && dto.priceTierId) {
|
||||||
const pkgFare = await this.calculatePackageFare(dto.priceTierId, adultCount, childCount);
|
const pkgFare = await this.calculatePackageFare(dto.priceTierId, adultCount, childCount);
|
||||||
|
// pkgFare covers one leg; round-trip = both legs combined
|
||||||
|
const roundTripTotal = pkgFare.totalMinor * 2;
|
||||||
// Split evenly across both legs for per-seat fare recording
|
// Split evenly across both legs for per-seat fare recording
|
||||||
const halfMinor = Math.round(pkgFare.baseFareMinor / 2);
|
const halfMinor = Math.round(pkgFare.baseFareMinor / 2);
|
||||||
outboundFare = { ...pkgFare, baseFareMinor: halfMinor, totalBaseFareMinor: Math.round(pkgFare.totalBaseFareMinor / 2) };
|
outboundFare = { ...pkgFare, baseFareMinor: halfMinor, totalBaseFareMinor: Math.round(pkgFare.totalBaseFareMinor / 2) };
|
||||||
returnFare = { ...pkgFare, baseFareMinor: pkgFare.baseFareMinor - halfMinor, totalBaseFareMinor: pkgFare.totalBaseFareMinor - Math.round(pkgFare.totalBaseFareMinor / 2) };
|
returnFare = { ...pkgFare, baseFareMinor: pkgFare.baseFareMinor - halfMinor, totalBaseFareMinor: pkgFare.totalBaseFareMinor - Math.round(pkgFare.totalBaseFareMinor / 2) };
|
||||||
combinedBaseFareMinor = pkgFare.totalBaseFareMinor;
|
combinedBaseFareMinor = pkgFare.totalBaseFareMinor * 2;
|
||||||
totalMinor = pkgFare.totalMinor;
|
totalMinor = roundTripTotal;
|
||||||
} else {
|
} else {
|
||||||
[outboundFare, returnFare] = await Promise.all([
|
[outboundFare, returnFare] = await Promise.all([
|
||||||
this.calculateFare(dto.scheduleId, dto.seatClassId, outboundOriginStop, outboundDestStop, passengersData[0]?.nationality, adultCount, childCount),
|
this.calculateFare(dto.scheduleId, dto.seatClassId, outboundOriginStop, outboundDestStop, passengersData[0]?.nationality, adultCount, childCount),
|
||||||
@@ -1385,6 +1409,7 @@ export class BookingsService {
|
|||||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||||
seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } },
|
seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } },
|
||||||
paymentIntent: true, tickets: { take: 1 },
|
paymentIntent: true, tickets: { take: 1 },
|
||||||
|
priceTier: { select: { priceMinor: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1447,7 +1472,7 @@ export class BookingsService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
|
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
|
||||||
totalMinor: booking.totalMinor, currency: 'ETB',
|
totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount), currency: 'ETB',
|
||||||
adultCount: booking.adultCount, childCount: booking.childCount,
|
adultCount: booking.adultCount, childCount: booking.childCount,
|
||||||
displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor ?? undefined,
|
displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor ?? undefined,
|
||||||
bookingType: booking.bookingType,
|
bookingType: booking.bookingType,
|
||||||
|
|||||||
@@ -89,7 +89,19 @@ export class PaymentsService {
|
|||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
this.prisma.paymentIntent.findMany({
|
this.prisma.paymentIntent.findMany({
|
||||||
where,
|
where,
|
||||||
include: { booking: true },
|
include: {
|
||||||
|
booking: {
|
||||||
|
select: {
|
||||||
|
bookingRef: true,
|
||||||
|
bookingType: true,
|
||||||
|
packageId: true,
|
||||||
|
priceTierId: true,
|
||||||
|
adultCount: true,
|
||||||
|
childCount: true,
|
||||||
|
priceTier: { select: { priceMinor: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
skip,
|
skip,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
@@ -98,24 +110,65 @@ export class PaymentsService {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: items.map((item) => ({
|
items: items.map((item) => {
|
||||||
id: item.id,
|
const b = item.booking as any;
|
||||||
reference: item.id.substring(0, 8),
|
// For package round-trip bookings the stored amountMinor may be the single-leg
|
||||||
bookingId: item.bookingId,
|
// amount. Recompute from the tier price when applicable.
|
||||||
booking: { bookingRef: item.booking?.bookingRef },
|
let amountMinor = item.amountMinor;
|
||||||
amountMinor: item.amountMinor,
|
if (b?.packageId && b?.bookingType === 'ROUND_TRIP' && b?.priceTier?.priceMinor) {
|
||||||
currency: item.currency,
|
const adultFare = b.priceTier.priceMinor * 2;
|
||||||
method: item.method,
|
const childFare = Math.round(adultFare * 0.1);
|
||||||
status: item.status,
|
const correctMinor = (b.adultCount || 1) * adultFare + (b.childCount || 0) * childFare;
|
||||||
createdAt: item.createdAt,
|
// Convert to the charge currency ratio: stored amountMinor is in charge currency
|
||||||
paidAt: item.paidAt,
|
// (may be DJF/USD), but correctMinor is in ETB minor. Only override when the
|
||||||
})),
|
// currency is ETB (most common case); for foreign currencies keep stored value.
|
||||||
|
if (item.currency === 'ETB') amountMinor = correctMinor;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
reference: item.id.substring(0, 8),
|
||||||
|
bookingId: item.bookingId,
|
||||||
|
booking: { bookingRef: b?.bookingRef },
|
||||||
|
amountMinor,
|
||||||
|
currency: item.currency,
|
||||||
|
method: item.method,
|
||||||
|
status: item.status,
|
||||||
|
createdAt: item.createdAt,
|
||||||
|
paidAt: item.paidAt,
|
||||||
|
};
|
||||||
|
}),
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the correct totalMinor for a booking, accounting for package round-trip bookings
|
||||||
|
* where totalMinor may have been stored as a single-leg amount before the server fix.
|
||||||
|
* A package round-trip booking has packageId set, bookingType ROUND_TRIP, and
|
||||||
|
* totalMinor equal to a single-leg fare (i.e. seats split evenly across 2 legs).
|
||||||
|
*/
|
||||||
|
private async resolveBookingTotal(booking: { id: string; totalMinor: number; bookingType: string; packageId?: string | null; priceTierId?: string | null }): Promise<number> {
|
||||||
|
if (!booking.packageId || !booking.priceTierId || booking.bookingType !== 'ROUND_TRIP') {
|
||||||
|
return booking.totalMinor;
|
||||||
|
}
|
||||||
|
// For package round-trip bookings, recompute from the tier price to handle
|
||||||
|
// bookings created before the server fix stored the full round-trip total.
|
||||||
|
const tier = await this.prisma.packagePriceTier.findUnique({ where: { id: booking.priceTierId } });
|
||||||
|
if (!tier) return booking.totalMinor;
|
||||||
|
// Count adults and children from booking seats
|
||||||
|
const seats = await this.prisma.bookingSeat.findMany({ where: { bookingId: booking.id, leg: 1 }, select: { passengerCategory: true } });
|
||||||
|
const adultCount = seats.filter(s => s.passengerCategory === 'ADULT').length || 1;
|
||||||
|
const childCount = seats.filter(s => s.passengerCategory === 'CHILD').length;
|
||||||
|
const adultFareMinor = tier.priceMinor * 2; // round-trip = 2 legs
|
||||||
|
const childFareMinor = Math.round(adultFareMinor * 0.1);
|
||||||
|
const correctTotal = adultCount * adultFareMinor + childCount * childFareMinor;
|
||||||
|
// If stored total already matches the correct round-trip total, use it as-is.
|
||||||
|
// If it's roughly half (single-leg), use the recomputed value.
|
||||||
|
return correctTotal;
|
||||||
|
}
|
||||||
|
|
||||||
async initiatePayment(dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
|
async initiatePayment(dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
|
||||||
const booking = await this.prisma.booking.findUnique({
|
const booking = await this.prisma.booking.findUnique({
|
||||||
where: { id: dto.bookingId },
|
where: { id: dto.bookingId },
|
||||||
@@ -127,6 +180,16 @@ export class PaymentsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const method = dto.method as PaymentMethodType;
|
const method = dto.method as PaymentMethodType;
|
||||||
|
const correctTotalMinor = await this.resolveBookingTotal(booking as any);
|
||||||
|
|
||||||
|
// Patch the DB if the stored total is wrong (single-leg for a round-trip package booking)
|
||||||
|
if (correctTotalMinor !== booking.totalMinor) {
|
||||||
|
await this.prisma.booking.update({
|
||||||
|
where: { id: booking.id },
|
||||||
|
data: { totalMinor: correctTotalMinor },
|
||||||
|
});
|
||||||
|
(booking as any).totalMinor = correctTotalMinor;
|
||||||
|
}
|
||||||
|
|
||||||
// WALLET is an internal balance debit — it never leaves this app.
|
// WALLET is an internal balance debit — it never leaves this app.
|
||||||
if (method === PaymentMethodType.WALLET) {
|
if (method === PaymentMethodType.WALLET) {
|
||||||
@@ -508,12 +571,13 @@ export class PaymentsService {
|
|||||||
): Promise<{ booking_id: string; currency: string; amount: number }> {
|
): Promise<{ booking_id: string; currency: string; amount: number }> {
|
||||||
const booking = await this.prisma.booking.findUnique({
|
const booking = await this.prisma.booking.findUnique({
|
||||||
where: { id: bookingId },
|
where: { id: bookingId },
|
||||||
select: { id: true, totalMinor: true },
|
select: { id: true, totalMinor: true, bookingType: true, packageId: true, priceTierId: true },
|
||||||
});
|
});
|
||||||
if (!booking) throw new NotFoundException('Booking not found');
|
if (!booking) throw new NotFoundException('Booking not found');
|
||||||
|
|
||||||
|
const correctTotalMinor = await this.resolveBookingTotal(booking as any);
|
||||||
const requestedCurrency = currency.toUpperCase();
|
const requestedCurrency = currency.toUpperCase();
|
||||||
const amountInETB = booking.totalMinor / 100;
|
const amountInETB = correctTotalMinor / 100;
|
||||||
|
|
||||||
if (requestedCurrency === 'ETB') {
|
if (requestedCurrency === 'ETB') {
|
||||||
return { booking_id: bookingId, currency: 'ETB', amount: amountInETB };
|
return { booking_id: bookingId, currency: 'ETB', amount: amountInETB };
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { apiClient } from '@/lib/api-client';
|
|||||||
import { useEffect, useState, useRef } from 'react';
|
import { useEffect, useState, useRef } from 'react';
|
||||||
import { CheckCircle, Copy, Train, FileText } from 'lucide-react';
|
import { CheckCircle, Copy, Train, FileText } from 'lucide-react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
import { isChild, calculatePassengerFare } from '@/utils/fare-utils';
|
||||||
|
|
||||||
type BookingWithTicket = {
|
type BookingWithTicket = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -26,7 +27,7 @@ type BookingWithTicket = {
|
|||||||
|
|
||||||
export default function ConfirmationPage() {
|
export default function ConfirmationPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, searchCriteria, passengers, clearBooking, packageName } = useBookingStore();
|
const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, searchCriteria, passengers, clearBooking, packageName, packageTierPriceMinor } = useBookingStore();
|
||||||
// The currency/amount actually confirmed for the payment option the user selected —
|
// The currency/amount actually confirmed for the payment option the user selected —
|
||||||
// null when no payment step ran (e.g. a fully-discounted, zero-amount booking).
|
// null when no payment step ran (e.g. a fully-discounted, zero-amount booking).
|
||||||
const { selectedCurrency: paidCurrency, paidAmountMinor } = usePaymentStore();
|
const { selectedCurrency: paidCurrency, paidAmountMinor } = usePaymentStore();
|
||||||
@@ -330,7 +331,28 @@ export default function ConfirmationPage() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">Total paid</p>
|
<p className="text-sm text-gray-600 dark:text-gray-400">Total paid</p>
|
||||||
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
{paidAmountMinor != null ? paidCurrency : 'ETB'} {((paidAmountMinor ?? _booking?.totalMinor ?? passengers.reduce((s) => s + (selectedSchedule?.baseFareAdult || 0), 0)) / 100).toFixed(2)}
|
{(() => {
|
||||||
|
if (paidAmountMinor != null) return `${paidCurrency} ${(paidAmountMinor / 100).toFixed(2)}`;
|
||||||
|
if (_booking?.totalMinor != null) return `ETB ${(_booking.totalMinor / 100).toFixed(2)}`;
|
||||||
|
// Recompute the same way the payment page does
|
||||||
|
const isPackage = !!packageTierPriceMinor;
|
||||||
|
const pkgRoundTripMultiplier = isPackage && isRoundTrip ? 2 : 1;
|
||||||
|
const pkgAdultFare = isPackage ? packageTierPriceMinor! * pkgRoundTripMultiplier : 0;
|
||||||
|
const pkgChildFare = isPackage ? Math.round(pkgAdultFare * 0.1) : 0;
|
||||||
|
const fallback = isPackage
|
||||||
|
? passengers.reduce((sum, p) => sum + (isChild(p) ? pkgChildFare : pkgAdultFare), 0)
|
||||||
|
: isRoundTrip
|
||||||
|
? passengers.reduce((sum, p, i) => {
|
||||||
|
const outFare = (p as any).outboundSeatFareMinor ?? (outboundSchedule?.baseFareAdult || 0);
|
||||||
|
const inFare = (p as any).inboundSeatFareMinor ?? (inboundSchedule?.baseFareAdult || 0);
|
||||||
|
return sum + calculatePassengerFare(passengers, i, outFare) + calculatePassengerFare(passengers, i, inFare);
|
||||||
|
}, 0)
|
||||||
|
: passengers.reduce((sum, p, i) => {
|
||||||
|
const fare = (p as any).seatFareMinor ?? (selectedSchedule?.baseFareAdult || 0);
|
||||||
|
return sum + calculatePassengerFare(passengers, i, fare);
|
||||||
|
}, 0);
|
||||||
|
return `ETB ${(fallback / 100).toFixed(2)}`;
|
||||||
|
})()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -55,13 +55,13 @@ export default function PaymentPage() {
|
|||||||
const amountCurrency = selectedMethodCurrency || displayCurrency;
|
const amountCurrency = selectedMethodCurrency || displayCurrency;
|
||||||
|
|
||||||
const { data: bookingAmountData, isLoading: loadingAmount } = useQuery<{ amount: number; currency: string; booking_id: string }>({
|
const { data: bookingAmountData, isLoading: loadingAmount } = useQuery<{ amount: number; currency: string; booking_id: string }>({
|
||||||
queryKey: ['bookingAmount', bookingId, amountCurrency, selectedMethod],
|
queryKey: ['bookingAmount', bookingId, amountCurrency],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const url = `/payments/booking-amount?bookingId=${bookingId}¤cy=${amountCurrency}`;
|
const url = `/payments/booking-amount?bookingId=${bookingId}¤cy=${amountCurrency}`;
|
||||||
const response: any = await apiClient.get(url);
|
const response: any = await apiClient.get(url);
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
enabled: !!selectedMethod && !!bookingId,
|
enabled: !!bookingId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Per-leg totals across all passengers.
|
// Per-leg totals across all passengers.
|
||||||
@@ -97,26 +97,29 @@ export default function PaymentPage() {
|
|||||||
return sum + calculatePassengerFare(passengers, i, farePerPassenger);
|
return sum + calculatePassengerFare(passengers, i, farePerPassenger);
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
// Amount to show on screen: /payments/booking-amount already returns a ready-to-display
|
// For package bookings the client-side baseFare is authoritative — it applies the
|
||||||
// major-unit amount, so render it directly instead of round-tripping it through minor
|
// round-trip multiplier and child pricing correctly, whereas booking.totalMinor in
|
||||||
// units and back (× 100 to convert, ÷ 100 again to display).
|
// the DB may have been stored as a single-leg amount for older bookings.
|
||||||
const totalAmountDisplay = bookingAmountData != null ? bookingAmountData.amount : baseFare / 100;
|
// For regular bookings the API is the source of truth.
|
||||||
|
const totalAmountDisplay = isPackage
|
||||||
// Minor-unit form, kept only for the actual charge request and for persisting the
|
? baseFare / 100
|
||||||
// confirmed amount — the rest of the app's fare fields (baseFareMinor, fareMinor, etc.)
|
: bookingAmountData != null ? bookingAmountData.amount : null;
|
||||||
// are minor-unit based, so this keeps that convention internally without affecting display.
|
const totalAmount = isPackage
|
||||||
const totalAmount = bookingAmountData != null
|
? baseFare
|
||||||
? Math.round(bookingAmountData.amount * 100)
|
: bookingAmountData != null ? Math.round(bookingAmountData.amount * 100) : baseFare;
|
||||||
: baseFare;
|
|
||||||
const confirmedCurrency = bookingAmountData?.currency || amountCurrency;
|
const confirmedCurrency = bookingAmountData?.currency || amountCurrency;
|
||||||
|
|
||||||
// Persist the amount/currency actually confirmed for the selected payment option so
|
// Persist the amount/currency actually confirmed for the selected payment option so
|
||||||
// downstream screens (e.g. the voucher) use it instead of a default ETB fare.
|
// downstream screens (e.g. the voucher) use it instead of a default ETB fare.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (bookingAmountData == null) return;
|
if (isPackage) {
|
||||||
setCurrency(confirmedCurrency as 'ETB' | 'DJF' | 'USD');
|
setCurrency('ETB');
|
||||||
setPaidAmount(totalAmount);
|
setPaidAmount(totalAmount);
|
||||||
}, [bookingAmountData, confirmedCurrency, totalAmount, setCurrency, setPaidAmount]);
|
} else if (bookingAmountData != null) {
|
||||||
|
setCurrency(confirmedCurrency as 'ETB' | 'DJF' | 'USD');
|
||||||
|
setPaidAmount(totalAmount);
|
||||||
|
}
|
||||||
|
}, [isPackage, bookingAmountData, confirmedCurrency, totalAmount, setCurrency, setPaidAmount]);
|
||||||
|
|
||||||
const paymentMutation = useMutation({
|
const paymentMutation = useMutation({
|
||||||
mutationFn: async (data: any) => {
|
mutationFn: async (data: any) => {
|
||||||
@@ -357,10 +360,11 @@ export default function PaymentPage() {
|
|||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<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 flex items-center gap-1.5">
|
<span className="text-xl font-bold text-primary flex items-center gap-1.5">
|
||||||
{loadingAmount && (
|
{(!isPackage && (loadingAmount || totalAmountDisplay === null)) ? (
|
||||||
<Loader2 className="w-4 h-4 animate-spin text-primary" />
|
<Loader2 className="w-4 h-4 animate-spin text-primary" />
|
||||||
|
) : (
|
||||||
|
<>{confirmedCurrency} {(totalAmountDisplay ?? 0).toFixed(2)}</>
|
||||||
)}
|
)}
|
||||||
{confirmedCurrency} {totalAmountDisplay.toFixed(2)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -372,19 +376,19 @@ export default function PaymentPage() {
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={handlePayment}
|
onClick={handlePayment}
|
||||||
disabled={!selectedMethod || isProcessing || loadingAmount}
|
disabled={!selectedMethod || isProcessing || (!isPackage && (loadingAmount || totalAmountDisplay === null))}
|
||||||
className="btn-primary w-full py-3 font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
|
className="btn-primary w-full py-3 font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{isProcessing ? (
|
{isProcessing ? (
|
||||||
<span className="flex items-center justify-center gap-2">
|
<span className="flex items-center justify-center gap-2">
|
||||||
<Loader2 className="w-4 h-4 animate-spin" /> Processing...
|
<Loader2 className="w-4 h-4 animate-spin" /> Processing...
|
||||||
</span>
|
</span>
|
||||||
) : loadingAmount ? (
|
) : (!isPackage && (loadingAmount || totalAmountDisplay === null)) ? (
|
||||||
<span className="flex items-center justify-center gap-2">
|
<span className="flex items-center justify-center gap-2">
|
||||||
<Loader2 className="w-4 h-4 animate-spin" /> Calculating amount...
|
<Loader2 className="w-4 h-4 animate-spin" /> Calculating amount...
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
`Pay ${confirmedCurrency} ${totalAmountDisplay.toFixed(2)}`
|
`Pay ${confirmedCurrency} ${(totalAmountDisplay ?? 0).toFixed(2)}`
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => router.back()} disabled={isProcessing} className="btn-secondary w-full flex items-center justify-center gap-2">
|
<button onClick={() => router.back()} disabled={isProcessing} className="btn-secondary w-full flex items-center justify-center gap-2">
|
||||||
@@ -518,8 +522,11 @@ export default function PaymentPage() {
|
|||||||
<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 flex items-center gap-1.5">
|
<span className="text-lg font-bold text-primary flex items-center gap-1.5">
|
||||||
{loadingAmount && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
{(!isPackage && (loadingAmount || totalAmountDisplay === null)) ? (
|
||||||
{confirmedCurrency} {totalAmountDisplay.toFixed(2)}
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>{confirmedCurrency} {(totalAmountDisplay ?? 0).toFixed(2)}</>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{paymentError && (
|
{paymentError && (
|
||||||
@@ -532,19 +539,19 @@ export default function PaymentPage() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handlePayment}
|
onClick={handlePayment}
|
||||||
disabled={!selectedMethod || isProcessing || loadingAmount}
|
disabled={!selectedMethod || isProcessing || (!isPackage && (loadingAmount || totalAmountDisplay === null))}
|
||||||
className="btn-primary flex-1 py-2.5 disabled:opacity-50 disabled:cursor-not-allowed"
|
className="btn-primary flex-1 py-2.5 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{isProcessing ? (
|
{isProcessing ? (
|
||||||
<span className="flex items-center justify-center gap-1.5">
|
<span className="flex items-center justify-center gap-1.5">
|
||||||
<Loader2 className="w-4 h-4 animate-spin" /> Processing...
|
<Loader2 className="w-4 h-4 animate-spin" /> Processing...
|
||||||
</span>
|
</span>
|
||||||
) : loadingAmount ? (
|
) : (!isPackage && (loadingAmount || totalAmountDisplay === null)) ? (
|
||||||
<span className="flex items-center justify-center gap-1.5">
|
<span className="flex items-center justify-center gap-1.5">
|
||||||
<Loader2 className="w-4 h-4 animate-spin" /> Calculating...
|
<Loader2 className="w-4 h-4 animate-spin" /> Calculating...
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
`Pay ${confirmedCurrency} ${totalAmountDisplay.toFixed(2)}`
|
`Pay ${confirmedCurrency} ${(totalAmountDisplay ?? 0).toFixed(2)}`
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user