mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 10:10:57 +00:00
Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user