mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 05:18:11 +00:00
Merge branch 'dev' into quick-fix
This commit is contained in:
@@ -0,0 +1 @@
|
||||
-- Migration already applied directly to the database.
|
||||
@@ -0,0 +1 @@
|
||||
-- Migration already applied directly to the database.
|
||||
@@ -0,0 +1 @@
|
||||
-- Migration already applied directly to the database.
|
||||
@@ -0,0 +1 @@
|
||||
-- Migration already applied directly to the database.
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[scheduleId,seatId,departureStationId]` on the table `JourneySegment` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- Deduplicate before applying the unique index.
|
||||
-- Keeps the row with the lowest id per (scheduleId, seatId, departureStationId) group.
|
||||
DELETE FROM passenger."JourneySegment"
|
||||
WHERE id NOT IN (
|
||||
SELECT MIN(id)
|
||||
FROM passenger."JourneySegment"
|
||||
WHERE "seatId" IS NOT NULL
|
||||
GROUP BY "scheduleId", "seatId", "departureStationId"
|
||||
)
|
||||
AND "seatId" IS NOT NULL;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "JourneySegment_scheduleId_seatId_departureStationId_key"
|
||||
ON "JourneySegment"("scheduleId", "seatId", "departureStationId");
|
||||
@@ -180,10 +180,10 @@ enum NotificationCategory {
|
||||
}
|
||||
|
||||
enum StopStatus {
|
||||
OPEN
|
||||
CHECKIN_CLOSED
|
||||
BOARDED
|
||||
COMPLETED
|
||||
APPROACHING
|
||||
CURRENT
|
||||
UPCOMING
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
@@ -397,7 +397,7 @@ model TripStopTime {
|
||||
plannedArrivalAt DateTime?
|
||||
plannedDepartureAt DateTime?
|
||||
actualArrivalAt DateTime?
|
||||
status StopStatus @default(UPCOMING)
|
||||
status StopStatus @default(OPEN)
|
||||
schedule TrainSchedule @relation(fields: [scheduleId], references: [id])
|
||||
station Station @relation(fields: [stationId], references: [id])
|
||||
|
||||
@@ -580,7 +580,7 @@ model BookingSeat {
|
||||
bookingId String
|
||||
seatId String
|
||||
leg Int @default(1) // 1=outbound/leg-1, 2=return/leg-2
|
||||
scheduleId String? // which schedule this seat belongs to
|
||||
scheduleId String // which schedule this seat belongs to
|
||||
passengerName String
|
||||
dateOfBirth DateTime?
|
||||
passengerCategory PassengerCategory @default(ADULT)
|
||||
@@ -599,6 +599,7 @@ model BookingSeat {
|
||||
displayFareMinor Int?
|
||||
booking Booking @relation(fields: [bookingId], references: [id])
|
||||
seat Seat @relation(fields: [seatId], references: [id])
|
||||
@@unique([scheduleId, seatId])
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -991,6 +992,10 @@ model JourneySegment {
|
||||
arrivalStationId String
|
||||
journey Journey @relation(fields: [journeyId], references: [id])
|
||||
schedule TrainSchedule @relation(fields: [scheduleId], references: [id])
|
||||
|
||||
// Prevents two confirmed bookings from occupying the same seat on the same
|
||||
// schedule hop — the hard DB backstop against application-level race conditions.
|
||||
@@unique([scheduleId, seatId, departureStationId])
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -1022,14 +1027,15 @@ model PasswordResetToken {
|
||||
}
|
||||
|
||||
model Route {
|
||||
id String @id @default(uuid())
|
||||
code String @unique
|
||||
name String
|
||||
description String?
|
||||
effectiveFrom DateTime
|
||||
effectiveUntil DateTime?
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
id String @id @default(uuid())
|
||||
code String @unique
|
||||
name String
|
||||
description String?
|
||||
effectiveFrom DateTime
|
||||
effectiveUntil DateTime?
|
||||
active Boolean @default(true)
|
||||
checkinMinutesBefore Int @default(30)
|
||||
createdAt DateTime @default(now())
|
||||
stops RouteStop[]
|
||||
fareRules RouteFareRule[]
|
||||
segmentFares SegmentFareRule[]
|
||||
@@ -1039,13 +1045,14 @@ model Route {
|
||||
}
|
||||
|
||||
model RouteStop {
|
||||
id String @id @default(uuid())
|
||||
routeId String
|
||||
stationId String
|
||||
sequence Int
|
||||
distanceKm Float?
|
||||
createdAt DateTime @default(now())
|
||||
route Route @relation(fields: [routeId], references: [id], onDelete: Cascade)
|
||||
id String @id @default(uuid())
|
||||
routeId String
|
||||
stationId String
|
||||
sequence Int
|
||||
distanceKm Float?
|
||||
checkinMinutesBefore Int?
|
||||
createdAt DateTime @default(now())
|
||||
route Route @relation(fields: [routeId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([routeId, sequence])
|
||||
@@index([routeId, stationId])
|
||||
|
||||
@@ -13,10 +13,17 @@ export const MAX_PAYMENT_HOURS = 2;
|
||||
export const CUTOFF_MINUTES = 30;
|
||||
|
||||
/**
|
||||
* payment_deadline = MIN(booking_time + 2h, departure_time - 30min)
|
||||
* payment_deadline = MIN(booking_time + MAX_PAYMENT_HOURS, segment_departure - checkinMinutes)
|
||||
*
|
||||
* checkinMinutes defaults to CUTOFF_MINUTES but callers should pass the route-level
|
||||
* checkinMinutesBefore so that each route's own window is respected.
|
||||
*/
|
||||
export function computePaymentDeadline(createdAt: Date, departureAt: Date): Date {
|
||||
export function computePaymentDeadline(
|
||||
createdAt: Date,
|
||||
departureAt: Date,
|
||||
checkinMinutes: number = CUTOFF_MINUTES,
|
||||
): Date {
|
||||
const maxDeadline = new Date(createdAt.getTime() + MAX_PAYMENT_HOURS * 60 * 60 * 1000);
|
||||
const cutoffDeadline = new Date(departureAt.getTime() - CUTOFF_MINUTES * 60 * 1000);
|
||||
const cutoffDeadline = new Date(departureAt.getTime() - checkinMinutes * 60 * 1000);
|
||||
return maxDeadline < cutoffDeadline ? maxDeadline : cutoffDeadline;
|
||||
}
|
||||
|
||||
@@ -322,7 +322,7 @@ Payment providers send notifications to:
|
||||
- \`POST /payments/webhooks/card\` (International)
|
||||
|
||||
## Support
|
||||
- **Email:** support@edr-platform.com
|
||||
- **Email:** edr_@edrsc.com
|
||||
- **Documentation:** https://docs.edr-platform.com
|
||||
- **Status Page:** https://status.edr-platform.com
|
||||
`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -901,6 +910,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,
|
||||
@@ -1050,6 +1060,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 +1845,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 +1875,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,
|
||||
})),
|
||||
|
||||
@@ -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 {
|
||||
@@ -291,6 +299,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,
|
||||
@@ -515,6 +524,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);
|
||||
|
||||
@@ -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(); }
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -440,8 +440,8 @@ export class PackagesService {
|
||||
passengerCount,
|
||||
adultCount,
|
||||
childCount,
|
||||
totalMinor,
|
||||
currency: 'ETB',
|
||||
totalMinor: displayTotalMinor,
|
||||
currency: displayCurrency,
|
||||
displayCurrency,
|
||||
displayTotalMinor,
|
||||
status: 'PENDING_PAYMENT',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -118,6 +118,7 @@ describe("Payments E2E", () => {
|
||||
data: {
|
||||
bookingId: booking.id,
|
||||
seatId: seat.id,
|
||||
scheduleId: schedule.id,
|
||||
passengerName: "Test Passenger",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -18,18 +18,6 @@ export class ReportsController {
|
||||
return this.service.generateReport(dto);
|
||||
}
|
||||
|
||||
@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) {
|
||||
return this.service.getPassengerList(scheduleId);
|
||||
}
|
||||
|
||||
@Get('passengers')
|
||||
@ApiOperation({ summary: 'Passengers report for a specific schedule' })
|
||||
getOccupancyReport(@Query('scheduleId') scheduleId: string) {
|
||||
|
||||
@@ -235,33 +235,51 @@ export class ReportsService {
|
||||
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 });
|
||||
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,
|
||||
}));
|
||||
|
||||
// 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;
|
||||
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;
|
||||
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';
|
||||
@@ -273,7 +291,10 @@ export class ReportsService {
|
||||
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: {
|
||||
@@ -287,75 +308,11 @@ 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,
|
||||
};
|
||||
}
|
||||
|
||||
async listSchedulesForPicker() {
|
||||
const schedules = await this.prisma.trainSchedule.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
departureAt: true,
|
||||
train: { select: { number: true } },
|
||||
originStation: { select: { name: true } },
|
||||
destinationStation: { select: { name: true } },
|
||||
},
|
||||
orderBy: { departureAt: 'desc' },
|
||||
take: 200,
|
||||
});
|
||||
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' })}`,
|
||||
}));
|
||||
}
|
||||
|
||||
async getPassengerList(scheduleId: string) {
|
||||
const seats = await this.prisma.bookingSeat.findMany({
|
||||
where: {
|
||||
leg: 1,
|
||||
booking: { scheduleId, status: { in: ['CONFIRMED', 'BOARDED'] } },
|
||||
},
|
||||
include: {
|
||||
booking: {
|
||||
select: {
|
||||
bookingRef: true,
|
||||
status: true,
|
||||
originStationId: true,
|
||||
destinationStationId: true,
|
||||
},
|
||||
},
|
||||
seat: { include: { coach: { select: { number: true } } } },
|
||||
},
|
||||
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[],
|
||||
)];
|
||||
const stations = stationIds.length > 0
|
||||
? await this.prisma.station.findMany({ where: { id: { in: stationIds } }, select: { id: true, name: true } })
|
||||
: [];
|
||||
const stationName = new Map(stations.map(s => [s.id, s.name]));
|
||||
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: scheduleId },
|
||||
select: { departureAt: true },
|
||||
});
|
||||
|
||||
return seats.map(bs => ({
|
||||
bookingRef: bs.booking.bookingRef,
|
||||
passengerName: bs.passengerName,
|
||||
coachSeat: bs.seat?.coach?.number && bs.seatLabelSnapshot
|
||||
? `${bs.seat.coach.number}·${bs.seatLabelSnapshot}`
|
||||
: (bs.seatLabelSnapshot ?? '—'),
|
||||
origin: bs.booking.originStationId ? (stationName.get(bs.booking.originStationId) ?? '—') : '—',
|
||||
destination: bs.booking.destinationStationId ? (stationName.get(bs.booking.destinationStationId) ?? '—') : '—',
|
||||
departureAt: schedule?.departureAt ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
async getReport(reportId: string) {
|
||||
return this.prisma.operationalReport.findUnique({ where: { id: reportId } });
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
@@ -531,8 +535,8 @@ export class SearchService {
|
||||
discountMinor: fare.discountMinor,
|
||||
taxesFeesMinor: 0,
|
||||
loyaltyRedemptionMinor: loyaltyMinor,
|
||||
totalMinor,
|
||||
currency: 'ETB',
|
||||
totalMinor: displayTotalMinor,
|
||||
currency: displayCurrency,
|
||||
displayCurrency,
|
||||
displayTotalMinor,
|
||||
};
|
||||
@@ -649,8 +653,8 @@ export class SearchService {
|
||||
passengers: passengerLines,
|
||||
subtotalMinor,
|
||||
discountMinor,
|
||||
totalMinor,
|
||||
currency: 'ETB',
|
||||
totalMinor: displayTotalMinor,
|
||||
currency: displayCurrency,
|
||||
displayCurrency,
|
||||
displayTotalMinor,
|
||||
};
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -983,4 +1000,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 } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
|
||||
@@ -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 ? {
|
||||
|
||||
Reference in New Issue
Block a user