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

This commit is contained in:
Abubeker Yasin
2026-07-18 09:55:31 +03:00
259 changed files with 11362 additions and 1462 deletions

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,
@@ -866,6 +866,15 @@ export class BookingsService {
resolvedTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
: dto.reviewedTotalMinor;
// For package bookings where per-seat fares weren't supplied, back-derive the
// per-seat fare from reviewedTotalMinor so BookingSeat.fareMinor reflects the
// actual berth price (Upper/Middle/Lower) rather than the tier's minimum price.
if (dto.packageId && 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 (allFaresProvided) {
// seatFareMinor is in display currency — sum is already the display total
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0);
@@ -1050,6 +1059,19 @@ export class BookingsService {
totalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
: dto.reviewedTotalMinor;
// For package bookings where per-seat fares weren't supplied, back-derive the
// per-leg per-seat fare from reviewedTotalMinor so BookingSeat.fareMinor reflects
// the actual berth price (Upper/Middle/Lower) rather than the tier's minimum price.
if (dto.packageId) {
const seatedCount = passengersData.filter(p => p.outboundSeatId).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 (allRTFaresProvided && !dto.packageId) {
// seatFareMinor/returnSeatFareMinor are in display currency — sum is already the display total
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
@@ -1822,8 +1844,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 +1874,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

@@ -231,6 +231,14 @@ export class GuestBookingService {
let resolvedTotalMinor: number;
if (dto.reviewedTotalMinor != null) {
displayTotalMinor = dto.reviewedTotalMinor;
// For package bookings, back-derive per-seat fareMinor from reviewedTotalMinor
// so BookingSeat records store the actual berth price, not the tier minimum.
if (isPackageOneway && 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 (allFaresProvided) {
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0);
} else {
@@ -515,6 +523,17 @@ export class GuestBookingService {
totalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
: displayTotalMinor;
// For package bookings, back-derive per-leg per-seat fareMinor from reviewedTotalMinor.
if (isPackageRoundTrip) {
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 (allRTFaresProvided && !isPackageRoundTrip) {
// seatFareMinor/returnSeatFareMinor are display-currency — sum is already display total
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);

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(),
this.prisma.booking.count({ where: { 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

@@ -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

@@ -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

@@ -129,7 +129,7 @@ export class SupplementaryChargesService {
referenceType: PaymentReferenceType.SUPPLEMENTARY_CHARGE,
referenceId: charge.id,
orderRef: `SC-${charge.id.substring(0, 8)}`,
amountMinor: charge.amountMinor,
amountMinor: charge.amountMinor / 100,
currency: charge.currency,
provider: paymentMethod,
platform,

View File

@@ -18,6 +18,12 @@ export class ReportsController {
return this.service.generateReport(dto);
}
@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) {

View File

@@ -191,6 +191,122 @@ export class ReportsService {
};
}
async getOccupancyBySchedule(scheduleId: string) {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
include: {
originStation: true,
destinationStation: true,
train: true,
coachAssignments: {
include: {
coach: {
include: {
coachType: true,
seats: { select: { id: true } },
},
},
},
},
bookings: {
where: { status: { in: ['CONFIRMED', 'BOARDED'] } },
include: {
seats: {
include: {
seat: { include: { coach: { include: { coachType: true } } } },
},
},
},
},
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 totalPassengers = allBookingSeats.length;
const occupancyRate = totalSeats > 0 ? +((totalPassengers / totalSeats) * 100).toFixed(1) : 0;
// 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,
});
}
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,
}));
// 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 });
originMap.get(stationId)!.passengers += booking.seats.length;
}
const byOrigin = [...originMap.values()].sort((a, b) => b.passengers - a.passengers);
// 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 });
destMap.get(stationId)!.passengers += booking.seats.length;
}
const byDestination = [...destMap.values()].sort((a, b) => b.passengers - a.passengers);
// 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 });
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 });
classMap.get(typeName)!.booked++;
}
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,
origin: (schedule as any).originStation?.name,
destination: (schedule as any).destinationStation?.name,
departureAt: schedule.departureAt,
arrivalAt: schedule.arrivalAt,
},
summary: { totalSeats, totalPassengers, occupancyRate },
byCoach,
byClass,
byOrigin,
byDestination,
};
}
async getReport(reportId: string) {
return this.prisma.operationalReport.findUnique({ where: { id: reportId } });
}

View File

@@ -6,6 +6,7 @@ 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;
}
export class CreateRouteDto {
@@ -35,6 +36,7 @@ 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;
}
export class UpdateRouteDto {
@@ -42,6 +44,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,7 @@ export class RoutesService {
stationId: s.stationId,
sequence: s.sequence,
distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null,
checkinMinutesBefore: s.checkinMinutesBefore ?? null,
})),
},
},
@@ -92,6 +93,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 +105,7 @@ export class RoutesService {
stationId: s.stationId,
sequence: s.sequence,
distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null,
checkinMinutesBefore: s.checkinMinutesBefore ?? null,
})),
});
}
@@ -221,6 +224,7 @@ export class RoutesService {
stationId: dto.stationId,
sequence: dto.sequence,
distanceKm: dto.distanceKm != null ? parseFloat(String(dto.distanceKm)) : null,
checkinMinutesBefore: dto.checkinMinutesBefore ?? 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,23 @@ 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;
}
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

@@ -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],

View File

@@ -6,7 +6,6 @@ import { FareEngineService } from '../fare-engine/fare-engine.service';
import { SegmentsService } from '../segments/segments.service';
import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto';
import { Currency } from '@prisma/client';
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
const POINTS_TO_MINOR = 10;
@@ -20,6 +19,7 @@ type ScheduleWithIncludes = {
train: any;
originStation: any;
destinationStation: any;
route: { checkinMinutesBefore: number; stops: Array<{ stationId: string; checkinMinutesBefore: number | null }> } | null;
stopTimes: Array<{ stationId: string; sequence: number; plannedArrivalAt: Date | null; plannedDepartureAt: Date | null; station: any }>;
coachAssignments: Array<{ coach: { id: string; seats: any[]; coachType: { id: string; name: string; code: string; seatClasses: any[] } | null } }>;
};
@@ -28,6 +28,7 @@ const SCHEDULE_INCLUDE = {
train: true,
originStation: true,
destinationStation: true,
route: { select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } } },
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
coachAssignments: {
include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } },
@@ -41,13 +42,8 @@ export class SearchService {
private currencyService: CurrencyService,
private fareEngine: FareEngineService,
private segmentsService: SegmentsService,
private systemConfig: SystemConfigService,
) {}
private async getCutoffHours(): Promise<number> {
return this.systemConfig.getNumber(CONFIG_KEYS.HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE);
}
async searchTrips(dto: SearchTripsDto) {
const [direct, transit] = await Promise.all([
this.searchSchedules(
@@ -218,10 +214,11 @@ export class SearchService {
const now = new Date();
const totalPassengers = adultCount + (childCount ?? 0);
const cutoffHours = await this.getCutoffHours();
const cutoffThreshold = new Date(now.getTime() + cutoffHours * 60 * 60 * 1000);
// Use now as the lower bound for today so we don't fetch schedules that have
// already fully departed. The per-segment cutoff check in buildScheduleResult
// handles the exact check using each stop's own plannedDepartureAt.
const isToday = now.getFullYear() === y && now.getMonth() === m - 1 && now.getDate() === d;
const earliest = isToday ? cutoffThreshold : date;
const earliest = isToday ? now : date;
const schedules = await this.prisma.trainSchedule.findMany({
where: {
@@ -284,12 +281,9 @@ export class SearchService {
}),
]);
const cutoffHours = await this.getCutoffHours();
const cutoffThreshold = new Date(Date.now() + cutoffHours * 60 * 60 * 1000);
const results: any[] = [];
for (const leg1 of (leg1Schedules as ScheduleWithIncludes[]).filter(s => new Date(s.departureAt) > cutoffThreshold)) {
for (const leg1 of leg1Schedules as ScheduleWithIncludes[]) {
const originStop = leg1.stopTimes.find(s => s.stationId === originStationId);
if (!originStop) continue;
@@ -376,6 +370,16 @@ export class SearchService {
const destStop = schedule.stopTimes.find(s => s.stationId === destinationStationId);
if (!originStop || !destStop || originStop.sequence >= destStop.sequence) return null;
// Segment-level cutoff: use the origin stop's planned departure, not the
// schedule's overall departureAt (which is station A's time). This lets
// B→D remain bookable even after A→D closes.
// Cutoff resolution: stop-level override → route default → 30 min fallback.
const now = new Date();
const segmentDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
const routeStop = schedule.route?.stops?.find(s => s.stationId === originStationId);
const checkinMinutes = routeStop?.checkinMinutesBefore ?? schedule.route?.checkinMinutesBefore ?? 30;
if (segmentDepartureAt.getTime() - now.getTime() <= checkinMinutes * 60 * 1000) return null;
// Collect all valid seat IDs upfront for a single batch availability check
const allValidSeatIds = schedule.coachAssignments.flatMap(a =>
a.coach.seats

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";
@@ -306,4 +308,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`,
);
}
@@ -960,4 +977,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 },
{ scheduleId: null, 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`,
);
}
}
@@ -101,13 +159,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 +177,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 +249,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 +262,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 +281,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,87 +333,6 @@ 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;
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;
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 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++;
}
// 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`,
);
}
}
// ─────────────────────────────────────────────────────────────────────────
// Daily at 02:00 EAT: purge expired/stale records to enforce data retention.
// ─────────────────────────────────────────────────────────────────────────

View File

@@ -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,

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 ? {