Merge pull request #855 from Tria-plc/alpha

Fix package price
This commit is contained in:
robiman
2026-07-20 23:10:10 +03:00
committed by GitHub
9 changed files with 88 additions and 46 deletions

View File

@@ -162,29 +162,39 @@ export class PaymentsService {
}
/**
* 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).
* Returns the correct totalMinor (in ETB) for a booking, accounting for package round-trip
* bookings where totalMinor may have been stored as a single-leg amount before the server fix.
*/
private async resolveBookingTotal(booking: { id: string; totalMinor: number; bookingType: string; packageId?: string | null; priceTierId?: string | null }): Promise<number> {
private async resolveBookingTotal(booking: {
id: string;
totalMinor: number;
bookingType: string;
packageId?: string | null;
priceTierId?: string | null;
displayTotalMinor?: number | 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.
// New bookings store displayTotalMinor from the frontend's reviewedTotalMinor; their
// totalMinor was already computed in ETB at creation time — no recomputation needed.
if (booking.displayTotalMinor != null && booking.displayTotalMinor > 0) {
return booking.totalMinor;
}
// Legacy path: old bookings may have stored a single-leg totalMinor — recompute from tier.
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
// tier.priceMinor may be in a non-ETB currency — convert to ETB so the result is
// always in the same units as totalMinor (which is always the ETB canonical).
const rawFare = tier.priceMinor * 2;
const adultFareMinor = tier.currency && (tier.currency as string) !== 'ETB'
? await this.currencyService.convertAmount(rawFare, tier.currency as any, 'ETB' as any)
: rawFare;
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;
return adultCount * adultFareMinor + childCount * childFareMinor;
}
async initiatePayment(

View File

@@ -691,10 +691,11 @@ export class ReportsService {
const paidMinor = pi.amountMinor;
const paidCurrency = pi.currency;
// b.totalMinor is always in ETB. Convert the paid amount to ETB for an
// apples-to-apples comparison regardless of which currency was used at checkout.
// b.totalMinor is always in ETB minor. pi.amountMinor is the charge MAJOR amount
// (the gateway receives major units — displayMinorToChargeMajor divides by 100 before
// sending). Multiply by 100 to convert back to minor before the ETB comparison.
const owedEtb = b.totalMinor;
const paidEtb = toEtbMinor(paidMinor, paidCurrency);
const paidEtb = toEtbMinor(paidMinor * 100, paidCurrency);
const balanceMinor = owedEtb - paidEtb;
const balanceCurrency = 'ETB';
@@ -802,7 +803,7 @@ export class ReportsService {
const paidCurrency = pi?.currency ?? b.currency;
const owedEtb = b.totalMinor;
const paidEtb = toEtbMinor(paidMinor, paidCurrency);
const paidEtb = toEtbMinor(paidMinor * 100, paidCurrency);
const balanceMinor = owedEtb - paidEtb;
const balanceCurrency = 'ETB';
@@ -960,7 +961,9 @@ export class ReportsService {
const isPackage = !!(b as any).package;
const effectiveActualMinor = isPackage ? actualMinor * 2 : actualMinor;
const effectiveVarianceMinor = effectiveActualMinor - paidMinor;
// actualMinor is in cents; paidMinor (PaymentIntent.amountMinor) is a Float in full units — convert to cents before comparing
const paidMinorCents = Math.round(paidMinor * 100);
const effectiveVarianceMinor = effectiveActualMinor - paidMinorCents;
const breakdown = b.seats.map(s => ({
passengerName: s.passengerName ?? '—',
@@ -985,7 +988,7 @@ export class ReportsService {
varianceMinor: effectiveVarianceMinor,
breakdown,
};
}).filter(r => r.varianceMinor > 0 && r.paidMinor > 0);
}).filter(r => r.varianceMinor > 0);
if (params.search?.trim()) {
const q = params.search.trim().toUpperCase();

View File

@@ -86,9 +86,9 @@ function fmtMinor(minor: number) {
return `ETB ${(minor / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}`;
}
// paidMinor from PaymentIntent.amountMinor is a Float stored as full units (not cents)
// paidMinor from PaymentIntent.amountMinor is converted to cents server-side before being returned
function fmtPaid(amount: number) {
return `ETB ${amount.toLocaleString('en-US', { minimumFractionDigits: 2 })}`;
return `ETB ${(amount / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}`;
}
function downloadCsv(csv: string, filename: string) {

View File

@@ -787,14 +787,6 @@ export default function RoutesPage() {
title="Check-in cutoff override (minutes) for this stop"
/>
</div>
<div className="w-44">
<DateTimePicker
value={stop.plannedDepartureTime ?? ''}
onChange={(v) => updateStop(index, 'plannedDepartureTime', v)}
placeholder="Dep time"
label="Planned Departure"
/>
</div>
<div className="w-44">
<DateTimePicker
value={stop.plannedArrivalTime ?? ''}
@@ -803,6 +795,14 @@ export default function RoutesPage() {
label="Planned Arrival"
/>
</div>
<div className="w-44">
<DateTimePicker
value={stop.plannedDepartureTime ?? ''}
onChange={(v) => updateStop(index, 'plannedDepartureTime', v)}
placeholder="Dep time"
label="Planned Departure"
/>
</div>
<button
type="button"
onClick={() => removeStop(index)}
@@ -854,7 +854,6 @@ export default function RoutesPage() {
/>
)}
</div>
<div className="w-44" />
<div className="w-44">
{destinationStationId && (
<DateTimePicker
@@ -865,6 +864,7 @@ export default function RoutesPage() {
/>
)}
</div>
<div className="w-44" />
<div className="w-24">
{destinationStationId && (
<input

View File

@@ -342,6 +342,9 @@ export default function SchedulesPage() {
(s: any) => s.plannedArrivalTime || s.plannedDepartureTime,
);
if (!hasRouteTimes) return;
// If the schedule already has saved stop times, keep them — don't overwrite
// with route template times. The user can use "Auto-fill" if they want to reset.
if (editingSchedule.stopTimes && editingSchedule.stopTimes.length > 0) return;
const eatDateStr = editForm.departureAt
? editForm.departureAt.slice(0, 10)
: null;

View File

@@ -66,7 +66,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
{ name: 'Tickets', href: '/tickets', icon: FileText, permission: PERMS.tickets.view },
{ name: 'Boarding', href: '/boarding', icon: LogIn, permission: PERMS.tickets.view },
{ name: 'Luggage', href: '/excess-baggage', icon: Banknote, permission: PERMS.bookings.view },
{ name: 'Discrepancy', href: '/discrepancy', icon: Layers, permission: PERMS.seats.manage },
// { name: 'Discrepancy', href: '/discrepancy', icon: Layers, permission: PERMS.seats.manage },
]
},
{

View File

@@ -249,6 +249,10 @@ export default function ConfirmationPage() {
}
: undefined;
// For settled amounts, free children (getEtbFare returns 0) should show 0 —
// split the total only among passengers who actually paid.
const paidPassengerCount = passengers.filter((_, j) => getEtbFare(j) > 0).length || passengers.length;
// Separate file per passenger, saved back-to-back with no macrotask (setTimeout)
// between them — a setTimeout delay would push later saves outside the click's
// synchronous user-activation window and risk iOS Safari silently blocking them.
@@ -278,7 +282,7 @@ export default function ConfirmationPage() {
outboundSchedule: outbound,
inboundSchedule: inbound,
isRoundTrip,
fareMinor: hasSettledAmount ? settledAmountMinor! : getEtbFare(i),
fareMinor: hasSettledAmount ? (getEtbFare(i) === 0 ? 0 : Math.round(settledAmountMinor! / paidPassengerCount)) : getEtbFare(i),
currency: voucherCurrency,
fareIsMajorUnits: hasSettledAmount,
createdAt,

View File

@@ -157,7 +157,8 @@ export default function ReviewPage() {
const isPackageBooking = packageTierPriceMinor !== null || !!packageName;
const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length;
const pkgAdultFare = isPackageBooking && packageTierPriceMinor != null ? packageTierPriceMinor * 2 : 0;
const pkgPriceMultiplier = isRoundTrip ? 2 : 1;
const pkgAdultFare = isPackageBooking && packageTierPriceMinor != null ? packageTierPriceMinor * pkgPriceMultiplier : 0;
const pkgChildFare = pkgAdultFare;
const isPackageChild = (index: number) =>
@@ -194,7 +195,7 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p =>
// it is exactly what was shown to the user. Use the fare-breakdown API only as fallback
// for cases where seatFareMinor was not captured (e.g. auto-assign without seat map).
if (p.seatFareMinor != null) {
return isPackageBooking ? p.seatFareMinor * 2 : p.seatFareMinor;
return (isPackageBooking && isRoundTrip) ? p.seatFareMinor * 2 : p.seatFareMinor;
}
if (!isPackageBooking && fareBreakdown?.passengers && index != null) {
const line = fareBreakdown.passengers[index];

View File

@@ -355,7 +355,7 @@ function drawFareSummary(doc: jsPDF, fareMinor: number, currency: string, y: num
doc.roundedRect(margin, y, pageWidth - margin * 2, cardH, 3, 3, 'F');
const padX = 7;
label(doc, 'Total fare paid', margin + padX, y + 8, { color: BODY });
label(doc, 'Fare paid', margin + padX, y + 8, { color: BODY });
doc.setFontSize(7.5); doc.setFont('helvetica', 'bold'); doc.setTextColor(...SUCCESS);
doc.text('✓ PAID', margin + padX, y + 15);
@@ -456,7 +456,7 @@ interface VoucherData {
// outbound, leg 2 = return), each with that leg's own seat — see bookings.service.ts's
// getByRef(). dateOfBirth is included purely to disambiguate same-name passengers when
// grouping leg rows back into one passenger below.
passengers: Array<{ fullName: string; dateOfBirth?: string; category: string; leg?: number; seat?: { number: string; coach: string; seatClass: string } }>;
passengers: Array<{ fullName: string; dateOfBirth?: string; category: string; leg?: number; fareMinor?: number; seat?: { number: string; coach: string; seatClass: string } }>;
schedule: VoucherSchedule;
returnSchedule?: VoucherSchedule | null;
totalMinor: number;
@@ -475,37 +475,44 @@ interface VoucherData {
}
export const generateVoucherPDF = async (booking: VoucherData): Promise<void> => {
// The amount shown is always a single raw field straight from the API — the settled
// payment amount when available, otherwise the booking total — never a derived value
// (previously this fell back to Math.round(totalMinor / passengers.length), which
// doesn't correspond to any real field and could disagree with what was actually
// charged). Same value on every passenger's voucher; no /100, no per-passenger split.
const settledAmountMinor = booking.payment?.amountMinor;
const settledCurrency = booking.payment?.currency;
const useSettledAmount = settledAmountMinor != null && !!settledCurrency;
// 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);
// Display total in the booking's display currency (minor units for ETB, major for settled).
const totalFareMinor = useSettledAmount ? settledAmountMinor! : (booking.displayTotalMinor ?? booking.totalMinor);
const isRoundTrip = booking.bookingType === 'ROUND_TRIP' && !!booking.returnSchedule;
// Group leg rows back into one entry per real passenger — without this, a round trip
// produced two half-passenger vouchers (one per leg, each showing only its own leg's
// seat) instead of one voucher per passenger covering both legs.
// Also accumulate the per-leg ETB fareMinor from the API so we can split the display
// total proportionally (adults vs children pay different rates).
type SeatInfo = VoucherData['passengers'][number]['seat'];
const grouped = new Map<
string,
{ fullName: string; category: string; outboundSeat?: SeatInfo; returnSeat?: SeatInfo }
{ fullName: string; category: string; outboundSeat?: SeatInfo; returnSeat?: SeatInfo; etbFareMinor: number }
>();
booking.passengers.forEach((p) => {
const key = `${p.fullName}|${p.dateOfBirth}|${p.category}`;
const entry = grouped.get(key) || { fullName: p.fullName, category: p.category, outboundSeat: undefined, returnSeat: undefined };
const entry = grouped.get(key) || { fullName: p.fullName, category: p.category, outboundSeat: undefined, returnSeat: undefined, etbFareMinor: 0 };
if (p.leg === 2) entry.returnSeat = p.seat;
else entry.outboundSeat = p.seat;
entry.etbFareMinor += p.fareMinor ?? 0;
grouped.set(key, entry);
});
const passengerCount = grouped.size || 1;
// Sum of all per-seat ETB fares — used as denominator for proportional splitting.
const totalEtbFareMinor = [...grouped.values()].reduce((sum, p) => sum + p.etbFareMinor, 0);
// When ETB fare data is present, free children have etbFareMinor === 0 — exclude them
// from the denominator so the settled amount is split only among paying passengers.
const paidPassengerCount = totalEtbFareMinor > 0
? ([...grouped.values()].filter(p => p.etbFareMinor > 0).length || passengerCount)
: passengerCount;
// Separate file per passenger, saved back-to-back with no macrotask (setTimeout) between
// them — a setTimeout delay here would push later saves outside the click's synchronous
// user-activation window and risk iOS Safari silently blocking them. The awaited work
@@ -522,6 +529,20 @@ export const generateVoucherPDF = async (booking: VoucherData): Promise<void> =>
// if it doesn't match what's actually on file.
const ticketNumber = matchedTicket?.barcodePayload || 'Not yet issued';
// Per-passenger fare:
// • Free children (etbFareMinor === 0 when ETB data exists) always show 0.
// • Settled amounts: split evenly among PAID passengers (no per-seat currency breakdown).
// • Booking totals: proportional ETB share; falls back to even split only when no
// seat fare data is available at all (older bookings before fareMinor was stored).
const isFreeChild = totalEtbFareMinor > 0 && p.etbFareMinor === 0;
const perPassengerFare = isFreeChild
? 0
: useSettledAmount
? Math.round(totalFareMinor / paidPassengerCount)
: totalEtbFareMinor > 0
? Math.round(totalFareMinor * p.etbFareMinor / totalEtbFareMinor)
: Math.round(totalFareMinor / passengerCount);
await generatePassengerVoucherPDF({
bookingRef: booking.bookingRef,
ticketNumber,
@@ -536,7 +557,7 @@ export const generateVoucherPDF = async (booking: VoucherData): Promise<void> =>
outboundCoachNumber: isRoundTrip ? p.outboundSeat?.coach : undefined,
inboundSeatNumber: isRoundTrip ? p.returnSeat?.number : undefined,
inboundCoachNumber: isRoundTrip ? p.returnSeat?.coach : undefined,
fareMinor: voucherFareMinor,
fareMinor: perPassengerFare,
currency: voucherCurrency,
fareIsMajorUnits: useSettledAmount,
createdAt: booking.createdAt,