mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 09:35:44 +00:00
fix: ( bookings ) report the full amount paid after a reschedule
This commit is contained in:
@@ -7,6 +7,7 @@ import { TicketsService } from '../tickets/tickets.service';
|
|||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import { CreateBookingDto } from './bookings.dto';
|
import { CreateBookingDto } from './bookings.dto';
|
||||||
import { assertIdentitiesNotAlreadyBooked, resolveIdentityRef } from './booking-identity.util';
|
import { assertIdentitiesNotAlreadyBooked, resolveIdentityRef } from './booking-identity.util';
|
||||||
|
import { buildPaymentBreakdown } from './payment-breakdown.util';
|
||||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
import { VerifaydaService } from '../verifayda/verifayda.service';
|
import { VerifaydaService } from '../verifayda/verifayda.service';
|
||||||
import { CurrencyService } from '../currency/currency.service';
|
import { CurrencyService } from '../currency/currency.service';
|
||||||
@@ -2037,6 +2038,8 @@ export class BookingsService {
|
|||||||
returnSchedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
|
returnSchedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
|
||||||
seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } },
|
seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } },
|
||||||
paymentIntent: true, tickets: true,
|
paymentIntent: true, tickets: true,
|
||||||
|
// Money collected after the original payment (reschedule fees, underpayments).
|
||||||
|
supplementaryCharges: { orderBy: { createdAt: 'asc' } },
|
||||||
priceTier: { select: { priceMinor: true } },
|
priceTier: { select: { priceMinor: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -2130,6 +2133,8 @@ export class BookingsService {
|
|||||||
returnSchedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
|
returnSchedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
|
||||||
seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } },
|
seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } },
|
||||||
paymentIntent: true, tickets: true,
|
paymentIntent: true, tickets: true,
|
||||||
|
// Money collected after the original payment (reschedule fees, underpayments).
|
||||||
|
supplementaryCharges: { orderBy: { createdAt: 'asc' } },
|
||||||
priceTier: { select: { priceMinor: true } },
|
priceTier: { select: { priceMinor: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -2208,12 +2213,20 @@ export class BookingsService {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
// `amountMinor` stays exactly as it was — the original intent, in the major units that
|
||||||
|
// column actually stores — so existing callers keep working. Everything collected since
|
||||||
|
// (reschedule fees and fare differences) lives in `breakdown`, whose `totalPaidMinor` is
|
||||||
|
// the number to show as "Total paid". See payment-breakdown.util.ts.
|
||||||
payment: (booking as any).paymentIntent
|
payment: (booking as any).paymentIntent
|
||||||
? {
|
? {
|
||||||
method: (booking as any).paymentIntent.method,
|
method: (booking as any).paymentIntent.method,
|
||||||
status: (booking as any).paymentIntent.status,
|
status: (booking as any).paymentIntent.status,
|
||||||
amountMinor: (booking as any).paymentIntent.amountMinor,
|
amountMinor: (booking as any).paymentIntent.amountMinor,
|
||||||
currency: (booking as any).paymentIntent.currency,
|
currency: (booking as any).paymentIntent.currency,
|
||||||
|
breakdown: buildPaymentBreakdown(
|
||||||
|
(booking as any).paymentIntent,
|
||||||
|
(booking as any).supplementaryCharges ?? [],
|
||||||
|
),
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
// One ticket per passenger per leg (round trips have a separate ticket — and
|
// One ticket per passenger per leg (round trips have a separate ticket — and
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
export type PaymentLineKind = 'BOOKING' | 'SUPPLEMENTARY';
|
||||||
|
|
||||||
|
export interface PaymentLine {
|
||||||
|
kind: PaymentLineKind;
|
||||||
|
/** Human label — "Original booking", "Reschedule", "Excess baggage". */
|
||||||
|
label: string;
|
||||||
|
/** Raw reason for supplementary lines (RESCHEDULE, UNDERPAYMENT, …); null for the booking line. */
|
||||||
|
reason: string | null;
|
||||||
|
/** Always true minor units, whatever the source column stored. */
|
||||||
|
amountMinor: number;
|
||||||
|
currency: string;
|
||||||
|
status: string;
|
||||||
|
paidAt: string | null;
|
||||||
|
/** Whether this line represents money actually collected. */
|
||||||
|
settled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentBreakdown {
|
||||||
|
lines: PaymentLine[];
|
||||||
|
/** Sum of settled lines, or null when they are not all in one currency. */
|
||||||
|
totalPaidMinor: number | null;
|
||||||
|
totalPaidCurrency: string | null;
|
||||||
|
/** True when something is still owed (a charge raised but not yet paid). */
|
||||||
|
hasOutstanding: boolean;
|
||||||
|
outstandingMinor: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `PaymentIntent.amountMinor` is a Float in major units — bring it onto the minor-unit scale. */
|
||||||
|
export function intentAmountToMinor(amount: number | null | undefined): number {
|
||||||
|
if (amount == null) return 0;
|
||||||
|
return Math.round(amount * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUPPLEMENTARY_LABELS: Record<string, string> = {
|
||||||
|
RESCHEDULE: 'Reschedule',
|
||||||
|
UNDERPAYMENT: 'Underpayment',
|
||||||
|
FARE_CORRECTION: 'Fare correction',
|
||||||
|
EXCESS_BAGGAGE: 'Excess baggage',
|
||||||
|
};
|
||||||
|
|
||||||
|
function labelFor(reason: string): string {
|
||||||
|
return (
|
||||||
|
SUPPLEMENTARY_LABELS[reason] ??
|
||||||
|
// "SOME_OTHER_REASON" → "Some other reason"
|
||||||
|
reason.charAt(0).toUpperCase() + reason.slice(1).toLowerCase().replace(/_/g, ' ')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPaymentBreakdown(
|
||||||
|
paymentIntent: { status?: string | null; amountMinor?: number | null; currency?: string | null } | null | undefined,
|
||||||
|
supplementaryCharges: Array<{
|
||||||
|
reason: string;
|
||||||
|
amountMinor: number;
|
||||||
|
currency: string;
|
||||||
|
status: string;
|
||||||
|
paidAt: Date | string | null;
|
||||||
|
}> = [],
|
||||||
|
): PaymentBreakdown {
|
||||||
|
const lines: PaymentLine[] = [];
|
||||||
|
|
||||||
|
if (paymentIntent) {
|
||||||
|
lines.push({
|
||||||
|
kind: 'BOOKING',
|
||||||
|
label: 'Original booking',
|
||||||
|
reason: null,
|
||||||
|
amountMinor: intentAmountToMinor(paymentIntent.amountMinor),
|
||||||
|
currency: paymentIntent.currency ?? 'ETB',
|
||||||
|
status: paymentIntent.status ?? 'UNKNOWN',
|
||||||
|
paidAt: null,
|
||||||
|
settled: paymentIntent.status === 'SUCCEEDED',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const charge of supplementaryCharges) {
|
||||||
|
if (charge.status === 'WAIVED' || charge.status === 'EXPIRED') continue;
|
||||||
|
lines.push({
|
||||||
|
kind: 'SUPPLEMENTARY',
|
||||||
|
label: labelFor(charge.reason),
|
||||||
|
reason: charge.reason,
|
||||||
|
amountMinor: charge.amountMinor,
|
||||||
|
currency: charge.currency ?? 'ETB',
|
||||||
|
status: charge.status,
|
||||||
|
paidAt: charge.paidAt ? new Date(charge.paidAt).toISOString() : null,
|
||||||
|
settled: charge.status === 'PAID',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const settled = lines.filter((l) => l.settled);
|
||||||
|
const currencies = new Set(settled.map((l) => l.currency));
|
||||||
|
const singleCurrency = currencies.size === 1 ? [...currencies][0] : null;
|
||||||
|
|
||||||
|
const outstanding = lines.filter((l) => l.status === 'PENDING');
|
||||||
|
|
||||||
|
return {
|
||||||
|
lines,
|
||||||
|
totalPaidMinor: singleCurrency ? settled.reduce((sum, l) => sum + l.amountMinor, 0) : null,
|
||||||
|
totalPaidCurrency: singleCurrency,
|
||||||
|
hasOutstanding: outstanding.length > 0,
|
||||||
|
outstandingMinor: outstanding.reduce((sum, l) => sum + l.amountMinor, 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -40,6 +40,8 @@ interface PassengerVoucherData {
|
|||||||
// payment.amountMinor straight from the API) and must NOT be divided by 100 — as
|
// payment.amountMinor straight from the API) and must NOT be divided by 100 — as
|
||||||
// opposed to the normal case where fareMinor is genuine minor units (cents).
|
// opposed to the normal case where fareMinor is genuine minor units (cents).
|
||||||
fareIsMajorUnits?: boolean;
|
fareIsMajorUnits?: boolean;
|
||||||
|
/** Itemises the total when a booking was paid more than once (e.g. after a reschedule). */
|
||||||
|
paymentLines?: Array<{ label: string; amountMinor: number; currency: string; status: string; settled: boolean }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── palette ───────────────────────────────────────────────────────────────
|
// ─── palette ───────────────────────────────────────────────────────────────
|
||||||
@@ -365,6 +367,43 @@ function drawFareSummary(doc: jsPDF, fareMinor: number, currency: string, y: num
|
|||||||
|
|
||||||
return y + cardH + 6;
|
return y + cardH + 6;
|
||||||
}
|
}
|
||||||
|
function drawPaymentBreakdown(
|
||||||
|
doc: jsPDF,
|
||||||
|
lines: Array<{ label: string; amountMinor: number; currency: string; status: string; settled: boolean }>,
|
||||||
|
y: number,
|
||||||
|
margin: number,
|
||||||
|
pageWidth: number,
|
||||||
|
): number {
|
||||||
|
if (lines.length < 2) return y;
|
||||||
|
|
||||||
|
const rowH = 6;
|
||||||
|
const padX = 7;
|
||||||
|
const headerH = 7;
|
||||||
|
const cardH = headerH + lines.length * rowH + 3;
|
||||||
|
|
||||||
|
doc.setDrawColor(...HAIRLINE);
|
||||||
|
doc.setLineWidth(0.2);
|
||||||
|
doc.roundedRect(margin, y, pageWidth - margin * 2, cardH, 3, 3, 'S');
|
||||||
|
|
||||||
|
doc.setFontSize(7.5); doc.setFont('helvetica', 'bold'); doc.setTextColor(...MUTED);
|
||||||
|
doc.text('PAYMENT BREAKDOWN', margin + padX, y + 5, { charSpace: 0.2 });
|
||||||
|
|
||||||
|
lines.forEach((line, i) => {
|
||||||
|
const rowY = y + headerH + i * rowH;
|
||||||
|
doc.setFontSize(8.5); doc.setFont('helvetica', 'normal'); doc.setTextColor(...BODY);
|
||||||
|
// No status suffix: the caller passes settled lines only, so every row here is paid.
|
||||||
|
doc.text(line.label, margin + padX, rowY + 4);
|
||||||
|
doc.setFont('helvetica', 'bold'); doc.setTextColor(...INK);
|
||||||
|
doc.text(
|
||||||
|
`${line.currency} ${(line.amountMinor / 100).toFixed(2)}`,
|
||||||
|
pageWidth - margin - padX,
|
||||||
|
rowY + 4,
|
||||||
|
{ align: 'right' },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return y + cardH + 6;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── instructions ──────────────────────────────────────────────────────────
|
// ─── instructions ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -425,6 +464,7 @@ async function drawPassengerVoucherPage(doc: jsPDF, data: PassengerVoucherData):
|
|||||||
|
|
||||||
y = drawPassengerDetails(doc, data, y, margin, pageW);
|
y = drawPassengerDetails(doc, data, y, margin, pageW);
|
||||||
y = drawFareSummary(doc, data.fareMinor, data.currency, y, margin, pageW, data.fareIsMajorUnits);
|
y = drawFareSummary(doc, data.fareMinor, data.currency, y, margin, pageW, data.fareIsMajorUnits);
|
||||||
|
y = drawPaymentBreakdown(doc, data.paymentLines ?? [], y, margin, pageW);
|
||||||
y = drawInstructions(doc, y, margin, pageW);
|
y = drawInstructions(doc, y, margin, pageW);
|
||||||
drawFooter(doc, data.createdAt, y);
|
drawFooter(doc, data.createdAt, y);
|
||||||
}
|
}
|
||||||
@@ -474,15 +514,32 @@ interface VoucherData {
|
|||||||
tickets?: Array<{ passengerName?: string; leg?: number; barcodePayload?: string }>;
|
tickets?: Array<{ passengerName?: string; leg?: number; barcodePayload?: string }>;
|
||||||
// The actual settled amount/currency for this booking's payment — preferred over the
|
// The actual settled amount/currency for this booking's payment — preferred over the
|
||||||
// ETB booking total once available, since it reflects what was really charged.
|
// ETB booking total once available, since it reflects what was really charged.
|
||||||
payment?: { amountMinor?: number; currency?: string };
|
payment?: {
|
||||||
|
amountMinor?: number;
|
||||||
|
currency?: string;
|
||||||
|
/** Every line collected for this booking — see the API's payment-breakdown.util.ts. */
|
||||||
|
breakdown?: {
|
||||||
|
lines: Array<{ label: string; amountMinor: number; currency: string; status: string; settled: boolean }>;
|
||||||
|
totalPaidMinor: number | null;
|
||||||
|
totalPaidCurrency: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const generateVoucherPDF = async (booking: VoucherData): Promise<void> => {
|
export const generateVoucherPDF = async (booking: VoucherData): Promise<void> => {
|
||||||
// Currency always comes straight from the booking/payment data, never hardcoded — the
|
// Currency always comes straight from the booking/payment data, never hardcoded — the
|
||||||
// settled payment currency when a payment has settled, otherwise the booking's own
|
// settled payment currency when a payment has settled, otherwise the booking's own
|
||||||
// display currency (falling back to the internal ETB currency field).
|
// display currency (falling back to the internal ETB currency field).
|
||||||
const settledAmountMinor = booking.payment?.amountMinor;
|
// `payment.amountMinor` is the ORIGINAL intent only — it never moves when a booking is
|
||||||
const settledCurrency = booking.payment?.currency;
|
// rescheduled, so a voucher built from it reports the pre-reschedule amount forever (a 51.85
|
||||||
|
// booking moved to a 1752.34 journey still printed 51.85). `payment.breakdown` counts every
|
||||||
|
// line collected, including the reschedule fee and fare difference. It arrives in true minor
|
||||||
|
// units, so it is divided down to the major-unit basis the rest of this function expects.
|
||||||
|
const breakdown = booking.payment?.breakdown;
|
||||||
|
const settledAmountMinor =
|
||||||
|
breakdown?.totalPaidMinor != null ? breakdown.totalPaidMinor / 100 : booking.payment?.amountMinor;
|
||||||
|
const settledCurrency =
|
||||||
|
(breakdown?.totalPaidMinor != null ? breakdown.totalPaidCurrency : booking.payment?.currency) ?? undefined;
|
||||||
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');
|
||||||
@@ -541,7 +598,10 @@ export const generateVoucherPDF = async (booking: VoucherData): Promise<void> =>
|
|||||||
|
|
||||||
// This passenger's own fare, scaled onto the same currency/amount basis as the rest of
|
// This passenger's own fare, scaled onto the same currency/amount basis as the rest of
|
||||||
// the voucher — not the booking's overall total, and not an equal share of it.
|
// the voucher — not the booking's overall total, and not an equal share of it.
|
||||||
const passengerFareMinor = Math.round(p.fareMinor * fareScaleFactor);
|
const scaledFare = p.fareMinor * fareScaleFactor;
|
||||||
|
const passengerFareMinor = useSettledAmount
|
||||||
|
? Math.round(scaledFare * 100) / 100
|
||||||
|
: Math.round(scaledFare);
|
||||||
|
|
||||||
await generatePassengerVoucherPDF({
|
await generatePassengerVoucherPDF({
|
||||||
bookingRef: booking.bookingRef,
|
bookingRef: booking.bookingRef,
|
||||||
@@ -560,6 +620,7 @@ export const generateVoucherPDF = async (booking: VoucherData): Promise<void> =>
|
|||||||
fareMinor: passengerFareMinor,
|
fareMinor: passengerFareMinor,
|
||||||
currency: voucherCurrency,
|
currency: voucherCurrency,
|
||||||
fareIsMajorUnits: useSettledAmount,
|
fareIsMajorUnits: useSettledAmount,
|
||||||
|
paymentLines: grouped.size === 1 ? breakdown?.lines.filter((l) => l.settled) : undefined,
|
||||||
createdAt: booking.createdAt,
|
createdAt: booking.createdAt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user