mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-03 19:03:40 +00:00
Merge pull request #946 from Tria-plc/alpha
Generate missing ticket and payment report currency updates
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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'] });
|
||||||
|
|||||||
@@ -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`),
|
||||||
|
|||||||
Reference in New Issue
Block a user