Fare and route-coach, production checklist updates

This commit is contained in:
Stephanos A
2026-07-02 22:42:15 +03:00
parent 4aadf588d4
commit 200476dfd6
37 changed files with 1672 additions and 248 deletions

View File

@@ -2,9 +2,11 @@ import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { CurrenciesController } from './currencies.controller';
import { CurrenciesService } from './currencies.service';
import { CurrencyModule } from '../currency/currency.module';
import { PrismaModule } from '../../common/prisma.module';
@Module({
imports: [HttpModule],
imports: [HttpModule, PrismaModule, CurrencyModule],
controllers: [CurrenciesController],
providers: [CurrenciesService],
exports: [CurrenciesService],

View File

@@ -1,10 +1,14 @@
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CurrencyService } from '../currency/currency.service';
import { CreateCurrencyDto, UpdateCurrencyDto } from './currencies.dto';
@Injectable()
export class CurrenciesService {
constructor(private prisma: PrismaService) {}
constructor(
private prisma: PrismaService,
private currencyService: CurrencyService,
) {}
async getAllCurrencies() {
const rates = await this.prisma.currencyExchangeRate.findMany({
@@ -108,7 +112,12 @@ export class CurrenciesService {
}
async syncExchangeRates() {
return { message: 'Exchange rates synced successfully', synced: 0 };
await this.currencyService.syncExchangeRates();
const rates = await this.prisma.currencyExchangeRate.findMany({
orderBy: { effectiveDate: 'desc' },
take: 10,
});
return { message: 'Exchange rates synced successfully', synced: rates.length };
}
private getCurrencyName(code: string): string {

View File

@@ -1,9 +1,10 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { CurrencyService } from './currency.service';
import { PrismaModule } from '../../common/prisma.module';
@Module({
imports: [PrismaModule],
imports: [PrismaModule, HttpModule],
providers: [CurrencyService],
exports: [CurrencyService],
})

View File

@@ -4,6 +4,9 @@ import {
NotFoundException,
BadRequestException,
} from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { firstValueFrom } from 'rxjs';
import { PrismaService } from '../../common/prisma.service';
import { Currency } from '@prisma/client';
@@ -21,7 +24,11 @@ const CHARGE_CURRENCY_DECIMALS: Record<string, number> = {
export class CurrencyService {
private readonly logger = new Logger(CurrencyService.name);
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly httpService: HttpService,
private readonly configService: ConfigService,
) {}
async convertEtbMinorToChargeMajor(
amountMinorEtb: number,
@@ -81,14 +88,11 @@ export class CurrencyService {
fromCurrency: Currency,
toCurrency: Currency,
): Promise<number> {
if (fromCurrency === toCurrency) return 1;
const exchangeRate = await this.prisma.currencyExchangeRate.findFirst({
where: {
fromCurrency,
toCurrency,
},
orderBy: {
effectiveDate: 'desc',
},
where: { fromCurrency, toCurrency },
orderBy: { effectiveDate: 'desc' },
});
if (!exchangeRate) {
@@ -98,26 +102,61 @@ export class CurrencyService {
return 1.0;
}
const ageMs = Date.now() - exchangeRate.effectiveDate.getTime();
if (ageMs > 2 * 24 * 60 * 60 * 1000) {
this.logger.warn(
`Stale exchange rate for ${fromCurrency}->${toCurrency}: last updated ${exchangeRate.effectiveDate.toISOString()}`,
);
}
return Number(exchangeRate.rate);
}
async syncExchangeRates(): Promise<void> {
this.logger.log('Syncing exchange rates from external provider');
this.logger.log('Syncing exchange rates from central bank API');
const today = this.todayUtc();
const rates = [
{ from: 'ETB', to: 'ETB', rate: 1.0 },
{ from: 'ETB', to: 'DJF', rate: 3.25 },
{ from: 'ETB', to: 'USD', rate: 0.018 },
{ from: 'DJF', to: 'ETB', rate: 0.3077 },
{ from: 'USD', to: 'ETB', rate: 55.56 },
// Fallback rates used when the API is unreachable
const fallbackRates = [
{ from: Currency.ETB, to: Currency.ETB, rate: 1.0 },
{ from: Currency.ETB, to: Currency.DJF, rate: 3.25 },
{ from: Currency.ETB, to: Currency.USD, rate: 0.018 },
{ from: Currency.DJF, to: Currency.ETB, rate: 0.3077 },
{ from: Currency.USD, to: Currency.ETB, rate: 55.56 },
];
for (const { from, to, rate } of rates) {
await this.upsertRate(from as Currency, to as Currency, rate, today, 'EXTERNAL_API');
const apiUrl = this.configService.get<string>('EXCHANGE_RATE_API_URL');
if (apiUrl) {
try {
const response = await firstValueFrom(
this.httpService.get<Record<string, number>>(apiUrl, { timeout: 5000 }),
);
// Expected response shape: { "ETB_DJF": 3.25, "ETB_USD": 0.018, ... }
const data = response.data;
const apiRates = [
{ from: Currency.ETB, to: Currency.ETB, rate: 1.0 },
{ from: Currency.ETB, to: Currency.DJF, rate: data['ETB_DJF'] ?? fallbackRates[1].rate },
{ from: Currency.ETB, to: Currency.USD, rate: data['ETB_USD'] ?? fallbackRates[2].rate },
{ from: Currency.DJF, to: Currency.ETB, rate: data['DJF_ETB'] ?? fallbackRates[3].rate },
{ from: Currency.USD, to: Currency.ETB, rate: data['USD_ETB'] ?? fallbackRates[4].rate },
];
for (const { from, to, rate } of apiRates) {
await this.upsertRate(from, to, rate, today, 'CENTRAL_BANK_API');
}
this.logger.log('Exchange rates synced from central bank API');
return;
} catch (err) {
this.logger.warn(
`Central bank API unreachable (${(err as Error).message}), falling back to configured rates`,
);
}
}
this.logger.log('Exchange rates synced successfully');
// Fallback: persist the static rates so the DB always has a current row
for (const { from, to, rate } of fallbackRates) {
await this.upsertRate(from, to, rate, today, 'FALLBACK');
}
this.logger.log('Exchange rates synced using fallback values');
}
async listRates() {

View File

@@ -4,8 +4,6 @@ import { CurrencyService } from '../currency/currency.service';
import { FareCalculateDto, resolveCurrencyFromNationality } from './fare-engine.dto';
import { Currency } from '@prisma/client';
const TAX_RATE = 0.05;
@Injectable()
export class FareEngineService {
constructor(
@@ -32,6 +30,22 @@ export class FareEngineService {
if (!seatClass) throw new NotFoundException('Seat class not found');
if (!seatClass.isActive) throw new BadRequestException('Seat class is not active');
// Resolve nationality type: Ethiopian and Djiboutian are LOCAL, everyone else INTERNATIONAL
const nationalityUpper = (dto.nationality ?? '').toUpperCase();
const nationalityType = (nationalityUpper === 'ETHIOPIAN' || nationalityUpper === 'DJIBOUTIAN')
? 'LOCAL' : 'INTERNATIONAL';
// Find the nationality-specific seat class for the same coach type and bed position.
// Falls back to the requested seatClass if no nationality-specific one exists.
const nationalitySeatClass = await this.prisma.seatClass.findFirst({
where: {
coachTypeId: seatClass.coachTypeId,
nationalityType,
bedPosition: seatClass.bedPosition ?? null,
isActive: true,
},
}) ?? seatClass;
// Calculate distance: distanceKm represents cumulative distance from route origin
// For a segment, distance = destination.distanceKm - origin.distanceKm
const totalDistanceKm = destStop.distanceKm! - originStop.distanceKm!;
@@ -102,9 +116,10 @@ export class FareEngineService {
ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0;
fareSource = 'SCHEDULE_FARE_RULE';
} else {
// Default: distance-based using live SeatClass rate
ratePerKmMinor = seatClass.baseFareMinor;
baseFarePerPassengerMinor = Math.round(ratePerKmMinor * totalDistanceKm);
// Default: distance-based using tariff formula: km × rate × 1.02
// baseFareMinor stores the per-km rate (tariff decimal × 100000)
ratePerKmMinor = nationalitySeatClass.baseFareMinor;
baseFarePerPassengerMinor = Math.round(ratePerKmMinor * totalDistanceKm * 1.02);
fareSource = 'SEAT_CLASS_BASE_FARE';
}
@@ -138,8 +153,7 @@ export class FareEngineService {
}
const afterDiscountMinor = subtotalMinor - discountMinor;
const taxMinor = Math.round(afterDiscountMinor * TAX_RATE);
const totalEtbMinor = afterDiscountMinor + taxMinor;
const totalEtbMinor = afterDiscountMinor;
const billingCurrency = resolveCurrencyFromNationality(dto.nationality);
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency);
@@ -147,8 +161,9 @@ export class FareEngineService {
const calculation = [
`Distance: ${totalDistanceKm} km (${originStation?.name}${destStation?.name})`,
`Rate per km: ${ratePerKmMinor} ETB minor (${seatClass.name})`,
`Base fare/pax: ${totalDistanceKm} km × ${ratePerKmMinor} = ${baseFarePerPassengerMinor} ETB minor`,
`Nationality: ${dto.nationality ?? 'unspecified'}${nationalityType}${nationalitySeatClass.name}`,
`Rate per km: ${ratePerKmMinor} ETB minor (${nationalitySeatClass.name})`,
`Base fare/pax: ${totalDistanceKm} km × ${ratePerKmMinor} × 1.02 = ${baseFarePerPassengerMinor} ETB minor`,
`Premium/pax: ${premiumPerPassenger} ETB minor`,
`Insurance/pax: ${insurancePerPassenger} ETB minor`,
`Total fare/pax: ${farePerPassengerMinor} ETB minor`,
@@ -160,10 +175,8 @@ export class FareEngineService {
``,
`Subtotal: ${subtotalMinor} ETB minor`,
`Discount: ${promoLabel} → -${discountMinor} ETB minor`,
`Tax (5%): +${taxMinor} ETB minor`,
`Total (ETB): ${totalEtbMinor} ETB minor`,
``,
`Nationality: ${dto.nationality ?? 'unspecified'}${billingCurrency}`,
`Exchange rate: 1 ETB = ${exchangeRate} ${billingCurrency}`,
`Total (${billingCurrency}): ${totalInBillingCurrency} ${billingCurrency} minor`,
`Fare source: ${fareSource}`,
@@ -174,8 +187,8 @@ export class FareEngineService {
routeCode: route.code,
originName: originStation?.name ?? dto.originStationId,
destinationName: destStation?.name ?? dto.destinationStationId,
seatClassId: seatClass.id,
seatClassName: seatClass.name,
seatClassId: nationalitySeatClass.id,
seatClassName: nationalitySeatClass.name,
totalDistanceKm,
ratePerKmMinor,
baseFarePerPassengerMinor,
@@ -188,7 +201,6 @@ export class FareEngineService {
paidChildrenCount,
subtotalMinor,
discountMinor,
taxMinor,
totalMinor: totalEtbMinor,
billingCurrency,
totalInBillingCurrency,
@@ -313,16 +325,13 @@ export class FareEngineService {
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency);
return fareRules.map(rule => {
const seatClassId = rule.seatClassId;
const taxMinor = Math.round(rule.baseFareMinor * TAX_RATE);
const totalMinor = rule.baseFareMinor + taxMinor;
return {
seatClassId,
seatClassName: 'Unknown',
baseFareMinor: rule.baseFareMinor,
taxMinor,
totalMinor,
totalMinor: rule.baseFareMinor,
billingCurrency,
totalInBillingCurrency: Math.round(totalMinor * exchangeRate),
totalInBillingCurrency: Math.round(rule.baseFareMinor * exchangeRate),
exchangeRate,
source: 'FARE_RULE',
};

View File

@@ -1,7 +1,7 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, ParseIntPipe, UseGuards } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, ParseIntPipe, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
import { RoutesService } from './routes.service';
import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto } from './routes.dto';
import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto, SetRouteCoachTemplateDto } from './routes.dto';
import { JwtGuard } from '../../common/jwt.guard';
@ApiTags('Routes')
@@ -93,4 +93,35 @@ Route stops carry distanceKm for fare-by-distance calculations.`,
@ApiResponse({ status: 200, description: 'Schedules with train and terminal station details' })
@ApiResponse({ status: 404, description: 'Route not found' })
getSchedules(@Param('id') id: string) { return this.service.getSchedulesForRoute(id); }
// ── Route Coach Template ───────────────────────────────────────────────────
@Get(':id/coaches')
@ApiOperation({ summary: 'Get the default coach lineup for this route' })
@ApiParam({ name: 'id', description: 'Route UUID' })
@ApiResponse({ status: 200, description: 'Ordered coach template with coach and coach type details' })
@ApiResponse({ status: 404, description: 'Route not found' })
getCoachTemplate(@Param('id') id: string) { return this.service.getRouteCoachTemplate(id); }
@Put(':id/coaches')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Set the default coach lineup for this route',
description: 'Replaces the entire coach template. Coaches are auto-assigned in this order when a new schedule is created for this route.',
})
@ApiParam({ name: 'id', description: 'Route UUID' })
@ApiResponse({ status: 200, description: 'Updated coach template' })
@ApiResponse({ status: 400, description: 'Duplicate positions or inactive coach' })
@ApiResponse({ status: 404, description: 'Route or coach not found' })
setCoachTemplate(@Param('id') id: string, @Body() dto: SetRouteCoachTemplateDto) {
return this.service.setRouteCoachTemplate(id, dto);
}
@Delete(':id/coaches')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Clear the default coach lineup for this route' })
@ApiParam({ name: 'id', description: 'Route UUID' })
@ApiResponse({ status: 200, description: 'Template cleared' })
@ApiResponse({ status: 404, description: 'Route not found' })
clearCoachTemplate(@Param('id') id: string) { return this.service.removeRouteCoachTemplate(id); }
}

View File

@@ -44,3 +44,14 @@ export class UpdateRouteDto {
@ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string;
@ApiPropertyOptional({ type: [RouteStopInputDto] }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => RouteStopInputDto) stops?: RouteStopInputDto[];
}
export class RouteCoachTemplateItemDto {
@ApiProperty({ example: 'coach-uuid', description: 'Coach UUID' }) @IsString() coachId: string;
@ApiProperty({ example: 1, description: 'Position in the train consist (1 = first coach)' }) @IsInt() @Min(1) positionNumber: number;
}
export class SetRouteCoachTemplateDto {
@ApiProperty({ type: [RouteCoachTemplateItemDto], description: 'Ordered list of coaches for this route. Replaces the existing template.' })
@IsArray() @ValidateNested({ each: true }) @Type(() => RouteCoachTemplateItemDto)
coaches: RouteCoachTemplateItemDto[];
}

View File

@@ -1,6 +1,6 @@
import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto } from './routes.dto';
import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto, SetRouteCoachTemplateDto } from './routes.dto';
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
@Injectable()
@@ -202,6 +202,44 @@ export class RoutesService {
});
}
async getRouteCoachTemplate(routeId: string) {
const route = await this.prisma.route.findUnique({ where: { id: routeId } });
if (!route) throw new NotFoundException('Route not found');
return this.prisma.routeCoachTemplate.findMany({
where: { routeId },
include: { coach: { include: { coachType: true } } },
orderBy: { positionNumber: 'asc' },
});
}
async setRouteCoachTemplate(routeId: string, dto: SetRouteCoachTemplateDto) {
const route = await this.prisma.route.findUnique({ where: { id: routeId } });
if (!route) throw new NotFoundException('Route not found');
const coachIds = dto.coaches.map(c => c.coachId);
const coaches = await this.prisma.coach.findMany({ where: { id: { in: coachIds } } });
if (coaches.length !== coachIds.length) throw new NotFoundException('One or more coaches not found');
const inactive = coaches.find(c => c.status !== 'ACTIVE');
if (inactive) throw new BadRequestException(`Coach ${inactive.number} is not active`);
const positions = dto.coaches.map(c => c.positionNumber);
if (new Set(positions).size !== positions.length) throw new BadRequestException('Duplicate positionNumber values');
await this.prisma.routeCoachTemplate.deleteMany({ where: { routeId } });
await this.prisma.routeCoachTemplate.createMany({
data: dto.coaches.map(c => ({ routeId, coachId: c.coachId, positionNumber: c.positionNumber })),
});
return this.getRouteCoachTemplate(routeId);
}
async removeRouteCoachTemplate(routeId: string) {
const route = await this.prisma.route.findUnique({ where: { id: routeId } });
if (!route) throw new NotFoundException('Route not found');
await this.prisma.routeCoachTemplate.deleteMany({ where: { routeId } });
return { deleted: true, routeId };
}
// ── Used by SchedulesService ───────────────────────────────────────────────
/**

View File

@@ -119,7 +119,7 @@ export class BulkCreateSchedulesDto {
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto)
plannedTimes?: PlannedStopTimeDto[];
@ApiPropertyOptional({ type: [String], description: 'Optional coach UUIDs to assign to every generated schedule' })
@ApiPropertyOptional({ type: [String], description: 'Optional coach UUIDs to assign to every generated schedule. Overrides the route coach template if provided.' })
@IsOptional() @IsArray() @IsString({ each: true })
coachIds?: string[];
}

View File

@@ -46,6 +46,8 @@ export class SchedulesService {
const schedule = await this.createSchedule(createDto);
scheduleIds.push(schedule.id);
// createSchedule already auto-applies the route coach template;
// only override if explicit coachIds are provided
if (dto.coachIds && dto.coachIds.length > 0) {
await this.assignCoaches(
schedule.id,
@@ -177,6 +179,18 @@ export class SchedulesService {
const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
await this.routesService.applyRouteToSchedule(dto.routeId, schedule.id, plannedTimesMap);
// Auto-apply route coach template if one is defined
const coachTemplates = await this.prisma.routeCoachTemplate.findMany({
where: { routeId: dto.routeId },
orderBy: { positionNumber: 'asc' },
});
if (coachTemplates.length > 0) {
await this.assignCoaches(
schedule.id,
coachTemplates.map(t => ({ coachId: t.coachId, positionNumber: t.positionNumber })),
);
}
return this.getSchedule(schedule.id);
}
@@ -583,10 +597,10 @@ export class SchedulesService {
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId } });
const data = coaches.map((c, idx) => ({
const data = coaches.map((c) => ({
scheduleId,
coachId: c.coachId,
positionNumber: idx + 1,
positionNumber: c.positionNumber,
isOperational: true,
}));

View File

@@ -468,7 +468,7 @@ export class SearchService {
insuranceFeeMinor: fare.insurancePerPassenger,
totalBaseFareMinor: fare.subtotalMinor,
discountMinor: fare.discountMinor,
taxesFeesMinor: fare.taxMinor,
taxesFeesMinor: 0,
loyaltyRedemptionMinor: loyaltyMinor,
totalMinor,
currency: 'ETB',

View File

@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../common/prisma.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { CurrencyModule } from '../currency/currency.module';
import { TasksService } from './tasks.service';
@Module({
imports: [PrismaModule, NotificationsModule],
imports: [PrismaModule, NotificationsModule, CurrencyModule],
providers: [TasksService],
})
export class TasksModule {}

View File

@@ -2,12 +2,20 @@ import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { PrismaService } from '../../common/prisma.service';
import { SmsClientService } from '../notifications/sms-client.service';
import { CurrencyService } from '../currency/currency.service';
/** Maximum time (hours) a passenger has to pay after booking. */
const MAX_PAYMENT_HOURS = 2;
/** Minutes before departure: cutoff for new bookings and payment deadline. */
const CUTOFF_MINUTES = 30;
// Retention windows
const OTP_RETENTION_HOURS = 1;
const FAYDA_SESSION_RETENTION_HOURS = 1;
const AUDIT_LOG_RETENTION_DAYS = 365;
const WEBHOOK_EVENT_RETENTION_DAYS = 90;
const GATE_LOG_RETENTION_DAYS = 180;
/**
* payment_deadline = MIN(booking_time + 2h, departure_time - 30min)
*/
@@ -32,6 +40,7 @@ export class TasksService {
constructor(
private readonly prisma: PrismaService,
private readonly sms: SmsClientService,
private readonly currencyService: CurrencyService,
) {}
// ─────────────────────────────────────────────────────────────────────────
@@ -243,4 +252,47 @@ export class TasksService {
this.logger.log(`Auto-cancelled ${cancelledCount} expired pending booking(s)`);
}
}
// ─────────────────────────────────────────────────────────────────────────
// Daily at 01:00 EAT: fetch mid-market rates from central bank API.
// ─────────────────────────────────────────────────────────────────────────
@Cron('0 1 * * *', { timeZone: 'Africa/Addis_Ababa' })
async syncExchangeRates() {
try {
await this.currencyService.syncExchangeRates();
} catch (err) {
this.logger.error(`Exchange rate sync failed: ${(err as Error).message}`);
}
}
// ─────────────────────────────────────────────────────────────────────────
// Daily at 02:00 EAT: purge expired/stale records to enforce data retention.
// ─────────────────────────────────────────────────────────────────────────
@Cron('0 2 * * *')
async purgeExpiredData() {
const now = new Date();
const otpCutoff = new Date(now.getTime() - OTP_RETENTION_HOURS * 60 * 60 * 1000);
const faydaCutoff = new Date(now.getTime() - FAYDA_SESSION_RETENTION_HOURS * 60 * 60 * 1000);
const auditCutoff = new Date(now.getTime() - AUDIT_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000);
const webhookCutoff = new Date(now.getTime() - WEBHOOK_EVENT_RETENTION_DAYS * 24 * 60 * 60 * 1000);
const gateCutoff = new Date(now.getTime() - GATE_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000);
const [otps, faydaSessions, auditLogs, webhookEvents, gateLogs] = await Promise.all([
this.prisma.otpCode.deleteMany({
where: { OR: [{ expiresAt: { lte: otpCutoff } }, { verified: true, createdAt: { lte: otpCutoff } }] },
}),
this.prisma.faydaVerificationSession.deleteMany({
where: { OR: [{ expiresAt: { lte: faydaCutoff } }, { status: { in: ['COMPLETED', 'FAILED'] }, createdAt: { lte: faydaCutoff } }] },
}),
this.prisma.auditLog.deleteMany({ where: { createdAt: { lte: auditCutoff } } }),
this.prisma.paymentWebhookEvent.deleteMany({ where: { receivedAt: { lte: webhookCutoff } } }),
this.prisma.gateValidationLog.deleteMany({ where: { validatedAt: { lte: gateCutoff } } }),
]);
this.logger.log(
`Data retention purge: ${otps.count} OTPs, ${faydaSessions.count} Fayda sessions, ` +
`${auditLogs.count} audit logs, ${webhookEvents.count} webhook events, ${gateLogs.count} gate logs deleted`,
);
}
}