Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha

This commit is contained in:
Stephanos A
2026-06-29 21:13:32 +03:00
343 changed files with 38602 additions and 6691 deletions

View File

@@ -483,8 +483,8 @@ export class BookingsService {
}
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
const taxesMinor = Math.round(combinedBaseFareMinor * 0.05);
const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor);
const taxesMinor = 0;
const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
let displayTotalMinor = totalMinor;
@@ -660,8 +660,8 @@ export class BookingsService {
}
}
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
const taxesMinor = Math.round(combinedBase * 0.05);
const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor + taxesMinor);
const taxesMinor = 0;
const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
const displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
@@ -854,8 +854,8 @@ export class BookingsService {
}
}
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
const taxesMinor = Math.round(combinedBase * 0.05);
const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor + taxesMinor);
const taxesMinor = 0;
const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
const displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
@@ -1060,7 +1060,7 @@ export class BookingsService {
loyaltyRedemptionPoints?: number
) {
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
const baseFareMinor = await this.getBaseFare(scheduleId, seatClassId, segmentRoute, undefined, nationality, originStop.sequence, destStop.sequence);
const baseFareMinor = await this.getBaseFare(scheduleId, seatClassId, segmentRoute, undefined, nationality, originStop.sequence, destStop.sequence, originStop.stationId, destStop.stationId);
const adultFareMinor = baseFareMinor * adultCount;
const paidChildrenCount = Math.max(0, childCount - 1);
@@ -1076,8 +1076,8 @@ export class BookingsService {
}
const loyaltyMinor = (loyaltyRedemptionPoints ?? 0) * 10;
const taxesMinor = Math.round(totalBaseFareMinor * 0.05);
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor);
const taxesMinor = 0;
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor);
return {
baseFareMinor,
@@ -1103,6 +1103,8 @@ export class BookingsService {
nationality?: string,
originStopSeq?: number,
destStopSeq?: number,
originStationId?: string,
destinationStationId?: string,
): Promise<number> {
const now = new Date();
@@ -1149,13 +1151,13 @@ export class BookingsService {
const bestMatch = this.selectBestFareRule(candidates, scheduleId, segmentRoute, fullRoute, nationality);
if (bestMatch) return bestMatch.baseFareMinor;
// 3. FareEngine — distance × rate-per-km from the schedule's route
// 3. FareEngine — distance × rate-per-km from the booking's actual segment stations
if (schedule?.routeId) {
try {
const fare = await this.fareEngine.calculate({
routeId: schedule.routeId,
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
originStationId: originStationId ?? schedule.originStationId,
destinationStationId: destinationStationId ?? schedule.destinationStationId,
seatClassId,
nationality,
});

View File

@@ -9,6 +9,9 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
import { CreateGuestBookingDto, SavedPassengerProfileDto } from './guest-booking.dto';
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
/** Booking cutoff: reject new bookings within this many ms of departure. */
const BOOKING_CUTOFF_MS = 30 * 60 * 1000;
function generateRef(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
return 'EDR-' + Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
@@ -74,6 +77,10 @@ export class GuestBookingService {
});
if (!schedule) throw new NotFoundException('Schedule not found');
if (Date.now() >= schedule.departureAt.getTime() - BOOKING_CUTOFF_MS) {
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
}
const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId);
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found');
@@ -142,7 +149,9 @@ export class GuestBookingService {
dto.seatClassId,
segmentRoute,
fullRoute,
primaryNationality
primaryNationality,
dto.originStationId,
dto.destinationStationId,
);
const adultFareMinor = baseFareMinor * adultCount;
@@ -160,8 +169,8 @@ export class GuestBookingService {
}
}
const taxesMinor = Math.round(totalBaseFareMinor * 0.05);
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor + taxesMinor);
const taxesMinor = 0;
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
let displayTotalMinor = totalMinor;
@@ -293,6 +302,10 @@ export class GuestBookingService {
if (!outboundSchedule) throw new NotFoundException('Outbound schedule not found');
if (!returnSchedule) throw new NotFoundException('Return schedule not found');
if (Date.now() >= outboundSchedule.departureAt.getTime() - BOOKING_CUTOFF_MS) {
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
}
const outboundOriginStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.originStationId);
const outboundDestStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
const returnOriginStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnOriginStationId);
@@ -350,8 +363,8 @@ export class GuestBookingService {
const primaryNationality = passengersData[0]?.nationality;
const [outboundBaseFare, returnBaseFare] = await Promise.all([
this.getBaseFare(dto.scheduleId, dto.seatClassId, outboundSegmentRoute, outboundFullRoute, primaryNationality),
this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality),
this.getBaseFare(dto.scheduleId, dto.seatClassId, outboundSegmentRoute, outboundFullRoute, primaryNationality, dto.originStationId, dto.destinationStationId),
this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality, dto.returnOriginStationId, dto.returnDestinationStationId),
]);
const paidChildrenCount = Math.max(0, childCount - 1);
@@ -369,8 +382,8 @@ export class GuestBookingService {
}
}
const taxesMinor = Math.round(combinedBaseFareMinor * 0.05);
const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor + taxesMinor);
const taxesMinor = 0;
const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
const displayTotalMinor = displayCurrency !== Currency.ETB
@@ -505,6 +518,10 @@ export class GuestBookingService {
if (!leg1Schedule) throw new NotFoundException('Leg-1 schedule not found');
if (!leg2Schedule) throw new NotFoundException('Leg-2 schedule not found');
if (Date.now() >= leg1Schedule.departureAt.getTime() - BOOKING_CUTOFF_MS) {
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
}
const leg1OriginStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.originStationId);
const leg1DestStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.transitStationId);
const leg2OriginStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.transitStationId);
@@ -551,11 +568,11 @@ export class GuestBookingService {
this.getBaseFare(dto.scheduleId, dto.seatClassId,
`${leg1OriginStop.station.code}-${leg1DestStop.station.code}`,
`${leg1Schedule.originStation.code}-${leg1Schedule.destinationStation.code}`,
primaryNationality),
primaryNationality, dto.originStationId, dto.transitStationId),
this.getBaseFare(dto.leg2ScheduleId, leg2SeatClassId,
`${leg2OriginStop.station.code}-${leg2DestStop.station.code}`,
`${leg2Schedule.originStation.code}-${leg2Schedule.destinationStation.code}`,
primaryNationality),
primaryNationality, dto.transitStationId, dto.leg2DestinationStationId),
]);
const leg1Total = leg1BaseFare * adultCount + leg1BaseFare * paidChildrenCount;
@@ -569,8 +586,8 @@ export class GuestBookingService {
discountMinor = promo.percentOff ? Math.round(combinedBase * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
}
}
const taxesMinor = Math.round(combinedBase * 0.05);
const totalMinor = Math.max(0, combinedBase - discountMinor + taxesMinor);
const taxesMinor = 0;
const totalMinor = Math.max(0, combinedBase - discountMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
const displayTotalMinor = displayCurrency !== Currency.ETB
@@ -702,6 +719,10 @@ export class GuestBookingService {
if (!retL1Sched) throw new NotFoundException('Return leg-1 schedule not found');
if (!retL2Sched) throw new NotFoundException('Return leg-2 schedule not found');
if (Date.now() >= obL1Sched.departureAt.getTime() - BOOKING_CUTOFF_MS) {
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
}
const obL1Origin = obL1Sched.stopTimes.find(s => s.stationId === dto.originStationId);
const obL1Dest = obL1Sched.stopTimes.find(s => s.stationId === dto.transitStationId);
const obL2Origin = obL2Sched.stopTimes.find(s => s.stationId === dto.transitStationId);
@@ -750,10 +771,10 @@ export class GuestBookingService {
const retL2ClassId = dto.returnLeg2SeatClassId ?? dto.seatClassId;
const [obL1Fare, obL2Fare, retL1Fare, retL2Fare] = await Promise.all([
this.getBaseFare(dto.scheduleId, dto.seatClassId, `${obL1Origin.station.code}-${obL1Dest.station.code}`, `${obL1Sched.originStation.code}-${obL1Sched.destinationStation.code}`, nat),
this.getBaseFare(dto.leg2ScheduleId!, obL2ClassId, `${obL2Origin.station.code}-${obL2Dest.station.code}`, `${obL2Sched.originStation.code}-${obL2Sched.destinationStation.code}`, nat),
this.getBaseFare(dto.returnScheduleId!, retL1ClassId, `${retL1Origin.station.code}-${retL1Dest.station.code}`, `${retL1Sched.originStation.code}-${retL1Sched.destinationStation.code}`, nat),
this.getBaseFare(dto.returnLeg2ScheduleId!,retL2ClassId, `${retL2Origin.station.code}-${retL2Dest.station.code}`, `${retL2Sched.originStation.code}-${retL2Sched.destinationStation.code}`, nat),
this.getBaseFare(dto.scheduleId, dto.seatClassId, `${obL1Origin.station.code}-${obL1Dest.station.code}`, `${obL1Sched.originStation.code}-${obL1Sched.destinationStation.code}`, nat, dto.originStationId, dto.transitStationId),
this.getBaseFare(dto.leg2ScheduleId!, obL2ClassId, `${obL2Origin.station.code}-${obL2Dest.station.code}`, `${obL2Sched.originStation.code}-${obL2Sched.destinationStation.code}`, nat, dto.transitStationId, dto.leg2DestinationStationId),
this.getBaseFare(dto.returnScheduleId!, retL1ClassId, `${retL1Origin.station.code}-${retL1Dest.station.code}`, `${retL1Sched.originStation.code}-${retL1Sched.destinationStation.code}`, nat, dto.returnOriginStationId, dto.returnTransitStationId),
this.getBaseFare(dto.returnLeg2ScheduleId!,retL2ClassId, `${retL2Origin.station.code}-${retL2Dest.station.code}`, `${retL2Sched.originStation.code}-${retL2Sched.destinationStation.code}`, nat, dto.returnTransitStationId, dto.returnLeg2DestinationStationId),
]);
const combinedBase = (obL1Fare + obL2Fare + retL1Fare + retL2Fare) * adultCount +
@@ -951,17 +972,28 @@ export class GuestBookingService {
segmentRoute?: string,
fullRoute?: string,
nationality?: string,
originStationId?: string,
destinationStationId?: string,
): Promise<number> {
const now = new Date();
// 1. FareRule table — explicit override rules
const candidates = await this.prisma.fareRule.findMany({
where: {
seatClassId,
validFrom: { lte: now },
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
},
});
// 1. FareRule table — explicit override rules (same priority logic as the fare engine)
const [candidates, seatClass] = await Promise.all([
this.prisma.fareRule.findMany({
where: {
seatClassId,
validFrom: { lte: now },
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
},
}),
this.prisma.seatClass.findUnique({
where: { id: seatClassId },
select: { premiumMinor: true, insuranceFeeMinor: true },
}),
]);
const premiumMinor = seatClass?.premiumMinor ?? 0;
const insuranceMinor = seatClass?.insuranceFeeMinor ?? 0;
const priorities = [
{ tripId: scheduleId, route: segmentRoute, nationality },
@@ -982,10 +1014,11 @@ export class GuestBookingService {
const match = candidates.find(
(c) => c.tripId === priority.tripId && c.route === priority.route && c.nationality === priority.nationality,
);
if (match) return match.baseFareMinor;
// Return base fare + seat-class surcharges so the booking total matches the quoted fare
if (match) return match.baseFareMinor + premiumMinor + insuranceMinor;
}
// 2. FareEngine — distance × rate-per-km from the schedule's route
// 2. FareEngine — distance × rate-per-km from the booking's actual segment stations
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
select: { routeId: true, originStationId: true, destinationStationId: true },
@@ -995,12 +1028,15 @@ export class GuestBookingService {
try {
const fare = await this.fareEngine.calculate({
routeId: schedule.routeId,
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
// Use the booking's boarding/alighting stations so the distance reflects the
// passenger's actual segment, not the full schedule route.
originStationId: originStationId ?? schedule.originStationId,
destinationStationId: destinationStationId ?? schedule.destinationStationId,
seatClassId,
nationality,
});
return fare.baseFarePerPassengerMinor;
// farePerPassengerMinor already includes base + premiumMinor + insuranceFeeMinor
return fare.farePerPassengerMinor;
} catch {
// FareEngine throws if distanceKm is missing; fall through to error
}

View File

@@ -284,13 +284,16 @@ 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,
totalMinor: rule.baseFareMinor,
taxMinor,
totalMinor,
billingCurrency,
totalInBillingCurrency: Math.round(rule.baseFareMinor * exchangeRate),
totalInBillingCurrency: Math.round(totalMinor * exchangeRate),
exchangeRate,
source: 'FARE_RULE',
};

View File

@@ -31,6 +31,7 @@ import {
SupportedPaymentMethodDto,
PaymentMethodTypeEnum,
PaymentPlatformDto,
BookingAmountResponseDto,
} from "./payments.dto";
import { PassengerStaff } from "../../common/passenger-guards";
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
@@ -139,16 +140,33 @@ export class PaymentsController {
@ApiOperation({
summary: "List payment systems supported by the platform",
description:
"Returns the global catalog of accepted payment systems. Filter by `currency` (e.g. ETB, DJF, USD) to get methods that settle in that currency, and/or by `region` to match a passenger's nationality. Both filters can be combined.",
"Returns all enabled payment methods. Optionally filter by `region` to narrow to methods available for a passenger's nationality.",
})
@ApiQuery({ name: "currency", required: false, example: "DJF", description: "Settlement currency — ETB, DJF, USD, etc." })
@ApiQuery({ name: "region", enum: PaymentRegionEnum, required: false })
@ApiOkResponse({ type: [SupportedPaymentMethodDto] })
getMethods(
@Query("currency") currency?: string,
@Query("region") region?: PaymentRegionEnum,
) {
return this.service.getSupportedPaymentMethods(region, currency);
return this.service.getSupportedPaymentMethods(region);
}
@Get("booking-amount")
@SetMetadata('isPublic', true)
@ApiOperation({
summary: "Get booking amount in a specific currency",
description:
"Returns the booking total converted from ETB to the requested currency using the latest exchange rate. " +
"If currency is ETB the stored amount is returned as-is (no conversion). " +
"Amounts are returned in major currency units (e.g. 162.50 DJF, not centimes).",
})
@ApiQuery({ name: "bookingId", required: true, description: "Booking UUID" })
@ApiQuery({ name: "currency", required: true, example: "DJF", description: "Target currency: ETB, DJF, or USD" })
@ApiOkResponse({ type: BookingAmountResponseDto })
getBookingAmount(
@Query("bookingId") bookingId: string,
@Query("currency") currency: string,
) {
return this.service.getBookingAmountByCurrency(bookingId, currency);
}
@Get("checkout")

View File

@@ -136,3 +136,9 @@ export class IntentStatusDto {
@ApiPropertyOptional() failureCode?: string;
@ApiPropertyOptional() failureMessage?: string;
}
export class BookingAmountResponseDto {
@ApiProperty({ example: 'booking-uuid' }) booking_id: string;
@ApiProperty({ example: 'DJF', description: 'Currency of the returned amount' }) currency: string;
@ApiProperty({ example: 162.5, description: 'Booking total converted to the requested currency (major units)' }) amount: number;
}

View File

@@ -476,7 +476,7 @@ export class PaymentsService {
});
}
getSupportedPaymentMethods(region?: PaymentRegionEnum, currency?: string) {
getSupportedPaymentMethods(region?: PaymentRegionEnum) {
return this.prisma.paymentMethod.findMany({
where: {
enabled: true,
@@ -490,12 +490,41 @@ export class PaymentsService {
},
}
: {}),
...(currency ? { currency: currency.toUpperCase() } : {}),
},
orderBy: [{ sortOrder: "asc" }, { displayName: "asc" }],
});
}
async getBookingAmountByCurrency(
bookingId: string,
currency: string,
): Promise<{ booking_id: string; currency: string; amount: number }> {
const booking = await this.prisma.booking.findUnique({
where: { id: bookingId },
select: { id: true, totalMinor: true },
});
if (!booking) throw new NotFoundException('Booking not found');
const requestedCurrency = currency.toUpperCase();
const amountInETB = booking.totalMinor / 100;
if (requestedCurrency === 'ETB') {
return { booking_id: bookingId, currency: 'ETB', amount: amountInETB };
}
const exchangeRate = await this.prisma.currencyExchangeRate.findFirst({
where: { fromCurrency: 'ETB' as any, toCurrency: requestedCurrency as any },
orderBy: { effectiveDate: 'desc' },
});
if (!exchangeRate) {
throw new NotFoundException(`Exchange rate not found for ETB → ${requestedCurrency}`);
}
const rate = Number(exchangeRate.rate);
const converted = parseFloat((amountInETB * rate).toFixed(2));
return { booking_id: bookingId, currency: requestedCurrency, amount: converted };
}
/**
* Guard against an implausible paidAt from a provider event (e.g. a Telebirr epoch parsed as
* ms×1000 → year 58429), which Prisma/Postgres rejects and would otherwise dead-letter the

View File

@@ -474,8 +474,8 @@ export class SearchService {
}
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * POINTS_TO_MINOR;
const taxesMinor = Math.round(totalBaseFareMinor * 0.05);
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor);
const taxesMinor = 0;
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor);
const displayCurrency = dto.displayCurrency ?? resolveCurrencyFromNationality(dto.nationality);
const displayTotalMinor = displayCurrency !== Currency.ETB

View File

@@ -3,12 +3,19 @@ import { Cron } from '@nestjs/schedule';
import { PrismaService } from '../../common/prisma.service';
import { SmsClientService } from '../notifications/sms-client.service';
/** Minutes before departure at which each action fires. */
const REMINDER_MINUTES = 3 * 60; // 3 h → send payment reminder SMS
const DEADLINE_MINUTES = 2 * 60; // 2 h → cancel unpaid booking
/** 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;
/** Half-width of the reminder detection window (cron runs every 2 min). */
const REMINDER_WINDOW_MINUTES = 2;
/**
* payment_deadline = MIN(booking_time + 2h, departure_time - 30min)
*/
function computePaymentDeadline(createdAt: Date, departureAt: Date): Date {
const maxDeadline = new Date(createdAt.getTime() + MAX_PAYMENT_HOURS * 60 * 60 * 1000);
const cutoffDeadline = new Date(departureAt.getTime() - CUTOFF_MINUTES * 60 * 1000);
return maxDeadline < cutoffDeadline ? maxDeadline : cutoffDeadline;
}
function fmtTime(d: Date): string {
return d.toLocaleTimeString('en-GB', {
@@ -28,66 +35,72 @@ export class TasksService {
) {}
// ─────────────────────────────────────────────────────────────────────────
// Every 2 min: advance TrainSchedule statuses (departure / arrival).
// Every 1 min: advance TrainSchedule statuses.
//
// SCHEDULED → BOARDING when departure ≤ 30 min away (closed to new bookings)
// BOARDING → EN_ROUTE at actual departure
// EN_ROUTE → ARRIVED at arrival time
// ─────────────────────────────────────────────────────────────────────────
@Cron('*/2 * * * *')
@Cron('*/1 * * * *')
async syncScheduleStatuses() {
const now = new Date();
const thirtyMinFromNow = new Date(now.getTime() + CUTOFF_MINUTES * 60 * 1000);
const [departed, arrived] = await Promise.all([
const [boarding, departed, arrived] = await Promise.all([
this.prisma.trainSchedule.updateMany({
where: { status: 'SCHEDULED', departureAt: { lte: now } },
where: { status: 'SCHEDULED', departureAt: { lte: thirtyMinFromNow } },
data: { status: 'BOARDING' },
}),
this.prisma.trainSchedule.updateMany({
where: { status: 'BOARDING', departureAt: { lte: now } },
data: { status: 'EN_ROUTE' },
}),
this.prisma.trainSchedule.updateMany({
where: { status: { in: ['EN_ROUTE', 'BOARDING'] }, arrivalAt: { lte: now } },
where: { status: 'EN_ROUTE', arrivalAt: { lte: now } },
data: { status: 'ARRIVED' },
}),
]);
if (departed.count > 0 || arrived.count > 0) {
if (boarding.count > 0 || departed.count > 0 || arrived.count > 0) {
this.logger.log(
`Schedule sync: ${departed.count} → EN_ROUTE, ${arrived.count} → ARRIVED`,
`Schedule sync: ${boarding.count} → BOARDING, ${departed.count} → EN_ROUTE, ${arrived.count} → ARRIVED`,
);
}
}
// ─────────────────────────────────────────────────────────────────────────
// Every 2 min: payment deadline enforcement.
// Every 1 min: payment deadline enforcement.
//
// • 3 h before departure → send one SMS reminder to complete payment.
// • 2 h before departure → cancel booking if payment is still pending
// and notify the passenger by SMS.
// Reminder — sent once at the midpoint of the booking's payment window:
// reminder_at = booking_time + total_window / 2
//
// Example: train departs 08:00
// 05:00 → reminder SMS sent ("pay before 06:00 or booking is cancelled")
// 06:00 → booking auto-cancelled, cancellation SMS sent
// Cancel — when now ≥ payment_deadline
// payment_deadline = MIN(booking_time + 2h, departure_time - 30min)
//
// Examples (departure 10:00, cutoff 9:30):
// Booked 8:00 → deadline 9:30, window 1.5h, reminder at 8:45
// Booked 9:00 → deadline 9:30, window 30min, reminder at 9:15
// ─────────────────────────────────────────────────────────────────────────
@Cron('*/2 * * * *')
@Cron('*/1 * * * *')
async enforcePaymentDeadlines() {
const now = new Date();
await Promise.all([
this.sendPaymentReminders(now),
this.cancelExpiredPendingBookings(now),
]);
}
// ── 3-hour reminder ───────────────────────────────────────────────────────
// ── Send reminder at the midpoint of each booking's payment window ────────
private async sendPaymentReminders(now: Date) {
// Narrow 4-minute window (±2 min around the 3-hour mark) so each booking
// is caught by exactly one cron tick and paymentReminderSentAt guards re-sends.
const windowMs = REMINDER_WINDOW_MINUTES * 60 * 1000;
const reminderMs = REMINDER_MINUTES * 60 * 1000;
const windowStart = new Date(now.getTime() + reminderMs - windowMs);
const windowEnd = new Date(now.getTime() + reminderMs + windowMs);
// Only look at bookings created within the last 3 h with a future departure.
const threeHoursAgo = new Date(now.getTime() - 3 * 60 * 60 * 1000);
const bookings = await this.prisma.booking.findMany({
where: {
status: 'PENDING_PAYMENT',
paymentReminderSentAt: null,
schedule: { departureAt: { gte: windowStart, lte: windowEnd } },
createdAt: { gte: threeHoursAgo },
schedule: { departureAt: { gte: now } },
} as any,
include: {
schedule: {
@@ -101,15 +114,28 @@ export class TasksService {
for (const booking of bookings) {
try {
const dep = booking.schedule.departureAt as Date;
const deadline = new Date(dep.getTime() - DEADLINE_MINUTES * 60 * 1000);
const origin = booking.schedule.originStation?.name ?? '';
const dest = booking.schedule.destinationStation?.name ?? '';
const createdAt = booking.createdAt as Date;
const dep = booking.schedule.departureAt as Date;
const paymentDeadline = computePaymentDeadline(createdAt, dep);
const totalWindowMs = paymentDeadline.getTime() - createdAt.getTime();
// Skip degenerate windows (< 2 min) — the cancel job will handle these immediately
if (totalWindowMs < 2 * 60 * 1000) continue;
// Remind once, at the midpoint of the total payment window
const reminderAt = new Date(createdAt.getTime() + totalWindowMs / 2);
if (now < reminderAt) continue;
const origin = booking.schedule.originStation?.name ?? '';
const dest = booking.schedule.destinationStation?.name ?? '';
const remainingMs = Math.max(0, paymentDeadline.getTime() - now.getTime());
const remainingMin = Math.round(remainingMs / 60_000);
const message =
`EDR: Your booking ${booking.bookingRef} ` +
`(${origin}${dest}) departs at ${fmtTime(dep)}. ` +
`Complete payment by ${fmtTime(deadline)} or your booking will be cancelled.`;
`Complete payment within ${remainingMin} minute(s) (by ${fmtTime(paymentDeadline)}) ` +
`or your booking will be cancelled.`;
if (booking.contactPhone) {
await this.sms.sendSms({ to: booking.contactPhone, message }).catch(() => null);
@@ -121,7 +147,8 @@ export class TasksService {
});
this.logger.log(
`Payment reminder sent: ${booking.bookingRef} (departs ${fmtTime(dep)}, deadline ${fmtTime(deadline)})`,
`Payment reminder sent: ${booking.bookingRef} ` +
`(deadline ${fmtTime(paymentDeadline)}, ${remainingMin} min remaining)`,
);
} catch (err) {
this.logger.error(
@@ -131,14 +158,22 @@ export class TasksService {
}
}
// ── 2-hour auto-cancel ────────────────────────────────────────────────────
// ── Cancel bookings whose payment deadline has passed ─────────────────────
private async cancelExpiredPendingBookings(now: Date) {
const cutoff = new Date(now.getTime() + DEADLINE_MINUTES * 60 * 1000); // now + 2 h
const twoHoursAgo = new Date(now.getTime() - MAX_PAYMENT_HOURS * 60 * 60 * 1000);
const departureCutoff = new Date(now.getTime() + CUTOFF_MINUTES * 60 * 1000);
// payment_deadline = MIN(createdAt + 2h, departureAt - 30min)
// Deadline is reached when either branch of the MIN is in the past:
// (a) createdAt ≤ now - 2h → 2-hour max window elapsed
// (b) departureAt ≤ now + 30min → departure within 30 min
const expiredBookings = await this.prisma.booking.findMany({
where: {
status: 'PENDING_PAYMENT',
schedule: { departureAt: { lte: cutoff } },
OR: [
{ createdAt: { lte: twoHoursAgo } },
{ schedule: { departureAt: { lte: departureCutoff } } },
],
},
include: {
schedule: {
@@ -151,8 +186,16 @@ export class TasksService {
},
});
let cancelledCount = 0;
for (const booking of expiredBookings) {
try {
// Re-verify exact deadline to avoid racing with a concurrent payment confirmation
const createdAt = booking.createdAt as Date;
const dep = booking.schedule.departureAt as Date;
const paymentDeadline = computePaymentDeadline(createdAt, dep);
if (now < paymentDeadline) continue;
// 1. Release held seats (Journey rows are the occupancy source of truth)
await this.prisma.journey.deleteMany({ where: { bookingId: booking.id } as any });
@@ -161,12 +204,12 @@ export class TasksService {
data: {
bookingId: booking.id,
cancelledBy: 'SYSTEM',
reason: 'Payment not completed before departure deadline',
reason: 'Payment not completed before deadline',
refundAmount: 0,
refundMethod: booking.paymentIntent?.method ?? 'NONE',
refundStatus: 'NOT_APPLICABLE',
},
}).catch(() => null); // booking may already have a cancellation record
}).catch(() => null);
// 3. Mark cancelled
await this.prisma.booking.update({
@@ -175,22 +218,20 @@ export class TasksService {
});
// 4. Notify passenger
const dep = booking.schedule.departureAt as Date;
const origin = booking.schedule.originStation?.name ?? '';
const dest = booking.schedule.destinationStation?.name ?? '';
const message =
`EDR: Your booking ${booking.bookingRef} ` +
`(${origin}${dest}, departs ${fmtTime(dep)}) has been cancelled ` +
`because payment was not completed before the deadline.`;
`because payment was not completed before the deadline (${fmtTime(paymentDeadline)}).`;
if (booking.contactPhone) {
await this.sms.sendSms({ to: booking.contactPhone, message }).catch(() => null);
}
this.logger.log(
`Auto-cancelled: ${booking.bookingRef} (payment deadline expired, departs ${fmtTime(dep)})`,
);
this.logger.log(`Auto-cancelled: ${booking.bookingRef} (deadline was ${fmtTime(paymentDeadline)})`);
cancelledCount++;
} catch (err) {
this.logger.error(
`Auto-cancel failed for ${booking.bookingRef}: ${err instanceof Error ? err.message : String(err)}`,
@@ -198,8 +239,8 @@ export class TasksService {
}
}
if (expiredBookings.length > 0) {
this.logger.log(`Auto-cancelled ${expiredBookings.length} expired pending booking(s)`);
if (cancelledCount > 0) {
this.logger.log(`Auto-cancelled ${cancelledCount} expired pending booking(s)`);
}
}
}