mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge remote-tracking branch 'origin/dev' into tests
Merging dev to my local test branch
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -47,6 +47,23 @@ export class ReportsController {
|
||||
return this.service.getPaymentDiscrepancyReport({ from, to, sortBy, search });
|
||||
}
|
||||
|
||||
@Get("payments")
|
||||
@ApiOperation({ summary: "Payments collected for a schedule" })
|
||||
getPaymentsReport(@Query('scheduleId') scheduleId: string) {
|
||||
return this.service.getPaymentsReport(scheduleId);
|
||||
}
|
||||
|
||||
@Get("payments/discrepancy")
|
||||
@ApiOperation({ summary: "Payment discrepancy breakdown for a schedule" })
|
||||
getPaymentDiscrepancyBySchedule(
|
||||
@Query('scheduleId') scheduleId: string,
|
||||
@Query('search') search?: string,
|
||||
@Query('seatClass') seatClass?: string,
|
||||
@Query('sort') sort?: string,
|
||||
) {
|
||||
return this.service.getPaymentDiscrepancyBySchedule(scheduleId, { search, seatClass, sort });
|
||||
}
|
||||
|
||||
@Get(":reportId")
|
||||
@ApiOperation({ summary: "Get report by ID" })
|
||||
getReport(@Param("reportId") reportId: string) {
|
||||
|
||||
@@ -412,20 +412,27 @@ export class ReportsService {
|
||||
}
|
||||
|
||||
async listSchedulesForPicker() {
|
||||
const now = new Date();
|
||||
const schedules = await this.prisma.trainSchedule.findMany({
|
||||
where: { departureAt: { gte: now } },
|
||||
select: {
|
||||
id: true,
|
||||
departureAt: true,
|
||||
isPackageOnly: true,
|
||||
train: { select: { number: true } },
|
||||
originStation: { select: { name: true } },
|
||||
destinationStation: { select: { name: true } },
|
||||
},
|
||||
orderBy: { departureAt: "desc" },
|
||||
orderBy: { departureAt: 'asc' },
|
||||
take: 200,
|
||||
});
|
||||
return schedules.map((s) => ({
|
||||
id: s.id,
|
||||
label: `${s.train.number} · ${s.originStation.name} → ${s.destinationStation.name} · ${new Date(s.departureAt).toLocaleString("en-GB", { dateStyle: "medium", timeStyle: "short" })}`,
|
||||
departureAt: s.departureAt,
|
||||
isPackage: s.isPackageOnly,
|
||||
label: `${s.train.number} · ${s.originStation.name} → ${s.destinationStation.name} · ${new Date(s.departureAt).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' })}${
|
||||
s.isPackageOnly ? ' (package)' : ''
|
||||
}`,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -684,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';
|
||||
|
||||
@@ -795,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';
|
||||
|
||||
@@ -843,6 +851,160 @@ export class ReportsService {
|
||||
}));
|
||||
}
|
||||
|
||||
async getPaymentsReport(scheduleId: string) {
|
||||
const bookings = await this.prisma.booking.findMany({
|
||||
where: {
|
||||
scheduleId,
|
||||
status: { in: ['CONFIRMED', 'BOARDED', 'NO_SHOW'] as any },
|
||||
paymentIntent: { status: 'SUCCEEDED' },
|
||||
},
|
||||
include: {
|
||||
paymentIntent: { select: { amountMinor: true, currency: true, method: true, paidAt: true } },
|
||||
seats: {
|
||||
where: { leg: 1 },
|
||||
select: {
|
||||
passengerName: true,
|
||||
fareMinor: true,
|
||||
passengerCategory: true,
|
||||
seatLabelSnapshot: true,
|
||||
seat: { select: { coach: { select: { number: true, coachType: { select: { name: true } } } } } },
|
||||
},
|
||||
},
|
||||
passenger: { select: { user: { select: { phone: true, fullName: true } } } },
|
||||
},
|
||||
});
|
||||
|
||||
const rows = bookings.map(b => {
|
||||
const actualMinor = b.seats.reduce((s, seat) => s + (seat.fareMinor ?? 0), 0);
|
||||
const paidMinor = Math.round(b.paymentIntent!.amountMinor);
|
||||
return {
|
||||
bookingRef: b.bookingRef,
|
||||
passengerName: b.seats[0]?.passengerName ?? b.passenger?.user?.fullName ?? '—',
|
||||
phone: b.passenger?.user?.phone ?? (b as any).contactPhone ?? '—',
|
||||
method: b.paymentIntent!.method,
|
||||
paidAt: b.paymentIntent!.paidAt,
|
||||
actualMinor,
|
||||
paidMinor,
|
||||
currency: 'ETB',
|
||||
passengerCount: b.seats.length,
|
||||
};
|
||||
});
|
||||
|
||||
const totalActualMinor = rows.reduce((s, r) => s + r.actualMinor, 0);
|
||||
const totalPaidMinor = rows.reduce((s, r) => s + r.paidMinor, 0);
|
||||
|
||||
const byMethod = rows.reduce((acc, r) => {
|
||||
acc[r.method] = (acc[r.method] ?? 0) + r.paidMinor;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
return { totalActualMinor, totalPaidMinor, byMethod, rows };
|
||||
}
|
||||
|
||||
async getPaymentDiscrepancyBySchedule(scheduleId: string, params: {
|
||||
search?: string;
|
||||
seatClass?: string;
|
||||
sort?: string;
|
||||
}) {
|
||||
const bookings = await this.prisma.booking.findMany({
|
||||
where: {
|
||||
scheduleId,
|
||||
status: { in: ['CONFIRMED', 'BOARDED', 'NO_SHOW'] as any },
|
||||
paymentIntent: { status: 'SUCCEEDED' },
|
||||
},
|
||||
include: {
|
||||
paymentIntent: { select: { amountMinor: true, currency: true } },
|
||||
schedule: {
|
||||
include: {
|
||||
originStation: { select: { name: true } },
|
||||
destinationStation: { select: { name: true } },
|
||||
},
|
||||
},
|
||||
package: { select: { id: true } },
|
||||
seats: {
|
||||
where: { leg: 1 },
|
||||
orderBy: [
|
||||
{ seat: { coach: { number: 'asc' as const } } },
|
||||
{ seat: { seatNumber: 'asc' as const } },
|
||||
],
|
||||
select: {
|
||||
passengerName: true,
|
||||
passengerCategory: true,
|
||||
seatLabelSnapshot: true,
|
||||
fareMinor: true,
|
||||
seat: {
|
||||
select: {
|
||||
seatNumber: true,
|
||||
bedPosition: true,
|
||||
coach: { select: { number: true, coachType: { select: { name: true, seatClasses: { select: { name: true, bedPosition: true } } } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
passenger: { select: { user: { select: { phone: true, fullName: true } } } },
|
||||
},
|
||||
});
|
||||
|
||||
const resolveSeatClass = (seat: any): string => {
|
||||
const classes = seat?.coach?.coachType?.seatClasses ?? [];
|
||||
const matched = seat?.bedPosition
|
||||
? classes.find((sc: any) => sc.bedPosition?.toLowerCase() === seat.bedPosition.toLowerCase())
|
||||
: null;
|
||||
return (matched ?? classes[0])?.name ?? seat?.coach?.coachType?.name ?? 'Unknown';
|
||||
};
|
||||
|
||||
let rows = bookings.map(b => {
|
||||
const pi = b.paymentIntent!;
|
||||
const actualMinor = b.seats.reduce((s, seat) => s + (seat.fareMinor ?? 0), 0);
|
||||
// pi.amountMinor is a Float in full currency units — convert to cents once
|
||||
const paidMinorCents = Math.round(pi.amountMinor * 100);
|
||||
|
||||
const isPackage = !!(b as any).package;
|
||||
const effectiveActualMinor = isPackage ? actualMinor * 2 : actualMinor;
|
||||
const effectiveVarianceMinor = effectiveActualMinor - paidMinorCents;
|
||||
|
||||
const breakdown = b.seats.map(s => ({
|
||||
passengerName: s.passengerName ?? '—',
|
||||
seatClass: resolveSeatClass(s.seat),
|
||||
coachNumber: s.seat?.coach?.number ?? null,
|
||||
seatNumber: s.seat?.seatNumber ?? null,
|
||||
fareMinor: isPackage ? (s.fareMinor ?? 0) * 2 : (s.fareMinor ?? 0),
|
||||
}));
|
||||
|
||||
const firstSeat = b.seats[0];
|
||||
return {
|
||||
bookingRef: b.bookingRef,
|
||||
isPackage,
|
||||
seatClass: firstSeat?.seatLabelSnapshot ?? firstSeat?.seat?.coach?.coachType?.name ?? '—',
|
||||
coachNumber: firstSeat?.seat?.coach?.number ?? null,
|
||||
seatNumber: firstSeat?.seat?.seatNumber ?? null,
|
||||
origin: b.schedule.originStation.name,
|
||||
destination: b.schedule.destinationStation.name,
|
||||
phone: b.passenger?.user?.phone ?? (b as any).contactPhone ?? '—',
|
||||
actualMinor: effectiveActualMinor,
|
||||
paidMinor: paidMinorCents,
|
||||
varianceMinor: effectiveVarianceMinor,
|
||||
breakdown,
|
||||
};
|
||||
}).filter(r => r.varianceMinor > 0);
|
||||
|
||||
if (params.search?.trim()) {
|
||||
const q = params.search.trim().toUpperCase();
|
||||
rows = rows.filter(r => r.bookingRef.toUpperCase().includes(q));
|
||||
}
|
||||
if (params.seatClass?.trim()) {
|
||||
const sc = params.seatClass.trim().toLowerCase();
|
||||
rows = rows.filter(r => r.breakdown.some(b => b.seatClass.toLowerCase().includes(sc)));
|
||||
}
|
||||
if (params.sort === 'asc') {
|
||||
rows.sort((a, b) => a.varianceMinor - b.varianceMinor);
|
||||
} else {
|
||||
rows.sort((a, b) => b.varianceMinor - a.varianceMinor);
|
||||
}
|
||||
|
||||
return { total: rows.length, rows };
|
||||
}
|
||||
|
||||
async getReport(reportId: string) {
|
||||
return this.prisma.operationalReport.findUnique({
|
||||
where: { id: reportId },
|
||||
|
||||
Reference in New Issue
Block a user