From 84346d77ac038c7181f71c9af492623cd2e48a35 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Thu, 23 Jul 2026 19:39:39 +0300 Subject: [PATCH] Generate missing ticket and payment report currency updates --- .../src/modules/reports/reports.service.ts | 56 +++++++++---------- .../src/modules/tickets/tickets.controller.ts | 7 ++- .../src/modules/tickets/tickets.service.ts | 32 +++++++---- .../backoffice/src/app/tickets/page.tsx | 17 +++++- .../backoffice/src/lib/api/index.ts | 2 +- 5 files changed, 69 insertions(+), 45 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/reports/reports.service.ts b/apps/edr-passenger-api/src/modules/reports/reports.service.ts index 7889c67a1..1aef00185 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -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(); + // Most-recent rate for each fromCurrency→toCurrency pair + const rateMap = new Map(); 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 = {}; @@ -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); diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts index 4ecaeb267..869a8578a 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts @@ -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') diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts index e6f3b0781..5f0965e7e 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -905,15 +905,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; @@ -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) { diff --git a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx index bd12def01..8731a1eb4 100644 --- a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx @@ -111,7 +111,22 @@ export default function TicketsPage() { }); 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) => { queryClient.invalidateQueries({ queryKey: ['tickets'] }); queryClient.invalidateQueries({ queryKey: ['bookings-missing-tickets'] }); diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts index 0d35ed2d3..24d5f1b7f 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts @@ -224,7 +224,7 @@ export const ticketsApi = { return Array.isArray(response) ? { items: response } : response; }, getById: (id: string) => apiClient.get(`/tickets/${id}`), - generateMissing: () => apiClient.post('/tickets/generate-missing', {}), + generateMissing: (limit = 10) => apiClient.post(`/tickets/generate-missing?limit=${limit}`, {}), validate: (ticketId: string, data: any) => apiClient.post(`/tickets/${ticketId}/validate`, data), scanAndBoard: (qrCodeOrRef: string, data: any) => apiClient.post(`/tickets/scan-board/${encodeURIComponent(qrCodeOrRef)}`, data), regenerate: (ticketId: string) => apiClient.post(`/tickets/${ticketId}/regenerate`),