Fix package price

This commit is contained in:
Roba Boru
2026-07-20 23:08:32 +03:00
parent 73f067aeed
commit 296e3af7dd
7 changed files with 81 additions and 41 deletions

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

@@ -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,