Merge pull request #946 from Tria-plc/alpha

Generate missing ticket and payment report currency updates
This commit is contained in:
Stephanos A.
2026-07-23 19:51:13 +03:00
committed by GitHub
5 changed files with 69 additions and 45 deletions

View File

@@ -599,32 +599,31 @@ export class ReportsService {
sortBy?: string; sortBy?: string;
search?: string; search?: string;
}) { }) {
// Load exchange rates once — we need DJF→ETB (and any other non-ETB currencies). // Load all exchange rates once — we need conversions in both directions.
// Keep only the most-recent rate per pair (rates are ordered desc by effectiveDate).
const rateRows = await this.prisma.currencyExchangeRate.findMany({ const rateRows = await this.prisma.currencyExchangeRate.findMany({
where: { toCurrency: 'ETB' as any },
orderBy: { effectiveDate: 'desc' }, 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) { for (const r of rateRows) {
if (!rateToEtb.has(r.fromCurrency)) { const key = `${r.fromCurrency}${r.toCurrency}`;
rateToEtb.set(r.fromCurrency, Number(r.rate)); if (!rateMap.has(key)) rateMap.set(key, Number(r.rate));
}
} }
// Convert any minor amount to its ETB equivalent using stored exchange rates. // Convert minor amount from one currency to another.
// b.totalMinor is the booking's canonical ETB amount (always stored in ETB), const convertMinor = (minor: number, from: string, to: string): number => {
// so callers should pass that directly rather than converting displayTotalMinor. if (from === to) return minor;
const toEtbMinor = (minor: number, currency: string): number => { const direct = rateMap.get(`${from}${to}`);
if (currency === 'ETB') return minor; if (direct) return Math.round(minor * direct);
const rate = rateToEtb.get(currency); // Try via ETB as pivot
// If no rate is on file fall back to the raw value (avoids silently hiding const toEtb = rateMap.get(`${from}→ETB`);
// cross-currency bookings, at the cost of an approximate comparison). const fromEtb = rateMap.get(`ETB→${to}`);
return rate ? Math.round(minor * rate) : minor; if (toEtb && fromEtb) return Math.round(minor * toEtb * fromEtb);
return minor; // fallback: no rate on file
}; };
if (params.search?.trim()) { if (params.search?.trim()) {
return this.getDiscrepancyForRef(params.search.trim(), toEtbMinor); return this.getDiscrepancyForRef(params.search.trim(), convertMinor);
} }
const dateFilter: Record<string, Date> = {}; const dateFilter: Record<string, Date> = {};
@@ -679,20 +678,18 @@ export class ReportsService {
.map(b => { .map(b => {
const pi = b.paymentIntent!; 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 actualMinor = b.displayTotalMinor ?? b.totalMinor;
const actualCurrency = (b.displayCurrency as string | null) ?? b.currency; const actualCurrency = (b.displayCurrency as string | null) ?? b.currency;
const paidMinor = pi.amountMinor; const paidMinor = pi.amountMinor;
const paidCurrency = pi.currency; const paidCurrency = pi.currency;
// b.totalMinor is always in ETB minor. pi.amountMinor is the charge MAJOR amount // Balance in the booking's display currency:
// (the gateway receives major units — displayMinorToChargeMajor divides by 100 before // convert paid (major units from gateway) to display currency minor, then subtract.
// sending). Multiply by 100 to convert back to minor before the ETB comparison. const paidInDisplayMinor = convertMinor(paidMinor * 100, paidCurrency, actualCurrency);
const owedEtb = b.totalMinor; const balanceMinor = actualMinor - paidInDisplayMinor;
const paidEtb = toEtbMinor(paidMinor * 100, paidCurrency); const balanceCurrency = actualCurrency;
const balanceMinor = owedEtb - paidEtb;
const balanceCurrency = 'ETB';
const firstSeat = b.seats[0]; const firstSeat = b.seats[0];
const passengers = this.buildSeatPassengers(b.seats, actualCurrency); const passengers = this.buildSeatPassengers(b.seats, actualCurrency);
@@ -731,7 +728,7 @@ export class ReportsService {
private async getDiscrepancyForRef( private async getDiscrepancyForRef(
search: string, search: string,
toEtbMinor: (minor: number, currency: string) => number, convertMinor: (minor: number, from: string, to: string) => number,
) { ) {
let bookingId: string | null = null; let bookingId: string | null = null;
const byPnr = await this.prisma.booking.findUnique({ const byPnr = await this.prisma.booking.findUnique({
@@ -797,10 +794,9 @@ export class ReportsService {
const paidMinor = pi?.amountMinor ?? 0; const paidMinor = pi?.amountMinor ?? 0;
const paidCurrency = pi?.currency ?? b.currency; const paidCurrency = pi?.currency ?? b.currency;
const owedEtb = b.totalMinor; const paidInDisplayMinor = convertMinor(paidMinor * 100, paidCurrency, actualCurrency);
const paidEtb = toEtbMinor(paidMinor * 100, paidCurrency); const balanceMinor = actualMinor - paidInDisplayMinor;
const balanceMinor = owedEtb - paidEtb; const balanceCurrency = actualCurrency;
const balanceCurrency = 'ETB';
const firstSeat = b.seats[0]; const firstSeat = b.seats[0];
const passengers = this.buildSeatPassengers(b.seats, actualCurrency); const passengers = this.buildSeatPassengers(b.seats, actualCurrency);

View File

@@ -14,10 +14,11 @@ export class TicketsController {
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ @ApiOperation({
summary: 'Generate tickets for all confirmed bookings that are missing them', 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() { @ApiQuery({ name: 'limit', required: false, description: 'Max bookings to process per call (default 10)' })
return this.service.generateMissing(); generateMissing(@Query('limit') limit?: string) {
return this.service.generateMissing(limit ? parseInt(limit, 10) : 10);
} }
@Post('smart-assign/:bookingId') @Post('smart-assign/:bookingId')

View File

@@ -905,15 +905,21 @@ export class TicketsService {
}; };
} }
async generateMissing(): Promise<{ processed: number; generated: number; failed: number; details: any[] }> { async generateMissing(limit = 10): Promise<{ processed: number; generated: number; failed: number; remaining: number; details: any[] }> {
const confirmedWithNoTickets = await this.prisma.booking.findMany({ const missingWhere = {
where: { status: 'CONFIRMED' as const,
status: 'CONFIRMED', tickets: { none: {} },
tickets: { none: {} }, paymentIntent: { status: 'SUCCEEDED' as const },
paymentIntent: { status: 'SUCCEEDED' }, };
},
select: { id: true, bookingRef: true }, 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[] = []; const details: any[] = [];
let generated = 0; let generated = 0;
@@ -930,7 +936,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) { async delete(id: string) {

View File

@@ -111,7 +111,22 @@ export default function TicketsPage() {
}); });
const generateMissingMutation = useMutation({ const generateMissingMutation = useMutation({
mutationFn: () => ticketsApi.generateMissing(), mutationFn: async () => {
let totalGenerated = 0;
let totalFailed = 0;
let totalProcessed = 0;
let remaining = 1;
while (remaining > 0) {
const result: any = await ticketsApi.generateMissing(10);
totalGenerated += result.generated ?? 0;
totalFailed += result.failed ?? 0;
totalProcessed += result.processed ?? 0;
remaining = result.remaining ?? 0;
}
return { generated: totalGenerated, processed: totalProcessed, failed: totalFailed };
},
onSuccess: (result: any) => { onSuccess: (result: any) => {
queryClient.invalidateQueries({ queryKey: ['tickets'] }); queryClient.invalidateQueries({ queryKey: ['tickets'] });
queryClient.invalidateQueries({ queryKey: ['bookings-missing-tickets'] }); queryClient.invalidateQueries({ queryKey: ['bookings-missing-tickets'] });

View File

@@ -224,7 +224,7 @@ export const ticketsApi = {
return Array.isArray(response) ? { items: response } : response; return Array.isArray(response) ? { items: response } : response;
}, },
getById: (id: string) => apiClient.get<any>(`/tickets/${id}`), getById: (id: string) => apiClient.get<any>(`/tickets/${id}`),
generateMissing: () => apiClient.post<any>('/tickets/generate-missing', {}), generateMissing: (limit = 10) => apiClient.post<any>(`/tickets/generate-missing?limit=${limit}`, {}),
validate: (ticketId: string, data: any) => apiClient.post<any>(`/tickets/${ticketId}/validate`, data), validate: (ticketId: string, data: any) => apiClient.post<any>(`/tickets/${ticketId}/validate`, data),
scanAndBoard: (qrCodeOrRef: string, data: any) => apiClient.post<any>(`/tickets/scan-board/${encodeURIComponent(qrCodeOrRef)}`, data), scanAndBoard: (qrCodeOrRef: string, data: any) => apiClient.post<any>(`/tickets/scan-board/${encodeURIComponent(qrCodeOrRef)}`, data),
regenerate: (ticketId: string) => apiClient.post<any>(`/tickets/${ticketId}/regenerate`), regenerate: (ticketId: string) => apiClient.post<any>(`/tickets/${ticketId}/regenerate`),