mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 15:18:11 +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('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const today = new Date();
|
||||
let age = today.getFullYear() - dateOfBirth.getFullYear();
|
||||
@@ -82,6 +101,7 @@ export class BookingsService {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
paymentIntent: true,
|
||||
seats: { include: { seat: true } },
|
||||
priceTier: { select: { priceMinor: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.booking.count({ where }),
|
||||
@@ -92,7 +112,7 @@ export class BookingsService {
|
||||
id: booking.id,
|
||||
bookingRef: booking.bookingRef,
|
||||
status: booking.status,
|
||||
totalMinor: booking.totalMinor,
|
||||
totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
|
||||
currency: 'ETB',
|
||||
displayCurrency: booking.displayCurrency,
|
||||
displayTotalMinor: booking.displayTotalMinor,
|
||||
@@ -161,6 +181,7 @@ export class BookingsService {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
paymentIntent: true,
|
||||
seats: { include: { seat: true } },
|
||||
priceTier: { select: { priceMinor: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.booking.count({ where }),
|
||||
@@ -171,7 +192,7 @@ export class BookingsService {
|
||||
id: booking.id,
|
||||
bookingRef: booking.bookingRef,
|
||||
status: booking.status,
|
||||
totalMinor: booking.totalMinor,
|
||||
totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
|
||||
currency: 'ETB',
|
||||
displayCurrency: booking.displayCurrency,
|
||||
displayTotalMinor: booking.displayTotalMinor,
|
||||
@@ -295,6 +316,7 @@ export class BookingsService {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
paymentIntent: true,
|
||||
seats: { include: { seat: true } },
|
||||
priceTier: { select: { priceMinor: true } },
|
||||
},
|
||||
}),
|
||||
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());
|
||||
return {
|
||||
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,
|
||||
contactEmail: booking.contactEmail, contactPhone: booking.contactPhone,
|
||||
bookingType: booking.bookingType, packageId: booking.packageId, isPackageBooking: true,
|
||||
@@ -374,7 +396,7 @@ export class BookingsService {
|
||||
paymentIntent: true,
|
||||
seats: { include: { seat: 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 }),
|
||||
@@ -410,7 +432,7 @@ export class BookingsService {
|
||||
id: booking.id,
|
||||
bookingRef: booking.bookingRef,
|
||||
status: booking.status,
|
||||
totalMinor: booking.totalMinor,
|
||||
totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
|
||||
currency: 'ETB',
|
||||
displayCurrency: booking.displayCurrency,
|
||||
displayTotalMinor: booking.displayTotalMinor,
|
||||
@@ -639,12 +661,14 @@ export class BookingsService {
|
||||
|
||||
if (dto.packageId && dto.priceTierId) {
|
||||
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
|
||||
const halfMinor = Math.round(pkgFare.baseFareMinor / 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) };
|
||||
combinedBaseFareMinor = pkgFare.totalBaseFareMinor;
|
||||
totalMinor = pkgFare.totalMinor;
|
||||
combinedBaseFareMinor = pkgFare.totalBaseFareMinor * 2;
|
||||
totalMinor = roundTripTotal;
|
||||
} else {
|
||||
[outboundFare, returnFare] = await Promise.all([
|
||||
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 } },
|
||||
seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } },
|
||||
paymentIntent: true, tickets: { take: 1 },
|
||||
priceTier: { select: { priceMinor: true } },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1447,7 +1472,7 @@ export class BookingsService {
|
||||
|
||||
return {
|
||||
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,
|
||||
displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor ?? undefined,
|
||||
bookingType: booking.bookingType,
|
||||
|
||||
@@ -89,7 +89,19 @@ export class PaymentsService {
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.paymentIntent.findMany({
|
||||
where,
|
||||
include: { booking: true },
|
||||
include: {
|
||||
booking: {
|
||||
select: {
|
||||
bookingRef: true,
|
||||
bookingType: true,
|
||||
packageId: true,
|
||||
priceTierId: true,
|
||||
adultCount: true,
|
||||
childCount: true,
|
||||
priceTier: { select: { priceMinor: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
skip,
|
||||
take: pageSize,
|
||||
orderBy: { createdAt: "desc" },
|
||||
@@ -98,24 +110,65 @@ export class PaymentsService {
|
||||
]);
|
||||
|
||||
return {
|
||||
items: items.map((item) => ({
|
||||
id: item.id,
|
||||
reference: item.id.substring(0, 8),
|
||||
bookingId: item.bookingId,
|
||||
booking: { bookingRef: item.booking?.bookingRef },
|
||||
amountMinor: item.amountMinor,
|
||||
currency: item.currency,
|
||||
method: item.method,
|
||||
status: item.status,
|
||||
createdAt: item.createdAt,
|
||||
paidAt: item.paidAt,
|
||||
})),
|
||||
items: items.map((item) => {
|
||||
const b = item.booking as any;
|
||||
// For package round-trip bookings the stored amountMinor may be the single-leg
|
||||
// amount. Recompute from the tier price when applicable.
|
||||
let amountMinor = item.amountMinor;
|
||||
if (b?.packageId && b?.bookingType === 'ROUND_TRIP' && b?.priceTier?.priceMinor) {
|
||||
const adultFare = b.priceTier.priceMinor * 2;
|
||||
const childFare = Math.round(adultFare * 0.1);
|
||||
const correctMinor = (b.adultCount || 1) * adultFare + (b.childCount || 0) * childFare;
|
||||
// Convert to the charge currency ratio: stored amountMinor is in charge currency
|
||||
// (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,
|
||||
page,
|
||||
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> {
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { id: dto.bookingId },
|
||||
@@ -127,6 +180,16 @@ export class PaymentsService {
|
||||
}
|
||||
|
||||
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.
|
||||
if (method === PaymentMethodType.WALLET) {
|
||||
@@ -508,12 +571,13 @@ export class PaymentsService {
|
||||
): Promise<{ booking_id: string; currency: string; amount: number }> {
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
select: { id: true, totalMinor: true },
|
||||
select: { id: true, totalMinor: true, bookingType: true, packageId: true, priceTierId: true },
|
||||
});
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
|
||||
const correctTotalMinor = await this.resolveBookingTotal(booking as any);
|
||||
const requestedCurrency = currency.toUpperCase();
|
||||
const amountInETB = booking.totalMinor / 100;
|
||||
const amountInETB = correctTotalMinor / 100;
|
||||
|
||||
if (requestedCurrency === 'ETB') {
|
||||
return { booking_id: bookingId, currency: 'ETB', amount: amountInETB };
|
||||
|
||||
Reference in New Issue
Block a user