Merge branch 'dev' into quick-fix-main

This commit is contained in:
Stephanos A.
2026-07-20 00:42:25 +03:00
committed by GitHub
377 changed files with 20932 additions and 3414 deletions

View File

@@ -85,6 +85,7 @@ export class AgentsService {
seats: {
create: dto.passengers.map(p => ({
seat: { connect: { id: p.seatId } },
scheduleId: dto.scheduleId,
passengerName: p.fullName,
idDocumentType: p.idDocumentType as IdDocumentType | undefined,
idDocumentNumber: p.idDocumentNumber

View File

@@ -320,8 +320,8 @@ export class BookingsService {
id: b.id,
bookingRef: b.bookingRef,
status: b.status,
totalMinor: b.totalMinor,
currency: b.currency || null,
totalMinor: b.displayTotalMinor ?? b.totalMinor,
currency: b.displayCurrency ?? b.currency ?? null,
displayCurrency: b.displayCurrency ?? null,
displayTotalMinor: b.displayTotalMinor ?? null,
adultCount: b.adultCount,
@@ -578,7 +578,7 @@ export class BookingsService {
const mappedPkg = pkgItems.map((b: any) => ({
id: b.id, bookingRef: b.bookingRef, status: b.status,
totalMinor: b.totalMinor, currency: b.currency || b.displayCurrency,
totalMinor: b.displayTotalMinor ?? b.totalMinor, currency: b.displayCurrency || b.currency,
displayCurrency: b.displayCurrency, displayTotalMinor: b.displayTotalMinor,
contactEmail: b.contactEmail, contactPhone: b.contactPhone,
bookingType: 'PACKAGE', packageId: b.packageId, priceTierId: b.priceTierId,
@@ -732,8 +732,8 @@ export class BookingsService {
id: b.id,
bookingRef: b.bookingRef,
status: b.status,
totalMinor: b.totalMinor,
currency: b.currency || b.displayCurrency,
totalMinor: b.displayTotalMinor ?? b.totalMinor,
currency: b.displayCurrency || b.currency,
displayCurrency: b.displayCurrency,
displayTotalMinor: b.displayTotalMinor,
contactEmail: b.contactEmail,
@@ -834,44 +834,63 @@ export class BookingsService {
// Track per-seat fare. Use the client-supplied seatFareMinor when present (berth-specific
// pricing for Upper/Middle/Lower beds). Fall back to the fare engine's baseFareMinor.
let freeChildUsed = false;
let pkgChildIdx = 0;
const passengersWithFares = passengersData.map(p => {
let fareMinor: number;
if (p.category === PassengerCategory.ADULT) {
fareMinor = p.seatFareMinor ?? fareCalculation.baseFareMinor;
} else if (dto.packageId) {
fareMinor = pkgChildIdx < adultCount ? 0 : (p.seatFareMinor ?? fareCalculation.baseFareMinor);
pkgChildIdx++;
} else {
if (!freeChildUsed) { fareMinor = 0; freeChildUsed = true; }
else fareMinor = p.seatFareMinor ?? fareCalculation.baseFareMinor;
// Free children have no seat (frontend excludes them from the DTO).
// Guard by seatId: unseated = free (0), seated = paid child.
// Applies to both package and regular bookings.
fareMinor = p.seatId ? (p.seatFareMinor ?? fareCalculation.baseFareMinor) : 0;
}
return { ...p, fareMinor };
});
// Use the sum of per-seat fares as the authoritative total when the client supplied
// seatFareMinor for every seat-holding passenger — this captures berth-specific pricing
// (Upper/Middle/Lower) that the fare engine cannot resolve from seatClassId alone.
// Free children have no seatId and no seatFareMinor — exclude them from the check.
// Determine the authoritative total.
// Priority (one-way, non-package):
// 1. Server-computed sum of per-seat fares when every seated passenger supplied
// seatFareMinor — this captures berth-specific pricing (Upper/Middle/Lower)
// exactly as shown to the user and cannot be corrupted by a frontend race
// condition that sends reviewedTotalMinor before all fares are resolved.
// 2. reviewedTotalMinor from the frontend — fallback when the server doesn't
// have complete per-seat data (e.g. auto-assign with no seat map loaded).
// 3. Fare engine total — last resort when neither is available.
// For package bookings reviewedTotalMinor always wins because the tier price
// may include berth-specific adjustments the server cannot derive alone.
// Free children have no seatId and no seatFareMinor — exclude from the check.
const seatedPassengers = passengersData.filter(p => p.seatId);
const allFaresProvided = seatedPassengers.length > 0 && seatedPassengers.every(p => p.seatFareMinor != null);
// seatFareMinor values from the client are in display-currency minor units (matching
// displayAmountMinor from search results). reviewedTotalMinor is also display-currency minor.
// In both cases: store as displayTotalMinor as-is, back-convert to ETB for totalMinor.
let resolvedTotalMinor: number;
let displayTotalMinor: number;
if (dto.reviewedTotalMinor != null) {
displayTotalMinor = dto.reviewedTotalMinor;
resolvedTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
: dto.reviewedTotalMinor;
} else if (allFaresProvided) {
// seatFareMinor is in display currency — sum is already the display total
if (allFaresProvided && !dto.packageId) {
// Server has every passenger's berth fare — sum is the authoritative display total.
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0);
resolvedTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
: displayTotalMinor;
if (dto.reviewedTotalMinor != null && dto.reviewedTotalMinor !== displayTotalMinor) {
this.logger.warn(`createOneWayBooking: reviewedTotalMinor=${dto.reviewedTotalMinor} ignored — using server-computed sum=${displayTotalMinor}`);
}
} else if (dto.packageId && dto.reviewedTotalMinor != null) {
// Package booking: client-supplied tier-adjusted total.
displayTotalMinor = dto.reviewedTotalMinor;
resolvedTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
: dto.reviewedTotalMinor;
if (seatedPassengers.length > 0) {
const perSeatFare = Math.round(dto.reviewedTotalMinor / seatedPassengers.length);
passengersWithFares.forEach(p => {
if (p.fareMinor > 0) p.fareMinor = p.seatFareMinor ?? perSeatFare;
});
}
} else if (dto.reviewedTotalMinor != null && dto.reviewedTotalMinor > 0) {
// Partial data on server: use client's total as best available.
displayTotalMinor = dto.reviewedTotalMinor;
resolvedTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
: dto.reviewedTotalMinor;
} else {
resolvedTotalMinor = fareCalculation.totalMinor;
displayTotalMinor = displayCurrency !== Currency.ETB
@@ -901,6 +920,7 @@ export class BookingsService {
seats: {
create: passengersWithFares.map(p => ({
seat: { connect: { id: p.seatId } },
scheduleId: dto.scheduleId,
passengerName: p.passengerName,
dateOfBirth: p.dateOfBirth,
passengerCategory: p.category,
@@ -1018,8 +1038,6 @@ export class BookingsService {
// Track per-seat fare. Use client-supplied seatFareMinor/returnSeatFareMinor when
// present (berth-specific pricing). Fall back to fare engine values.
let outboundFreeChildUsed = false;
let returnFreeChildUsed = false;
const passengersWithFares = passengersData.map(p => {
let outboundFareMinor: number;
let returnFareMinor: number;
@@ -1027,14 +1045,12 @@ export class BookingsService {
if (p.category === PassengerCategory.ADULT) {
outboundFareMinor = p.seatFareMinor ?? outboundFare.baseFareMinor;
returnFareMinor = p.returnSeatFareMinor ?? returnFare.baseFareMinor;
} else if (dto.packageId) {
outboundFareMinor = 0;
returnFareMinor = 0;
} else {
if (!outboundFreeChildUsed) { outboundFareMinor = 0; outboundFreeChildUsed = true; }
else outboundFareMinor = p.seatFareMinor ?? outboundFare.baseFareMinor;
if (!returnFreeChildUsed) { returnFareMinor = 0; returnFreeChildUsed = true; }
else returnFareMinor = p.returnSeatFareMinor ?? returnFare.baseFareMinor;
// Free children have no outbound seat (frontend excludes them).
// Guard by outboundSeatId: unseated = free (0), seated = paid child.
// Applies to both package and regular bookings.
outboundFareMinor = p.outboundSeatId ? (p.seatFareMinor ?? outboundFare.baseFareMinor) : 0;
returnFareMinor = p.outboundSeatId ? (p.returnSeatFareMinor ?? returnFare.baseFareMinor) : 0;
}
return { ...p, outboundFareMinor, returnFareMinor };
@@ -1042,20 +1058,39 @@ export class BookingsService {
// Override totalMinor with the sum of actual per-seat fares when all seated passengers
// supplied their fares — free children (no seatId) are excluded from the check.
// Same priority logic as one-way: server-computed sum wins when all per-seat fares
// are present; reviewedTotalMinor is used only as fallback to avoid a frontend
// race condition from under-counting passengers.
const rtSeatedPassengers = passengersData.filter(p => p.outboundSeatId);
const allRTFaresProvided = rtSeatedPassengers.length > 0 &&
rtSeatedPassengers.every(p => p.seatFareMinor != null && p.returnSeatFareMinor != null);
if (dto.reviewedTotalMinor != null) {
displayTotalMinor = dto.reviewedTotalMinor;
totalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
: dto.reviewedTotalMinor;
} else if (allRTFaresProvided && !dto.packageId) {
// seatFareMinor/returnSeatFareMinor are in display currency — sum is already the display total
if (allRTFaresProvided && !dto.packageId) {
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
totalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
: displayTotalMinor;
if (dto.reviewedTotalMinor != null && dto.reviewedTotalMinor !== displayTotalMinor) {
this.logger.warn(`createRoundTripBooking: reviewedTotalMinor=${dto.reviewedTotalMinor} ignored — using server-computed sum=${displayTotalMinor}`);
}
} else if (dto.packageId && dto.reviewedTotalMinor != null) {
displayTotalMinor = dto.reviewedTotalMinor;
totalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
: dto.reviewedTotalMinor;
const seatedCount = rtSeatedPassengers.length;
if (seatedCount > 0) {
const perSeatPerLeg = Math.round(dto.reviewedTotalMinor / (seatedCount * 2));
passengersWithFares.forEach(p => {
if (p.outboundFareMinor > 0) p.outboundFareMinor = p.seatFareMinor ?? perSeatPerLeg;
if (p.returnFareMinor > 0) p.returnFareMinor = p.returnSeatFareMinor ?? perSeatPerLeg;
});
}
} else if (dto.reviewedTotalMinor != null && dto.reviewedTotalMinor > 0) {
displayTotalMinor = dto.reviewedTotalMinor;
totalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
: dto.reviewedTotalMinor;
}
const booking = await this.prisma.booking.create({
@@ -1822,8 +1857,8 @@ export class BookingsService {
id: pkgBooking.id,
bookingRef: pkgBooking.bookingRef,
status: pkgBooking.status,
totalMinor: pkgBooking.totalMinor,
currency: pkgBooking.currency || pkgBooking.displayCurrency,
totalMinor: pkgBooking.displayTotalMinor ?? pkgBooking.totalMinor,
currency: pkgBooking.displayCurrency || pkgBooking.currency,
adultCount: pkgBooking.passengerCount,
childCount: 0,
displayCurrency: pkgBooking.displayCurrency,
@@ -1852,7 +1887,7 @@ export class BookingsService {
fullName: p.passengerName,
category: 'ADULT',
leg: 1,
fareMinor: Math.round(pkgBooking.totalMinor / pkgBooking.passengerCount),
fareMinor: Math.round((pkgBooking.displayTotalMinor ?? pkgBooking.totalMinor) / pkgBooking.passengerCount),
verifaydaVerified: false,
seat: null,
})),

View File

@@ -204,35 +204,41 @@ export class GuestBookingService {
const taxesMinor = 0;
// Per-seat fare: use client-supplied seatFareMinor when present (berth-specific pricing).
// Free children (first child, non-package) get fareMinor=0.
let freeChildUsed = false;
let pkgChildIdx = 0;
const passengersWithFares = passengersData.map(p => {
let fareMinor: number;
if (p.category === PassengerCategory.ADULT) {
fareMinor = p.seatFareMinor ?? baseFareMinor;
} else if (isPackageOneway) {
fareMinor = pkgChildIdx < adultCount ? 0 : (p.seatFareMinor ?? childUnitFare);
pkgChildIdx++;
} else {
if (!freeChildUsed) { fareMinor = 0; freeChildUsed = true; }
else fareMinor = p.seatFareMinor ?? childUnitFare;
// Free children have no seat (frontend excludes them from the DTO).
// Guard by seatId: unseated = free (0), seated = paid child.
// Applies to both package and regular bookings.
fareMinor = p.seatId ? (p.seatFareMinor ?? childUnitFare) : 0;
}
return { ...p, fareMinor };
});
// reviewedTotalMinor and seatFareMinor are both in display-currency minor units.
// Store as displayTotalMinor as-is; back-convert to ETB for totalMinor.
// Server-computed sum from per-seat fares is the authoritative total when all
// seated passengers supplied seatFareMinor. This prevents a frontend race
// condition (fareBreakdown not yet loaded → only partial fares summed →
// reviewedTotalMinor reflects one passenger's fare instead of all).
const displayCurrency = dto.displayCurrency || Currency.ETB;
const seatedPassengers = passengersData.filter(p => p.seatId);
const allFaresProvided = seatedPassengers.length > 0 && seatedPassengers.every(p => p.seatFareMinor != null);
let displayTotalMinor: number;
let resolvedTotalMinor: number;
if (dto.reviewedTotalMinor != null) {
displayTotalMinor = dto.reviewedTotalMinor;
} else if (allFaresProvided) {
if (allFaresProvided && !isPackageOneway) {
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0);
} else if (isPackageOneway && dto.reviewedTotalMinor != null) {
displayTotalMinor = dto.reviewedTotalMinor;
if (seatedPassengers.length > 0) {
const perSeatFare = Math.round(dto.reviewedTotalMinor / seatedPassengers.length);
passengersWithFares.forEach(p => {
if (p.fareMinor > 0) p.fareMinor = p.seatFareMinor ?? perSeatFare;
});
}
} else if (dto.reviewedTotalMinor != null && dto.reviewedTotalMinor > 0) {
displayTotalMinor = dto.reviewedTotalMinor;
} else {
// fare engine returns ETB — convert forward to display currency
const etbTotal = Math.max(0, totalBaseFareMinor - discountMinor);
@@ -291,6 +297,7 @@ export class GuestBookingService {
seats: {
create: passengersWithFares.map((p) => ({
seat: { connect: { id: p.seatId } },
scheduleId: dto.scheduleId,
passengerName: p.passengerName,
dateOfBirth: p.dateOfBirth,
passengerCategory: p.category,
@@ -485,22 +492,18 @@ export class GuestBookingService {
: totalMinor;
// Per-seat fares: use client-supplied seatFareMinor/returnSeatFareMinor when present.
let outboundFreeChildUsed = false;
let returnFreeChildUsed = false;
const passengersWithFares = passengersData.map(p => {
let outboundFareMinor: number;
let returnFareMinor: number;
if (p.category === PassengerCategory.ADULT) {
outboundFareMinor = p.seatFareMinor ?? outboundBaseFare;
returnFareMinor = p.returnSeatFareMinor ?? returnBaseFare;
} else if (isPackageRoundTrip) {
outboundFareMinor = 0;
returnFareMinor = 0;
} else {
if (!outboundFreeChildUsed) { outboundFareMinor = 0; outboundFreeChildUsed = true; }
else outboundFareMinor = p.seatFareMinor ?? outboundChildUnitFare;
if (!returnFreeChildUsed) { returnFareMinor = 0; returnFreeChildUsed = true; }
else returnFareMinor = p.returnSeatFareMinor ?? returnChildUnitFare;
// Free children have no seat (frontend excludes them from the DTO).
// Guard by seatId: unseated = free (0), seated = paid child.
// Applies to both package and regular bookings.
outboundFareMinor = p.seatId ? (p.seatFareMinor ?? outboundChildUnitFare) : 0;
returnFareMinor = p.seatId ? (p.returnSeatFareMinor ?? returnChildUnitFare) : 0;
}
return { ...p, outboundFareMinor, returnFareMinor };
});
@@ -510,14 +513,28 @@ export class GuestBookingService {
const rtSeatedPassengers = passengersData.filter(p => p.seatId);
const allRTFaresProvided = rtSeatedPassengers.length > 0 &&
rtSeatedPassengers.every(p => p.seatFareMinor != null && p.returnSeatFareMinor != null);
if (dto.reviewedTotalMinor != null) {
if (allRTFaresProvided && !isPackageRoundTrip) {
// Server-computed sum is authoritative — prevents race-condition under-count.
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
totalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
: displayTotalMinor;
} else if (isPackageRoundTrip && dto.reviewedTotalMinor != null) {
displayTotalMinor = dto.reviewedTotalMinor;
totalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
: displayTotalMinor;
} else if (allRTFaresProvided && !isPackageRoundTrip) {
// seatFareMinor/returnSeatFareMinor are display-currency — sum is already display total
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
const seatedCount = passengersData.filter(p => p.seatId).length;
if (seatedCount > 0) {
const perSeatPerLeg = Math.round(dto.reviewedTotalMinor / (seatedCount * 2));
passengersWithFares.forEach(p => {
if (p.outboundFareMinor > 0) p.outboundFareMinor = p.seatFareMinor ?? perSeatPerLeg;
if (p.returnFareMinor > 0) p.returnFareMinor = p.returnSeatFareMinor ?? perSeatPerLeg;
});
}
} else if (dto.reviewedTotalMinor != null && dto.reviewedTotalMinor > 0) {
displayTotalMinor = dto.reviewedTotalMinor;
totalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
: displayTotalMinor;

View File

@@ -2,7 +2,8 @@ import { Controller, Get, Param, SetMetadata, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { DashboardService } from './dashboard.service';
import { JwtGuard } from '../../common/jwt.guard';
import { PassengerAdmin } from '../../common/passenger-guards';
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
@ApiTags('Dashboard')
@Controller('dashboard')
@@ -10,7 +11,7 @@ export class DashboardController {
constructor(private service: DashboardService) {}
@Get('backoffice-stats')
@PassengerAdmin()
@PassengerStaff([PASSENGER_PERMS.dashboard.view, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Backoffice summary: totals and revenue by currency' })
getBackofficeStats() { return this.service.getBackofficeStats(); }

View File

@@ -11,12 +11,13 @@ export class DashboardService {
) {}
async getBackofficeStats() {
const [totalBookings, totalPackageBookings, totalTickets, totalPassengers, revenueRows, packageRevenueRows] =
const [totalBookings, totalPackageBookings, totalTickets, totalPassengers, blockedSeatsCount, revenueRows, packageRevenueRows] =
await Promise.all([
this.prisma.booking.count({ where: { status: { in: ['CONFIRMED', 'BOARDED'] } } }),
this.prisma.booking.count({ where: { status: { in: ['CONFIRMED', 'BOARDED'] }, packageId: { not: null } } }),
this.prisma.ticket.count(),
this.prisma.passenger.count(),
this.prisma.seat.count({ where: { status: 'BLOCKED' } }),
this.prisma.$queryRaw<{ currency: string; total: bigint }[]>`
SELECT
COALESCE("displayCurrency"::text, "currency"::text) AS currency,
@@ -56,6 +57,7 @@ export class DashboardService {
totalPackageTickets,
totalNormalTickets: totalTickets - totalPackageTickets,
totalPassengers,
blockedSeatsCount,
revenueByCurrency: toMap(revenueRows),
packageRevenueByCurrency: toMap(packageRevenueRows),
};

View File

@@ -12,7 +12,7 @@ export class LiveService {
});
if (!schedule) throw new NotFoundException('Schedule not found');
const live = schedule.liveStatus;
const nextStop = schedule.stopTimes.find((s) => s.status === 'UPCOMING' || s.status === 'APPROACHING');
const nextStop = schedule.stopTimes.find((s) => s.status === 'OPEN' || s.status === 'CHECKIN_CLOSED');
return {
scheduleId: schedule.id, trainName: schedule.train.name,
fromStationName: schedule.originStation.name, toStationName: schedule.destinationStation.name,

View File

@@ -128,4 +128,13 @@ export class BookPackageDto {
/** Number of child passengers (<5 years). Derived from passengers array if omitted. */
@ApiPropertyOptional({ example: 1 }) @IsOptional() @IsInt() @Min(0) childCount?: number;
/**
* SeatHold UUID returned by POST /seats/hold when the user selected seats on the
* seatmap before proceeding to book. When provided, the hold's expiry is extended
* to the payment deadline so the specific seat stays reserved on the seatmap for
* the full payment window, matching the behaviour of normal bookings.
*/
@ApiPropertyOptional({ description: 'SeatHold ID from seatmap selection' })
@IsOptional() @IsUUID() holdId?: string;
}

View File

@@ -6,6 +6,7 @@ import { Currency } from '@prisma/client';
import { BookingsService } from '../bookings/bookings.service';
import { GuestBookingService } from '../bookings/guest-booking.service';
import { AuditService } from '../../common/audit.service';
import { computePaymentDeadline, CUTOFF_MINUTES } from '../../common/utils/payment-deadline.utils';
/** Package-specific fare rules */
const PKG_MAX_ADULTS = 5;
@@ -235,18 +236,27 @@ export class PackagesService {
});
if (!pkg) throw new NotFoundException('Package not found');
// Fetch live route stops so the departure station dropdown always reflects
// the current route definition, not stale TripStopTime snapshots.
// Build departure station list from live route stops when a routeId exists,
// falling back to the schedule's own stopTimes (already included in the query).
let routeStops: { sequence: number; station: any }[] = [];
if (pkg.outboundSchedule.routeId) {
const stops = await this.prisma.routeStop.findMany({
where: { routeId: pkg.outboundSchedule.routeId },
orderBy: { sequence: 'asc' },
});
const stationIds = stops.map((s) => s.stationId);
const stations = await this.prisma.station.findMany({ where: { id: { in: stationIds } } });
const stationMap = Object.fromEntries(stations.map((s) => [s.id, s]));
routeStops = stops.map((s) => ({ sequence: s.sequence, station: stationMap[s.stationId] }));
if (stops.length > 0) {
const stationIds = stops.map((s) => s.stationId);
const stations = await this.prisma.station.findMany({ where: { id: { in: stationIds } } });
const stationMap = Object.fromEntries(stations.map((s) => [s.id, s]));
routeStops = stops.map((s) => ({ sequence: s.sequence, station: stationMap[s.stationId] }));
}
}
// Fall back to the schedule's own TripStopTimes when RouteStop table has no rows
// for this route (e.g. route exists but stops were never seeded).
if (routeStops.length === 0) {
routeStops = (pkg.outboundSchedule.stopTimes ?? [])
.filter((st: any) => st.station)
.map((st: any) => ({ sequence: st.sequence, station: st.station }));
}
return {
@@ -382,7 +392,10 @@ export class PackagesService {
async book(dto: BookPackageDto, passengerId?: string) {
const pkg = await this.prisma.travelPackage.findUnique({
where: { id: dto.packageId },
include: { priceTiers: true },
include: {
priceTiers: true,
outboundSchedule: { select: { departureAt: true, route: { select: { checkinMinutesBefore: true } } } },
},
});
if (!pkg) throw new NotFoundException('Package not found');
if (pkg.status !== 'ACTIVE') throw new BadRequestException('Package is not available for booking');
@@ -440,8 +453,8 @@ export class PackagesService {
passengerCount,
adultCount,
childCount,
totalMinor,
currency: 'ETB',
totalMinor: displayTotalMinor,
currency: displayCurrency,
displayCurrency,
displayTotalMinor,
status: 'PENDING_PAYMENT',
@@ -477,6 +490,27 @@ export class PackagesService {
]);
});
// Extend the seatmap SeatHold (if one was passed) to the payment deadline so the
// specific seat remains visually reserved on the seatmap during the full payment
// window — matching the behaviour of normal bookings (which call confirmSeats).
if (dto.holdId) {
const dep = (pkg as any).outboundSchedule?.departureAt as Date | undefined;
if (dep) {
const checkinMinutes = (pkg as any).outboundSchedule?.route?.checkinMinutesBefore ?? CUTOFF_MINUTES;
const paymentDeadline = computePaymentDeadline(booking.createdAt as Date, dep, checkinMinutes);
const hold = await this.prisma.seatHold.findUnique({
where: { id: dto.holdId },
select: { expiresAt: true },
});
if (hold && paymentDeadline > hold.expiresAt) {
await this.prisma.seatHold.update({
where: { id: dto.holdId },
data: { expiresAt: paymentDeadline },
});
}
}
}
return {
...booking,
fareBreakdown: {

View File

@@ -12,8 +12,15 @@ import {
PaymentIntentSnapshot,
PaymentReferenceType,
PaymentService,
ProviderStatus,
} from "@edr/types";
/** Side-by-side DB row + live provider status from the payment service diagnostic endpoints. */
export interface PaymentDiagnostic {
db: Record<string, unknown> | null;
provider: ProviderStatus | null;
}
/**
* Thin HTTP client for the payment microservice (apps/edr-payment-api) — the passenger app's
* side of the Phase 6 cutover (docs/payment-service §10). Domain validation stays here;
@@ -55,6 +62,28 @@ export class PaymentClientService {
}
}
/**
* GET /payments/diagnostic?… — DB intent row + live provider status for a domain reference,
* side by side. Returns { db: null, provider: null } when the payment service has no intent.
*/
async getDiagnosticByReference(
referenceType: PaymentReferenceType,
referenceId: string,
): Promise<PaymentDiagnostic> {
const query = new URLSearchParams({
service: PaymentService.PASSENGER,
referenceType,
referenceId,
});
try {
return await this.call("GET", `/payments/diagnostic?${query.toString()}`);
} catch (err) {
if (err instanceof AxiosError && err.response?.status === 404)
return { db: null, provider: null };
throw err;
}
}
/**
* POST /payments/intents/:id/confirm — submit an OTP for a COLLECT_OTP provider (CAC Bank).
* A wrong/expired OTP comes back as 400 from the payment service; surface that as a

View File

@@ -0,0 +1,109 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { ModuleRef } from '@nestjs/core';
import { PrismaService } from '../../common/prisma.service';
import { PaymentClientService } from './payment-client.service';
import { PaymentsService } from './payments.service';
import { PaymentReferenceType, ProviderPaymentStatus } from '@edr/types';
// PaymentsService is request-scoped (AuditService.@Inject(REQUEST) bubbles up).
// This service keeps only singleton deps so its @Cron method registers correctly,
// then resolves PaymentsService per-tick via ModuleRef (same pattern as
// PaymentEventsConsumer).
@Injectable()
export class PaymentSyncService {
private readonly logger = new Logger(PaymentSyncService.name);
constructor(
private readonly prisma: PrismaService,
private readonly paymentClient: PaymentClientService,
private readonly moduleRef: ModuleRef,
) {}
// ─────────────────────────────────────────────────────────────────────────
// Every 1 min: poll the payment service for any PENDING_PAYMENT bookings
// whose payment intent has moved to SUCCEEDED on the gateway but whose
// confirmation event was never delivered (missed RabbitMQ message, network
// blip, etc.). finalizePaymentSuccess() is fully idempotent so re-running
// it for an already-confirmed booking is safe.
//
// Processes at most 50 bookings per cycle to avoid hammering the payment
// service; the next tick picks up the remainder.
// ─────────────────────────────────────────────────────────────────────────
@Cron('*/1 * * * *')
async syncPaymentStatuses() {
const BATCH_SIZE = 50;
const bookings = await this.prisma.booking.findMany({
where: {
status: 'PENDING_PAYMENT',
paymentIntent: { status: { in: ['REQUIRES_ACTION', 'PROCESSING'] } },
},
include: { paymentIntent: true },
take: BATCH_SIZE,
orderBy: { createdAt: 'asc' },
});
if (bookings.length === 0) return;
let confirmed = 0;
let failed = 0;
let errored = 0;
// resolve() (not get()) because PaymentsService is scoped — same pattern
// as PaymentEventsConsumer.
const paymentsService = await this.moduleRef.resolve(
PaymentsService,
undefined,
{ strict: false },
);
for (const booking of bookings) {
if (!booking.paymentIntent) continue;
try {
const snapshot = await this.paymentClient.getIntentByReference(
PaymentReferenceType.BOOKING,
booking.id,
);
if (!snapshot) continue;
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
const result = await paymentsService.finalizePaymentSuccess({
intentId: booking.paymentIntent.id,
providerTxnId: snapshot.providerTxnId,
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
});
if (!result.alreadyFinalized) {
this.logger.log(`Payment sync confirmed: ${booking.bookingRef}`);
confirmed++;
}
} else if (
snapshot.status === ProviderPaymentStatus.FAILED ||
snapshot.status === ProviderPaymentStatus.CANCELLED
) {
this.logger.warn(
`Payment sync: ${booking.bookingRef} intent is ${snapshot.status}` +
`booking will be auto-cancelled at payment deadline`,
);
failed++;
}
// REQUIRES_ACTION / PROCESSING → still pending, retry next cycle
} catch (err) {
this.logger.error(
`Payment sync error for ${booking.bookingRef}: ` +
`${err instanceof Error ? err.message : String(err)}`,
);
errored++;
}
}
if (confirmed > 0 || failed > 0 || errored > 0) {
this.logger.log(
`Payment sync run: ${bookings.length} checked, ` +
`${confirmed} confirmed, ${failed} failed/cancelled, ${errored} errors`,
);
}
}
}

View File

@@ -133,6 +133,33 @@ export class PaymentsController {
return this.service.getIntentByBookingId(bookingId);
}
@Get("status/:bookingRefOrId")
@SetMetadata("isPublic", true)
@ApiOperation({
summary: "Get payment status by booking id or booking reference (PNR)",
description:
"Accepts either a booking UUID or a booking reference / PNR (e.g. EDR-20240001), " +
"resolves it to the booking, and returns the authoritative payment status pulled from " +
"the payment microservice.",
})
getStatusByBookingRefOrId(@Param("bookingRefOrId") bookingRefOrId: string) {
return this.service.getIntentByBookingRefOrId(bookingRefOrId);
}
@Get("diagnostic/:bookingRefOrId")
@SetMetadata("isPublic", true)
@ApiOperation({
summary:
"Get { db, provider } by booking id or booking reference (PNR) — diagnostic",
description:
"Accepts a booking UUID or a booking reference / PNR (e.g. EDR-20240001), resolves it to " +
"the booking, and returns { db, provider }: the payment service's stored intent row and a " +
"live provider status query, side by side. Pure read — does not reconcile the booking.",
})
getPaymentDiagnostic(@Param("bookingRefOrId") bookingRefOrId: string) {
return this.service.getPaymentDiagnosticByBookingRefOrId(bookingRefOrId);
}
@Post(":bookingId/confirm")
@SetMetadata("isPublic", true)
@ApiOperation({

View File

@@ -118,6 +118,7 @@ describe("Payments E2E", () => {
data: {
bookingId: booking.id,
seatId: seat.id,
scheduleId: schedule.id,
passengerName: "Test Passenger",
},
});

View File

@@ -16,6 +16,7 @@ import { SupplementaryChargesService } from "./supplementary-charges.service";
import { InternalPaymentsController } from "./internal-payments.controller";
import { PaymentClientService } from "./payment-client.service";
import { PaymentEventsConsumer } from "./payment-events.consumer";
import { PaymentSyncService } from "./payment-sync.service";
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
import { SeatsModule } from "../seats/seats.module";
import { TicketsModule } from "../tickets/tickets.module";
@@ -70,6 +71,7 @@ function rabbitMQImport(): DynamicModule[] {
SupplementaryChargesService,
PaymentClientService,
PaymentEventsConsumer,
PaymentSyncService,
ServiceAuthGuard,
],
exports: [PaymentClientService, PaymentsService],

View File

@@ -24,7 +24,10 @@ import {
ForceConfirmDto,
} from "./payments.dto";
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto";
import { PaymentClientService } from "./payment-client.service";
import {
PaymentClientService,
PaymentDiagnostic,
} from "./payment-client.service";
import { CurrencyService } from "../currency/currency.service";
import { AuditService } from "../../common/audit.service";
import { rebaseUrlOrigin } from "../../common/utils/redirect-origin.util";
@@ -534,6 +537,51 @@ export class PaymentsService {
};
}
/**
* Payment status by booking id (UUID) OR booking reference / PNR (e.g. EDR-20240001).
* Resolves the PNR to its booking id, then pulls the authoritative status from the payment
* microservice (via {@link getIntentByBookingId}).
*/
async getIntentByBookingRefOrId(
bookingRefOrId: string,
): Promise<IntentStatusDto> {
const bookingId = await this.resolveBookingId(bookingRefOrId);
return this.getIntentByBookingId(bookingId);
}
/**
* Diagnostic view by booking id (UUID) OR booking reference / PNR: the payment service's
* stored intent row and a live provider status query, side by side ({ db, provider }).
* Pure read — does not reconcile or confirm the booking.
*/
async getPaymentDiagnosticByBookingRefOrId(
bookingRefOrId: string,
): Promise<PaymentDiagnostic> {
const bookingId = await this.resolveBookingId(bookingRefOrId);
return this.paymentClient.getDiagnosticByReference(
PaymentReferenceType.BOOKING,
bookingId,
);
}
/** Accept a booking UUID as-is; otherwise look the id up from its bookingRef/PNR. */
private async resolveBookingId(bookingRefOrId: string): Promise<string> {
const isUuid =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
bookingRefOrId,
);
if (isUuid) return bookingRefOrId;
const booking = await this.prisma.booking.findUnique({
where: { bookingRef: bookingRefOrId },
select: { id: true },
});
if (!booking) {
throw new NotFoundException(`Booking not found: ${bookingRefOrId}`);
}
return booking.id;
}
async getIntentByBookingId(bookingId: string): Promise<IntentStatusDto> {
const local = await this.prisma.paymentIntent.findUnique({
where: { bookingId },

View File

@@ -1,56 +1,50 @@
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { ReportsService } from './reports.service';
import { GenerateReportDto } from './reports.dto';
import { PassengerStaff } from '../../common/passenger-guards';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common";
import { ApiTags, ApiOperation, ApiBearerAuth } from "@nestjs/swagger";
import { ReportsService } from "./reports.service";
import { GenerateReportDto } from "./reports.dto";
import { PassengerStaff } from "../../common/passenger-guards";
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
@ApiTags('Reports')
@Controller('reports')
@ApiTags("Reports")
@Controller("reports")
@PassengerStaff([PASSENGER_PERMS.reports.view, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiBearerAuth("IAM-auth")
export class ReportsController {
constructor(private service: ReportsService) {}
@Post('generate')
@ApiOperation({ summary: 'Generate operational report' })
@Post("generate")
@ApiOperation({ summary: "Generate operational report" })
generateReport(@Body() dto: GenerateReportDto) {
return this.service.generateReport(dto);
}
@Get('schedules')
@ApiOperation({ summary: 'List schedules for the passengers report picker' })
@Get("schedules")
@ApiOperation({ summary: "List schedules for the passengers report picker" })
listSchedulesForPicker() {
return this.service.listSchedulesForPicker();
}
@Get('passengers/list')
@ApiOperation({ summary: 'Flat passenger list for a specific schedule' })
getPassengerList(@Query('scheduleId') scheduleId: string) {
@Get("passengers/list")
@ApiOperation({ summary: "Flat passenger list for a specific schedule" })
getPassengerList(@Query("scheduleId") scheduleId: string) {
return this.service.getPassengerList(scheduleId);
}
@Get('seats')
@ApiOperation({ summary: 'Seat status report for a specific schedule' })
getSeatStatusReport(@Query('scheduleId') scheduleId: string) {
return this.service.getSeatStatusReport(scheduleId);
}
@Get('passengers')
@ApiOperation({ summary: 'Passengers report for a specific schedule' })
getOccupancyReport(@Query('scheduleId') scheduleId: string) {
@Get("passengers")
@ApiOperation({ summary: "Passengers report for a specific schedule" })
getOccupancyReport(@Query("scheduleId") scheduleId: string) {
return this.service.getOccupancyBySchedule(scheduleId);
}
@Get(':reportId')
@ApiOperation({ summary: 'Get report by ID' })
getReport(@Param('reportId') reportId: string) {
@Get(":reportId")
@ApiOperation({ summary: "Get report by ID" })
getReport(@Param("reportId") reportId: string) {
return this.service.getReport(reportId);
}
@Get()
@ApiOperation({ summary: 'List reports' })
listReports(@Query('type') type?: string) {
@ApiOperation({ summary: "List reports" })
listReports(@Query("type") type?: string) {
return this.service.listReports(type);
}
}

View File

@@ -1,8 +1,8 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { PrismaService } from '../../common/prisma.service';
import { GenerateReportDto, ReportType } from './reports.dto';
import { Injectable, Logger } from "@nestjs/common";
import { InjectDataSource } from "@nestjs/typeorm";
import { DataSource } from "typeorm";
import { PrismaService } from "../../common/prisma.service";
import { GenerateReportDto, ReportType } from "./reports.dto";
@Injectable()
export class ReportsService {
@@ -15,7 +15,7 @@ export class ReportsService {
async generateReport(dto: GenerateReportDto) {
const dateFrom = new Date(dto.dateFrom);
dateFrom.setHours(0, 0, 0, 0);
const dateTo = new Date(dto.dateTo);
dateTo.setHours(23, 59, 59, 999);
@@ -28,7 +28,11 @@ export class ReportsService {
data = await this.generateOccupancyReport(dateFrom, dateTo);
break;
case ReportType.AGENT_SALES:
data = await this.generateAgentSalesReport(dateFrom, dateTo, dto.agentId);
data = await this.generateAgentSalesReport(
dateFrom,
dateTo,
dto.agentId,
);
break;
case ReportType.CANCELLATIONS:
data = await this.generateCancellationsReport(dateFrom, dateTo);
@@ -45,8 +49,8 @@ export class ReportsService {
reportType: dto.reportType,
dateFrom,
dateTo,
data
}
data,
},
});
return { reportId: report.id, reportType: dto.reportType, data };
@@ -56,37 +60,43 @@ export class ReportsService {
// Fetch all bookings in date range, regardless of status
const bookings = await this.prisma.booking.findMany({
where: {
createdAt: { gte: dateFrom, lte: dateTo }
createdAt: { gte: dateFrom, lte: dateTo },
},
include: { paymentIntent: true }
include: { paymentIntent: true },
});
const totalRevenue = bookings.reduce((sum, b) => sum + b.totalMinor, 0);
const byPaymentMethod = bookings.reduce((acc, b) => {
const method = b.paymentIntent?.method ?? 'UNKNOWN';
acc[method] = (acc[method] || 0) + b.totalMinor;
return acc;
}, {} as Record<string, number>);
const byPaymentMethod = bookings.reduce(
(acc, b) => {
const method = b.paymentIntent?.method ?? "UNKNOWN";
acc[method] = (acc[method] || 0) + b.totalMinor;
return acc;
},
{} as Record<string, number>,
);
// Group by date for charts
const byDate = bookings.reduce((acc, b) => {
const date = b.createdAt.toISOString().split('T')[0];
if (!acc[date]) {
acc[date] = { totalMinor: 0, count: 0 };
}
acc[date].totalMinor += b.totalMinor;
acc[date].count += 1;
return acc;
}, {} as Record<string, any>);
const byDate = bookings.reduce(
(acc, b) => {
const date = b.createdAt.toISOString().split("T")[0];
if (!acc[date]) {
acc[date] = { totalMinor: 0, count: 0 };
}
acc[date].totalMinor += b.totalMinor;
acc[date].count += 1;
return acc;
},
{} as Record<string, any>,
);
return {
totalBookings: bookings.length,
totalRevenueMinor: totalRevenue,
totalRevenue: totalRevenue / 100,
currency: 'ETB',
currency: "ETB",
byPaymentMethod,
byDate,
cancellationRate: 0
cancellationRate: 0,
};
}
@@ -96,79 +106,115 @@ export class ReportsService {
include: {
coachAssignments: { include: { coach: { include: { seats: true } } } },
bookings: {
where: { status: { in: ['CONFIRMED', 'BOARDED'] } },
where: { status: { in: ["CONFIRMED", "BOARDED"] } },
include: { seats: true },
},
},
});
const tripData = schedules.map(schedule => {
const totalSeats = schedule.coachAssignments.reduce((sum, a) => sum + a.coach.seats.length, 0);
const bookedSeats = schedule.bookings.reduce(
(sum, b) => sum + b.seats.filter((s: any) => s.leg === 1).length, 0,
const tripData = schedules.map((schedule) => {
const totalSeats = schedule.coachAssignments.reduce(
(sum, a) => sum + a.coach.seats.length,
0,
);
const occupancyRate = totalSeats > 0 ? (bookedSeats / totalSeats) * 100 : 0;
return { scheduleId: schedule.id, departureAt: schedule.departureAt, totalSeats, bookedSeats, occupancyRate: +occupancyRate.toFixed(2) };
const bookedSeats = schedule.bookings.reduce(
(sum, b) =>
sum + b.seats.filter((s: any) => s.scheduleId === schedule.id).length,
0,
);
const occupancyRate =
totalSeats > 0 ? (bookedSeats / totalSeats) * 100 : 0;
return {
scheduleId: schedule.id,
departureAt: schedule.departureAt,
totalSeats,
bookedSeats,
occupancyRate: +occupancyRate.toFixed(2),
};
});
const avgOccupancy = tripData.length > 0 ? tripData.reduce((sum, t) => sum + t.occupancyRate, 0) / tripData.length : 0;
return { totalSchedules: schedules.length, averageOccupancyRate: +avgOccupancy.toFixed(2), schedules: tripData };
const avgOccupancy =
tripData.length > 0
? tripData.reduce((sum, t) => sum + t.occupancyRate, 0) /
tripData.length
: 0;
return {
totalSchedules: schedules.length,
averageOccupancyRate: +avgOccupancy.toFixed(2),
schedules: tripData,
};
}
private async generateAgentSalesReport(dateFrom: Date, dateTo: Date, agentId?: string) {
private async generateAgentSalesReport(
dateFrom: Date,
dateTo: Date,
agentId?: string,
) {
const agentBookings = await this.prisma.agentBooking.findMany({
where: {
createdAt: { gte: dateFrom, lte: dateTo },
...(agentId ? { agentId } : {})
...(agentId ? { agentId } : {}),
},
include: {
agent: { select: { id: true, iamUserId: true, agentCode: true } },
booking: true
}
booking: true,
},
});
const iamUserIds = [...new Set(
agentBookings.map(ab => ab.agent.iamUserId).filter(Boolean) as string[]
)];
const iamRows = iamUserIds.length > 0
? await this.dataSource.query<{ id: string; name: { en?: string; am?: string } | null }[]>(
`SELECT id, name FROM iam.users WHERE id = ANY($1)`,
[iamUserIds],
)
: [];
const iamMap = new Map(iamRows.map(r => [r.id, r]));
const iamUserIds = [
...new Set(
agentBookings
.map((ab) => ab.agent.iamUserId)
.filter(Boolean) as string[],
),
];
const iamRows =
iamUserIds.length > 0
? await this.dataSource.query<
{ id: string; name: { en?: string; am?: string } | null }[]
>(`SELECT id, name FROM iam.users WHERE id = ANY($1)`, [iamUserIds])
: [];
const iamMap = new Map(iamRows.map((r) => [r.id, r]));
const byAgent = agentBookings.reduce((acc, ab) => {
const iam = ab.agent.iamUserId ? iamMap.get(ab.agent.iamUserId) : undefined;
const agentName = iam?.name?.en ?? iam?.name?.am ?? ab.agent.agentCode;
if (!acc[agentName]) {
acc[agentName] = { bookings: 0, revenueMinor: 0, cashCollected: 0 };
}
acc[agentName].bookings += 1;
acc[agentName].revenueMinor += ab.booking.totalMinor;
acc[agentName].cashCollected += ab.cashReceived ?? 0;
return acc;
}, {} as Record<string, any>);
const byAgent = agentBookings.reduce(
(acc, ab) => {
const iam = ab.agent.iamUserId
? iamMap.get(ab.agent.iamUserId)
: undefined;
const agentName = iam?.name?.en ?? iam?.name?.am ?? ab.agent.agentCode;
if (!acc[agentName]) {
acc[agentName] = { bookings: 0, revenueMinor: 0, cashCollected: 0 };
}
acc[agentName].bookings += 1;
acc[agentName].revenueMinor += ab.booking.totalMinor;
acc[agentName].cashCollected += ab.cashReceived ?? 0;
return acc;
},
{} as Record<string, any>,
);
return {
totalAgentBookings: agentBookings.length,
byAgent
byAgent,
};
}
private async generateCancellationsReport(dateFrom: Date, dateTo: Date) {
const cancellations = await this.prisma.bookingCancellation.findMany({
where: { createdAt: { gte: dateFrom, lte: dateTo } },
include: { booking: true }
include: { booking: true },
});
const totalRefunded = cancellations.reduce((sum, c) => sum + c.refundAmount, 0);
const totalRefunded = cancellations.reduce(
(sum, c) => sum + c.refundAmount,
0,
);
return {
totalCancellations: cancellations.length,
totalRefundedMinor: totalRefunded,
totalRefunded: totalRefunded / 100,
currency: 'ETB'
currency: "ETB",
};
}
@@ -176,23 +222,26 @@ export class ReportsService {
const payments = await this.prisma.paymentIntent.findMany({
where: {
createdAt: { gte: dateFrom, lte: dateTo },
status: 'SUCCEEDED'
}
status: "SUCCEEDED",
},
});
const byMethod = payments.reduce((acc, p) => {
const method = p.method;
if (!acc[method]) {
acc[method] = { count: 0, totalMinor: 0 };
}
acc[method].count += 1;
acc[method].totalMinor += p.amountMinor;
return acc;
}, {} as Record<string, any>);
const byMethod = payments.reduce(
(acc, p) => {
const method = p.method;
if (!acc[method]) {
acc[method] = { count: 0, totalMinor: 0 };
}
acc[method].count += 1;
acc[method].totalMinor += p.amountMinor;
return acc;
},
{} as Record<string, any>,
);
return {
totalPayments: payments.length,
byMethod
byMethod,
};
}
@@ -214,7 +263,7 @@ export class ReportsService {
},
},
bookings: {
where: { status: { in: ['CONFIRMED', 'BOARDED'] } },
where: { status: { in: ["CONFIRMED", "BOARDED"] } },
include: {
seats: {
where: { leg: 1 },
@@ -224,61 +273,131 @@ export class ReportsService {
},
},
},
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
stopTimes: { include: { station: true }, orderBy: { sequence: "asc" } },
},
});
if (!schedule) return null;
const totalSeats = (schedule as any).coachAssignments.reduce((s: number, a: any) => s + a.coach.seats.length, 0);
const allBookingSeats = (schedule as any).bookings.flatMap((b: any) => b.seats);
const totalSeats = (schedule as any).coachAssignments.reduce(
(s: number, a: any) => s + a.coach.seats.length,
0,
);
const allBookingSeats = (schedule as any).bookings.flatMap(
(b: any) => b.seats,
);
const totalPassengers = allBookingSeats.length;
const occupancyRate = totalSeats > 0 ? +((totalPassengers / totalSeats) * 100).toFixed(1) : 0;
const occupancyRate =
totalSeats > 0 ? +((totalPassengers / totalSeats) * 100).toFixed(1) : 0;
const coachMap = new Map<string, { coachNumber: string; coachType: string; totalSeats: number; booked: number }>();
// Per-coach breakdown
const coachMap = new Map<
string,
{
coachNumber: string;
coachType: string;
totalSeats: number;
booked: number;
}
>();
for (const assignment of (schedule as any).coachAssignments) {
const c = assignment.coach;
coachMap.set(c.id, { coachNumber: c.number, coachType: (c as any).coachType?.name ?? 'Unknown', totalSeats: c.seats.length, booked: 0 });
coachMap.set(c.id, {
coachNumber: c.number,
coachType: (c as any).coachType?.name ?? "Unknown",
totalSeats: c.seats.length,
booked: 0,
});
}
for (const bs of allBookingSeats) {
const coachId = bs.seat?.coachId;
if (coachId && coachMap.has(coachId)) coachMap.get(coachId)!.booked++;
}
const byCoach = [...coachMap.values()].map(c => ({ ...c, occupancyRate: c.totalSeats > 0 ? +((c.booked / c.totalSeats) * 100).toFixed(1) : 0 }));
const byCoach = [...coachMap.values()].map((c) => ({
...c,
occupancyRate:
c.totalSeats > 0 ? +((c.booked / c.totalSeats) * 100).toFixed(1) : 0,
}));
const originMap = new Map<string, { stationName: string; passengers: number }>();
// Per-origin station breakdown (using booking's originStationId)
const originMap = new Map<
string,
{ stationName: string; passengers: number }
>();
for (const booking of (schedule as any).bookings) {
const stationId = booking.originStationId ?? schedule.originStationId;
const stationName = (schedule as any).stopTimes.find((st: any) => st.stationId === stationId)?.station?.name ?? (schedule as any).originStation?.name ?? stationId;
if (!originMap.has(stationId)) originMap.set(stationId, { stationName, passengers: 0 });
const stationName =
(schedule as any).stopTimes.find(
(st: any) => st.stationId === stationId,
)?.station?.name ??
(schedule as any).originStation?.name ??
stationId;
if (!originMap.has(stationId))
originMap.set(stationId, { stationName, passengers: 0 });
originMap.get(stationId)!.passengers += booking.seats.length;
}
const byOrigin = [...originMap.values()].sort(
(a, b) => b.passengers - a.passengers,
);
const destMap = new Map<string, { stationName: string; passengers: number }>();
// Per-destination station breakdown
const destMap = new Map<
string,
{ stationName: string; passengers: number }
>();
for (const booking of (schedule as any).bookings) {
const stationId = booking.destinationStationId ?? schedule.destinationStationId;
const stationName = (schedule as any).stopTimes.find((st: any) => st.stationId === stationId)?.station?.name ?? (schedule as any).destinationStation?.name ?? stationId;
if (!destMap.has(stationId)) destMap.set(stationId, { stationName, passengers: 0 });
const stationId =
booking.destinationStationId ?? schedule.destinationStationId;
const stationName =
(schedule as any).stopTimes.find(
(st: any) => st.stationId === stationId,
)?.station?.name ??
(schedule as any).destinationStation?.name ??
stationId;
if (!destMap.has(stationId))
destMap.set(stationId, { stationName, passengers: 0 });
destMap.get(stationId)!.passengers += booking.seats.length;
}
const byDestination = [...destMap.values()].sort(
(a, b) => b.passengers - a.passengers,
);
const classMap = new Map<string, { className: string; totalSeats: number; booked: number }>();
// Per-class breakdown
const classMap = new Map<
string,
{ className: string; totalSeats: number; booked: number }
>();
for (const assignment of (schedule as any).coachAssignments) {
const typeName = (assignment.coach as any).coachType?.name ?? 'Unknown';
if (!classMap.has(typeName)) classMap.set(typeName, { className: typeName, totalSeats: 0, booked: 0 });
const typeName = (assignment.coach as any).coachType?.name ?? "Unknown";
if (!classMap.has(typeName))
classMap.set(typeName, {
className: typeName,
totalSeats: 0,
booked: 0,
});
classMap.get(typeName)!.totalSeats += assignment.coach.seats.length;
}
for (const bs of allBookingSeats) {
const typeName = bs.seat?.coach?.coachType?.name ?? 'Unknown';
if (!classMap.has(typeName)) classMap.set(typeName, { className: typeName, totalSeats: 0, booked: 0 });
const typeName = bs.seat?.coach?.coachType?.name ?? "Unknown";
if (!classMap.has(typeName))
classMap.set(typeName, {
className: typeName,
totalSeats: 0,
booked: 0,
});
classMap.get(typeName)!.booked++;
}
const byClass = [...classMap.values()].map(c => ({ ...c, occupancyRate: c.totalSeats > 0 ? +((c.booked / c.totalSeats) * 100).toFixed(1) : 0 }));
const byClass = [...classMap.values()].map((c) => ({
...c,
occupancyRate:
c.totalSeats > 0 ? +((c.booked / c.totalSeats) * 100).toFixed(1) : 0,
}));
return {
schedule: {
id: schedule.id,
trainName: (schedule as any).train?.name ?? (schedule as any).train?.number,
trainName:
(schedule as any).train?.name ?? (schedule as any).train?.number,
origin: (schedule as any).originStation?.name,
destination: (schedule as any).destinationStation?.name,
departureAt: schedule.departureAt,
@@ -287,8 +406,8 @@ export class ReportsService {
summary: { totalSeats, totalPassengers, occupancyRate },
byCoach,
byClass,
byOrigin: [...originMap.values()].sort((a, b) => b.passengers - a.passengers),
byDestination: [...destMap.values()].sort((a, b) => b.passengers - a.passengers),
byOrigin,
byDestination,
};
}
@@ -301,12 +420,12 @@ export class ReportsService {
originStation: { select: { name: true } },
destinationStation: { select: { name: true } },
},
orderBy: { departureAt: 'desc' },
orderBy: { departureAt: "desc" },
take: 200,
});
return schedules.map(s => ({
return schedules.map((s) => ({
id: s.id,
label: `${s.train.number} · ${s.originStation.name}${s.destinationStation.name} · ${new Date(s.departureAt).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' })}`,
label: `${s.train.number} · ${s.originStation.name}${s.destinationStation.name} · ${new Date(s.departureAt).toLocaleString("en-GB", { dateStyle: "medium", timeStyle: "short" })}`,
}));
}
@@ -314,7 +433,7 @@ export class ReportsService {
const seats = await this.prisma.bookingSeat.findMany({
where: {
leg: 1,
booking: { scheduleId, status: { in: ['CONFIRMED', 'BOARDED'] } },
booking: { scheduleId, status: { in: ["CONFIRMED", "BOARDED"] } },
},
include: {
booking: {
@@ -330,10 +449,9 @@ export class ReportsService {
},
seat: { select: { seatNumber: true, bedPosition: true, coach: { select: { number: true, coachType: { select: { name: true, seatClasses: { select: { name: true, bedPosition: true } } } } } } } },
},
orderBy: [{ seat: { coach: { number: 'asc' } } }, { seat: { seatNumber: 'asc' } }],
orderBy: [{ seat: { coach: { number: "asc" } } }, { seat: { seatNumber: "asc" } }],
});
// Resolve station names in one query
const stationIds = [...new Set(
seats.flatMap(bs => [bs.booking.originStationId, bs.booking.destinationStationId]).filter(Boolean) as string[],
)];
@@ -347,7 +465,7 @@ export class ReportsService {
select: { departureAt: true },
});
return seats.map(bs => ({
return seats.map((bs) => ({
bookingRef: bs.booking.bookingRef,
passengerName: bs.passengerName,
passengerCategory: bs.passengerCategory,
@@ -474,14 +592,16 @@ export class ReportsService {
}
async getReport(reportId: string) {
return this.prisma.operationalReport.findUnique({ where: { id: reportId } });
return this.prisma.operationalReport.findUnique({
where: { id: reportId },
});
}
async listReports(reportType?: string) {
return this.prisma.operationalReport.findMany({
where: reportType ? { reportType } : {},
orderBy: { createdAt: 'desc' },
take: 50
orderBy: { createdAt: "desc" },
take: 50,
});
}
}

View File

@@ -6,6 +6,9 @@ export class RouteStopInputDto {
@ApiProperty({ example: 'station-uuid', description: 'Station UUID' }) @IsString() stationId: string;
@ApiProperty({ example: 1, description: 'Stop order (1 = origin, ascending)' }) @IsInt() @Min(1) sequence: number;
@ApiPropertyOptional({ example: 120.5, description: 'Distance in km from previous stop' }) @IsOptional() @IsNumber() distanceKm?: number;
@ApiPropertyOptional({ example: 45, description: 'Override check-in cutoff (minutes) for this stop. Falls back to route-level checkinMinutesBefore if omitted.' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number;
@ApiPropertyOptional({ example: '2026-06-15T06:30:00Z', description: 'Template planned arrival time at this stop. Only the time-of-day (EAT) is used when auto-populating new schedules. Omit for first stop.' }) @IsOptional() @IsDateString() plannedArrivalTime?: string;
@ApiPropertyOptional({ example: '2026-06-15T06:45:00Z', description: 'Template planned departure time from this stop. Only the time-of-day (EAT) is used when auto-populating new schedules. Omit for last stop.' }) @IsOptional() @IsDateString() plannedDepartureTime?: string;
}
export class CreateRouteDto {
@@ -35,6 +38,9 @@ export class AddRouteStopDto {
@ApiProperty({ example: 'station-uuid' }) @IsString() stationId: string;
@ApiProperty({ example: 3 }) @IsInt() @Min(1) sequence: number;
@ApiPropertyOptional({ example: 75.5 }) @IsOptional() @IsNumber() distanceKm?: number;
@ApiPropertyOptional({ example: 45, description: 'Override check-in cutoff (minutes) for this stop. Falls back to route-level checkinMinutesBefore if omitted.' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number;
@ApiPropertyOptional({ example: '2026-06-15T06:30:00Z', description: 'Template planned arrival time at this stop (only time-of-day is used)' }) @IsOptional() @IsDateString() plannedArrivalTime?: string;
@ApiPropertyOptional({ example: '2026-06-15T06:45:00Z', description: 'Template planned departure time from this stop (only time-of-day is used)' }) @IsOptional() @IsDateString() plannedDepartureTime?: string;
}
export class UpdateRouteDto {
@@ -42,6 +48,7 @@ export class UpdateRouteDto {
@ApiPropertyOptional() @IsOptional() @IsString() description?: string;
@ApiPropertyOptional({ example: true }) @IsOptional() @IsBoolean() active?: boolean;
@ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string;
@ApiPropertyOptional({ example: 30, description: 'Minutes before departure to close check-in for this route' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number;
@ApiPropertyOptional({ type: [RouteStopInputDto] }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => RouteStopInputDto) stops?: RouteStopInputDto[];
}

View File

@@ -36,6 +36,9 @@ export class RoutesService {
stationId: s.stationId,
sequence: s.sequence,
distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null,
checkinMinutesBefore: s.checkinMinutesBefore ?? null,
plannedArrivalTime: s.plannedArrivalTime ? new Date(s.plannedArrivalTime) : null,
plannedDepartureTime: s.plannedDepartureTime ? new Date(s.plannedDepartureTime) : null,
})),
},
},
@@ -92,6 +95,7 @@ export class RoutesService {
description: dto.description,
active: dto.active,
effectiveUntil: dto.effectiveUntil ? new Date(dto.effectiveUntil) : undefined,
...(dto.checkinMinutesBefore != null ? { checkinMinutesBefore: dto.checkinMinutesBefore } : {}),
},
});
@@ -103,6 +107,9 @@ export class RoutesService {
stationId: s.stationId,
sequence: s.sequence,
distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null,
checkinMinutesBefore: s.checkinMinutesBefore ?? null,
plannedArrivalTime: s.plannedArrivalTime ?? null,
plannedDepartureTime: s.plannedDepartureTime ?? null,
})),
});
}
@@ -221,6 +228,9 @@ export class RoutesService {
stationId: dto.stationId,
sequence: dto.sequence,
distanceKm: dto.distanceKm != null ? parseFloat(String(dto.distanceKm)) : null,
checkinMinutesBefore: dto.checkinMinutesBefore ?? null,
plannedArrivalTime: dto.plannedArrivalTime ?? null,
plannedDepartureTime: dto.plannedDepartureTime ?? null,
},
});
}

View File

@@ -12,10 +12,10 @@ export enum TripStatus {
}
export enum StopStatus {
OPEN = 'OPEN',
CHECKIN_CLOSED = 'CHECKIN_CLOSED',
BOARDED = 'BOARDED',
COMPLETED = 'COMPLETED',
APPROACHING = 'APPROACHING',
CURRENT = 'CURRENT',
UPCOMING = 'UPCOMING',
}
export enum PassengerCategory {
@@ -54,18 +54,26 @@ export class CreateScheduleDto {
plannedTimes?: PlannedStopTimeDto[];
}
export class CoachAssignmentDto {
@ApiProperty({ example: 'coach-uuid' }) @IsString() coachId: string;
@ApiProperty({ example: 1 }) @IsInt() @Min(1) positionNumber: number;
}
export class UpdateScheduleDto {
@ApiPropertyOptional({ example: '2026-06-15T08:00:00Z', description: 'Scheduled departure from the first stop (origin)' }) @IsOptional() @IsDateString() departureAt?: string;
@ApiPropertyOptional({ example: '2026-06-15T20:00:00Z', description: 'Scheduled arrival at the last stop (destination)' }) @IsOptional() @IsDateString() arrivalAt?: string;
@ApiPropertyOptional({ enum: TripStatus, example: TripStatus.SCHEDULED }) @IsOptional() @IsEnum(TripStatus) status?: TripStatus;
@ApiPropertyOptional({ type: Array, description: 'List of coaches to assign' }) @IsOptional() @IsArray() coaches?: Array<{ coachId: string; positionNumber: number }>;
@ApiPropertyOptional({ type: [CoachAssignmentDto], description: 'List of coaches to assign' }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => CoachAssignmentDto) coaches?: CoachAssignmentDto[];
@ApiPropertyOptional({ example: false, description: 'Exclude from public search (reserved for packages)' }) @IsOptional() isPackageOnly?: boolean;
@ApiPropertyOptional({ type: [PlannedStopTimeDto], description: 'Planned times per stop — when provided, replaces all existing stop times for the schedule' })
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto)
plannedTimes?: PlannedStopTimeDto[];
}
export class UpdateStopTimeDto {
@ApiPropertyOptional({ example: '2026-06-15T09:30:00Z' }) @IsOptional() @IsDateString() plannedArrivalAt?: string;
@ApiPropertyOptional({ example: '2026-06-15T09:45:00Z' }) @IsOptional() @IsDateString() plannedDepartureAt?: string;
@ApiPropertyOptional({ enum: StopStatus, example: StopStatus.UPCOMING }) @IsOptional() @IsEnum(StopStatus) status?: StopStatus;
@ApiPropertyOptional({ enum: StopStatus, example: StopStatus.OPEN }) @IsOptional() @IsEnum(StopStatus) status?: StopStatus;
}
export class CreateFareRuleDto {

View File

@@ -133,26 +133,62 @@ export class SchedulesService {
let plannedTimes = dto.plannedTimes;
if (!plannedTimes || plannedTimes.length === 0) {
const totalDuration = arr.getTime() - dep.getTime();
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
const hasRouteTimes = route.stops.some(
s => (s as any).plannedArrivalTime != null || (s as any).plannedDepartureTime != null,
);
plannedTimes = route.stops.map((stop, index) => {
let stopTime: Date;
if (index === 0) {
stopTime = dep;
} else if (index === route.stops.length - 1) {
stopTime = arr;
} else {
const stopDistance = stop.distanceKm || 0;
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
stopTime = new Date(dep.getTime() + totalDuration * progress);
}
return {
sequence: stop.sequence,
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(),
if (hasRouteTimes) {
// Extract EAT time-of-day from a template DateTime and anchor to the schedule's EAT date.
const EAT_MS = 3 * 60 * 60 * 1000;
const depEATMs = dep.getTime() + EAT_MS;
const depMsIntoDay = depEATMs % (24 * 60 * 60 * 1000);
const eatMidnightUTC = dep.getTime() - depMsIntoDay;
const templateToScheduleUTC = (templateDt: Date): Date => {
// Pull the time-of-day in EAT from the template DateTime
const templateEATMs = templateDt.getTime() + EAT_MS;
const timeOfDayMs = templateEATMs % (24 * 60 * 60 * 1000);
const candidate = new Date(eatMidnightUTC + timeOfDayMs);
// Overnight: if the stop time lands before departure, move to next day
if (candidate < dep) return new Date(candidate.getTime() + 24 * 60 * 60 * 1000);
return candidate;
};
});
plannedTimes = route.stops.map((stop, index) => {
const arrDt: Date | null = (stop as any).plannedArrivalTime ?? null;
const depDt: Date | null = (stop as any).plannedDepartureTime ?? null;
return {
sequence: stop.sequence,
plannedArrivalAt: index > 0 && arrDt != null
? templateToScheduleUTC(arrDt).toISOString()
: undefined,
plannedDepartureAt: index < route.stops.length - 1 && depDt != null
? templateToScheduleUTC(depDt).toISOString()
: undefined,
};
});
} else {
const totalDuration = arr.getTime() - dep.getTime();
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
plannedTimes = route.stops.map((stop, index) => {
let stopTime: Date;
if (index === 0) {
stopTime = dep;
} else if (index === route.stops.length - 1) {
stopTime = arr;
} else {
const stopDistance = stop.distanceKm || 0;
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
stopTime = new Date(dep.getTime() + totalDuration * progress);
}
return {
sequence: stop.sequence,
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(),
};
});
}
}
const providedSeqs = new Set((plannedTimes ?? []).map(t => t.sequence));
@@ -304,26 +340,59 @@ export class SchedulesService {
let plannedTimes = dto.plannedTimes;
if (!plannedTimes || plannedTimes.length === 0) {
const totalDuration = arr.getTime() - dep.getTime();
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
const hasRouteTimes = route.stops.some(
s => (s as any).plannedArrivalTime != null || (s as any).plannedDepartureTime != null,
);
plannedTimes = route.stops.map((stop, index) => {
let stopTime: Date;
if (index === 0) {
stopTime = dep;
} else if (index === route.stops.length - 1) {
stopTime = arr;
} else {
const stopDistance = stop.distanceKm || 0;
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
stopTime = new Date(dep.getTime() + totalDuration * progress);
}
return {
sequence: stop.sequence,
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(),
if (hasRouteTimes) {
const EAT_MS = 3 * 60 * 60 * 1000;
const depEATMs = dep.getTime() + EAT_MS;
const depMsIntoDay = depEATMs % (24 * 60 * 60 * 1000);
const eatMidnightUTC = dep.getTime() - depMsIntoDay;
const templateToScheduleUTC = (templateDt: Date): Date => {
const templateEATMs = templateDt.getTime() + EAT_MS;
const timeOfDayMs = templateEATMs % (24 * 60 * 60 * 1000);
const candidate = new Date(eatMidnightUTC + timeOfDayMs);
if (candidate < dep) return new Date(candidate.getTime() + 24 * 60 * 60 * 1000);
return candidate;
};
});
plannedTimes = route.stops.map((stop, index) => {
const arrDt: Date | null = (stop as any).plannedArrivalTime ?? null;
const depDt: Date | null = (stop as any).plannedDepartureTime ?? null;
return {
sequence: stop.sequence,
plannedArrivalAt: index > 0 && arrDt != null
? templateToScheduleUTC(arrDt).toISOString()
: undefined,
plannedDepartureAt: index < route.stops.length - 1 && depDt != null
? templateToScheduleUTC(depDt).toISOString()
: undefined,
};
});
} else {
const totalDuration = arr.getTime() - dep.getTime();
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
plannedTimes = route.stops.map((stop, index) => {
let stopTime: Date;
if (index === 0) {
stopTime = dep;
} else if (index === route.stops.length - 1) {
stopTime = arr;
} else {
const stopDistance = stop.distanceKm || 0;
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
stopTime = new Date(dep.getTime() + totalDuration * progress);
}
return {
sequence: stop.sequence,
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(),
};
});
}
}
const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
@@ -678,6 +747,12 @@ export class SchedulesService {
}
}
if (dto.plannedTimes && dto.plannedTimes.length > 0 && schedule.routeId) {
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } });
const plannedTimesMap = Object.fromEntries(dto.plannedTimes.map(t => [t.sequence, t]));
await this.routesService.applyRouteToSchedule(schedule.routeId, id, plannedTimesMap);
}
return this.getSchedule(id);
}

View File

@@ -4,10 +4,9 @@ import { SearchService } from './search.service';
import { CurrencyModule } from '../currency/currency.module';
import { FareEngineModule } from '../fare-engine/fare-engine.module';
import { SegmentsModule } from '../segments/segments.module';
import { SystemConfigModule } from '../system-config/system-config.module';
@Module({
imports: [CurrencyModule, FareEngineModule, SegmentsModule, SystemConfigModule],
imports: [CurrencyModule, FareEngineModule, SegmentsModule],
controllers: [SearchController],
providers: [SearchService],
exports: [SearchService],

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,33 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsArray, IsDateString, IsOptional, IsUUID } from 'class-validator';
export class GetDuplicateSeatsQuery {
@ApiProperty({ example: '2026-07-17', description: 'Schedule date (YYYY-MM-DD)' })
@IsDateString()
date: string;
@ApiPropertyOptional({ description: 'Filter to a specific schedule ID' })
@IsOptional()
@IsUUID()
scheduleId?: string;
}
export class ResolveDuplicatesDto {
@ApiProperty({
description: 'BookingSeat IDs of the duplicate bookings to reassign',
type: [String],
example: ['uuid-booking-seat-1', 'uuid-booking-seat-2'],
})
@IsArray()
@IsUUID(undefined, { each: true })
bookingSeatIds: string[];
@ApiProperty({
description: 'Coach IDs to source replacement seats from (searched in order; first available seat per coach is used)',
type: [String],
example: ['uuid-coach-1', 'uuid-coach-2'],
})
@IsArray()
@IsUUID(undefined, { each: true })
coachIds: string[];
}

View File

@@ -17,9 +17,11 @@ import {
ApiParam,
ApiQuery,
ApiResponse,
ApiBody,
} from "@nestjs/swagger";
import { SeatsService } from "./seats.service";
import { HoldSeatsDto, ReleaseHoldDto } from "./seats.dto";
import { GetDuplicateSeatsQuery, ResolveDuplicatesDto } from "./duplicate-seats.dto";
import { JwtGuard } from "../../common/jwt.guard";
import { PassengerStaff } from "../../common/passenger-guards";
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
@@ -316,4 +318,90 @@ This makes it clear which segment of the route each seat is held for, enabling s
) {
return this.service.importSeatsCSV(body.scheduleId, body.csv, body.commit);
}
// ── Duplicate seat management (backoffice) ────────────────────────────────
@Get("duplicates")
@UseGuards(JwtGuard)
@ApiBearerAuth("JWT-auth")
@ApiOperation({
summary: "List duplicate seat assignments by schedule date",
description:
"Returns all schedules on the given date that have bookings sharing " +
"the same seat, grouped by coach. Each coach entry includes the duplicate " +
"groups (with full booking info) and the list of currently available seats " +
"that can be used for reassignment.",
})
@ApiQuery({ name: "date", example: "2026-07-17", description: "Schedule date (YYYY-MM-DD)" })
@ApiQuery({ name: "scheduleId", required: false, description: "Filter to a specific schedule" })
@ApiResponse({
status: 200,
description: "Duplicate seat report grouped by schedule → coach",
schema: {
example: {
date: "2026-07-17",
totalDuplicates: 1,
schedules: [{
scheduleId: "uuid",
departureAt: "2026-07-17T06:00:00.000Z",
origin: "Addis Ababa",
destination: "Dire Dawa",
coaches: [{
coachId: "uuid",
coachNumber: "C1",
coachTypeName: "SBC",
duplicates: [{
seatId: "uuid",
seatNumber: "12A",
leg: 1,
bookings: [
{ bookingSeatId: "uuid", bookingId: "uuid", bookingRef: "ATPC9F", passengerName: "Abebe", contactPhone: "+251911000000", createdAt: "2026-07-16T10:00:00.000Z" },
{ bookingSeatId: "uuid", bookingId: "uuid", bookingRef: "XYZ123", passengerName: "Kebede", contactPhone: "+251922000000", createdAt: "2026-07-16T11:00:00.000Z" },
],
}],
availableSeats: [
{ seatId: "uuid", seatNumber: "14B" },
{ seatId: "uuid", seatNumber: "15A" },
],
}],
}],
},
},
})
getDuplicateSeats(@Query() query: GetDuplicateSeatsQuery) {
return this.service.getDuplicateSeats(query.date, query.scheduleId);
}
@Post("duplicates/resolve")
@UseGuards(JwtGuard)
@ApiBearerAuth("JWT-auth")
@ApiOperation({
summary: "Auto-assign duplicate bookings to seats in selected coaches",
description:
"Staff selects which duplicate BookingSeat IDs to fix and which coaches to pull replacement seats from. " +
"The system automatically picks the first available (non-blocked, non-occupied) seat in the given coaches " +
"for each booking, updates BookingSeat + Ticket + JourneySegment atomically so the seatmap reflects the " +
"change immediately, then sends an SMS notification to the passenger. " +
"Coaches are searched in the order provided; seats within each coach are assigned by row then column.",
})
@ApiBody({ type: ResolveDuplicatesDto })
@ApiResponse({
status: 200,
description: "Resolution summary — resolved count, unresolved count, per-booking results",
schema: {
example: {
resolved: 2,
unresolved: 0,
results: [
{ bookingRef: "XYZ123", oldSeatNumber: "1A", newSeatNumber: "14B", contactPhone: "+251922000000" },
{ bookingRef: "ABC456", oldSeatNumber: "1A", newSeatNumber: "15A", contactPhone: "+251933000000" },
],
},
},
})
@ApiResponse({ status: 400, description: "Booking not in CONFIRMED/BOARDED status" })
@ApiResponse({ status: 404, description: "BookingSeat ID not found" })
resolveDuplicateSeats(@Body() dto: ResolveDuplicatesDto) {
return this.service.resolveDuplicateSeats(dto.bookingSeatIds, dto.coachIds);
}
}

View File

@@ -5,9 +5,10 @@ import { SeatsService } from './seats.service';
import { SegmentsModule } from '../segments/segments.module';
import { SystemConfigModule } from '../system-config/system-config.module';
import { AuditModule } from '../../common/audit.module';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [SegmentsModule, HttpModule, SystemConfigModule, AuditModule],
imports: [SegmentsModule, HttpModule, SystemConfigModule, AuditModule, NotificationsModule],
controllers: [SeatsController],
providers: [SeatsService],
exports: [SeatsService],

View File

@@ -5,6 +5,7 @@ import { Cron, CronExpression } from '@nestjs/schedule';
import { SegmentsService } from '../segments/segments.service';
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
import { AuditService } from '../../common/audit.service';
import { SmsClientService } from '../notifications/sms-client.service';
import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
import { checkDirectionConflict } from '../../common/utils/journey-direction.utils';
@@ -17,6 +18,7 @@ export class SeatsService {
private segmentsService: SegmentsService,
private systemConfig: SystemConfigService,
private auditService: AuditService,
private sms: SmsClientService,
) {}
async getSeatMap(scheduleId: string, coachTypeId?: string, journeyDirection?: JourneyDirection, originStationId?: string, destinationStationId?: string) {
@@ -266,23 +268,38 @@ export class SeatsService {
if (new Set(seatIds).size !== seatIds.length)
throw new BadRequestException('Duplicate seatId in passengers list');
const [holdMinutes, cutoffHours] = await Promise.all([
this.systemConfig.getNumber(CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES),
this.systemConfig.getNumber(CONFIG_KEYS.HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE),
]);
const holdMinutes = await this.systemConfig.getNumber(CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES);
const expiresAt = new Date(Date.now() + holdMinutes * 60 * 1000);
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: dto.scheduleId },
select: { departureAt: true },
});
const [schedule, originStopTime, originRouteStop] = await Promise.all([
this.prisma.trainSchedule.findUnique({
where: { id: dto.scheduleId },
select: {
departureAt: true,
route: { select: { checkinMinutesBefore: true } },
},
}),
this.prisma.tripStopTime.findFirst({
where: { scheduleId: dto.scheduleId, stationId: dto.originStationId },
select: { plannedDepartureAt: true },
}),
this.prisma.routeStop.findFirst({
where: {
route: { schedules: { some: { id: dto.scheduleId } } },
stationId: dto.originStationId,
},
select: { checkinMinutesBefore: true },
}),
]);
if (!schedule) throw new NotFoundException('Schedule not found');
const msUntilDeparture = schedule.departureAt.getTime() - Date.now();
const cutoffMs = cutoffHours * 60 * 60 * 1000;
if (msUntilDeparture <= cutoffMs) {
// Stop-level override wins; falls back to route-level; then to 30 min.
const checkinMinutes = originRouteStop?.checkinMinutesBefore ?? schedule.route?.checkinMinutesBefore ?? 30;
const segmentDepartureAt = originStopTime?.plannedDepartureAt ?? schedule.departureAt;
const msUntilDeparture = segmentDepartureAt.getTime() - Date.now();
if (msUntilDeparture <= checkinMinutes * 60 * 1000) {
throw new BadRequestException(
`Seats cannot be held within ${cutoffHours} hour${cutoffHours !== 1 ? 's' : ''} of departure`,
`Seats cannot be held within ${checkinMinutes} minute${checkinMinutes !== 1 ? 's' : ''} of departure`,
);
}
@@ -308,6 +325,18 @@ export class SeatsService {
throw new ConflictException(`Seat(s) ${blocked.map(s => s.seatNumber).join(', ')} are already taken`);
const seatLabelById = Object.fromEntries(seats.map(s => [s.id, s.seatNumber]));
// Schedule-scoped blocks prevent holding a seat on this specific schedule
// even if its global Seat.status is AVAILABLE.
const scheduleBlockRecords = await tx.seatBlock.findMany({
where: { scheduleId: dto.scheduleId, seatId: { in: seatIds } },
select: { seatId: true },
});
if (scheduleBlockRecords.length > 0) {
const blockedNums = scheduleBlockRecords.map(b => seatLabelById[b.seatId]).join(', ');
throw new ConflictException(`Seat(s) ${blockedNums} are blocked for this schedule`);
}
const stopTimes = await tx.tripStopTime.findMany({
where: { scheduleId: dto.scheduleId },
select: { stationId: true, sequence: true },
@@ -600,9 +629,6 @@ export class SeatsService {
async getBlockedSeats() {
const blocks = await this.prisma.seatBlock.findMany({
where: {
NOT: { reason: { startsWith: 'MAINTENANCE:' } },
},
include: {
seat: { include: { coach: { select: { number: true } } } },
},
@@ -697,11 +723,18 @@ export class SeatsService {
const reqFrom = seqOf(schedule.originStationId) ?? 0;
const reqTo = seqOf(schedule.destinationStationId) ?? stopTimes.length;
const unavailable = await this.segmentsService.getSeatAvailabilityMap(
scheduleId, allSeatIds, stopTimes, reqFrom, reqTo,
);
const [unavailable, scheduleBlocks] = await Promise.all([
this.segmentsService.getSeatAvailabilityMap(
scheduleId, allSeatIds, stopTimes, reqFrom, reqTo,
),
this.prisma.seatBlock.findMany({
where: { scheduleId, seatId: { in: allSeatIds } },
select: { seatId: true },
}),
]);
const scheduleBlockedIds = new Set(scheduleBlocks.map(b => b.seatId));
const availableSeats = seats.filter(s => !unavailable.has(s.id));
const availableSeats = seats.filter(s => !unavailable.has(s.id) && !scheduleBlockedIds.has(s.id));
if (availableSeats.length < count) {
throw new ConflictException(`Only ${availableSeats.length} seats available, requested ${count}`);
@@ -983,4 +1016,416 @@ export class SeatsService {
skippedSeatIds: Array.from(skippedSeatIds), // kept for logging/API compat; no DB writes needed
};
}
// ─────────────────────────────────────────────────────────────────────────
// Duplicate-seat management (backoffice)
// ─────────────────────────────────────────────────────────────────────────
async getDuplicateSeats(date: string, scheduleId?: string) {
const dayStart = new Date(`${date}T00:00:00.000Z`);
const dayEnd = new Date(`${date}T23:59:59.999Z`);
const schedules = await this.prisma.trainSchedule.findMany({
where: {
departureAt: { gte: dayStart, lte: dayEnd },
...(scheduleId ? { id: scheduleId } : {}),
},
orderBy: { departureAt: 'asc' },
include: {
originStation: { select: { name: true } },
destinationStation: { select: { name: true } },
coachAssignments: {
orderBy: { positionNumber: 'asc' },
include: {
coach: {
include: {
coachType: { select: { name: true } },
seats: {
orderBy: [{ row: 'asc' }, { col: 'asc' }],
select: { id: true, seatNumber: true, status: true, coachId: true },
},
},
},
},
},
},
});
const result = [];
for (const schedule of schedules) {
// All confirmed BookingSeat rows for this schedule
const bookingSeats = await this.prisma.bookingSeat.findMany({
where: {
OR: [
{ scheduleId: schedule.id },
{ booking: { scheduleId: schedule.id } },
],
booking: { status: { in: ['CONFIRMED', 'BOARDED'] } },
},
select: {
id: true, seatId: true, scheduleId: true, leg: true, passengerName: true,
seat: { select: { coachId: true } },
booking: {
select: {
id: true, bookingRef: true, scheduleId: true,
createdAt: true, contactPhone: true,
},
},
},
});
// Seats occupied by any confirmed journey on this schedule (source of truth)
const journeySegments = await this.prisma.journeySegment.findMany({
where: {
scheduleId: schedule.id,
seatId: { not: null },
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
},
select: { seatId: true },
});
const occupiedIds = new Set(journeySegments.map(js => js.seatId!));
// Group BookingSeat rows by (seatId::leg) to detect duplicates
type BS = (typeof bookingSeats)[number];
const groups = new Map<string, BS[]>();
for (const bs of bookingSeats) {
const key = `${bs.seatId}::${bs.leg}`;
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(bs);
}
// All seats held by any confirmed BookingSeat — union of JourneySegment-based
// occupancy AND BookingSeat-based occupancy so that seats whose JourneySegments
// are missing (e.g. created via enhanced-seats path without bookingId) are still
// excluded from the available list.
const bookedSeatIds = new Set<string>([
...occupiedIds,
...bookingSeats.map(bs => bs.seatId).filter((id): id is string => id !== null && id !== undefined),
]);
const coachReports = [];
for (const assignment of schedule.coachAssignments) {
const coach = assignment.coach;
// Duplicate groups whose seat belongs to this coach
const duplicates = [];
for (const [key, group] of groups) {
if (group.length <= 1) continue;
if (group[0].seat.coachId !== coach.id) continue;
const [seatId] = key.split('::');
const seat = coach.seats.find(s => s.id === seatId);
duplicates.push({
seatId,
seatNumber: seat?.seatNumber ?? seatId,
leg: group[0].leg,
bookings: group.map(bs => ({
bookingSeatId: bs.id,
bookingId: bs.booking.id,
bookingRef: bs.booking.bookingRef,
passengerName: bs.passengerName,
contactPhone: bs.booking.contactPhone,
createdAt: bs.booking.createdAt,
})),
});
}
// Free seats in this coach — excludes BLOCKED, all confirmed BookingSeat
// assignments, and all confirmed JourneySegment occupancies.
const availableSeats = coach.seats
.filter(s =>
(s.status as string) !== 'BLOCKED' &&
!s.seatNumber.startsWith('-') &&
!bookedSeatIds.has(s.id),
)
.map(s => ({ seatId: s.id, seatNumber: s.seatNumber }));
coachReports.push({
coachId: coach.id,
coachNumber: coach.number,
coachTypeName: coach.coachType.name,
duplicates,
availableSeats,
});
}
if (coachReports.some(c => c.duplicates.length > 0)) {
result.push({
scheduleId: schedule.id,
departureAt: schedule.departureAt,
origin: schedule.originStation.name,
destination: schedule.destinationStation.name,
coaches: coachReports,
});
}
}
const totalDuplicates = result.reduce(
(sum, s) => sum + s.coaches.reduce((cs, c) => cs + c.duplicates.length, 0),
0,
);
return { date, schedules: result, totalDuplicates };
}
async resolveDuplicateSeats(bookingSeatIds: string[], coachIds: string[]) {
if (bookingSeatIds.length === 0) return { resolved: 0, unresolved: 0, results: [] };
// Load BookingSeat rows with full booking + schedule context
const bookingSeats = await this.prisma.bookingSeat.findMany({
where: { id: { in: bookingSeatIds } },
select: {
id: true, seatId: true, leg: true, scheduleId: true,
seat: { select: { seatNumber: true } },
booking: {
select: {
id: true, bookingRef: true, scheduleId: true,
status: true, contactPhone: true, passengerId: true,
totalMinor: true, currency: true,
originStationId: true, destinationStationId: true,
schedule: {
select: {
originStationId: true,
destinationStationId: true,
originStation: { select: { name: true } },
destinationStation: { select: { name: true } },
departureAt: true,
},
},
},
},
},
});
if (bookingSeats.length !== bookingSeatIds.length) {
const found = new Set(bookingSeats.map(bs => bs.id));
const missing = bookingSeatIds.filter(id => !found.has(id));
throw new NotFoundException(`BookingSeat(s) not found: ${missing.join(', ')}`);
}
const invalid = bookingSeats.filter(bs => !['CONFIRMED', 'BOARDED'].includes(bs.booking.status));
if (invalid.length > 0) {
throw new BadRequestException(
`Bookings must be CONFIRMED or BOARDED: ${invalid.map(bs => bs.booking.bookingRef).join(', ')}`,
);
}
// Load all non-blocked, non-removed seats from the selected coaches (ordered for deterministic pick)
const coachSeats = await this.prisma.seat.findMany({
where: {
coachId: { in: coachIds },
status: { not: 'BLOCKED' },
NOT: { seatNumber: { startsWith: '-' } },
},
select: { id: true, seatNumber: true, coachId: true, row: true, col: true },
orderBy: [{ coachId: 'asc' }, { row: 'asc' }, { col: 'asc' }],
});
// Build occupied-seat sets per schedule from confirmed JourneySegments
const scheduleIds = [
...new Set(
bookingSeats
.map(bs => bs.scheduleId ?? bs.booking.scheduleId)
.filter((id): id is string => id !== null && id !== undefined),
),
];
const occupiedBySchedule = new Map<string, Set<string>>();
await Promise.all(
scheduleIds.map(async scheduleId => {
const segments = await this.prisma.journeySegment.findMany({
where: {
scheduleId,
seatId: { not: null },
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT', 'BOARDED'] } },
},
select: { seatId: true },
});
occupiedBySchedule.set(scheduleId, new Set(segments.map(s => s.seatId!)));
}),
);
// Track seats assigned within this batch to prevent double-assignment
const assignedInBatch = new Set<string>();
const results: { bookingRef: string; oldSeatNumber: string; newSeatNumber: string; contactPhone: string | null }[] = [];
const unresolved: { bookingRef: string; reason: string }[] = [];
for (const bs of bookingSeats) {
const scheduleId = (bs.scheduleId ?? bs.booking.scheduleId)!;
const occupied = occupiedBySchedule.get(scheduleId) ?? new Set<string>();
// Pick the first available seat across the selected coaches
const newSeat = coachSeats.find(
seat =>
!occupied.has(seat.id) &&
!assignedInBatch.has(seat.id) &&
seat.id !== bs.seatId,
);
if (!newSeat) {
unresolved.push({
bookingRef: bs.booking.bookingRef,
reason: 'No available seat found in selected coaches',
});
this.logger.warn(
`Duplicate resolve: no seat available for ${bs.booking.bookingRef} (schedule ${scheduleId})`,
);
continue;
}
await this.prisma.$transaction(async tx => {
// 1. Change the seat on the booking and ticket.
await tx.bookingSeat.update({
where: { id: bs.id },
data: { seatId: newSeat.id, seatLabelSnapshot: newSeat.seatNumber },
});
await tx.ticket.updateMany({
where: { bookingId: bs.booking.id, seatId: bs.seatId, leg: bs.leg },
data: { seatId: newSeat.id },
});
// 2. Point the existing JourneySegments to the new seat.
// The Journey is already linked to this booking via bookingId;
// just update the seatId in its hop rows for this schedule.
const journey = await tx.journey.findFirst({
where: { bookingId: bs.booking.id },
select: { id: true },
});
if (!journey) {
// No Journey/JourneySegment for this booking (e.g. duplicate that was never
// processed by finalizePaymentSuccess). Create them now using the same logic,
// scoped to the booking's origin→destination leg so the seatmap shows BOOKED
// only for the correct range of stops.
const originId = bs.booking.originStationId ?? bs.booking.schedule?.originStationId;
const destId = bs.booking.destinationStationId ?? bs.booking.schedule?.destinationStationId;
const stopTimes = await tx.tripStopTime.findMany({
where: { scheduleId },
orderBy: { sequence: 'asc' },
select: { stationId: true },
});
const originIdx = originId ? stopTimes.findIndex(s => s.stationId === originId) : 0;
const destIdx = destId ? stopTimes.findIndex(s => s.stationId === destId) : stopTimes.length - 1;
const fromIdx = originIdx >= 0 ? originIdx : 0;
const toIdx = destIdx >= 0 ? destIdx : stopTimes.length - 1;
const newJourney = await tx.journey.create({
data: {
passengerId: bs.booking.passengerId,
bookingId: bs.booking.id,
status: 'CONFIRMED',
totalMinor: bs.booking.totalMinor,
currency: bs.booking.currency,
} as any,
});
const segments = [];
for (let i = fromIdx; i < toIdx; i++) {
segments.push({
journeyId: newJourney.id,
scheduleId,
segmentOrder: i - fromIdx,
seatId: newSeat.id,
coachId: newSeat.coachId,
departureStationId: stopTimes[i].stationId,
arrivalStationId: stopTimes[i + 1].stationId,
});
}
if (segments.length > 0) {
await tx.journeySegment.createMany({ data: segments, skipDuplicates: true });
}
this.logger.log(
`No Journey for ${bs.booking.bookingRef} — created Journey + ${segments.length} segment(s) for seat ${newSeat.seatNumber}`,
);
return;
}
const { count } = await tx.journeySegment.updateMany({
where: { journeyId: journey.id, scheduleId, seatId: bs.seatId },
data: { seatId: newSeat.id },
});
// Journey exists but had no segments (e.g. booking confirmed via a path
// that skipped JourneySegment creation). Create them now for the new seat
// so the seatmap reflects BOOKED.
if (count === 0) {
const originId = bs.booking.originStationId ?? bs.booking.schedule?.originStationId;
const destId = bs.booking.destinationStationId ?? bs.booking.schedule?.destinationStationId;
const stopTimes = await tx.tripStopTime.findMany({
where: { scheduleId },
orderBy: { sequence: 'asc' },
select: { stationId: true },
});
const originIdx = originId ? stopTimes.findIndex(s => s.stationId === originId) : 0;
const destIdx = destId ? stopTimes.findIndex(s => s.stationId === destId) : stopTimes.length - 1;
const fromIdx = originIdx >= 0 ? originIdx : 0;
const toIdx = destIdx >= 0 ? destIdx : stopTimes.length - 1;
const segments = [];
for (let i = fromIdx; i < toIdx; i++) {
segments.push({
journeyId: journey.id,
scheduleId,
segmentOrder: i - fromIdx,
seatId: newSeat.id,
coachId: newSeat.coachId,
departureStationId: stopTimes[i].stationId,
arrivalStationId: stopTimes[i + 1].stationId,
});
}
if (segments.length > 0) {
await tx.journeySegment.createMany({ data: segments, skipDuplicates: true });
}
this.logger.log(
`Seat reassigned: ${bs.booking.bookingRef} ` +
`${bs.seat?.seatNumber ?? bs.seatId}${newSeat.seatNumber} ` +
`(0 existing segments — created ${segments.length} new hop(s))`,
);
} else {
this.logger.log(
`Seat reassigned: ${bs.booking.bookingRef} ` +
`${bs.seat?.seatNumber ?? bs.seatId}${newSeat.seatNumber} ` +
`(${count} segment hop(s) updated)`,
);
}
});
// Mark as taken so the next booking in this batch doesn't get the same seat
assignedInBatch.add(newSeat.id);
occupied.add(newSeat.id);
const oldSeatNumber = bs.seat?.seatNumber ?? '?';
const origin = bs.booking.schedule?.originStation?.name ?? '';
const dest = bs.booking.schedule?.destinationStation?.name ?? '';
if (bs.booking.contactPhone) {
const message =
`EDR: Your booking ${bs.booking.bookingRef} (${origin}${dest}): ` +
`your seat has been changed from seat ${oldSeatNumber} to seat ${newSeat.seatNumber}. ` +
`We apologize for any inconvenience.`;
await this.sms.sendSms({ to: bs.booking.contactPhone, message }).catch(() => null);
}
this.logger.log(
`Duplicate resolved: ${bs.booking.bookingRef} seat ${oldSeatNumber}${newSeat.seatNumber}`,
);
results.push({
bookingRef: bs.booking.bookingRef,
oldSeatNumber,
newSeatNumber: newSeat.seatNumber,
contactPhone: bs.booking.contactPhone,
});
}
return {
resolved: results.length,
unresolved: unresolved.length,
results,
...(unresolved.length > 0 ? { unresolvedDetails: unresolved } : {}),
};
}
}

View File

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

View File

@@ -3,9 +3,6 @@ import { Cron } from '@nestjs/schedule';
import { PrismaService } from '../../common/prisma.service';
import { SmsClientService } from '../notifications/sms-client.service';
import { CurrencyService } from '../currency/currency.service';
import { PaymentsService } from '../payments/payments.service';
import { PaymentClientService } from '../payments/payment-client.service';
import { PaymentReferenceType, ProviderPaymentStatus } from '@edr/types';
import { MAX_PAYMENT_HOURS, CUTOFF_MINUTES, computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
// Retention windows
@@ -31,8 +28,6 @@ export class TasksService {
private readonly prisma: PrismaService,
private readonly sms: SmsClientService,
private readonly currencyService: CurrencyService,
private readonly paymentsService: PaymentsService,
private readonly paymentClient: PaymentClientService,
) {}
// ─────────────────────────────────────────────────────────────────────────
@@ -47,6 +42,7 @@ export class TasksService {
const now = new Date();
const thirtyMinFromNow = new Date(now.getTime() + CUTOFF_MINUTES * 60 * 1000);
// ── Schedule-level transitions (operational display) ───────────────────
const [boarding, departed, arrived] = await Promise.all([
this.prisma.trainSchedule.updateMany({
where: { status: 'SCHEDULED', departureAt: { lte: thirtyMinFromNow } },
@@ -62,9 +58,71 @@ export class TasksService {
}),
]);
if (boarding.count > 0 || departed.count > 0 || arrived.count > 0) {
// ── Per-stop transitions (segment-level status) ────────────────────────
// OPEN → CHECKIN_CLOSED: each RouteStop carries its own checkinMinutesBefore
// override; falls back to the Route-level value when null.
// Group by effective cutoff → one updateMany per (effectiveMins, routeId) pair.
const routeStops = await this.prisma.routeStop.findMany({
select: {
routeId: true,
stationId: true,
checkinMinutesBefore: true,
route: { select: { checkinMinutesBefore: true } },
},
});
// Map: effectiveMins → Map<routeId, stationId[]>
const byMins = new Map<number, Map<string, string[]>>();
for (const stop of routeStops) {
const mins = stop.checkinMinutesBefore ?? stop.route.checkinMinutesBefore;
if (!byMins.has(mins)) byMins.set(mins, new Map());
const byRoute = byMins.get(mins)!;
if (!byRoute.has(stop.routeId)) byRoute.set(stop.routeId, []);
byRoute.get(stop.routeId)!.push(stop.stationId);
}
let reopenedCount = 0;
let checkinClosedCount = 0;
for (const [mins, byRoute] of byMins) {
const cutoffAt = new Date(now.getTime() + mins * 60 * 1000);
for (const [routeId, stationIds] of byRoute) {
// Revert first: if the cutoff was reduced, stops that were prematurely closed
// should reopen (departure is still beyond the new cutoff window).
const reverted = await this.prisma.tripStopTime.updateMany({
where: {
status: 'CHECKIN_CLOSED',
plannedDepartureAt: { gt: cutoffAt },
stationId: { in: stationIds },
schedule: { routeId },
},
data: { status: 'OPEN' },
});
reopenedCount += reverted.count;
// Forward: close stops now within the cutoff window.
const closed = await this.prisma.tripStopTime.updateMany({
where: {
status: 'OPEN',
plannedDepartureAt: { lte: cutoffAt },
stationId: { in: stationIds },
schedule: { routeId },
},
data: { status: 'CHECKIN_CLOSED' },
});
checkinClosedCount += closed.count;
}
}
const boardedStops = await this.prisma.tripStopTime.updateMany({
where: { status: 'CHECKIN_CLOSED', plannedDepartureAt: { lte: now } },
data: { status: 'BOARDED' },
});
if (boarding.count > 0 || departed.count > 0 || arrived.count > 0 ||
reopenedCount > 0 || checkinClosedCount > 0 || boardedStops.count > 0) {
this.logger.log(
`Schedule sync: ${boarding.count} → BOARDING, ${departed.count} → EN_ROUTE, ${arrived.count} → ARRIVED`,
`Schedule sync: ${boarding.count} → BOARDING, ${departed.count} → EN_ROUTE, ${arrived.count} → ARRIVED | ` +
`Stops: ${reopenedCount} → OPEN (reverted), ${checkinClosedCount} → CHECKIN_CLOSED, ${boardedStops.count} → BOARDED`,
);
}
}
@@ -88,6 +146,7 @@ export class TasksService {
await Promise.all([
this.sendPaymentReminders(now),
this.cancelExpiredPendingBookings(now),
this.cancelExpiredPendingPackageBookings(now),
]);
}
@@ -101,13 +160,17 @@ export class TasksService {
status: 'PENDING_PAYMENT',
paymentReminderSentAt: null,
createdAt: { gte: threeHoursAgo },
schedule: { departureAt: { gte: now } },
} as any,
// Do NOT filter by schedule.departureAt here: for multi-stop routes the
// passenger's segment may depart well after the schedule's first stop, and
// that first-stop time could already be in the past even though B→C is still open.
},
include: {
schedule: {
include: {
originStation: { select: { name: true } },
destinationStation: { select: { name: true } },
stopTimes: { select: { stationId: true, plannedDepartureAt: true } },
route: { select: { checkinMinutesBefore: true } },
},
},
},
@@ -115,9 +178,15 @@ export class TasksService {
for (const booking of bookings) {
try {
const createdAt = booking.createdAt as Date;
const dep = booking.schedule.departureAt as Date;
const paymentDeadline = computePaymentDeadline(createdAt, dep);
const createdAt = booking.createdAt as Date;
// Use the booking's origin-segment departure and the route's own check-in window.
const originStop = (booking.schedule as any).stopTimes?.find(
(s: any) => s.stationId === (booking as any).originStationId,
);
const dep = (originStop?.plannedDepartureAt ?? booking.schedule.departureAt) as Date;
const checkinMinutes = (booking.schedule as any).route?.checkinMinutesBefore ?? 30;
if (dep <= now) continue; // segment has already departed; cancel job handles clean-up
const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes);
const totalWindowMs = paymentDeadline.getTime() - createdAt.getTime();
// Skip degenerate windows (< 2 min) — the cancel job will handle these immediately
@@ -181,6 +250,8 @@ export class TasksService {
include: {
originStation: { select: { name: true } },
destinationStation: { select: { name: true } },
stopTimes: { select: { stationId: true, plannedDepartureAt: true } },
route: { select: { checkinMinutesBefore: true } },
},
},
paymentIntent: { select: { method: true } },
@@ -192,9 +263,14 @@ export class TasksService {
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;
// Re-verify exact deadline to avoid racing with a concurrent payment confirmation.
// Use the booking's origin-segment departure for the deadline so that a B→C booking
// on an A→B→C→D schedule gets the correct payment window anchored to B, not A.
const createdAt = booking.createdAt as Date;
const originStop = (booking.schedule as any).stopTimes?.find(
(s: any) => s.stationId === (booking as any).originStationId,
);
const dep = (originStop?.plannedDepartureAt ?? booking.schedule.departureAt) as Date;
const paymentDeadline = computePaymentDeadline(createdAt, dep);
if (now < paymentDeadline) continue;
@@ -206,7 +282,7 @@ export class TasksService {
// this they'd otherwise keep the seat locked for up to MAX_PAYMENT_HOURS even
// though the booking is now cancelled. Scoped to this booking's own schedule,
// since the same physical Seat row is reused across other recurring dates.
const seatIds = booking.seats.map(s => s.seatId);
const seatIds = booking.seats.map((s: any) => s.seatId);
if (seatIds.length > 0) {
await this.prisma.seatHold.deleteMany({
where: { scheduleId: booking.scheduleId, seatIds: { hasSome: seatIds } },
@@ -258,84 +334,75 @@ export class TasksService {
}
}
// ─────────────────────────────────────────────────────────────────────────
// Every 1 min: poll the payment service for any PENDING_PAYMENT bookings
// whose payment intent has moved to SUCCEEDED on the gateway but whose
// confirmation event was never delivered (missed RabbitMQ message, network
// blip, etc.). finalizePaymentSuccess() is fully idempotent so re-running
// it for an already-confirmed booking is safe.
//
// Processes at most 50 bookings per cycle to avoid hammering the payment
// service; the next tick picks up the remainder.
// ─────────────────────────────────────────────────────────────────────────
@Cron('*/1 * * * *')
async syncPaymentStatuses() {
const BATCH_SIZE = 50;
// ── Cancel PackageBookings whose payment deadline has passed ──────────────
private async cancelExpiredPendingPackageBookings(now: Date) {
const twoHoursAgo = new Date(now.getTime() - MAX_PAYMENT_HOURS * 60 * 60 * 1000);
const departureCutoff = new Date(now.getTime() + CUTOFF_MINUTES * 60 * 1000);
const bookings = await this.prisma.booking.findMany({
const expiredBookings = await this.prisma.packageBooking.findMany({
where: {
status: 'PENDING_PAYMENT',
paymentIntent: { status: { in: ['REQUIRES_ACTION', 'PROCESSING'] } },
OR: [
{ createdAt: { lte: twoHoursAgo } },
{ package: { outboundSchedule: { departureAt: { lte: departureCutoff } } } },
],
},
include: {
package: {
include: {
outboundSchedule: {
include: { route: { select: { checkinMinutesBefore: true } } },
},
},
},
},
include: { paymentIntent: true },
take: BATCH_SIZE,
orderBy: { createdAt: 'asc' },
});
if (bookings.length === 0) return;
let confirmed = 0;
let failed = 0;
let errored = 0;
for (const booking of bookings) {
if (!booking.paymentIntent) continue;
let cancelledCount = 0;
for (const booking of expiredBookings) {
try {
const snapshot = await this.paymentClient.getIntentByReference(
PaymentReferenceType.BOOKING,
booking.id,
);
const createdAt = booking.createdAt as Date;
const dep = (booking.package as any).outboundSchedule.departureAt as Date;
const checkinMinutes = (booking.package as any).outboundSchedule.route?.checkinMinutesBefore ?? CUTOFF_MINUTES;
const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes);
if (now < paymentDeadline) continue;
if (!snapshot) continue;
// Revert the tier's seat counters that were incremented when the booking was created.
const seatsReserved = booking.adultCount + Math.max(0, booking.childCount - booking.adultCount);
await this.prisma.packagePriceTier.update({
where: { id: booking.priceTierId },
data: {
bookedSeats: { decrement: seatsReserved },
availableSeats: { increment: seatsReserved },
},
});
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
const result = await this.paymentsService.finalizePaymentSuccess({
intentId: booking.paymentIntent.id,
providerTxnId: snapshot.providerTxnId,
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
});
if (!result.alreadyFinalized) {
this.logger.log(`Payment sync confirmed: ${booking.bookingRef}`);
confirmed++;
}
} else if (
snapshot.status === ProviderPaymentStatus.FAILED ||
snapshot.status === ProviderPaymentStatus.CANCELLED
) {
// The payment deadline enforcer will cancel the booking when its
// window expires; log now so operations can see failed intents early.
this.logger.warn(
`Payment sync: ${booking.bookingRef} intent is ${snapshot.status}` +
`booking will be auto-cancelled at payment deadline`,
);
failed++;
await this.prisma.packageBooking.update({
where: { id: booking.id },
data: { status: 'CANCELLED' },
});
const message =
`EDR: Your package booking ${booking.bookingRef} ` +
`(departs ${fmtTime(dep)}) has been cancelled ` +
`because payment was not completed by ${fmtTime(paymentDeadline)}.`;
if (booking.contactPhone) {
await this.sms.sendSms({ to: booking.contactPhone, message }).catch(() => null);
}
// REQUIRES_ACTION / PROCESSING → still pending, retry next cycle
this.logger.log(`Auto-cancelled package booking: ${booking.bookingRef} (deadline was ${fmtTime(paymentDeadline)})`);
cancelledCount++;
} catch (err) {
this.logger.error(
`Payment sync error for ${booking.bookingRef}: ` +
`${err instanceof Error ? err.message : String(err)}`,
`Auto-cancel failed for package booking ${(booking as any).bookingRef}: ${err instanceof Error ? err.message : String(err)}`,
);
errored++;
}
}
if (confirmed > 0 || failed > 0 || errored > 0) {
this.logger.log(
`Payment sync run: ${bookings.length} checked, ` +
`${confirmed} confirmed, ${failed} failed/cancelled, ${errored} errors`,
);
if (cancelledCount > 0) {
this.logger.log(`Auto-cancelled ${cancelledCount} expired pending package booking(s)`);
}
}

View File

@@ -2,7 +2,7 @@ import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch, Se
import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger';
import { TicketsService } from './tickets.service';
import { JwtGuard } from '../../common/jwt.guard';
import { PassengerStaff } from '../../common/passenger-guards';
import { PassengerStaff, PassengerAdmin } from '../../common/passenger-guards';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
@ApiTags('Tickets')
@@ -53,6 +53,7 @@ export class TicketsController {
@ApiQuery({ name: 'originStationId', required: false })
@ApiQuery({ name: 'destinationStationId', required: false })
@ApiQuery({ name: 'arrivalDate', required: false })
@ApiQuery({ name: 'departureDate', required: false })
@ApiQuery({ name: 'dateFrom', required: false })
@ApiQuery({ name: 'dateTo', required: false })
@ApiQuery({ name: 'coachId', required: false })
@@ -64,6 +65,7 @@ export class TicketsController {
@Query('originStationId') originStationId?: string,
@Query('destinationStationId') destinationStationId?: string,
@Query('arrivalDate') arrivalDate?: string,
@Query('departureDate') departureDate?: string,
@Query('dateFrom') dateFrom?: string,
@Query('dateTo') dateTo?: string,
@Query('coachId') coachId?: string,
@@ -76,6 +78,7 @@ export class TicketsController {
originStationId,
destinationStationId,
arrivalDate,
departureDate,
dateFrom,
dateTo,
coachId,
@@ -207,9 +210,9 @@ export class TicketsController {
}
@Delete(':id')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
@PassengerAdmin()
@ApiBearerAuth('IAM-auth')
@ApiOperation({
summary: 'Delete ticket (admin only)',
description: 'Permanently deletes a ticket record and removes associated seat blocks'
})

View File

@@ -27,7 +27,7 @@ export class TicketsService {
@InjectDataSource() private readonly dataSource: DataSource,
) {}
async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; arrivalDate?: string; dateFrom?: string; dateTo?: string; coachId?: string; skip: number; take: number }) {
async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; departureDate?: string; arrivalDate?: string; dateFrom?: string; dateTo?: string; coachId?: string; skip: number; take: number }) {
const where: any = {};
if (filters.search) {
where.OR = [
@@ -41,10 +41,16 @@ export class TicketsService {
where.status = filters.status;
}
if (filters.originStationId) {
where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, originStationId: filters.originStationId } };
where.booking = { ...where.booking, originStationId: filters.originStationId };
}
if (filters.destinationStationId) {
where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, destinationStationId: filters.destinationStationId } };
where.booking = { ...where.booking, destinationStationId: filters.destinationStationId };
}
if (filters.departureDate) {
const start = new Date(filters.departureDate);
const end = new Date(filters.departureDate);
end.setDate(end.getDate() + 1);
where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, departureAt: { gte: start, lt: end } } };
}
if (filters.arrivalDate) {
const start = new Date(filters.arrivalDate);
@@ -70,7 +76,7 @@ export class TicketsService {
include: {
booking: {
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
returnSchedule: { include: { originStation: true, destinationStation: true } },
passenger: { include: { travelerProfiles: true } },
seats: { include: { seat: { include: { coach: true } } } },
@@ -146,6 +152,18 @@ export class TicketsService {
contactPhone: t.booking?.contactPhone,
returnSchedule: t.booking?.returnSchedule ?? null,
seats: t.booking?.seats ?? [],
originStation: (() => {
const id = t.booking?.originStationId;
if (!id) return t.booking?.schedule?.originStation ?? null;
const stop = t.booking?.schedule?.stopTimes?.find((st: any) => st.stationId === id);
return stop?.station ?? t.booking?.schedule?.originStation ?? null;
})(),
destinationStation: (() => {
const id = t.booking?.destinationStationId;
if (!id) return t.booking?.schedule?.destinationStation ?? null;
const stop = t.booking?.schedule?.stopTimes?.find((st: any) => st.stationId === id);
return stop?.station ?? t.booking?.schedule?.destinationStation ?? null;
})(),
},
schedule: t.booking?.schedule,
seat: t.seat ? {