mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Fix package price
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user