mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 09:58:12 +00:00
@@ -162,29 +162,39 @@ export class PaymentsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the correct totalMinor for a booking, accounting for package round-trip bookings
|
* Returns the correct totalMinor (in ETB) for a booking, accounting for package round-trip
|
||||||
* where totalMinor may have been stored as a single-leg amount before the server fix.
|
* 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> {
|
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') {
|
if (!booking.packageId || !booking.priceTierId || booking.bookingType !== 'ROUND_TRIP') {
|
||||||
return booking.totalMinor;
|
return booking.totalMinor;
|
||||||
}
|
}
|
||||||
// For package round-trip bookings, recompute from the tier price to handle
|
// New bookings store displayTotalMinor from the frontend's reviewedTotalMinor; their
|
||||||
// bookings created before the server fix stored the full round-trip total.
|
// 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 } });
|
const tier = await this.prisma.packagePriceTier.findUnique({ where: { id: booking.priceTierId } });
|
||||||
if (!tier) return booking.totalMinor;
|
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 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 adultCount = seats.filter(s => s.passengerCategory === 'ADULT').length || 1;
|
||||||
const childCount = seats.filter(s => s.passengerCategory === 'CHILD').length;
|
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 childFareMinor = Math.round(adultFareMinor * 0.1);
|
||||||
const correctTotal = adultCount * adultFareMinor + childCount * childFareMinor;
|
return 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(
|
async initiatePayment(
|
||||||
|
|||||||
@@ -691,10 +691,11 @@ export class ReportsService {
|
|||||||
const paidMinor = pi.amountMinor;
|
const paidMinor = pi.amountMinor;
|
||||||
const paidCurrency = pi.currency;
|
const paidCurrency = pi.currency;
|
||||||
|
|
||||||
// b.totalMinor is always in ETB. Convert the paid amount to ETB for an
|
// b.totalMinor is always in ETB minor. pi.amountMinor is the charge MAJOR amount
|
||||||
// apples-to-apples comparison regardless of which currency was used at checkout.
|
// (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 owedEtb = b.totalMinor;
|
||||||
const paidEtb = toEtbMinor(paidMinor, paidCurrency);
|
const paidEtb = toEtbMinor(paidMinor * 100, paidCurrency);
|
||||||
const balanceMinor = owedEtb - paidEtb;
|
const balanceMinor = owedEtb - paidEtb;
|
||||||
const balanceCurrency = 'ETB';
|
const balanceCurrency = 'ETB';
|
||||||
|
|
||||||
@@ -802,7 +803,7 @@ export class ReportsService {
|
|||||||
const paidCurrency = pi?.currency ?? b.currency;
|
const paidCurrency = pi?.currency ?? b.currency;
|
||||||
|
|
||||||
const owedEtb = b.totalMinor;
|
const owedEtb = b.totalMinor;
|
||||||
const paidEtb = toEtbMinor(paidMinor, paidCurrency);
|
const paidEtb = toEtbMinor(paidMinor * 100, paidCurrency);
|
||||||
const balanceMinor = owedEtb - paidEtb;
|
const balanceMinor = owedEtb - paidEtb;
|
||||||
const balanceCurrency = 'ETB';
|
const balanceCurrency = 'ETB';
|
||||||
|
|
||||||
@@ -960,7 +961,9 @@ export class ReportsService {
|
|||||||
|
|
||||||
const isPackage = !!(b as any).package;
|
const isPackage = !!(b as any).package;
|
||||||
const effectiveActualMinor = isPackage ? actualMinor * 2 : actualMinor;
|
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 => ({
|
const breakdown = b.seats.map(s => ({
|
||||||
passengerName: s.passengerName ?? '—',
|
passengerName: s.passengerName ?? '—',
|
||||||
@@ -985,7 +988,7 @@ export class ReportsService {
|
|||||||
varianceMinor: effectiveVarianceMinor,
|
varianceMinor: effectiveVarianceMinor,
|
||||||
breakdown,
|
breakdown,
|
||||||
};
|
};
|
||||||
}).filter(r => r.varianceMinor > 0 && r.paidMinor > 0);
|
}).filter(r => r.varianceMinor > 0);
|
||||||
|
|
||||||
if (params.search?.trim()) {
|
if (params.search?.trim()) {
|
||||||
const q = params.search.trim().toUpperCase();
|
const q = params.search.trim().toUpperCase();
|
||||||
|
|||||||
@@ -86,9 +86,9 @@ function fmtMinor(minor: number) {
|
|||||||
return `ETB ${(minor / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}`;
|
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) {
|
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) {
|
function downloadCsv(csv: string, filename: string) {
|
||||||
|
|||||||
@@ -787,14 +787,6 @@ export default function RoutesPage() {
|
|||||||
title="Check-in cutoff override (minutes) for this stop"
|
title="Check-in cutoff override (minutes) for this stop"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="w-44">
|
||||||
<DateTimePicker
|
<DateTimePicker
|
||||||
value={stop.plannedArrivalTime ?? ''}
|
value={stop.plannedArrivalTime ?? ''}
|
||||||
@@ -803,6 +795,14 @@ export default function RoutesPage() {
|
|||||||
label="Planned Arrival"
|
label="Planned Arrival"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="w-44">
|
||||||
|
<DateTimePicker
|
||||||
|
value={stop.plannedDepartureTime ?? ''}
|
||||||
|
onChange={(v) => updateStop(index, 'plannedDepartureTime', v)}
|
||||||
|
placeholder="Dep time"
|
||||||
|
label="Planned Departure"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeStop(index)}
|
onClick={() => removeStop(index)}
|
||||||
@@ -854,7 +854,6 @@ export default function RoutesPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-44" />
|
|
||||||
<div className="w-44">
|
<div className="w-44">
|
||||||
{destinationStationId && (
|
{destinationStationId && (
|
||||||
<DateTimePicker
|
<DateTimePicker
|
||||||
@@ -865,6 +864,7 @@ export default function RoutesPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="w-44" />
|
||||||
<div className="w-24">
|
<div className="w-24">
|
||||||
{destinationStationId && (
|
{destinationStationId && (
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -342,6 +342,9 @@ export default function SchedulesPage() {
|
|||||||
(s: any) => s.plannedArrivalTime || s.plannedDepartureTime,
|
(s: any) => s.plannedArrivalTime || s.plannedDepartureTime,
|
||||||
);
|
);
|
||||||
if (!hasRouteTimes) return;
|
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
|
const eatDateStr = editForm.departureAt
|
||||||
? editForm.departureAt.slice(0, 10)
|
? editForm.departureAt.slice(0, 10)
|
||||||
: null;
|
: null;
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
|
|||||||
{ name: 'Tickets', href: '/tickets', icon: FileText, permission: PERMS.tickets.view },
|
{ name: 'Tickets', href: '/tickets', icon: FileText, permission: PERMS.tickets.view },
|
||||||
{ name: 'Boarding', href: '/boarding', icon: LogIn, 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: '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 },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -249,6 +249,10 @@ export default function ConfirmationPage() {
|
|||||||
}
|
}
|
||||||
: undefined;
|
: 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)
|
// 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
|
// between them — a setTimeout delay would push later saves outside the click's
|
||||||
// synchronous user-activation window and risk iOS Safari silently blocking them.
|
// synchronous user-activation window and risk iOS Safari silently blocking them.
|
||||||
@@ -278,7 +282,7 @@ export default function ConfirmationPage() {
|
|||||||
outboundSchedule: outbound,
|
outboundSchedule: outbound,
|
||||||
inboundSchedule: inbound,
|
inboundSchedule: inbound,
|
||||||
isRoundTrip,
|
isRoundTrip,
|
||||||
fareMinor: hasSettledAmount ? settledAmountMinor! : getEtbFare(i),
|
fareMinor: hasSettledAmount ? (getEtbFare(i) === 0 ? 0 : Math.round(settledAmountMinor! / paidPassengerCount)) : getEtbFare(i),
|
||||||
currency: voucherCurrency,
|
currency: voucherCurrency,
|
||||||
fareIsMajorUnits: hasSettledAmount,
|
fareIsMajorUnits: hasSettledAmount,
|
||||||
createdAt,
|
createdAt,
|
||||||
|
|||||||
@@ -157,7 +157,8 @@ export default function ReviewPage() {
|
|||||||
const isPackageBooking = packageTierPriceMinor !== null || !!packageName;
|
const isPackageBooking = packageTierPriceMinor !== null || !!packageName;
|
||||||
|
|
||||||
const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length;
|
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 pkgChildFare = pkgAdultFare;
|
||||||
|
|
||||||
const isPackageChild = (index: number) =>
|
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
|
// 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).
|
// for cases where seatFareMinor was not captured (e.g. auto-assign without seat map).
|
||||||
if (p.seatFareMinor != null) {
|
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) {
|
if (!isPackageBooking && fareBreakdown?.passengers && index != null) {
|
||||||
const line = fareBreakdown.passengers[index];
|
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');
|
doc.roundedRect(margin, y, pageWidth - margin * 2, cardH, 3, 3, 'F');
|
||||||
|
|
||||||
const padX = 7;
|
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.setFontSize(7.5); doc.setFont('helvetica', 'bold'); doc.setTextColor(...SUCCESS);
|
||||||
doc.text('✓ PAID', margin + padX, y + 15);
|
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
|
// 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
|
// getByRef(). dateOfBirth is included purely to disambiguate same-name passengers when
|
||||||
// grouping leg rows back into one passenger below.
|
// 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;
|
schedule: VoucherSchedule;
|
||||||
returnSchedule?: VoucherSchedule | null;
|
returnSchedule?: VoucherSchedule | null;
|
||||||
totalMinor: number;
|
totalMinor: number;
|
||||||
@@ -475,37 +475,44 @@ interface VoucherData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const generateVoucherPDF = async (booking: VoucherData): Promise<void> => {
|
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 settledAmountMinor = booking.payment?.amountMinor;
|
||||||
const settledCurrency = booking.payment?.currency;
|
const settledCurrency = booking.payment?.currency;
|
||||||
const useSettledAmount = settledAmountMinor != null && !!settledCurrency;
|
const useSettledAmount = settledAmountMinor != null && !!settledCurrency;
|
||||||
// Prefer displayCurrency (passenger's home currency) over the internal ETB currency field.
|
// Prefer displayCurrency (passenger's home currency) over the internal ETB currency field.
|
||||||
const voucherCurrency = useSettledAmount ? settledCurrency! : (booking.displayCurrency || booking.currency || 'ETB');
|
const voucherCurrency = useSettledAmount ? settledCurrency! : (booking.displayCurrency || booking.currency || 'ETB');
|
||||||
// Use displayTotalMinor when available so the voucher shows the passenger's currency amount.
|
// Display total in the booking's display currency (minor units for ETB, major for settled).
|
||||||
const voucherFareMinor = useSettledAmount ? settledAmountMinor! : (booking.displayTotalMinor ?? booking.totalMinor);
|
const totalFareMinor = useSettledAmount ? settledAmountMinor! : (booking.displayTotalMinor ?? booking.totalMinor);
|
||||||
|
|
||||||
const isRoundTrip = booking.bookingType === 'ROUND_TRIP' && !!booking.returnSchedule;
|
const isRoundTrip = booking.bookingType === 'ROUND_TRIP' && !!booking.returnSchedule;
|
||||||
|
|
||||||
// Group leg rows back into one entry per real passenger — without this, a round trip
|
// 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
|
// 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.
|
// 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'];
|
type SeatInfo = VoucherData['passengers'][number]['seat'];
|
||||||
const grouped = new Map<
|
const grouped = new Map<
|
||||||
string,
|
string,
|
||||||
{ fullName: string; category: string; outboundSeat?: SeatInfo; returnSeat?: SeatInfo }
|
{ fullName: string; category: string; outboundSeat?: SeatInfo; returnSeat?: SeatInfo; etbFareMinor: number }
|
||||||
>();
|
>();
|
||||||
booking.passengers.forEach((p) => {
|
booking.passengers.forEach((p) => {
|
||||||
const key = `${p.fullName}|${p.dateOfBirth}|${p.category}`;
|
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;
|
if (p.leg === 2) entry.returnSeat = p.seat;
|
||||||
else entry.outboundSeat = p.seat;
|
else entry.outboundSeat = p.seat;
|
||||||
|
entry.etbFareMinor += p.fareMinor ?? 0;
|
||||||
grouped.set(key, entry);
|
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
|
// 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
|
// 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
|
// 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.
|
// if it doesn't match what's actually on file.
|
||||||
const ticketNumber = matchedTicket?.barcodePayload || 'Not yet issued';
|
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({
|
await generatePassengerVoucherPDF({
|
||||||
bookingRef: booking.bookingRef,
|
bookingRef: booking.bookingRef,
|
||||||
ticketNumber,
|
ticketNumber,
|
||||||
@@ -536,7 +557,7 @@ export const generateVoucherPDF = async (booking: VoucherData): Promise<void> =>
|
|||||||
outboundCoachNumber: isRoundTrip ? p.outboundSeat?.coach : undefined,
|
outboundCoachNumber: isRoundTrip ? p.outboundSeat?.coach : undefined,
|
||||||
inboundSeatNumber: isRoundTrip ? p.returnSeat?.number : undefined,
|
inboundSeatNumber: isRoundTrip ? p.returnSeat?.number : undefined,
|
||||||
inboundCoachNumber: isRoundTrip ? p.returnSeat?.coach : undefined,
|
inboundCoachNumber: isRoundTrip ? p.returnSeat?.coach : undefined,
|
||||||
fareMinor: voucherFareMinor,
|
fareMinor: perPassengerFare,
|
||||||
currency: voucherCurrency,
|
currency: voucherCurrency,
|
||||||
fareIsMajorUnits: useSettledAmount,
|
fareIsMajorUnits: useSettledAmount,
|
||||||
createdAt: booking.createdAt,
|
createdAt: booking.createdAt,
|
||||||
|
|||||||
Reference in New Issue
Block a user