mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 04:08:11 +00:00
Added payment discrepancy report
This commit is contained in:
@@ -36,6 +36,17 @@ export class ReportsController {
|
||||
return this.service.getOccupancyBySchedule(scheduleId);
|
||||
}
|
||||
|
||||
@Get("payment-discrepancy")
|
||||
@ApiOperation({ summary: "Payment discrepancy report — bookings where paid amount is less than the fare. Pass `search` to look up a specific PNR or ticket number." })
|
||||
getPaymentDiscrepancy(
|
||||
@Query('from') from?: string,
|
||||
@Query('to') to?: string,
|
||||
@Query('sortBy') sortBy?: string,
|
||||
@Query('search') search?: string,
|
||||
) {
|
||||
return this.service.getPaymentDiscrepancyReport({ from, to, sortBy, search });
|
||||
}
|
||||
|
||||
@Get(":reportId")
|
||||
@ApiOperation({ summary: "Get report by ID" })
|
||||
getReport(@Param("reportId") reportId: string) {
|
||||
|
||||
@@ -591,6 +591,226 @@ export class ReportsService {
|
||||
};
|
||||
}
|
||||
|
||||
async getPaymentDiscrepancyReport(params: {
|
||||
from?: string;
|
||||
to?: string;
|
||||
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).
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
};
|
||||
|
||||
if (params.search?.trim()) {
|
||||
return this.getDiscrepancyForRef(params.search.trim(), toEtbMinor);
|
||||
}
|
||||
|
||||
const dateFilter: Record<string, Date> = {};
|
||||
if (params.from) dateFilter.gte = new Date(params.from + 'T00:00:00.000Z');
|
||||
if (params.to) dateFilter.lte = new Date(params.to + 'T23:59:59.999Z');
|
||||
|
||||
const bookings = await this.prisma.booking.findMany({
|
||||
where: {
|
||||
status: { in: ['CONFIRMED', 'BOARDED', 'NO_SHOW'] as any },
|
||||
paymentIntent: { status: 'SUCCEEDED' },
|
||||
...(Object.keys(dateFilter).length > 0 && { createdAt: dateFilter }),
|
||||
},
|
||||
include: {
|
||||
paymentIntent: { select: { amountMinor: true, currency: true, paidAt: true } },
|
||||
schedule: {
|
||||
include: {
|
||||
originStation: { select: { name: true, code: true, city: true } },
|
||||
destinationStation: { select: { name: true, code: true, city: true } },
|
||||
},
|
||||
},
|
||||
seats: {
|
||||
take: 1,
|
||||
orderBy: { leg: 'asc' },
|
||||
select: {
|
||||
passengerName: true,
|
||||
seatLabelSnapshot: true,
|
||||
seat: {
|
||||
select: {
|
||||
seatNumber: true,
|
||||
bedPosition: true,
|
||||
coach: { select: { number: true, coachType: { select: { name: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
passenger: {
|
||||
select: { user: { select: { phone: true, fullName: true } } },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const rows = bookings
|
||||
.map(b => {
|
||||
const pi = b.paymentIntent!;
|
||||
|
||||
// Display amounts shown to the passenger (may be in DJF).
|
||||
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. Convert the paid amount to ETB for an
|
||||
// apples-to-apples comparison regardless of which currency was used at checkout.
|
||||
const owedEtb = b.totalMinor;
|
||||
const paidEtb = toEtbMinor(paidMinor, paidCurrency);
|
||||
const balanceMinor = owedEtb - paidEtb;
|
||||
const balanceCurrency = 'ETB';
|
||||
|
||||
const firstSeat = b.seats[0];
|
||||
return {
|
||||
pnr: b.bookingRef,
|
||||
passengerName: firstSeat?.passengerName ?? b.passenger?.user?.fullName ?? '—',
|
||||
phone: b.passenger?.user?.phone ?? (b as any).contactPhone ?? '—',
|
||||
bookingDate: b.createdAt,
|
||||
origin: b.schedule.originStation,
|
||||
destination: b.schedule.destinationStation,
|
||||
departureAt: b.schedule.departureAt,
|
||||
seatType: firstSeat?.seatLabelSnapshot ?? firstSeat?.seat?.coach?.coachType?.name ?? '—',
|
||||
coachNumber: firstSeat?.seat?.coach?.number ?? null,
|
||||
actualMinor,
|
||||
actualCurrency,
|
||||
paidMinor,
|
||||
paidCurrency,
|
||||
balanceMinor,
|
||||
balanceCurrency,
|
||||
};
|
||||
})
|
||||
.filter(r => r.balanceMinor > 0);
|
||||
|
||||
if (params.sortBy === 'departure') {
|
||||
rows.sort((a, b) => new Date(a.departureAt).getTime() - new Date(b.departureAt).getTime());
|
||||
} else {
|
||||
rows.sort((a, b) => b.balanceMinor - a.balanceMinor);
|
||||
}
|
||||
|
||||
const totalBalanceEtbMinor = rows.reduce((sum, r) => sum + r.balanceMinor, 0);
|
||||
|
||||
return { total: rows.length, totalBalanceEtbMinor, rows };
|
||||
}
|
||||
|
||||
private async getDiscrepancyForRef(
|
||||
search: string,
|
||||
toEtbMinor: (minor: number, currency: string) => number,
|
||||
) {
|
||||
let bookingId: string | null = null;
|
||||
const byPnr = await this.prisma.booking.findUnique({
|
||||
where: { bookingRef: search.toUpperCase() },
|
||||
select: { id: true },
|
||||
});
|
||||
if (byPnr) {
|
||||
bookingId = byPnr.id;
|
||||
} else {
|
||||
const ticket = await this.prisma.ticket.findFirst({
|
||||
where: { barcodePayload: search },
|
||||
select: { bookingId: true },
|
||||
});
|
||||
bookingId = ticket?.bookingId ?? null;
|
||||
}
|
||||
|
||||
if (!bookingId) {
|
||||
return { total: 0, totalBalanceEtbMinor: 0, rows: [], notFound: true };
|
||||
}
|
||||
|
||||
const b = await this.prisma.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
include: {
|
||||
paymentIntent: { select: { amountMinor: true, currency: true, paidAt: true, status: true } },
|
||||
schedule: {
|
||||
include: {
|
||||
originStation: { select: { name: true, code: true, city: true } },
|
||||
destinationStation: { select: { name: true, code: true, city: true } },
|
||||
},
|
||||
},
|
||||
seats: {
|
||||
take: 1,
|
||||
orderBy: { leg: 'asc' },
|
||||
select: {
|
||||
passengerName: true,
|
||||
seatLabelSnapshot: true,
|
||||
seat: {
|
||||
select: {
|
||||
seatNumber: true,
|
||||
bedPosition: true,
|
||||
coach: { select: { number: true, coachType: { select: { name: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
passenger: {
|
||||
select: { user: { select: { phone: true, fullName: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!b) return { total: 0, totalBalanceEtbMinor: 0, rows: [], notFound: true };
|
||||
|
||||
const pi = b.paymentIntent;
|
||||
const actualMinor = b.displayTotalMinor ?? b.totalMinor;
|
||||
const actualCurrency = (b.displayCurrency as string | null) ?? b.currency;
|
||||
const paidMinor = pi?.amountMinor ?? 0;
|
||||
const paidCurrency = pi?.currency ?? b.currency;
|
||||
|
||||
const owedEtb = b.totalMinor;
|
||||
const paidEtb = toEtbMinor(paidMinor, paidCurrency);
|
||||
const balanceMinor = owedEtb - paidEtb;
|
||||
const balanceCurrency = 'ETB';
|
||||
|
||||
const firstSeat = b.seats[0];
|
||||
const row = {
|
||||
pnr: b.bookingRef,
|
||||
passengerName: firstSeat?.passengerName ?? b.passenger?.user?.fullName ?? '—',
|
||||
phone: b.passenger?.user?.phone ?? (b as any).contactPhone ?? '—',
|
||||
bookingDate: b.createdAt,
|
||||
origin: b.schedule.originStation,
|
||||
destination: b.schedule.destinationStation,
|
||||
departureAt: b.schedule.departureAt,
|
||||
seatType: firstSeat?.seatLabelSnapshot ?? firstSeat?.seat?.coach?.coachType?.name ?? '—',
|
||||
coachNumber: firstSeat?.seat?.coach?.number ?? null,
|
||||
actualMinor,
|
||||
actualCurrency,
|
||||
paidMinor,
|
||||
paidCurrency,
|
||||
balanceMinor,
|
||||
balanceCurrency,
|
||||
bookingStatus: b.status,
|
||||
paymentStatus: pi?.status ?? null,
|
||||
};
|
||||
|
||||
return {
|
||||
total: balanceMinor > 0 ? 1 : 0,
|
||||
totalBalanceEtbMinor: balanceMinor > 0 ? balanceMinor : 0,
|
||||
rows: [row],
|
||||
notFound: false,
|
||||
};
|
||||
}
|
||||
|
||||
async getReport(reportId: string) {
|
||||
return this.prisma.operationalReport.findUnique({
|
||||
where: { id: reportId },
|
||||
|
||||
Reference in New Issue
Block a user