Added payment discrepancy report

This commit is contained in:
Roba Boru
2026-07-20 15:50:03 +03:00
parent 5e9feab869
commit 1e7324d276
9 changed files with 892 additions and 124 deletions

View File

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

View File

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