Merge remote-tracking branch 'origin/dev' into tests

Merging dev to my local test branch
This commit is contained in:
Muluhabt
2026-07-21 00:08:49 +03:00
33 changed files with 1645 additions and 159 deletions

View File

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

View File

@@ -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) {

View File

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