mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 10:52:53 +00:00
Merge remote-tracking branch 'origin/dev' into tests
Merging remote repo
This commit is contained in:
@@ -598,6 +598,17 @@ export class PaymentsService {
|
||||
where: { bookingId },
|
||||
});
|
||||
|
||||
|
||||
if (local?.status === PaymentIntentStatus.SUCCEEDED) {
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
select: { status: true },
|
||||
});
|
||||
if (booking?.status === "CONFIRMED") {
|
||||
return this.formatIntentStatus(local);
|
||||
}
|
||||
}
|
||||
|
||||
// WALLET payments never leave this app — no remote intent exists for them.
|
||||
if (local?.method === PaymentMethodType.WALLET) {
|
||||
return this.formatIntentStatus(local);
|
||||
|
||||
@@ -599,32 +599,31 @@ export class ReportsService {
|
||||
sortBy?: string;
|
||||
search?: string;
|
||||
}) {
|
||||
// Load exchange rates once — we need DJF→ETB (and any other non-ETB currencies).
|
||||
// Keep only the most-recent rate per pair (rates are ordered desc by effectiveDate).
|
||||
// Load all exchange rates once — we need conversions in both directions.
|
||||
const rateRows = await this.prisma.currencyExchangeRate.findMany({
|
||||
where: { toCurrency: 'ETB' as any },
|
||||
orderBy: { effectiveDate: 'desc' },
|
||||
});
|
||||
const rateToEtb = new Map<string, number>();
|
||||
// Most-recent rate for each fromCurrency→toCurrency pair
|
||||
const rateMap = new Map<string, number>();
|
||||
for (const r of rateRows) {
|
||||
if (!rateToEtb.has(r.fromCurrency)) {
|
||||
rateToEtb.set(r.fromCurrency, Number(r.rate));
|
||||
}
|
||||
const key = `${r.fromCurrency}→${r.toCurrency}`;
|
||||
if (!rateMap.has(key)) rateMap.set(key, Number(r.rate));
|
||||
}
|
||||
|
||||
// Convert any minor amount to its ETB equivalent using stored exchange rates.
|
||||
// b.totalMinor is the booking's canonical ETB amount (always stored in ETB),
|
||||
// so callers should pass that directly rather than converting displayTotalMinor.
|
||||
const toEtbMinor = (minor: number, currency: string): number => {
|
||||
if (currency === 'ETB') return minor;
|
||||
const rate = rateToEtb.get(currency);
|
||||
// If no rate is on file fall back to the raw value (avoids silently hiding
|
||||
// cross-currency bookings, at the cost of an approximate comparison).
|
||||
return rate ? Math.round(minor * rate) : minor;
|
||||
// Convert minor amount from one currency to another.
|
||||
const convertMinor = (minor: number, from: string, to: string): number => {
|
||||
if (from === to) return minor;
|
||||
const direct = rateMap.get(`${from}→${to}`);
|
||||
if (direct) return Math.round(minor * direct);
|
||||
// Try via ETB as pivot
|
||||
const toEtb = rateMap.get(`${from}→ETB`);
|
||||
const fromEtb = rateMap.get(`ETB→${to}`);
|
||||
if (toEtb && fromEtb) return Math.round(minor * toEtb * fromEtb);
|
||||
return minor; // fallback: no rate on file
|
||||
};
|
||||
|
||||
if (params.search?.trim()) {
|
||||
return this.getDiscrepancyForRef(params.search.trim(), toEtbMinor);
|
||||
return this.getDiscrepancyForRef(params.search.trim(), convertMinor);
|
||||
}
|
||||
|
||||
const dateFilter: Record<string, Date> = {};
|
||||
@@ -679,20 +678,18 @@ export class ReportsService {
|
||||
.map(b => {
|
||||
const pi = b.paymentIntent!;
|
||||
|
||||
// Display amounts shown to the passenger (may be in DJF).
|
||||
// Display amounts shown to the passenger (may be in DJF/USD).
|
||||
const actualMinor = b.displayTotalMinor ?? b.totalMinor;
|
||||
const actualCurrency = (b.displayCurrency as string | null) ?? b.currency;
|
||||
|
||||
const paidMinor = pi.amountMinor;
|
||||
const paidCurrency = pi.currency;
|
||||
|
||||
// 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 * 100, paidCurrency);
|
||||
const balanceMinor = owedEtb - paidEtb;
|
||||
const balanceCurrency = 'ETB';
|
||||
// Balance in the booking's display currency:
|
||||
// convert paid (major units from gateway) to display currency minor, then subtract.
|
||||
const paidInDisplayMinor = convertMinor(paidMinor * 100, paidCurrency, actualCurrency);
|
||||
const balanceMinor = actualMinor - paidInDisplayMinor;
|
||||
const balanceCurrency = actualCurrency;
|
||||
|
||||
const firstSeat = b.seats[0];
|
||||
const passengers = this.buildSeatPassengers(b.seats, actualCurrency);
|
||||
@@ -731,7 +728,7 @@ export class ReportsService {
|
||||
|
||||
private async getDiscrepancyForRef(
|
||||
search: string,
|
||||
toEtbMinor: (minor: number, currency: string) => number,
|
||||
convertMinor: (minor: number, from: string, to: string) => number,
|
||||
) {
|
||||
let bookingId: string | null = null;
|
||||
const byPnr = await this.prisma.booking.findUnique({
|
||||
@@ -797,10 +794,9 @@ export class ReportsService {
|
||||
const paidMinor = pi?.amountMinor ?? 0;
|
||||
const paidCurrency = pi?.currency ?? b.currency;
|
||||
|
||||
const owedEtb = b.totalMinor;
|
||||
const paidEtb = toEtbMinor(paidMinor * 100, paidCurrency);
|
||||
const balanceMinor = owedEtb - paidEtb;
|
||||
const balanceCurrency = 'ETB';
|
||||
const paidInDisplayMinor = convertMinor(paidMinor * 100, paidCurrency, actualCurrency);
|
||||
const balanceMinor = actualMinor - paidInDisplayMinor;
|
||||
const balanceCurrency = actualCurrency;
|
||||
|
||||
const firstSeat = b.seats[0];
|
||||
const passengers = this.buildSeatPassengers(b.seats, actualCurrency);
|
||||
@@ -847,6 +843,20 @@ export class ReportsService {
|
||||
}
|
||||
|
||||
async getPaymentsReport(scheduleId: string) {
|
||||
const rateRows = await this.prisma.currencyExchangeRate.findMany({
|
||||
where: { toCurrency: 'ETB' as any },
|
||||
orderBy: { effectiveDate: 'desc' },
|
||||
});
|
||||
const rateToEtb = new Map<string, number>();
|
||||
for (const r of rateRows) {
|
||||
if (!rateToEtb.has(r.fromCurrency)) rateToEtb.set(r.fromCurrency, Number(r.rate));
|
||||
}
|
||||
const toEtbMinor = (minor: number, currency: string): number => {
|
||||
if (currency === 'ETB') return minor;
|
||||
const rate = rateToEtb.get(currency);
|
||||
return rate ? Math.round(minor * rate) : minor;
|
||||
};
|
||||
|
||||
const bookings = await this.prisma.booking.findMany({
|
||||
where: {
|
||||
scheduleId,
|
||||
@@ -860,6 +870,8 @@ export class ReportsService {
|
||||
select: {
|
||||
passengerName: true,
|
||||
fareMinor: true,
|
||||
displayFareMinor: true,
|
||||
displayCurrency: true,
|
||||
passengerCategory: true,
|
||||
seatLabelSnapshot: true,
|
||||
seat: { select: { coach: { select: { number: true, coachType: { select: { name: true } } } } } },
|
||||
@@ -870,30 +882,38 @@ export class ReportsService {
|
||||
});
|
||||
|
||||
const rows = bookings.map(b => {
|
||||
const actualMinor = b.seats.reduce((s, seat) => s + (seat.fareMinor ?? 0), 0);
|
||||
const paidMinor = Math.round(b.paymentIntent!.amountMinor);
|
||||
const pi = b.paymentIntent!;
|
||||
const actualMinor = b.displayTotalMinor ?? b.totalMinor;
|
||||
const actualCurrency = (b.displayCurrency as string | null) ?? b.currency;
|
||||
// pi.amountMinor is stored in major units — convert to minor
|
||||
const paidMinor = Math.round(pi.amountMinor * 100);
|
||||
const paidCurrency = pi.currency;
|
||||
const varianceMinor = toEtbMinor(actualMinor, actualCurrency) - toEtbMinor(paidMinor, paidCurrency);
|
||||
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,
|
||||
method: pi.method,
|
||||
paidAt: pi.paidAt,
|
||||
actualMinor,
|
||||
actualCurrency,
|
||||
paidMinor,
|
||||
currency: 'ETB',
|
||||
paidCurrency,
|
||||
varianceMinor,
|
||||
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 totalActualEtbMinor = rows.reduce((s, r) => s + toEtbMinor(r.actualMinor, r.actualCurrency), 0);
|
||||
const totalPaidEtbMinor = rows.reduce((s, r) => s + toEtbMinor(r.paidMinor, r.paidCurrency), 0);
|
||||
|
||||
const byMethod = rows.reduce((acc, r) => {
|
||||
acc[r.method] = (acc[r.method] ?? 0) + r.paidMinor;
|
||||
if (!acc[r.method]) acc[r.method] = { totalPaidEtbMinor: 0, currency: 'ETB' };
|
||||
acc[r.method].totalPaidEtbMinor += toEtbMinor(r.paidMinor, r.paidCurrency);
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
}, {} as Record<string, { totalPaidEtbMinor: number; currency: string }>);
|
||||
|
||||
return { totalActualMinor, totalPaidMinor, byMethod, rows };
|
||||
return { totalActualEtbMinor, totalPaidEtbMinor, byMethod, rows };
|
||||
}
|
||||
|
||||
async getPaymentDiscrepancyBySchedule(scheduleId: string, params: {
|
||||
@@ -909,13 +929,7 @@ export class ReportsService {
|
||||
},
|
||||
include: {
|
||||
paymentIntent: { select: { amountMinor: true, currency: true } },
|
||||
schedule: {
|
||||
include: {
|
||||
originStation: { select: { name: true } },
|
||||
destinationStation: { select: { name: true } },
|
||||
},
|
||||
},
|
||||
package: { select: { id: true } },
|
||||
schedule: { select: { id: true } },
|
||||
seats: {
|
||||
where: { leg: 1 },
|
||||
orderBy: [
|
||||
@@ -940,6 +954,14 @@ export class ReportsService {
|
||||
},
|
||||
});
|
||||
|
||||
const stationIds = [...new Set(
|
||||
bookings.flatMap(b => [b.originStationId, b.destinationStationId]).filter(Boolean) as string[],
|
||||
)];
|
||||
const stations = stationIds.length > 0
|
||||
? await this.prisma.station.findMany({ where: { id: { in: stationIds } }, select: { id: true, name: true } })
|
||||
: [];
|
||||
const stationName = new Map(stations.map(s => [s.id, s.name]));
|
||||
|
||||
const resolveSeatClass = (seat: any): string => {
|
||||
const classes = seat?.coach?.coachType?.seatClasses ?? [];
|
||||
const matched = seat?.bedPosition
|
||||
@@ -954,7 +976,7 @@ export class ReportsService {
|
||||
// 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 isPackage = !!(b as any).packageId;
|
||||
const effectiveActualMinor = isPackage ? actualMinor * 2 : actualMinor;
|
||||
const effectiveVarianceMinor = effectiveActualMinor - paidMinorCents;
|
||||
|
||||
@@ -973,8 +995,8 @@ export class ReportsService {
|
||||
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,
|
||||
origin: b.originStationId ? (stationName.get(b.originStationId) ?? '—') : '—',
|
||||
destination: b.destinationStationId ? (stationName.get(b.destinationStationId) ?? '—') : '—',
|
||||
phone: b.passenger?.user?.phone ?? (b as any).contactPhone ?? '—',
|
||||
actualMinor: effectiveActualMinor,
|
||||
paidMinor: paidMinorCents,
|
||||
@@ -989,7 +1011,7 @@ export class ReportsService {
|
||||
}
|
||||
if (params.seatClass?.trim()) {
|
||||
const sc = params.seatClass.trim().toLowerCase();
|
||||
rows = rows.filter(r => r.breakdown.some(b => b.seatClass.toLowerCase().includes(sc)));
|
||||
rows = rows.filter(r => r.breakdown.some(bd => bd.seatClass.toLowerCase().includes(sc)));
|
||||
}
|
||||
if (params.sort === 'asc') {
|
||||
rows.sort((a, b) => a.varianceMinor - b.varianceMinor);
|
||||
|
||||
@@ -14,10 +14,11 @@ export class TicketsController {
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Generate tickets for all confirmed bookings that are missing them',
|
||||
description: 'Finds every CONFIRMED booking with no ticket rows and attempts to generate tickets for each. Returns a summary of processed/generated/failed counts.',
|
||||
description: 'Finds every CONFIRMED booking with no ticket rows and attempts to generate tickets for each. Returns a summary of processed/generated/failed/remaining counts. Call repeatedly until remaining=0.',
|
||||
})
|
||||
generateMissing() {
|
||||
return this.service.generateMissing();
|
||||
@ApiQuery({ name: 'limit', required: false, description: 'Max bookings to process per call (default 10)' })
|
||||
generateMissing(@Query('limit') limit?: string) {
|
||||
return this.service.generateMissing(limit ? parseInt(limit, 10) : 10);
|
||||
}
|
||||
|
||||
@Post('smart-assign/:bookingId')
|
||||
|
||||
@@ -892,15 +892,21 @@ export class TicketsService {
|
||||
};
|
||||
}
|
||||
|
||||
async generateMissing(): Promise<{ processed: number; generated: number; failed: number; details: any[] }> {
|
||||
const confirmedWithNoTickets = await this.prisma.booking.findMany({
|
||||
where: {
|
||||
status: 'CONFIRMED',
|
||||
tickets: { none: {} },
|
||||
paymentIntent: { status: 'SUCCEEDED' },
|
||||
},
|
||||
select: { id: true, bookingRef: true },
|
||||
});
|
||||
async generateMissing(limit = 10): Promise<{ processed: number; generated: number; failed: number; remaining: number; details: any[] }> {
|
||||
const missingWhere = {
|
||||
status: 'CONFIRMED' as const,
|
||||
tickets: { none: {} },
|
||||
paymentIntent: { status: 'SUCCEEDED' as const },
|
||||
};
|
||||
|
||||
const [confirmedWithNoTickets, totalRemaining] = await Promise.all([
|
||||
this.prisma.booking.findMany({
|
||||
where: missingWhere,
|
||||
select: { id: true, bookingRef: true },
|
||||
take: limit,
|
||||
}),
|
||||
this.prisma.booking.count({ where: missingWhere }),
|
||||
]);
|
||||
|
||||
const details: any[] = [];
|
||||
let generated = 0;
|
||||
@@ -908,7 +914,7 @@ export class TicketsService {
|
||||
|
||||
for (const booking of confirmedWithNoTickets) {
|
||||
try {
|
||||
await this.generate(booking.id);
|
||||
await this.smartAssignAndGenerate(booking.id);
|
||||
generated++;
|
||||
details.push({ bookingId: booking.id, bookingRef: booking.bookingRef, status: 'generated' });
|
||||
} catch (err) {
|
||||
@@ -917,7 +923,13 @@ export class TicketsService {
|
||||
}
|
||||
}
|
||||
|
||||
return { processed: confirmedWithNoTickets.length, generated, failed, details };
|
||||
return {
|
||||
processed: confirmedWithNoTickets.length,
|
||||
generated,
|
||||
failed,
|
||||
remaining: Math.max(0, totalRemaining - confirmedWithNoTickets.length),
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
||||
async delete(id: string) {
|
||||
|
||||
Reference in New Issue
Block a user