mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 00:38:11 +00:00
Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -261,10 +261,26 @@ export class PassengerAuthService {
|
||||
|
||||
if (!passenger) throw new Error('Passenger not found');
|
||||
const iam = iamRows[0];
|
||||
const meta = iam?.metadata ?? {};
|
||||
const faydaVerified = iam?.verified_by === 'fayda';
|
||||
const nationality = iam?.metadata?.nationality ?? null;
|
||||
// A Fayda-verified holder is an Ethiopian national ID holder, so default nationality to
|
||||
// Ethiopian when the metadata doesn't carry it explicitly.
|
||||
const nationality = meta.nationality ?? (faydaVerified ? 'ETHIOPIAN' : null);
|
||||
// Fayda stores gender as { am, en }; tolerate a legacy plain string too.
|
||||
const gender =
|
||||
meta.gender && typeof meta.gender === 'object'
|
||||
? (meta.gender.en ?? meta.gender.am ?? null)
|
||||
: (meta.gender ?? null);
|
||||
// birthdate is persisted as ISO by the Fayda upsert; tolerate a "/"-separated legacy value.
|
||||
const rawDob = meta.dateOfBirth ?? meta.birthdate ?? null;
|
||||
const dateOfBirth = rawDob ? String(rawDob).replace(/\//g, '-') : null;
|
||||
|
||||
return {
|
||||
// The web User object keys on `id` (the IAM user id) — the login response returns it, so
|
||||
// this profile refresh MUST too, otherwise fetchProfile() overwrites the logged-in user
|
||||
// with an id-less object and everything guarded on `user.id` (passenger-form prefill,
|
||||
// save-details userId) silently breaks.
|
||||
id: iamUserId,
|
||||
iamUserId,
|
||||
// Top-level passengerId keeps the profile shape consistent with the login
|
||||
// response so the web User object always carries it (the JWT does not).
|
||||
@@ -272,8 +288,11 @@ export class PassengerAuthService {
|
||||
email: iam?.email ?? null,
|
||||
phone: iam?.phone_number ?? null,
|
||||
fullName: iam?.name?.en ?? iam?.name?.am ?? null,
|
||||
gender,
|
||||
dateOfBirth,
|
||||
nationality,
|
||||
faydaVerified,
|
||||
faydaSub: meta.sub ?? null,
|
||||
preferredCurrency: resolvePreferredCurrency(nationality, faydaVerified),
|
||||
createdAt: passenger.createdAt,
|
||||
passenger: {
|
||||
|
||||
@@ -32,9 +32,11 @@ import {
|
||||
import {
|
||||
CreateGuestBookingDto,
|
||||
GetSavedPassengersDto,
|
||||
IssueReservationBookingDto,
|
||||
} from "./guest-booking.dto";
|
||||
import { JwtGuard } from "../../common/jwt.guard";
|
||||
import { PassengerAdmin } from "../../common/passenger-guards";
|
||||
import { PassengerAdmin, PassengerStaff, PassengerStaffStrict } from "../../common/passenger-guards";
|
||||
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
||||
|
||||
@ApiTags("Booking")
|
||||
@Controller("bookings")
|
||||
@@ -150,6 +152,12 @@ export class BookingsController {
|
||||
@ApiQuery({ name: "returnLegStatus", required: false })
|
||||
@ApiQuery({ name: "bookingType", required: false })
|
||||
@ApiQuery({ name: "paymentStatus", required: false })
|
||||
@ApiQuery({
|
||||
name: "providerTxnId",
|
||||
required: false,
|
||||
description:
|
||||
"Payment provider transaction / order / merchant reference (partial, case-insensitive)",
|
||||
})
|
||||
@ApiQuery({ name: "dateFrom", required: false })
|
||||
@ApiQuery({ name: "dateTo", required: false })
|
||||
@ApiQuery({ name: "page", required: false })
|
||||
@@ -160,6 +168,7 @@ export class BookingsController {
|
||||
@Query("returnLegStatus") returnLegStatus?: string,
|
||||
@Query("bookingType") bookingType?: string,
|
||||
@Query("paymentStatus") paymentStatus?: string,
|
||||
@Query("providerTxnId") providerTxnId?: string,
|
||||
@Query("dateFrom") dateFrom?: string,
|
||||
@Query("dateTo") dateTo?: string,
|
||||
@Query("page") page?: string,
|
||||
@@ -171,6 +180,7 @@ export class BookingsController {
|
||||
returnLegStatus,
|
||||
bookingType,
|
||||
paymentStatus,
|
||||
providerTxnId,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
page: page ? parseInt(page) : 1,
|
||||
@@ -349,6 +359,53 @@ export class BookingsController {
|
||||
return this.guestService.createGuestBooking(dto, req);
|
||||
}
|
||||
|
||||
@Post("reservations/:seatId/issue")
|
||||
@PassengerStaffStrict(PASSENGER_PERMS.tickets.generate)
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({
|
||||
summary: "Issue a booking from a reserved (blocked) seat — requires tickets:generate",
|
||||
description:
|
||||
"Converts an admin-reserved seat into a real booking for one traveler. bookingKind STAFF waives the fee and issues the ticket immediately; bookingKind PASSENGER creates the booking as PENDING_PAYMENT and texts a payment link to the traveler's phone. Because STAFF issuance waives the fare, this requires edr_passenger_app:tickets:generate to be explicitly granted — super admins and org admins do NOT bypass it.",
|
||||
})
|
||||
@ApiBody({ type: IssueReservationBookingDto })
|
||||
issueBookingFromReservation(
|
||||
@Param("seatId") seatId: string,
|
||||
@Body() dto: IssueReservationBookingDto,
|
||||
@Req() req: any,
|
||||
) {
|
||||
const actingUserId = req.user?.id ?? req.user?.sub ?? null;
|
||||
return this.guestService.issueBookingFromReservation(seatId, dto, actingUserId);
|
||||
}
|
||||
|
||||
@Delete("reservations/:seatId")
|
||||
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.bookings.manage, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({
|
||||
summary: "Cancel a seat's pending-payment reservation and release the seat",
|
||||
description:
|
||||
"For a seat with an active PASSENGER-kind reservation (payment link sent, not yet paid): cancels that booking and releases the seat's hold, so it's genuinely free for someone else. The old payment link stops working immediately (the booking is no longer PENDING_PAYMENT).",
|
||||
})
|
||||
@ApiQuery({ name: "scheduleId", required: true, description: "TrainSchedule UUID the reservation was issued on" })
|
||||
cancelReservationForSeat(
|
||||
@Param("seatId") seatId: string,
|
||||
@Query("scheduleId") scheduleId: string,
|
||||
@Req() req: any,
|
||||
) {
|
||||
const actingUserId = req.user?.id ?? req.user?.sub ?? null;
|
||||
return this.service.cancelReservationForSeat(seatId, scheduleId, actingUserId);
|
||||
}
|
||||
|
||||
@Get("pay/:token")
|
||||
@SetMetadata("isPublic", true)
|
||||
@ApiOperation({
|
||||
summary: "Resolve a reservation booking by its pay token (public)",
|
||||
description:
|
||||
"Used by the portal's standalone pay-by-link page for a reservation booking awaiting passenger payment — no login required.",
|
||||
})
|
||||
getByPayToken(@Param("token") token: string) {
|
||||
return this.service.getByPayToken(token);
|
||||
}
|
||||
|
||||
@Get("saved-passengers")
|
||||
@SetMetadata("isPublic", true)
|
||||
@ApiOperation({
|
||||
@@ -526,10 +583,13 @@ export class BookingsController {
|
||||
})
|
||||
@ApiResponse({ status: 404, description: "Schedule or seat hold not found" })
|
||||
create(@Req() req: any, @Body() dto: CreateBookingDto) {
|
||||
// Always resolve passengerId from the authenticated JWT — never trust the request body
|
||||
// Always resolve identity from the authenticated JWT — never trust the request body.
|
||||
// Routed through the unified GuestBookingService: because req.user.id is present, it
|
||||
// resolves the existing passenger from the token and layers on the authenticated-only
|
||||
// behaviours (iam.users contact, loyalty, audit, package inventory, seat-vs-hold guard).
|
||||
const iamUserId = req.user?.id;
|
||||
if (!iamUserId) throw new UnauthorizedException();
|
||||
return this.service.create({ ...dto, passengerId: iamUserId });
|
||||
return this.guestService.createGuestBooking(dto as unknown as CreateGuestBookingDto, req);
|
||||
}
|
||||
|
||||
@Get(":id/usage")
|
||||
|
||||
@@ -10,9 +10,11 @@ import { CurrencyModule } from '../currency/currency.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { FareEngineModule } from '../fare-engine/fare-engine.module';
|
||||
import { TicketsModule } from '../tickets/tickets.module';
|
||||
import { PaymentsModule } from '../payments/payments.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule, AuthModule, TicketsModule],
|
||||
imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule, AuthModule, TicketsModule, PaymentsModule, NotificationsModule],
|
||||
controllers: [BookingsController],
|
||||
providers: [BookingsService, GuestBookingService],
|
||||
exports: [BookingsService, GuestBookingService]
|
||||
|
||||
@@ -10,6 +10,8 @@ import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { VerifaydaService } from '../verifayda/verifayda.service';
|
||||
import { CurrencyService } from '../currency/currency.service';
|
||||
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
||||
import { PaymentsService } from '../payments/payments.service';
|
||||
import { MAX_PAYMENT_HOURS } from '../../common/utils/payment-deadline.utils';
|
||||
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
|
||||
import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto';
|
||||
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
|
||||
@@ -89,6 +91,7 @@ interface BookingFilters {
|
||||
returnLegStatus?: string;
|
||||
bookingType?: string;
|
||||
paymentStatus?: string;
|
||||
providerTxnId?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
page?: number;
|
||||
@@ -109,6 +112,7 @@ export class BookingsService {
|
||||
private readonly currencyService: CurrencyService,
|
||||
private readonly fareEngine: FareEngineService,
|
||||
private readonly auditService: AuditService,
|
||||
private readonly paymentsService: PaymentsService,
|
||||
) {}
|
||||
|
||||
async findByIamUserId(iamUserId: string, filters: BookingFilters = {}) {
|
||||
@@ -450,8 +454,9 @@ export class BookingsService {
|
||||
}
|
||||
|
||||
async findAll(filters: BookingFilters = {}) {
|
||||
const { search, status, returnLegStatus, bookingType, paymentStatus, dateFrom, dateTo, page = 1, pageSize = 20 } = filters;
|
||||
const { search, status, returnLegStatus, bookingType, paymentStatus, providerTxnId, dateFrom, dateTo, page = 1, pageSize = 20 } = filters;
|
||||
const skip = (page - 1) * pageSize;
|
||||
const txn = providerTxnId?.trim() || undefined;
|
||||
|
||||
const onlyPackages = bookingType === 'PACKAGE';
|
||||
const includePackageBookings = !returnLegStatus && bookingType !== 'ONE_WAY' && bookingType !== 'ROUND_TRIP' && bookingType !== 'TRANSIT' && bookingType !== 'ROUND_TRIP_TRANSIT';
|
||||
@@ -492,11 +497,24 @@ export class BookingsService {
|
||||
...(dateTo ? { lte: new Date(new Date(dateTo).setHours(23, 59, 59, 999)) } : {}),
|
||||
};
|
||||
}
|
||||
// paymentStatus and providerTxnId both narrow the same relation — build one `is` filter
|
||||
// so the second doesn't overwrite the first.
|
||||
const paymentIntentIs: any = {};
|
||||
if (paymentStatus) {
|
||||
const statusMap: Record<string, string> = { PAID: 'SUCCEEDED', PENDING: 'REQUIRES_ACTION', FAILED: 'FAILED', REFUNDED: 'REFUNDED' };
|
||||
const mapped = statusMap[paymentStatus] ?? paymentStatus;
|
||||
where.paymentIntent = { is: { status: mapped } };
|
||||
paymentIntentIs.status = statusMap[paymentStatus] ?? paymentStatus;
|
||||
}
|
||||
if (txn) {
|
||||
// Providers are inconsistent about which reference they hand back to the customer —
|
||||
// match the transaction id, the provider/merchant order ids, and the generic ref.
|
||||
paymentIntentIs.OR = [
|
||||
{ providerTxnId: { contains: txn, mode: 'insensitive' } },
|
||||
{ providerOrderId: { contains: txn, mode: 'insensitive' } },
|
||||
{ merchantOrderId: { contains: txn, mode: 'insensitive' } },
|
||||
{ providerRef: { contains: txn, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
if (Object.keys(paymentIntentIs).length) where.paymentIntent = { is: paymentIntentIs };
|
||||
|
||||
const pkgWhere: any = {};
|
||||
if (search) {
|
||||
@@ -509,7 +527,12 @@ export class BookingsService {
|
||||
}
|
||||
if (status) pkgWhere.status = status;
|
||||
if (dateFrom || dateTo) pkgWhere.createdAt = where.createdAt;
|
||||
if (paymentStatus) pkgWhere.paymentIntent = { is: { status: (where.paymentIntent as any)?.is?.status } };
|
||||
const pkgPaymentIntentIs: any = {};
|
||||
if (paymentStatus) pkgPaymentIntentIs.status = paymentIntentIs.status;
|
||||
// PackagePaymentIntent has no providerTxnId/providerOrderId/merchantOrderId columns —
|
||||
// providerRef is the only reference we can match a package booking on.
|
||||
if (txn) pkgPaymentIntentIs.providerRef = { contains: txn, mode: 'insensitive' };
|
||||
if (Object.keys(pkgPaymentIntentIs).length) pkgWhere.paymentIntent = { is: pkgPaymentIntentIs };
|
||||
|
||||
if (onlyPackages) {
|
||||
// Package bookings live in two places:
|
||||
@@ -518,7 +541,7 @@ export class BookingsService {
|
||||
const bookingPkgWhere: any = { packageId: { not: null } };
|
||||
if (status) bookingPkgWhere.status = status;
|
||||
if (dateFrom || dateTo) bookingPkgWhere.createdAt = where.createdAt;
|
||||
if (paymentStatus) bookingPkgWhere.paymentIntent = where.paymentIntent;
|
||||
if (where.paymentIntent) bookingPkgWhere.paymentIntent = where.paymentIntent;
|
||||
if (search) bookingPkgWhere.OR = where.OR;
|
||||
|
||||
const [pkgItems, pkgTotal, regPkgItems, regPkgTotal] = await Promise.all([
|
||||
@@ -1860,6 +1883,52 @@ export class BookingsService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a reservation booking by its standalone payToken — the public entry point for
|
||||
* the portal's pay-by-link page (a traveler who never had a portal session, texted a link
|
||||
* by a reservation-issuing staff member). Payment itself still goes through the already-
|
||||
* public /payments/* endpoints (initiate/methods/booking-amount/status), keyed by the
|
||||
* booking id this returns — no change needed there.
|
||||
*/
|
||||
async getByPayToken(token: string) {
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { payToken: token },
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
|
||||
seats: { include: { seat: { include: { coach: true } } } },
|
||||
},
|
||||
});
|
||||
if (!booking) throw new NotFoundException('Payment link not found');
|
||||
if (booking.status !== 'PENDING_PAYMENT') throw new BadRequestException('This booking is no longer awaiting payment');
|
||||
if ((booking as any).payTokenExpiresAt && (booking as any).payTokenExpiresAt < new Date()) {
|
||||
throw new BadRequestException('This payment link has expired');
|
||||
}
|
||||
|
||||
const segment = resolveBookingSegment((booking as any).schedule, (booking as any).originStationId, (booking as any).destinationStationId);
|
||||
return {
|
||||
id: booking.id,
|
||||
bookingRef: booking.bookingRef,
|
||||
status: booking.status,
|
||||
totalMinor: booking.totalMinor,
|
||||
currency: booking.currency,
|
||||
displayCurrency: booking.displayCurrency,
|
||||
displayTotalMinor: booking.displayTotalMinor,
|
||||
schedule: {
|
||||
trainNumber: (booking as any).schedule.train.number,
|
||||
trainName: (booking as any).schedule.train.name,
|
||||
origin: { id: segment.origin.id, name: segment.origin.name, code: segment.origin.code },
|
||||
destination: { id: segment.destination.id, name: segment.destination.name, code: segment.destination.code },
|
||||
departureAt: segment.departureAt,
|
||||
arrivalAt: segment.arrivalAt,
|
||||
},
|
||||
seats: (booking as any).seats.map((s: any) => ({
|
||||
passengerName: s.passengerName,
|
||||
seatNumber: s.seat?.seatNumber ?? null,
|
||||
coach: s.seat?.coach?.number ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async getByRef(bookingRefOrId: 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);
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
@@ -2083,6 +2152,14 @@ export class BookingsService {
|
||||
const booking = await this.prisma.booking.findUnique({ where: { bookingRef }, include: { seats: true, paymentIntent: true } });
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
if (booking.status === 'CANCELLED') throw new BadRequestException('Booking already cancelled');
|
||||
// Verify-before-cancel: a still-PENDING_PAYMENT booking may actually be paid (its confirm event
|
||||
// was lost/late). reconcileAndConfirmIfPaid confirms it synchronously if so — refuse to cancel a
|
||||
// paid, or currently-unverifiable, booking as "unpaid".
|
||||
if (booking.status === 'PENDING_PAYMENT') {
|
||||
const { paid, verified } = await this.paymentsService.reconcileAndConfirmIfPaid(booking.id);
|
||||
if (paid) throw new BadRequestException('Payment for this booking has completed; it is now confirmed and cannot be cancelled as unpaid.');
|
||||
if (!verified) throw new BadRequestException('Could not verify payment status right now; please try again shortly.');
|
||||
}
|
||||
const refundAmount = booking.status === 'CONFIRMED' ? Math.floor(booking.totalMinor * 0.8) : 0;
|
||||
await this.prisma.bookingCancellation.create({ data: { bookingId: booking.id, cancelledBy: booking.passengerId, reason, refundAmount, refundMethod: booking.paymentIntent?.method ?? 'ORIGINAL', refundStatus: 'PENDING' } });
|
||||
await this.seatsService.releaseSeats(booking.id);
|
||||
@@ -2092,6 +2169,39 @@ export class BookingsService {
|
||||
return { cancelled: true, refundAmount: refundAmount / 100, currency: booking.displayCurrency};
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff releasing a seat that already has an in-flight backoffice reservation must not
|
||||
* leave that booking dangling as PENDING_PAYMENT with a still-payable link — the traveler
|
||||
* could pay for a seat that's since been given away. Finds the active reservation covering
|
||||
* this exact seat+schedule and cancels it via the normal cancel() path (refund=0, since it's
|
||||
* still unpaid), then separately releases the SeatHold issueBookingFromReservation created —
|
||||
* cancel()'s releaseSeats() only deletes Journey/JourneySegment rows, which don't exist yet
|
||||
* for an unpaid reservation, so without this the seat would stay held until the hold's own
|
||||
* expiry. Once status flips to CANCELLED, getByPayToken's existing status check already
|
||||
* rejects the old payToken with "This booking is no longer awaiting payment" — no separate
|
||||
* payToken invalidation needed.
|
||||
*/
|
||||
async cancelReservationForSeat(seatId: string, scheduleId: string, actingUserId: string | null) {
|
||||
const bookingSeat = await this.prisma.bookingSeat.findFirst({
|
||||
where: {
|
||||
seatId,
|
||||
scheduleId,
|
||||
booking: { source: 'BACKOFFICE_RESERVATION', status: 'PENDING_PAYMENT' },
|
||||
},
|
||||
include: { booking: true },
|
||||
});
|
||||
if (!bookingSeat) throw new NotFoundException('No pending reservation found for this seat');
|
||||
|
||||
const { bookingRef } = bookingSeat.booking;
|
||||
const result = await this.cancel(bookingRef, 'Seat released by staff before payment', actingUserId ?? undefined);
|
||||
|
||||
await this.prisma.seatHold.deleteMany({
|
||||
where: { scheduleId, seatIds: { hasSome: [seatId] } },
|
||||
});
|
||||
|
||||
return { ...result, bookingRef };
|
||||
}
|
||||
|
||||
async update(id: string, dto: any) {
|
||||
const booking = await this.prisma.booking.findUnique({ where: { id } });
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
@@ -2186,11 +2296,24 @@ export class BookingsService {
|
||||
|
||||
@Cron(CronExpression.EVERY_MINUTE)
|
||||
async expirePendingBookings() {
|
||||
const cutoff = new Date(Date.now() - 20 * 60 * 1000);
|
||||
// NEUTRALIZED (was 20 minutes): the payment window is MAX_PAYMENT_HOURS (2h). Bookings must
|
||||
// NEVER be cancelled at 20 minutes — the payer still has up to 2 hours, and the seat hold is
|
||||
// held for exactly this window. Aligned to the 2-hour window so this cron can only ever act as
|
||||
// a safe backup to the primary deadline-aware sweep (TasksService.cancelExpiredPendingBookings);
|
||||
// it never cancels prematurely, and paid bookings are still protected by the guard below.
|
||||
const cutoff = new Date(Date.now() - MAX_PAYMENT_HOURS * 60 * 60 * 1000);
|
||||
const expired = await this.prisma.booking.findMany({ where: { status: 'PENDING_PAYMENT', createdAt: { lt: cutoff } }, include: { seats: true } });
|
||||
for (const b of expired) {
|
||||
await this.seatsService.releaseSeats(b.id);
|
||||
await this.prisma.booking.update({ where: { id: b.id }, data: { status: 'CANCELLED' } });
|
||||
try {
|
||||
// Never cancel a paid booking whose confirm event was lost/late — verify first (this
|
||||
// confirms it synchronously if paid). Skip when paid or currently unverifiable.
|
||||
const { paid, verified } = await this.paymentsService.reconcileAndConfirmIfPaid(b.id);
|
||||
if (paid || !verified) continue;
|
||||
await this.seatsService.releaseSeats(b.id);
|
||||
await this.prisma.booking.update({ where: { id: b.id }, data: { status: 'CANCELLED' } });
|
||||
} catch (err) {
|
||||
this.logger.error(`expirePendingBookings failed for ${b.id}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsString, IsArray, ValidateNested, IsOptional, IsEnum, IsDateString, IsBoolean, IsInt, IsNumber } from 'class-validator';
|
||||
import { IsString, IsArray, ValidateNested, IsOptional, IsEnum, IsDateString, IsBoolean, IsInt, IsNumber, IsIn } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Currency, IdDocumentType } from '@prisma/client';
|
||||
@@ -174,6 +174,67 @@ export class SavedPassengerProfileDto {
|
||||
}
|
||||
|
||||
export class GetSavedPassengersDto {
|
||||
@ApiPropertyOptional({ example: 'device-uuid-12345', description: 'Device ID to retrieve saved passengers' })
|
||||
@ApiPropertyOptional({ example: 'device-uuid-12345', description: 'Device ID to retrieve saved passengers' })
|
||||
@IsOptional() @IsString() deviceId?: string;
|
||||
}
|
||||
|
||||
export enum ReservationBookingKind {
|
||||
STAFF = 'STAFF',
|
||||
PASSENGER = 'PASSENGER',
|
||||
}
|
||||
|
||||
export const RESERVATION_NATIONALITIES = ['Ethiopian', 'Djiboutian', 'Other'] as const;
|
||||
export type ReservationNationality = (typeof RESERVATION_NATIONALITIES)[number];
|
||||
|
||||
/**
|
||||
* Issues a real booking against a seat an admin/staff previously reserved (SeatBlock) —
|
||||
* one traveler per seat, fare always server-computed (no seatFareMinor/reviewedTotalMinor
|
||||
* override: unlike the guest DTO, there's no untrusted client-displayed price to defend
|
||||
* against here). seatId comes from the route param, not the body.
|
||||
*
|
||||
* No seatClassId — the seat (and therefore its coach/class) is already fixed by the
|
||||
* reservation being converted; the service resolves the correct seat class itself from the
|
||||
* seat's own coach type and nationality (LOCAL vs INTERNATIONAL pricing tier), the same
|
||||
* matching search results already use. No passportCountry — nationality alone is what
|
||||
* drives both fare-tier selection and passport-vs-national-ID validation.
|
||||
*/
|
||||
export class IssueReservationBookingDto {
|
||||
@ApiProperty({ example: 'schedule-uuid' })
|
||||
@IsString() scheduleId: string;
|
||||
|
||||
@ApiProperty({ example: 'station-uuid' })
|
||||
@IsString() originStationId: string;
|
||||
|
||||
@ApiProperty({ example: 'station-uuid' })
|
||||
@IsString() destinationStationId: string;
|
||||
|
||||
@ApiProperty({ enum: ReservationBookingKind, description: 'STAFF: no fee, ticket issued immediately. PASSENGER: a payment link is sent to phone.' })
|
||||
@IsEnum(ReservationBookingKind) bookingKind: ReservationBookingKind;
|
||||
|
||||
@ApiProperty({ example: 'Abebe Kebede' })
|
||||
@IsString() passengerName: string;
|
||||
|
||||
@ApiProperty({ example: '1990-05-15', description: 'Date of birth (YYYY-MM-DD) for age calculation' })
|
||||
@IsDateString() dateOfBirth: string;
|
||||
|
||||
@ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType })
|
||||
@IsEnum(IdDocumentType) idDocumentType: IdDocumentType;
|
||||
|
||||
@ApiPropertyOptional({ example: 'ET123456789' })
|
||||
@IsOptional() @IsString() idDocumentNumber?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'P1234567' })
|
||||
@IsOptional() @IsString() passportNumber?: string;
|
||||
|
||||
@ApiProperty({ example: 'Ethiopian', enum: RESERVATION_NATIONALITIES, description: 'Drives both the LOCAL/INTERNATIONAL fare tier and passport-vs-national-ID validation.' })
|
||||
@IsIn(RESERVATION_NATIONALITIES) nationality: ReservationNationality;
|
||||
|
||||
@ApiPropertyOptional({ example: '+251912345678', description: 'Required when bookingKind is PASSENGER — the payment link is sent here.' })
|
||||
@IsOptional() @IsString() phone?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'abebe@email.com' })
|
||||
@IsOptional() @IsString() email?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'ETB', enum: Currency })
|
||||
@IsOptional() @IsEnum(Currency) displayCurrency?: Currency;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { Injectable, BadRequestException, NotFoundException, Logger } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { SeatsService } from '../seats/seats.service';
|
||||
import { VerifaydaService } from '../verifayda/verifayda.service';
|
||||
import { CurrencyService } from '../currency/currency.service';
|
||||
import { PassengerAuthService } from '../auth/passenger-auth.service';
|
||||
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
||||
import { AuditService } from '../../common/audit.service';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { CreateGuestBookingDto, SavedPassengerProfileDto } from './guest-booking.dto';
|
||||
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
|
||||
import { PaymentsService } from '../payments/payments.service';
|
||||
import { SmsClientService } from '../notifications/sms-client.service';
|
||||
import { CreateGuestBookingDto, SavedPassengerProfileDto, IssueReservationBookingDto, ReservationBookingKind } from './guest-booking.dto';
|
||||
import { Currency, PassengerCategory, IdDocumentType, PaymentMethodType, PaymentIntentStatus } from '@prisma/client';
|
||||
import { resolveCheckinCutoff } from '../../common/utils/checkin-cutoff.utils';
|
||||
import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
/**
|
||||
* Throws if the given boarding stop's own configurable check-in cutoff (route/stop
|
||||
@@ -25,6 +32,29 @@ function assertWithinCheckinCutoff(schedule: any, stopTime: any, stationId: stri
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the seat class for a specific, already-known seat (its coach type only ever
|
||||
* offers a fixed set of classes) given the traveler's nationality tier — mirrors
|
||||
* search.service.ts's own LOCAL/INTERNATIONAL + bed-position matching so the price a
|
||||
* reservation-issued booking charges is the exact same "already configured price setup"
|
||||
* search results would have quoted, without asking the admin to redundantly re-pick a class
|
||||
* for a seat whose class is already fixed.
|
||||
*/
|
||||
function resolveSeatClassForSeat(seat: any, nationality: string): { id: string; name: string } {
|
||||
const nationalityUpper = (nationality ?? '').toUpperCase();
|
||||
const resolvedNationalityType = nationalityUpper === 'ETHIOPIAN' || nationalityUpper === 'DJIBOUTIAN' ? 'LOCAL' : 'INTERNATIONAL';
|
||||
const candidates = (seat.coach?.coachType?.seatClasses ?? []).filter(
|
||||
(sc: any) => !sc.nationalityType || sc.nationalityType === resolvedNationalityType,
|
||||
);
|
||||
const matchingClass = seat.bedPosition
|
||||
? candidates.find((sc: any) => sc.bedPosition?.toLowerCase() === seat.bedPosition)
|
||||
: candidates[0];
|
||||
if (!matchingClass) {
|
||||
throw new BadRequestException('No seat class is configured for this seat and nationality — set up seat classes for this coach type first.');
|
||||
}
|
||||
return matchingClass;
|
||||
}
|
||||
|
||||
function generateRef(): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
|
||||
@@ -59,12 +89,16 @@ export class GuestBookingService {
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
private seatsService: SeatsService,
|
||||
private verifaydaService: VerifaydaService,
|
||||
private currencyService: CurrencyService,
|
||||
private passengerAuthService: PassengerAuthService,
|
||||
private fareEngine: FareEngineService,
|
||||
private auditService: AuditService,
|
||||
private eventEmitter: EventEmitter2,
|
||||
private paymentsService: PaymentsService,
|
||||
private smsClient: SmsClientService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
@@ -107,6 +141,7 @@ export class GuestBookingService {
|
||||
}
|
||||
|
||||
private async createGuestOneWayBooking(dto: CreateGuestBookingDto, req?: any) {
|
||||
const authUserId: string | null = req?.user?.id ?? null;
|
||||
// Validate hold
|
||||
const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } });
|
||||
if (!hold || hold.expiresAt < new Date()) {
|
||||
@@ -259,8 +294,12 @@ export class GuestBookingService {
|
||||
|
||||
let displayTotalMinor: number;
|
||||
let resolvedTotalMinor: number;
|
||||
// True when the total came from a client-summed subtotal (per-seat sum or reviewedTotalMinor),
|
||||
// which the portal computes UNDISCOUNTED — the promo must still be applied to it (H-13).
|
||||
let usedClientSubtotal = false;
|
||||
if (allFaresProvided && !isPackageOneway) {
|
||||
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0);
|
||||
usedClientSubtotal = true;
|
||||
} else if (isPackageOneway && dto.reviewedTotalMinor != null) {
|
||||
displayTotalMinor = dto.reviewedTotalMinor;
|
||||
if (seatedPassengers.length > 0) {
|
||||
@@ -271,6 +310,7 @@ export class GuestBookingService {
|
||||
}
|
||||
} else if (dto.reviewedTotalMinor != null && dto.reviewedTotalMinor > 0) {
|
||||
displayTotalMinor = dto.reviewedTotalMinor;
|
||||
usedClientSubtotal = true;
|
||||
} else {
|
||||
// fare engine returns ETB — convert forward to display currency
|
||||
const etbTotal = Math.max(0, totalBaseFareMinor - discountMinor);
|
||||
@@ -278,6 +318,18 @@ export class GuestBookingService {
|
||||
? await this.currencyService.convertAmount(etbTotal, Currency.ETB, displayCurrency)
|
||||
: etbTotal;
|
||||
}
|
||||
|
||||
// H-13: the portal sums UNDISCOUNTED per-passenger fares into a client subtotal, silently
|
||||
// dropping the promo the fare engine recognized. Apply the authoritative discount now so the
|
||||
// customer is charged the discounted price. The non-client-subtotal branch above already
|
||||
// nets the discount out of etbTotal, so it's excluded here to avoid double-subtracting.
|
||||
if (usedClientSubtotal && discountMinor > 0) {
|
||||
const discountDisplayMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(discountMinor, Currency.ETB, displayCurrency)
|
||||
: discountMinor;
|
||||
displayTotalMinor = Math.max(0, displayTotalMinor - discountDisplayMinor);
|
||||
}
|
||||
|
||||
resolvedTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
|
||||
: displayTotalMinor;
|
||||
@@ -288,6 +340,7 @@ export class GuestBookingService {
|
||||
// Resolve or create the guest Passenger record
|
||||
const firstPassenger = passengersData[0];
|
||||
const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, firstPassenger, req);
|
||||
const contact = await this.resolveActorContact(req, firstPassenger);
|
||||
|
||||
// Save passenger details for future use (if requested)
|
||||
if (dto.savePassengerDetails && (dto.createAccount || dto.deviceId)) {
|
||||
@@ -329,8 +382,8 @@ export class GuestBookingService {
|
||||
bookingType: 'ONE_WAY',
|
||||
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
|
||||
userAgent: dto.deviceId,
|
||||
contactEmail: firstPassenger.email || null,
|
||||
contactPhone: firstPassenger.phone || null,
|
||||
contactEmail: contact.contactEmail,
|
||||
contactPhone: contact.contactPhone,
|
||||
seats: {
|
||||
create: passengersWithFares.map((p) => ({
|
||||
seat: { connect: { id: p.seatId } },
|
||||
@@ -354,11 +407,18 @@ export class GuestBookingService {
|
||||
},
|
||||
});
|
||||
|
||||
// Save passenger details as traveler profiles
|
||||
await this.createTravelerProfiles(guestPassengerId, passengersData);
|
||||
// Save passenger details as traveler profiles — guest bookings only.
|
||||
// Authenticated passengers already have a profile, matching the old BookingsService.
|
||||
if (!authUserId) await this.createTravelerProfiles(guestPassengerId, passengersData);
|
||||
|
||||
// Confirm seats
|
||||
await this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId).filter((id): id is string => !!id));
|
||||
|
||||
// Authenticated-only side effect: audit the booking creation.
|
||||
if (authUserId) {
|
||||
await this.auditService.log({ userId: guestPassengerId, action: 'CREATE', entityType: 'Booking', entityId: booking.id, newData: { bookingRef: booking.bookingRef, bookingType: 'ONE_WAY', totalMinor: resolvedTotalMinor } });
|
||||
}
|
||||
|
||||
this.eventEmitter.emit('booking.created', { booking });
|
||||
|
||||
return {
|
||||
@@ -384,7 +444,244 @@ export class GuestBookingService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an admin-reserved seat (SeatBlock) into a real booking for one traveler —
|
||||
* no SeatHold involved (the seat was already set aside), so this mirrors
|
||||
* createGuestOneWayBooking's schedule/fare/passenger resolution but skips the hold check
|
||||
* and instead validates+releases the SeatBlock. STAFF bookings are fee-waived and
|
||||
* finalized immediately via the same PaymentsService.finalizePaymentSuccess() path every
|
||||
* real payment webhook uses; PASSENGER bookings are left PENDING_PAYMENT with a payToken
|
||||
* texted to the traveler so they can pay via the existing, already-public /payments/*
|
||||
* endpoints without a portal session.
|
||||
*/
|
||||
async issueBookingFromReservation(
|
||||
seatId: string,
|
||||
dto: IssueReservationBookingDto,
|
||||
actingUserId: string | null,
|
||||
): Promise<{ booking: any; payUrl?: string }> {
|
||||
if (dto.bookingKind === ReservationBookingKind.PASSENGER && !dto.phone) {
|
||||
throw new BadRequestException('Phone number is required for a passenger booking');
|
||||
}
|
||||
if (dto.idDocumentType === IdDocumentType.NATIONAL_ID && dto.nationality !== 'Ethiopian') {
|
||||
throw new BadRequestException('National ID is only valid for Ethiopian nationality — use a passport instead');
|
||||
}
|
||||
|
||||
const seatBlock = await this.prisma.seatBlock.findFirst({
|
||||
where: { seatId, OR: [{ scheduleId: dto.scheduleId }, { scheduleId: null }] },
|
||||
});
|
||||
if (!seatBlock) throw new NotFoundException('Seat is not reserved');
|
||||
|
||||
// The seat (and therefore its coach) is already fixed by the reservation — resolve the
|
||||
// seat class from the seat's own coach type + the traveler's nationality tier, the same
|
||||
// LOCAL/INTERNATIONAL + bed-position matching search results already use, instead of
|
||||
// asking the admin to redundantly pick a class.
|
||||
const seat = await this.prisma.seat.findUnique({
|
||||
where: { id: seatId },
|
||||
include: { coach: { include: { coachType: { include: { seatClasses: true } } } } },
|
||||
});
|
||||
if (!seat) throw new NotFoundException('Seat not found');
|
||||
const resolvedSeatClass = resolveSeatClassForSeat(seat, dto.nationality);
|
||||
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: dto.scheduleId },
|
||||
include: {
|
||||
originStation: true,
|
||||
destinationStation: true,
|
||||
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
|
||||
route: { include: { stops: true } },
|
||||
},
|
||||
});
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
|
||||
const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId)
|
||||
?? (schedule.stopTimes.length === 0 ? { stationId: schedule.originStationId, sequence: 0, station: schedule.originStation } : undefined);
|
||||
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId)
|
||||
?? (schedule.stopTimes.length === 0 ? { stationId: schedule.destinationStationId, sequence: 1, station: schedule.destinationStation } : undefined);
|
||||
if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found');
|
||||
|
||||
assertWithinCheckinCutoff(schedule, originStop, dto.originStationId);
|
||||
|
||||
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
|
||||
const fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`;
|
||||
|
||||
// Single-traveler passenger processing. nationality is a fixed dropdown value (Ethiopian/
|
||||
// Djiboutian/Other), so — unlike the guest-booking loop this mirrors — there's no need to
|
||||
// infer it from document type/country; only the Verifayda check (NATIONAL_ID) and the
|
||||
// passport-number requirement (PASSPORT) still depend on the chosen document type.
|
||||
const dateOfBirth = new Date(dto.dateOfBirth);
|
||||
const age = calculateAge(dateOfBirth);
|
||||
const category: PassengerCategory = age < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT;
|
||||
|
||||
let passengerName = dto.passengerName;
|
||||
let verifaydaVerified = false;
|
||||
let verifaydaData: Record<string, any> | undefined;
|
||||
|
||||
if (dto.idDocumentType === IdDocumentType.NATIONAL_ID) {
|
||||
if (dto.idDocumentNumber) {
|
||||
const verification = await this.verifaydaService.verifyNationalId(dto.idDocumentNumber);
|
||||
if (!verification.verified) {
|
||||
throw new BadRequestException(`Verifayda verification failed for ${dto.passengerName}: ${verification.failureReason}`);
|
||||
}
|
||||
passengerName = verification.passengerData?.fullName || passengerName;
|
||||
verifaydaVerified = true;
|
||||
verifaydaData = verification.passengerData?.profileData;
|
||||
}
|
||||
} else if (dto.idDocumentType === IdDocumentType.PASSPORT) {
|
||||
if (!dto.passportNumber) {
|
||||
throw new BadRequestException(`Passport number required for ${dto.passengerName}`);
|
||||
}
|
||||
}
|
||||
|
||||
const passengerData = {
|
||||
passengerName,
|
||||
dateOfBirth,
|
||||
category,
|
||||
verifaydaVerified,
|
||||
verifaydaData,
|
||||
nationality: dto.nationality,
|
||||
idDocumentType: dto.idDocumentType,
|
||||
idDocumentNumber: dto.idDocumentNumber,
|
||||
passportNumber: dto.passportNumber,
|
||||
phone: dto.phone,
|
||||
email: dto.email,
|
||||
};
|
||||
|
||||
const baseFareMinor = await this.getBaseFare(
|
||||
dto.scheduleId,
|
||||
resolvedSeatClass.id,
|
||||
segmentRoute,
|
||||
fullRoute,
|
||||
dto.nationality,
|
||||
dto.originStationId,
|
||||
dto.destinationStationId,
|
||||
);
|
||||
|
||||
const isStaff = dto.bookingKind === ReservationBookingKind.STAFF;
|
||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||
const totalMinor = isStaff ? 0 : baseFareMinor;
|
||||
if (!isStaff) {
|
||||
// Defense-in-depth — there's no client-forgeable price on this DTO, but keep the
|
||||
// same authoritative-fare floor every other booking path enforces.
|
||||
this.assertTotalNotUnderAuthoritative(totalMinor, baseFareMinor, 'issueBookingFromReservation');
|
||||
}
|
||||
const displayTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||||
: totalMinor;
|
||||
|
||||
const { guestPassengerId } = await this.resolveGuestPassenger({}, passengerData);
|
||||
|
||||
const payToken = isStaff ? undefined : randomUUID();
|
||||
const payTokenExpiresAt = isStaff ? undefined : computePaymentDeadline(new Date(), schedule.departureAt);
|
||||
|
||||
const booking = await this.prisma.booking.create({
|
||||
data: {
|
||||
bookingRef: generateRef(),
|
||||
passengerId: guestPassengerId,
|
||||
scheduleId: dto.scheduleId,
|
||||
originStationId: dto.originStationId,
|
||||
destinationStationId: dto.destinationStationId,
|
||||
status: 'PENDING_PAYMENT',
|
||||
totalMinor,
|
||||
currency: Currency.ETB,
|
||||
adultCount: category === PassengerCategory.ADULT ? 1 : 0,
|
||||
childCount: category === PassengerCategory.CHILD ? 1 : 0,
|
||||
displayCurrency,
|
||||
displayTotalMinor,
|
||||
bookingType: 'ONE_WAY',
|
||||
source: 'BACKOFFICE_RESERVATION',
|
||||
contactEmail: dto.email || null,
|
||||
contactPhone: dto.phone || null,
|
||||
payToken,
|
||||
payTokenExpiresAt,
|
||||
seats: {
|
||||
create: [{
|
||||
seat: { connect: { id: seatId } },
|
||||
scheduleId: dto.scheduleId,
|
||||
passengerName: passengerData.passengerName,
|
||||
dateOfBirth: passengerData.dateOfBirth,
|
||||
passengerCategory: passengerData.category,
|
||||
idDocumentType: passengerData.idDocumentType,
|
||||
passportNumber: passengerData.passportNumber,
|
||||
verifaydaVerified: passengerData.verifaydaVerified,
|
||||
verifaydaData: passengerData.verifaydaData || undefined,
|
||||
fareMinor: totalMinor,
|
||||
displayCurrency,
|
||||
}],
|
||||
},
|
||||
},
|
||||
include: {
|
||||
seats: { include: { seat: { include: { coach: true } } } },
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
},
|
||||
});
|
||||
|
||||
await this.createTravelerProfiles(guestPassengerId, [passengerData]);
|
||||
|
||||
// Release the reservation using the SAME scope it was created with (global vs
|
||||
// schedule-scoped) — unblockSeat already correctly resets Seat.status for a global
|
||||
// block; reimplementing that here would risk missing that reset. This alone leaves a
|
||||
// window where the seat has no SeatBlock, no SeatHold, and no JourneySegment (the
|
||||
// latter is only created on payment success — see PaymentsService.createJourneySegments)
|
||||
// — i.e. fully available to the public — the instant this returns, since confirmSeats()
|
||||
// is a no-op with no existing hold to extend. holdSeats() immediately re-reserves the
|
||||
// seat with the same createdBy segment-range metadata the search/hold-conflict checks
|
||||
// already rely on (getSeatAvailabilityMap); confirmSeats() then extends that hold to the
|
||||
// real payment deadline (same mechanism createGuestOneWayBooking uses), so the seat stays
|
||||
// unavailable to everyone else until the passenger pays or the hold/booking expires.
|
||||
await this.seatsService.unblockSeat(seatId, seatBlock.scheduleId ?? undefined);
|
||||
await this.seatsService.holdSeats({
|
||||
scheduleId: dto.scheduleId,
|
||||
originStationId: dto.originStationId,
|
||||
destinationStationId: dto.destinationStationId,
|
||||
passengers: [{ passengerId: guestPassengerId, seatId }],
|
||||
});
|
||||
await this.seatsService.confirmSeats([seatId]);
|
||||
this.eventEmitter.emit('booking.created', { booking });
|
||||
|
||||
if (isStaff) {
|
||||
await this.auditService.log({
|
||||
userId: actingUserId ?? undefined,
|
||||
action: 'CREATE',
|
||||
entityType: 'Booking',
|
||||
entityId: booking.id,
|
||||
newData: { feeWaived: true, waivedBy: actingUserId, originalFareMinor: baseFareMinor },
|
||||
});
|
||||
|
||||
const intent = await this.prisma.paymentIntent.create({
|
||||
data: {
|
||||
bookingId: booking.id,
|
||||
amountMinor: 0,
|
||||
currency: 'ETB',
|
||||
// WALLET is an internal-only method that never leaves this app (see
|
||||
// payments.service.ts) — safe, inert placeholder for a zero-charge waiver.
|
||||
method: PaymentMethodType.WALLET,
|
||||
status: PaymentIntentStatus.REQUIRES_ACTION,
|
||||
},
|
||||
});
|
||||
await this.paymentsService.finalizePaymentSuccess({ intentId: intent.id });
|
||||
// finalizePaymentSuccess mutates the booking (status -> CONFIRMED) in the DB —
|
||||
// re-fetch so the caller sees the actual outcome, not the pre-finalization snapshot.
|
||||
const confirmedBooking = await this.prisma.booking.findUnique({ where: { id: booking.id } });
|
||||
return { booking: confirmedBooking };
|
||||
}
|
||||
|
||||
const portalUrl = process.env.PORTAL_URL ?? 'http://localhost:5174';
|
||||
const payUrl = `${portalUrl}/reserve/pay/${payToken}`;
|
||||
const amountStr = (totalMinor / 100).toFixed(2);
|
||||
try {
|
||||
await this.smsClient.sendSms({
|
||||
to: dto.phone!,
|
||||
message: `EDR: Your seat is reserved. Pay ${amountStr} ETB to confirm your ticket: ${payUrl}`,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.warn(`Reservation payment-link SMS failed for booking ${booking.bookingRef}: ${err}`);
|
||||
}
|
||||
|
||||
return { booking, payUrl };
|
||||
}
|
||||
|
||||
private async createGuestRoundTripBooking(dto: CreateGuestBookingDto, req?: any) {
|
||||
const authUserId: string | null = req?.user?.id ?? null;
|
||||
if (!dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId) {
|
||||
throw new BadRequestException('returnScheduleId, returnHoldId, returnOriginStationId and returnDestinationStationId are required for ROUND_TRIP');
|
||||
}
|
||||
@@ -554,17 +851,16 @@ export class GuestBookingService {
|
||||
const allRTFaresProvided = rtSeatedPassengers.length > 0 &&
|
||||
rtSeatedPassengers.every(p => p.seatFareMinor != null && p.returnSeatFareMinor != null);
|
||||
|
||||
// True when the total came from a client-summed subtotal (per-seat sum or reviewedTotalMinor),
|
||||
// which the portal computes UNDISCOUNTED — the promo must still be applied to it (H-13).
|
||||
let usedClientSubtotal = false;
|
||||
|
||||
if (allRTFaresProvided && !isPackageRoundTrip) {
|
||||
// Server-computed sum is authoritative — prevents race-condition under-count.
|
||||
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
|
||||
totalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
|
||||
: displayTotalMinor;
|
||||
usedClientSubtotal = true;
|
||||
} else if (isPackageRoundTrip && dto.reviewedTotalMinor != null) {
|
||||
displayTotalMinor = dto.reviewedTotalMinor;
|
||||
totalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
|
||||
: displayTotalMinor;
|
||||
const seatedCount = passengersData.filter(p => p.seatId).length;
|
||||
if (seatedCount > 0) {
|
||||
const perSeatPerLeg = Math.round(dto.reviewedTotalMinor / (seatedCount * 2));
|
||||
@@ -575,16 +871,30 @@ export class GuestBookingService {
|
||||
}
|
||||
} else if (dto.reviewedTotalMinor != null && dto.reviewedTotalMinor > 0) {
|
||||
displayTotalMinor = dto.reviewedTotalMinor;
|
||||
totalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
|
||||
: displayTotalMinor;
|
||||
usedClientSubtotal = true;
|
||||
}
|
||||
|
||||
// H-13: the portal sums UNDISCOUNTED per-passenger fares into a client subtotal, silently
|
||||
// dropping the promo the fare engine recognized. Apply the authoritative discount now so the
|
||||
// customer is charged the discounted price. The no-override case above already starts from a
|
||||
// discounted displayTotalMinor, so it's excluded here to avoid double-subtracting.
|
||||
if (usedClientSubtotal && discountMinor > 0) {
|
||||
const discountDisplayMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(discountMinor, Currency.ETB, displayCurrency)
|
||||
: discountMinor;
|
||||
displayTotalMinor = Math.max(0, displayTotalMinor - discountDisplayMinor);
|
||||
}
|
||||
|
||||
totalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
|
||||
: displayTotalMinor;
|
||||
|
||||
// C-1 guard: never charge less than the server-recomputed authoritative round-trip fare.
|
||||
this.assertTotalNotUnderAuthoritative(totalMinor, authoritativeTotalMinor, 'createGuestRoundTripBooking');
|
||||
|
||||
// Create or resolve guest passenger (same as one-way)
|
||||
const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req);
|
||||
const contact = await this.resolveActorContact(req, passengersData[0]);
|
||||
|
||||
// Create booking with outbound seats; return seats confirmed separately
|
||||
const outboundSeatIds = dto.passengers.map(p => p.seatId).filter((id): id is string => !!id);
|
||||
@@ -613,8 +923,8 @@ export class GuestBookingService {
|
||||
returnLegStatus: 'NEITHER_USED',
|
||||
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
|
||||
userAgent: dto.deviceId,
|
||||
contactEmail: passengersData[0]?.email || null,
|
||||
contactPhone: passengersData[0]?.phone || null,
|
||||
contactEmail: contact.contactEmail,
|
||||
contactPhone: contact.contactPhone,
|
||||
seats: {
|
||||
create: [
|
||||
...passengersWithFares.map((p) => ({
|
||||
@@ -656,12 +966,19 @@ export class GuestBookingService {
|
||||
},
|
||||
});
|
||||
|
||||
await this.createTravelerProfiles(guestPassengerId, passengersData);
|
||||
// Traveler profiles: guest bookings only (authenticated passengers already have one).
|
||||
if (!authUserId) await this.createTravelerProfiles(guestPassengerId, passengersData);
|
||||
|
||||
await Promise.all([
|
||||
this.seatsService.confirmSeats(outboundSeatIds),
|
||||
this.seatsService.confirmSeats(returnSeatIds),
|
||||
]);
|
||||
|
||||
// Authenticated-only side effect: audit the booking creation.
|
||||
if (authUserId) {
|
||||
await this.auditService.log({ userId: guestPassengerId, action: 'CREATE', entityType: 'Booking', entityId: booking.id, newData: { bookingRef: booking.bookingRef, bookingType: 'ROUND_TRIP', totalMinor } });
|
||||
}
|
||||
|
||||
this.eventEmitter.emit('booking.created', { booking });
|
||||
|
||||
return {
|
||||
@@ -687,6 +1004,7 @@ export class GuestBookingService {
|
||||
}
|
||||
|
||||
private async createGuestTransitBooking(dto: CreateGuestBookingDto, req?: any) {
|
||||
const authUserId: string | null = req?.user?.id ?? null;
|
||||
if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId) {
|
||||
throw new BadRequestException('leg2ScheduleId, leg2HoldId, transitStationId and leg2DestinationStationId are required for TRANSIT bookings');
|
||||
}
|
||||
@@ -792,6 +1110,7 @@ export class GuestBookingService {
|
||||
: totalMinor;
|
||||
|
||||
const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req);
|
||||
const contact = await this.resolveActorContact(req, passengersData[0]);
|
||||
|
||||
// Single booking — leg-1 seats at leg=1, leg-2 seats at leg=2
|
||||
const booking = await this.prisma.booking.create({
|
||||
@@ -814,8 +1133,8 @@ export class GuestBookingService {
|
||||
leg2DestinationStationId: dto.leg2DestinationStationId,
|
||||
leg2SeatClassId: leg2SeatClassId,
|
||||
userAgent: dto.deviceId,
|
||||
contactEmail: passengersData[0]?.email || null,
|
||||
contactPhone: passengersData[0]?.phone || null,
|
||||
contactEmail: contact.contactEmail,
|
||||
contactPhone: contact.contactPhone,
|
||||
seats: {
|
||||
create: [
|
||||
...passengersData.map(p => ({
|
||||
@@ -857,7 +1176,8 @@ export class GuestBookingService {
|
||||
},
|
||||
});
|
||||
|
||||
await this.createTravelerProfiles(guestPassengerId, passengersData);
|
||||
// Traveler profiles: guest bookings only (authenticated passengers already have one).
|
||||
if (!authUserId) await this.createTravelerProfiles(guestPassengerId, passengersData);
|
||||
|
||||
await Promise.all([
|
||||
this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId).filter((id): id is string => !!id)),
|
||||
@@ -883,6 +1203,7 @@ export class GuestBookingService {
|
||||
}
|
||||
|
||||
private async createGuestRoundTripTransitBooking(dto: CreateGuestBookingDto, req?: any) {
|
||||
const authUserId: string | null = req?.user?.id ?? null;
|
||||
if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId ||
|
||||
!dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId ||
|
||||
!dto.returnLeg2ScheduleId || !dto.returnLeg2HoldId || !dto.returnTransitStationId || !dto.returnLeg2DestinationStationId) {
|
||||
@@ -994,6 +1315,7 @@ export class GuestBookingService {
|
||||
: totalMinor;
|
||||
|
||||
const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req);
|
||||
const contact = await this.resolveActorContact(req, passengersData[0]);
|
||||
|
||||
const makeSeat = (p: any, seatId: string, leg: number, scheduleId: string, fare: number) => ({
|
||||
seat: { connect: { id: seatId } },
|
||||
@@ -1035,8 +1357,8 @@ export class GuestBookingService {
|
||||
returnLeg2SeatClassId: retL2ClassId,
|
||||
returnLegStatus: 'NEITHER_USED',
|
||||
userAgent: dto.deviceId,
|
||||
contactEmail: passengersData[0]?.email || null,
|
||||
contactPhone: passengersData[0]?.phone || null,
|
||||
contactEmail: contact.contactEmail,
|
||||
contactPhone: contact.contactPhone,
|
||||
seats: {
|
||||
create: [
|
||||
...passengersData.map(p => makeSeat(p, p.seatId, 1, dto.scheduleId, obL1Fare)),
|
||||
@@ -1052,7 +1374,8 @@ export class GuestBookingService {
|
||||
},
|
||||
});
|
||||
|
||||
await this.createTravelerProfiles(guestPassengerId, passengersData);
|
||||
// Traveler profiles: guest bookings only (authenticated passengers already have one).
|
||||
if (!authUserId) await this.createTravelerProfiles(guestPassengerId, passengersData);
|
||||
|
||||
await Promise.all([
|
||||
this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId).filter((id): id is string => !!id)),
|
||||
@@ -1081,11 +1404,40 @@ export class GuestBookingService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the booking contact. Authenticated callers get their contact from iam.users
|
||||
* (matching the old BookingsService.resolveIamContact); guests fall back to the first
|
||||
* passenger's inline phone/email exactly as before.
|
||||
*/
|
||||
private async resolveActorContact(
|
||||
req: any,
|
||||
firstPassenger: any,
|
||||
): Promise<{ contactEmail: string | null; contactPhone: string | null }> {
|
||||
const iamUserId = req?.user?.id;
|
||||
if (iamUserId) {
|
||||
const rows = await this.dataSource.query<{ email: string; phone_number: string | null }[]>(
|
||||
`SELECT email, phone_number FROM iam.users WHERE id = $1 LIMIT 1`,
|
||||
[iamUserId],
|
||||
);
|
||||
return { contactEmail: rows[0]?.email ?? null, contactPhone: rows[0]?.phone_number ?? null };
|
||||
}
|
||||
return { contactEmail: firstPassenger?.email || null, contactPhone: firstPassenger?.phone || null };
|
||||
}
|
||||
|
||||
private async resolveGuestPassenger(
|
||||
dto: Pick<CreateGuestBookingDto, 'createAccount' | 'password' | 'deviceId'>,
|
||||
firstPassenger: any,
|
||||
req?: any,
|
||||
): Promise<{ guestPassengerId: string; iamUserId: string | null; createdAccount: boolean }> {
|
||||
// Authenticated caller: resolve the existing passenger from the JWT subject.
|
||||
// Never trust a client-supplied passengerId — identity comes from the token only.
|
||||
const authUserId = req?.user?.id;
|
||||
if (authUserId) {
|
||||
const passenger = await this.prisma.passenger.findUnique({ where: { iamUserId: authUserId }, select: { id: true } });
|
||||
if (!passenger) throw new NotFoundException('Passenger profile not found for this account');
|
||||
return { guestPassengerId: passenger.id, iamUserId: authUserId, createdAccount: false };
|
||||
}
|
||||
|
||||
if (dto.createAccount && firstPassenger.email && dto.password) {
|
||||
const guestName = firstPassenger.passengerName ?? 'Guest';
|
||||
const result = await this.passengerAuthService.registerWithPassword(
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DashboardController } from './dashboard.controller';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
import { ReportsModule } from '../reports/reports.module';
|
||||
|
||||
@Module({ controllers: [DashboardController], providers: [DashboardService] })
|
||||
@Module({
|
||||
// ReportsModule owns the blocked-seat revenue loss rule; the dashboard's roll-up
|
||||
// reads it from there instead of keeping a second copy of the definition.
|
||||
imports: [ReportsModule],
|
||||
controllers: [DashboardController],
|
||||
providers: [DashboardService],
|
||||
})
|
||||
export class DashboardModule {}
|
||||
|
||||
@@ -1,17 +1,34 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { BlockedSeatRevenueLossStat } from '@edr/types';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { ReportsService } from '../reports/reports.service';
|
||||
|
||||
/** Window the dashboard's blocked-seat loss roll-up covers. Matches the report's default. */
|
||||
const BLOCKED_SEAT_LOSS_PERIOD_DAYS = 30;
|
||||
|
||||
/** Shown when nothing is blocked, or when the loss roll-up could not be computed. */
|
||||
const EMPTY_BLOCKED_SEAT_LOSS: BlockedSeatRevenueLossStat = {
|
||||
periodDays: BLOCKED_SEAT_LOSS_PERIOD_DAYS,
|
||||
lossByCurrency: [],
|
||||
schedulesAffected: 0,
|
||||
blockedSeatCount: 0,
|
||||
topReasonCategory: null,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class DashboardService {
|
||||
private readonly logger = new Logger(DashboardService.name);
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
@InjectDataSource() private dataSource: DataSource,
|
||||
private reports: ReportsService,
|
||||
) {}
|
||||
|
||||
async getBackofficeStats() {
|
||||
const [totalBookings, totalPackageBookings, totalTickets, totalPassengers, blockedSeatsCount, revenueRows, packageRevenueRows] =
|
||||
const [totalBookings, totalPackageBookings, totalTickets, totalPassengers, blockedSeatsCount, revenueRows, packageRevenueRows, blockedSeatRevenueLoss] =
|
||||
await Promise.all([
|
||||
this.prisma.booking.count({ where: { status: { in: ['CONFIRMED', 'BOARDED'] } } }),
|
||||
this.prisma.booking.count({ where: { status: { in: ['CONFIRMED', 'BOARDED'] }, packageId: { not: null } } }),
|
||||
@@ -38,6 +55,9 @@ export class DashboardService {
|
||||
AND id IN (SELECT "bookingId" FROM passenger."PaymentIntent" WHERE status = 'SUCCEEDED')
|
||||
GROUP BY COALESCE("displayCurrency"::text, "currency"::text)
|
||||
`,
|
||||
// Joined into this same call on purpose: the dashboard's request count stays
|
||||
// exactly where it was, and the card renders from the payload it already fetches.
|
||||
this.getBlockedSeatRevenueLossStat(),
|
||||
]);
|
||||
|
||||
const totalPackageTickets = await this.prisma.ticket.count({
|
||||
@@ -58,11 +78,43 @@ export class DashboardService {
|
||||
totalNormalTickets: totalTickets - totalPackageTickets,
|
||||
totalPassengers,
|
||||
blockedSeatsCount,
|
||||
blockedSeatRevenueLoss,
|
||||
revenueByCurrency: toMap(revenueRows),
|
||||
packageRevenueByCurrency: toMap(packageRevenueRows),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact roll-up of the Blocked Seat Revenue Loss report over the last 30 days.
|
||||
*
|
||||
* Reuses the report service rather than re-deriving the rule — there is exactly one
|
||||
* definition of what a blocked seat costs. A failure here degrades to zeroes instead of
|
||||
* taking the whole dashboard down with it.
|
||||
*/
|
||||
private async getBlockedSeatRevenueLossStat(): Promise<BlockedSeatRevenueLossStat> {
|
||||
try {
|
||||
// pageSize 1: only the summary is read, and paging does not change what it covers.
|
||||
const report = await this.reports.getBlockedSeatsRevenueLoss({ page: 1, pageSize: 1 });
|
||||
const { summary } = report;
|
||||
|
||||
return {
|
||||
periodDays: BLOCKED_SEAT_LOSS_PERIOD_DAYS,
|
||||
lossByCurrency: summary.lossByCurrency,
|
||||
schedulesAffected: summary.schedulesAffected,
|
||||
blockedSeatCount: summary.blockedSeatCount,
|
||||
// topReasonCategories is already sorted by estimated loss, descending.
|
||||
topReasonCategory: summary.topReasonCategories[0]?.reasonCategory ?? null,
|
||||
};
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Blocked-seat revenue loss roll-up unavailable — ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
return EMPTY_BLOCKED_SEAT_LOSS;
|
||||
}
|
||||
}
|
||||
|
||||
async getHomeDashboard(passengerId: string) {
|
||||
const now = new Date();
|
||||
const [passenger, upcomingBooking, wallet, promos, weatherAlerts, stationSignals, savedRoutes] = await Promise.all([
|
||||
|
||||
@@ -2,5 +2,5 @@ import { Module } from '@nestjs/common';
|
||||
import { LiveController } from './live.controller';
|
||||
import { LiveService } from './live.service';
|
||||
|
||||
@Module({ controllers: [LiveController], providers: [LiveService] })
|
||||
@Module({ controllers: [LiveController], providers: [LiveService], exports: [LiveService] })
|
||||
export class LiveModule {}
|
||||
|
||||
@@ -312,6 +312,18 @@ export class NotificationsService {
|
||||
const ref = booking?.bookingRef ?? payload.booking.bookingRef;
|
||||
const passengerId = booking?.passengerId ?? payload.booking.passengerId;
|
||||
|
||||
// A backoffice-issued reservation already sends its own purpose-built message —
|
||||
// GuestBookingService.issueBookingFromReservation texts /reserve/pay/<payToken> for a
|
||||
// PASSENGER-kind booking (the traveler has no portal session, so this generic template's
|
||||
// /booking/detail?ref= link doesn't work), and for STAFF kind the booking is finalized
|
||||
// immediately after this event fires, so onPaymentSucceeded's "ticket ready" message is
|
||||
// the correct one to send, not a redundant/contradictory "awaiting payment" notice.
|
||||
const source = (booking as any)?.source ?? payload.booking?.source;
|
||||
if (source === 'BACKOFFICE_RESERVATION') {
|
||||
this.logger.log(`Skipping generic booking.created notification for ${ref} — reservation flow sends its own`);
|
||||
return;
|
||||
}
|
||||
|
||||
const template = await this.prisma.notificationTemplate.findUnique({
|
||||
where: { code: 'booking.created' },
|
||||
});
|
||||
|
||||
@@ -254,11 +254,10 @@ export class PassengersController {
|
||||
}
|
||||
|
||||
try {
|
||||
const passenger = await this.prisma.passenger.findUnique({
|
||||
where: { iamUserId: req.user.id },
|
||||
});
|
||||
if (!passenger) return null;
|
||||
return this.service.getProfile(passenger.id);
|
||||
// Identity (name, DOB, gender, nationality, Fayda status) lives on the IAM user record,
|
||||
// not the Passenger row — return the full booking-form payload built from it so an
|
||||
// already-verified passenger's form can prefill and lock. See getMyProfile.
|
||||
return await this.service.getMyProfile(req.user.id);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ type IamUserRow = {
|
||||
name: { en: string; am: string } | null;
|
||||
phone_number: string | null;
|
||||
metadata: Record<string, any> | null;
|
||||
verified_by?: string | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@@ -237,6 +238,66 @@ export class PassengersService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Full passenger-form payload for the logged-in user, sourced from the IAM user record
|
||||
* (iam.users) — where the Fayda-verified identity actually lives — rather than the sparse
|
||||
* Passenger row. The booking passenger form (/booking/passengers) calls this via
|
||||
* GET /passengers/me to prefill (and lock) an already-verified passenger's details.
|
||||
*
|
||||
* Identity fields don't depend on a Passenger row existing; only `id` (used later to tag the
|
||||
* primary passenger on the booking) does, and it's null if no Passenger row is linked yet.
|
||||
*/
|
||||
async getMyProfile(iamUserId: string) {
|
||||
const [passenger, iamRows] = await Promise.all([
|
||||
this.prisma.passenger.findUnique({ where: { iamUserId } }),
|
||||
this.dataSource.query<IamUserRow[]>(
|
||||
`SELECT id, email, name, phone_number, metadata, verified_by FROM iam.users WHERE id = $1 LIMIT 1`,
|
||||
[iamUserId],
|
||||
),
|
||||
]);
|
||||
|
||||
const iam = iamRows[0] ?? null;
|
||||
if (!iam && !passenger) return null;
|
||||
|
||||
const meta = iam?.metadata ?? {};
|
||||
const faydaVerified =
|
||||
iam?.verified_by === 'fayda' ||
|
||||
meta.faydaVerified === true ||
|
||||
meta.faydaVerified === 'true';
|
||||
|
||||
// Fayda writes gender as { am, en }; older/manual records may store a plain string.
|
||||
const gender =
|
||||
meta.gender && typeof meta.gender === 'object'
|
||||
? (meta.gender.en ?? meta.gender.am ?? null)
|
||||
: (meta.gender ?? null);
|
||||
|
||||
const fullName = iam?.name?.en ?? iam?.name?.am ?? null;
|
||||
|
||||
// Stored as ISO by the Fayda upsert; tolerate a "/"-separated legacy value.
|
||||
const rawDob = meta.dateOfBirth ?? meta.birthdate ?? null;
|
||||
const dateOfBirth = rawDob ? String(rawDob).replace(/\//g, '-') : null;
|
||||
|
||||
// Nationality isn't always in metadata; a Fayda-verified holder is Ethiopian by definition.
|
||||
const nationality = meta.nationality ?? (faydaVerified ? 'ETHIOPIAN' : null);
|
||||
|
||||
return {
|
||||
id: passenger?.id ?? null,
|
||||
fullName,
|
||||
email: iam?.email ?? meta.email ?? null,
|
||||
phone: iam?.phone_number ?? meta.phoneNumber ?? null,
|
||||
gender,
|
||||
dateOfBirth,
|
||||
nationality,
|
||||
faydaVerified,
|
||||
faydaSub: meta.sub ?? null,
|
||||
passportNumber: meta.passportNumber ?? null,
|
||||
passportCountry: meta.passportCountry ?? null,
|
||||
passportIssueDate: meta.passportIssueDate ?? null,
|
||||
passportExpiryDate: meta.passportExpiryDate ?? null,
|
||||
passportIssuingAuthority: meta.passportIssuingAuthority ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async getStats(passengerId: string) {
|
||||
const [totalTrips, totalSpendResult, loyalty] = await Promise.all([
|
||||
this.prisma.booking.count({ where: { passengerId, status: 'BOARDED' as any } }),
|
||||
|
||||
@@ -4,11 +4,17 @@ import {
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
SetMetadata,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
||||
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto";
|
||||
import {
|
||||
PaymentEventDto,
|
||||
MarkPaidResponseDto,
|
||||
BillQueryRequestDto,
|
||||
BillQueryResponseDto,
|
||||
} from "./internal-payments.dto";
|
||||
import { PaymentsService } from "./payments.service";
|
||||
|
||||
/**
|
||||
@@ -18,6 +24,9 @@ import { PaymentsService } from "./payments.service";
|
||||
* consumer when RabbitMQ lands — the handler logic is transport-agnostic.
|
||||
*/
|
||||
@ApiTags("Internal Payments")
|
||||
// isPublic only skips the global IAM user-JWT guard — these routes stay protected by
|
||||
// ServiceAuthGuard's shared service token (the payment service is not an IAM user).
|
||||
@SetMetadata("isPublic", true)
|
||||
@UseGuards(ServiceAuthGuard)
|
||||
@Controller("internal/payments")
|
||||
export class InternalPaymentsController {
|
||||
@@ -32,4 +41,16 @@ export class InternalPaymentsController {
|
||||
async markPaid(@Body() event: PaymentEventDto): Promise<MarkPaidResponseDto> {
|
||||
return this.paymentsService.handlePaymentEvent(event);
|
||||
}
|
||||
|
||||
@Post("bill-query")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Live still-payable check + payer name for a CBE bill (called while CBE is on the line)",
|
||||
})
|
||||
async billQuery(
|
||||
@Body() request: BillQueryRequestDto,
|
||||
): Promise<BillQueryResponseDto> {
|
||||
return this.paymentsService.billQuery(request.referenceId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,3 +55,30 @@ export class MarkPaidResponseDto {
|
||||
@ApiPropertyOptional() alreadyFinalized?: boolean;
|
||||
@ApiPropertyOptional() reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* CBE bill-query hop (docs/cbe/CBE_IMPLEMENTATION_PLAN.md Phase 4): the payment service asks
|
||||
* "is this order still payable, by whom, for how much" while a CBE teller/app is on the line.
|
||||
*/
|
||||
export class BillQueryRequestDto {
|
||||
// Typed `string`, not the enum: the @nestjs/swagger CLI plugin resolves an enum-typed
|
||||
// property to a relative require() into packages/types, which does not exist inside the
|
||||
// Docker image (only /app is copied) and crashes at boot with MODULE_NOT_FOUND. The
|
||||
// decorators below still give us enum docs + runtime validation.
|
||||
@ApiProperty({ enum: PaymentReferenceType })
|
||||
@IsEnum(PaymentReferenceType)
|
||||
referenceType!: string;
|
||||
|
||||
@ApiProperty() @IsString() referenceId!: string;
|
||||
}
|
||||
|
||||
export class BillQueryResponseDto {
|
||||
@ApiProperty() stillPayable!: boolean;
|
||||
@ApiPropertyOptional() payerName?: string | null;
|
||||
@ApiPropertyOptional() currentAmountMinor?: number | null;
|
||||
@ApiPropertyOptional() currency?: string | null;
|
||||
/** When stillPayable=false: "ALREADY_PAID" | "CANCELLED" | "REFUNDED" | "EXPIRED" | "NOT_FOUND" | "NOT_PAYABLE". */
|
||||
@ApiPropertyOptional() reason?: string | null;
|
||||
/** What the payer is paying for — CBE renders it beside the amount (Payment_Reason). */
|
||||
@ApiPropertyOptional() paymentReason?: string | null;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,17 @@ export interface PaymentDiagnostic {
|
||||
provider: ProviderStatus | null;
|
||||
}
|
||||
|
||||
/** Settlement check from POST /payments/reconcile (verify-before-cancel). */
|
||||
export interface SettlementResult {
|
||||
/** At least one intent for the order is paid (incl. a late capture just registered). */
|
||||
paid: boolean;
|
||||
/** The paying intent when `paid`. */
|
||||
intent?: PaymentIntentSnapshot;
|
||||
/** Settlement could not be confirmed — a provider query errored, a payment is in flight, OR the
|
||||
* payment service was unreachable. The caller MUST NOT cancel the order. */
|
||||
unverifiable: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
@@ -85,6 +96,31 @@ export class PaymentClientService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /payments/reconcile — settlement check before cancelling an order. Live-queries every
|
||||
* intent at the provider and registers any late capture found. A transport failure (payment
|
||||
* service unreachable) is caught and returned as `unverifiable: true` — NEVER as "not paid" — so
|
||||
* the caller does not cancel a booking whose payment simply could not be verified.
|
||||
*/
|
||||
async reconcileByReference(
|
||||
referenceType: PaymentReferenceType,
|
||||
referenceId: string,
|
||||
): Promise<SettlementResult> {
|
||||
try {
|
||||
return await this.call<SettlementResult>("POST", "/payments/reconcile", {
|
||||
service: PaymentService.PASSENGER,
|
||||
referenceType,
|
||||
referenceId,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.warn(
|
||||
`reconcile ${referenceType}/${referenceId} failed: ${message}; treating as unverifiable (will not cancel)`,
|
||||
);
|
||||
return { paid: false, unverifiable: true };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -2,9 +2,7 @@ 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,
|
||||
@@ -16,12 +14,11 @@ export class PaymentSyncService {
|
||||
|
||||
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
|
||||
// Every 30 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
|
||||
@@ -30,7 +27,7 @@ export class PaymentSyncService {
|
||||
// Processes at most 50 bookings per cycle to avoid hammering the payment
|
||||
// service; the next tick picks up the remainder.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@Cron('*/1 * * * *')
|
||||
@Cron('*/30 * * * *')
|
||||
async syncPaymentStatuses() {
|
||||
const BATCH_SIZE = 50;
|
||||
|
||||
@@ -47,7 +44,6 @@ export class PaymentSyncService {
|
||||
if (bookings.length === 0) return;
|
||||
|
||||
let confirmed = 0;
|
||||
let failed = 0;
|
||||
let errored = 0;
|
||||
|
||||
// resolve() (not get()) because PaymentsService is scoped — same pattern
|
||||
@@ -59,37 +55,17 @@ export class PaymentSyncService {
|
||||
);
|
||||
|
||||
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++;
|
||||
// Reconcile ALL intents at the provider — including terminal (cancelled/expired) ones —
|
||||
// and confirm synchronously if any is paid. Unlike getIntentByReference this catches BOTH
|
||||
// a lost confirm event (payment-api already SUCCEEDED) AND a payment recorded only at the
|
||||
// provider (local intent terminal). Idempotent, so a re-run is safe.
|
||||
const { paid } = await paymentsService.reconcileAndConfirmIfPaid(booking.id);
|
||||
if (paid) {
|
||||
this.logger.log(`Payment sync confirmed: ${booking.bookingRef}`);
|
||||
confirmed++;
|
||||
}
|
||||
// REQUIRES_ACTION / PROCESSING → still pending, retry next cycle
|
||||
// not paid / unverifiable → still pending; retried next cycle (or cancelled at deadline)
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Payment sync error for ${booking.bookingRef}: ` +
|
||||
@@ -99,10 +75,9 @@ export class PaymentSyncService {
|
||||
}
|
||||
}
|
||||
|
||||
if (confirmed > 0 || failed > 0 || errored > 0) {
|
||||
if (confirmed > 0 || errored > 0) {
|
||||
this.logger.log(
|
||||
`Payment sync run: ${bookings.length} checked, ` +
|
||||
`${confirmed} confirmed, ${failed} failed/cancelled, ${errored} errors`,
|
||||
`Payment sync run: ${bookings.length} checked, ${confirmed} confirmed, ${errored} errors`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,8 +415,18 @@ export class PaymentsController {
|
||||
paySupplementaryCharge(
|
||||
@Param('token') token: string,
|
||||
@Body() dto: PaySupplementaryChargeDto,
|
||||
@Headers('origin') origin?: string,
|
||||
@Headers('referer') referer?: string,
|
||||
@Headers('x-frontend-base-url') frontendBaseUrl?: string,
|
||||
) {
|
||||
return this.supplementaryService.pay(token, dto.method, dto.platform);
|
||||
// Same domain-follows-the-user rule as /initiate — the self-pay page can be
|
||||
// opened on either portal domain.
|
||||
return this.supplementaryService.pay(
|
||||
token,
|
||||
dto.method,
|
||||
dto.platform,
|
||||
resolveAllowedOrigin(origin, referer, frontendBaseUrl),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('supplementary/:id/mark-paid')
|
||||
|
||||
@@ -25,6 +25,7 @@ export enum PaymentMethodTypeEnum {
|
||||
CAC_BANK = "CAC_BANK", // Djibouti (OTP debit)
|
||||
CARD = "CARD", // International
|
||||
WALLET = "WALLET", // Internal
|
||||
CBE_BILL = "CBE_BILL", // Ethiopia (pay at any CBE channel by bill number)
|
||||
}
|
||||
|
||||
export type PaymentPlatformDto = "web" | "mobile";
|
||||
@@ -115,8 +116,10 @@ export class SupportedPaymentMethodDto {
|
||||
}
|
||||
|
||||
export class ClientActionDto {
|
||||
@ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP"] })
|
||||
type: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP";
|
||||
@ApiProperty({
|
||||
enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP", "SHOW_BILL_REFERENCE"],
|
||||
})
|
||||
type: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP" | "SHOW_BILL_REFERENCE";
|
||||
@ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" })
|
||||
url?: string;
|
||||
@ApiPropertyOptional({
|
||||
@@ -135,6 +138,14 @@ export class ClientActionDto {
|
||||
providerOrderId?: string;
|
||||
@ApiPropertyOptional({ description: "Set when type=COLLECT_OTP" })
|
||||
message?: string;
|
||||
@ApiPropertyOptional({
|
||||
description: "Set when type=SHOW_BILL_REFERENCE (CBE bill payment)",
|
||||
})
|
||||
billReference?: string;
|
||||
@ApiPropertyOptional({ description: "Set when type=SHOW_BILL_REFERENCE" })
|
||||
instructions?: string;
|
||||
@ApiPropertyOptional({ description: "Set when type=SHOW_BILL_REFERENCE" })
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export class InitiateResponseDto {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { PaymentsService } from "./payments.service";
|
||||
import { PaymentClientService } from "./payment-client.service";
|
||||
import { CurrencyService } from "../currency/currency.service";
|
||||
import { PrismaService } from "../../common/prisma.service";
|
||||
import { AuditService } from "../../common/audit.service";
|
||||
import { SeatsService } from "../seats/seats.service";
|
||||
import { TicketsService } from "../tickets/tickets.service";
|
||||
import { EventEmitter2 } from "@nestjs/event-emitter";
|
||||
@@ -27,6 +28,7 @@ describe("PaymentsService", () => {
|
||||
booking: {
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
paymentIntent: {
|
||||
findUnique: jest.fn(),
|
||||
@@ -81,6 +83,8 @@ describe("PaymentsService", () => {
|
||||
convertEtbMinorToChargeMajor: jest.fn((minor: number) =>
|
||||
Promise.resolve(minor),
|
||||
),
|
||||
displayMinorToChargeMajor: jest.fn((minor: number) => minor / 100),
|
||||
convertMinorToChargeMajor: jest.fn(async (minor: number) => minor / 100),
|
||||
getRateOrThrow: jest.fn(),
|
||||
};
|
||||
|
||||
@@ -109,6 +113,7 @@ describe("PaymentsService", () => {
|
||||
{ provide: EventEmitter2, useValue: mockEventEmitter },
|
||||
{ provide: PaymentClientService, useValue: mockPaymentClient },
|
||||
{ provide: CurrencyService, useValue: mockCurrencyService },
|
||||
{ provide: AuditService, useValue: { log: jest.fn() } },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
||||
@@ -24,7 +24,12 @@ import {
|
||||
PaymentRegionEnum,
|
||||
ForceConfirmDto,
|
||||
} from "./payments.dto";
|
||||
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto";
|
||||
import {
|
||||
PaymentEventDto,
|
||||
MarkPaidResponseDto,
|
||||
BillQueryResponseDto,
|
||||
} from "./internal-payments.dto";
|
||||
import { computePaymentDeadline } from "../../common/utils/payment-deadline.utils";
|
||||
import {
|
||||
PaymentClientService,
|
||||
PaymentDiagnostic,
|
||||
@@ -48,12 +53,14 @@ const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [
|
||||
];
|
||||
|
||||
// Methods whose return/failure URLs are browser-facing pages on the passenger
|
||||
// portal, so they should follow whichever domain the user came in on. DMONEY is
|
||||
// deliberately excluded — its return URL is a server-to-server webhook host, not
|
||||
// a page the browser lands on.
|
||||
// portal, so they should follow whichever domain the user came in on. For DMONEY
|
||||
// this is the preOrder `redirect_url` (the page the browser lands on after
|
||||
// checkout) — NOT `notify_url`, which is the server-to-server webhook and is
|
||||
// configured provider-side, never rebased.
|
||||
const DOMAIN_AWARE_METHODS = new Set<PaymentMethodType>([
|
||||
PaymentMethodType.TELEBIRR,
|
||||
PaymentMethodType.WAAFI,
|
||||
PaymentMethodType.DMONEY,
|
||||
]);
|
||||
|
||||
@Injectable()
|
||||
@@ -220,6 +227,17 @@ export class PaymentsService {
|
||||
);
|
||||
}
|
||||
|
||||
// CBE settles ETB only (docs/cbe/CBE_IMPLEMENTATION_PLAN.md D8). payerAccount is NOT
|
||||
// required — CBE identifies the payer at its own channel.
|
||||
if (
|
||||
method === PaymentMethodType.CBE_BILL &&
|
||||
(booking.currency ?? "ETB").toUpperCase() !== "ETB"
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"CBE bill payment is only available for bookings charged in ETB",
|
||||
);
|
||||
}
|
||||
|
||||
const correctTotalMinor = await this.resolveBookingTotal(booking as any);
|
||||
|
||||
// Patch the DB if the stored total is wrong (single-leg for a round-trip package booking)
|
||||
@@ -242,61 +260,9 @@ export class PaymentsService {
|
||||
return this.initiateWalletPayment(booking);
|
||||
}
|
||||
|
||||
// Double-charge guard for payment-method switches. Before opening a fresh charge over
|
||||
// this booking, reconcile any still-open intent against the authoritative provider
|
||||
// status — the booking-status check above only blocks once the booking is CONFIRMED,
|
||||
// which leaves a window where the first attempt actually paid but the mark-paid
|
||||
// webhook/poll hasn't landed yet.
|
||||
const existingIntent = await this.prisma.paymentIntent.findUnique({
|
||||
where: { bookingId: booking.id },
|
||||
});
|
||||
if (existingIntent && NON_TERMINAL_STATUSES.includes(existingIntent.status)) {
|
||||
let snapshot: PaymentIntentSnapshot | null = null;
|
||||
try {
|
||||
snapshot = await this.paymentClient.getIntentByReference(
|
||||
PaymentReferenceType.BOOKING,
|
||||
booking.id,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.warn(
|
||||
`payment reconcile before initiate failed for booking ${booking.id}: ${message}; treating existing intent as still open`,
|
||||
);
|
||||
}
|
||||
|
||||
// The previous attempt actually paid (provider SUCCEEDED, event just late):
|
||||
// converge the booking now and return it — never charge a second time.
|
||||
if (snapshot?.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||
let intent = await this.syncIntentProjection(booking.id, snapshot);
|
||||
await this.finalizePaymentSuccess({
|
||||
intentId: intent.id,
|
||||
providerTxnId: snapshot.providerTxnId,
|
||||
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
|
||||
});
|
||||
intent = await this.prisma.paymentIntent.findUniqueOrThrow({
|
||||
where: { id: intent.id },
|
||||
});
|
||||
return this.formatIntentResponse(intent);
|
||||
}
|
||||
|
||||
// Still pending at the provider (REQUIRES_ACTION/PROCESSING) — or the payment
|
||||
// service was unreachable and the local status is non-terminal. Block the switch:
|
||||
// return the existing intent so the payer completes or waits out the open attempt
|
||||
// rather than opening a second concurrent charge.
|
||||
if (
|
||||
!snapshot ||
|
||||
snapshot.status === ProviderPaymentStatus.REQUIRES_ACTION ||
|
||||
snapshot.status === ProviderPaymentStatus.PROCESSING
|
||||
) {
|
||||
const intent = snapshot
|
||||
? await this.syncIntentProjection(booking.id, snapshot)
|
||||
: existingIntent;
|
||||
return this.formatIntentResponse(intent);
|
||||
}
|
||||
// Otherwise the provider reports FAILED/CANCELLED — fall through and initiate
|
||||
// the newly selected method below.
|
||||
}
|
||||
|
||||
// Free method changes: no reuse/blocking. Every initiate opens a fresh provider session; the
|
||||
// single passenger projection row (upserted by bookingId below) tracks the latest session.
|
||||
// Confirm-once is enforced when a payment succeeds (finalizePaymentSuccess), not here.
|
||||
const { returnUrl, failureUrl } = this.resolveReturnUrls(
|
||||
method,
|
||||
requestOrigin,
|
||||
@@ -309,15 +275,22 @@ export class PaymentsService {
|
||||
const paymentMethod = await this.prisma.paymentMethod.findUnique({
|
||||
where: { type: method },
|
||||
});
|
||||
const chargeCurrency = (
|
||||
paymentMethod?.currency ?? booking.currency
|
||||
).toUpperCase();
|
||||
const chargeCurrency =
|
||||
method === PaymentMethodType.CBE_BILL
|
||||
? "ETB"
|
||||
: (paymentMethod?.currency ?? booking.currency).toUpperCase();
|
||||
|
||||
const bookingDisplayCurrency = ((booking as any).displayCurrency ?? 'ETB').toUpperCase();
|
||||
const bookingDisplayTotalMinor = (booking as any).displayTotalMinor as number | null;
|
||||
|
||||
let chargeAmount: number;
|
||||
if (
|
||||
if (method === PaymentMethodType.CBE_BILL) {
|
||||
// Force ETB, no conversion (D8) — eligibility was already checked above.
|
||||
chargeAmount = this.currencyService.displayMinorToChargeMajor(
|
||||
booking.totalMinor,
|
||||
"ETB",
|
||||
);
|
||||
} else if (
|
||||
chargeCurrency === bookingDisplayCurrency &&
|
||||
chargeCurrency !== 'ETB' &&
|
||||
bookingDisplayTotalMinor != null
|
||||
@@ -335,6 +308,20 @@ export class PaymentsService {
|
||||
);
|
||||
}
|
||||
|
||||
// CBE_BILL: the bill lives in CBE's system for as long as the booking is payable, so the
|
||||
// intent expiry is the booking's own payment deadline — never a provider-session TTL
|
||||
// (plan §6.4); payerName feeds the mandatory Full_Name of CBE's query response.
|
||||
let payerName: string | undefined;
|
||||
let expiresAt: string | undefined;
|
||||
if (method === PaymentMethodType.CBE_BILL) {
|
||||
payerName =
|
||||
booking.seats.find((s) => s.leg === 1)?.passengerName ??
|
||||
booking.seats[0]?.passengerName;
|
||||
expiresAt = (
|
||||
await this.computeBookingPaymentDeadline(booking.id)
|
||||
)?.toISOString();
|
||||
}
|
||||
|
||||
const snapshot = await this.paymentClient.initiate({
|
||||
service: PaymentServiceEnum.PASSENGER,
|
||||
referenceType: PaymentReferenceType.BOOKING,
|
||||
@@ -347,6 +334,8 @@ export class PaymentsService {
|
||||
payerAccount: dto.payerAccount,
|
||||
returnUrl,
|
||||
failureUrl,
|
||||
payerName,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
let intent = await this.syncIntentProjection(booking.id, snapshot);
|
||||
@@ -400,6 +389,111 @@ export class PaymentsService {
|
||||
return this.formatIntentStatus(intent);
|
||||
}
|
||||
|
||||
/**
|
||||
* CBE bill-query (docs/cbe/CBE_IMPLEMENTATION_PLAN.md Phase 4): live still-payable check +
|
||||
* payer identity for a booking. Called by the payment service while a CBE teller/app is
|
||||
* waiting — read-only and fast. This is the double-payment guard: once the booking is
|
||||
* confirmed by ANY method, stillPayable=false and CBE refuses the bill (§6.3).
|
||||
*/
|
||||
async billQuery(bookingId: string): Promise<BillQueryResponseDto> {
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
include: { seats: true, passenger: { include: { user: true } } },
|
||||
});
|
||||
// Distinct from CANCELLED: the payment service issued a bill reference for a booking that
|
||||
// no longer exists at all, which is a data problem, not a customer-facing cancellation.
|
||||
if (!booking) return { stillPayable: false, reason: "NOT_FOUND" };
|
||||
|
||||
const base = {
|
||||
// Full_Name is mandatory in CBE's envelope: lead passenger first, then account holder.
|
||||
payerName:
|
||||
booking.seats.find((s) => s.leg === 1)?.passengerName ??
|
||||
booking.seats[0]?.passengerName ??
|
||||
booking.passenger?.user?.fullName ??
|
||||
null,
|
||||
currentAmountMinor: this.currencyService.displayMinorToChargeMajor(
|
||||
booking.totalMinor,
|
||||
"ETB",
|
||||
),
|
||||
currency: "ETB",
|
||||
// CBE shows this beside the amount on the confirmation screen. bookingRef is the same
|
||||
// code on the customer's ticket, so they can match the two before confirming.
|
||||
paymentReason: `Train ticket booking ${booking.bookingRef}`,
|
||||
};
|
||||
|
||||
// Paid first: a booking that was paid and then boarded/refunded must never be reported as
|
||||
// merely "not payable" — the payer needs to hear that their money already went through.
|
||||
if (booking.status === "CONFIRMED" || booking.paidAt) {
|
||||
return { ...base, stillPayable: false, reason: "ALREADY_PAID" };
|
||||
}
|
||||
if (booking.status === "REFUNDED") {
|
||||
return { ...base, stillPayable: false, reason: "REFUNDED" };
|
||||
}
|
||||
if (booking.status === "CANCELLED") {
|
||||
return { ...base, stillPayable: false, reason: "CANCELLED" };
|
||||
}
|
||||
// DRAFT / BOARDED / NO_SHOW without a payment: no honest specific wording exists.
|
||||
if (booking.status !== "PENDING_PAYMENT") {
|
||||
return { ...base, stillPayable: false, reason: "NOT_PAYABLE" };
|
||||
}
|
||||
const deadline = await this.computeBookingPaymentDeadline(booking.id);
|
||||
if (deadline && deadline.getTime() < Date.now()) {
|
||||
return { ...base, stillPayable: false, reason: "EXPIRED" };
|
||||
}
|
||||
return { ...base, stillPayable: true, reason: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* The booking's payment deadline, resolved exactly like the auto-cancel job: the booking's
|
||||
* origin-segment time and that stop's own check-in window, falling back to the route default.
|
||||
*/
|
||||
private async computeBookingPaymentDeadline(
|
||||
bookingId: string,
|
||||
): Promise<Date | null> {
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
select: {
|
||||
createdAt: true,
|
||||
originStationId: true,
|
||||
schedule: {
|
||||
select: {
|
||||
departureAt: true,
|
||||
stopTimes: {
|
||||
select: {
|
||||
stationId: true,
|
||||
plannedArrivalAt: true,
|
||||
plannedDepartureAt: true,
|
||||
},
|
||||
},
|
||||
route: {
|
||||
select: {
|
||||
checkinMinutesBefore: true,
|
||||
stops: {
|
||||
select: { stationId: true, checkinMinutesBefore: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!booking?.schedule) return null;
|
||||
const originStop = booking.schedule.stopTimes?.find(
|
||||
(s) => s.stationId === booking.originStationId,
|
||||
);
|
||||
const dep = (originStop?.plannedArrivalAt ??
|
||||
originStop?.plannedDepartureAt ??
|
||||
booking.schedule.departureAt) as Date;
|
||||
const originRouteStop = booking.schedule.route?.stops?.find(
|
||||
(s) => s.stationId === booking.originStationId,
|
||||
);
|
||||
const checkinMinutes =
|
||||
originRouteStop?.checkinMinutesBefore ??
|
||||
booking.schedule.route?.checkinMinutesBefore ??
|
||||
undefined;
|
||||
return computePaymentDeadline(booking.createdAt, dep, checkinMinutes);
|
||||
}
|
||||
|
||||
private resolveReturnUrls(
|
||||
method: PaymentMethodType,
|
||||
requestOrigin?: string | null,
|
||||
@@ -883,10 +977,76 @@ export class PaymentsService {
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the payment service (over HTTP — bypassing the possibly-down RabbitMQ) whether a booking is
|
||||
* actually paid, and CONFIRM it synchronously if so. Used by (a) every cancellation site as a
|
||||
* verify-before-cancel guard, and (b) the PaymentSyncService poller as lost-event recovery. Unlike
|
||||
* getIntentByReference, POST /payments/reconcile loops ALL intents and live-queries even
|
||||
* terminal (cancelled/expired) ones — so it catches a payment recorded only at the provider.
|
||||
*
|
||||
* - paid → the confirming payment is synced + finalized HERE (synchronously); the booking is
|
||||
* now CONFIRMED, so a cancellation caller must NOT cancel.
|
||||
* - not paid → verified unpaid; a cancellation caller may proceed.
|
||||
* - unverifiable (provider query errored, in-flight, or payment service unreachable) → a
|
||||
* cancellation caller must NOT cancel this cycle; defer and retry later.
|
||||
*/
|
||||
async reconcileAndConfirmIfPaid(
|
||||
bookingId: string,
|
||||
): Promise<{ paid: boolean; verified: boolean }> {
|
||||
const settlement = await this.paymentClient.reconcileByReference(
|
||||
PaymentReferenceType.BOOKING,
|
||||
bookingId,
|
||||
);
|
||||
|
||||
if (settlement.unverifiable) {
|
||||
this.logger.warn(
|
||||
`reconcile-before-cancel: settlement UNVERIFIABLE for booking ${bookingId} — not cancelling`,
|
||||
);
|
||||
return { paid: false, verified: false };
|
||||
}
|
||||
|
||||
if (settlement.paid) {
|
||||
if (settlement.intent) {
|
||||
// Paid, but the confirm event may have been lost. Confirm synchronously (idempotent).
|
||||
const intent = await this.syncIntentProjection(
|
||||
bookingId,
|
||||
settlement.intent,
|
||||
);
|
||||
await this.finalizePaymentSuccess({
|
||||
intentId: intent.id,
|
||||
providerTxnId: settlement.intent.providerTxnId,
|
||||
paidAt: settlement.intent.paidAt
|
||||
? new Date(settlement.intent.paidAt)
|
||||
: undefined,
|
||||
}).catch((err) => {
|
||||
this.logger.error(
|
||||
`reconcile-before-cancel: finalize failed for booking ${bookingId}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
return { alreadyFinalized: false };
|
||||
});
|
||||
this.logger.log(
|
||||
`reconcile-before-cancel: booking ${bookingId} is PAID (${settlement.intent.merchantOrderId}) — confirmed, NOT cancelling`,
|
||||
);
|
||||
} else {
|
||||
this.logger.error(
|
||||
`reconcile-before-cancel: booking ${bookingId} reported PAID but no intent snapshot — NOT cancelling`,
|
||||
);
|
||||
}
|
||||
return { paid: true, verified: true };
|
||||
}
|
||||
|
||||
// Verified not paid — safe to cancel.
|
||||
return { paid: false, verified: true };
|
||||
}
|
||||
|
||||
async finalizePaymentSuccess(input: {
|
||||
intentId: string;
|
||||
providerTxnId?: string;
|
||||
paidAt?: Date;
|
||||
/** Staff force-confirm: confirm the booking even if it is not PENDING_PAYMENT. */
|
||||
force?: boolean;
|
||||
}): Promise<{ alreadyFinalized: boolean }> {
|
||||
const intent = await this.prisma.paymentIntent.findUnique({
|
||||
where: { id: input.intentId },
|
||||
@@ -896,32 +1056,35 @@ export class PaymentsService {
|
||||
// Idempotency guard — but still repair missing tickets. They can be absent
|
||||
// when the first finalization threw from generate() after the transaction
|
||||
// committed: the caller got a 500, retried, and now hits this early-return.
|
||||
const ticketCount = await this.prisma.ticket.count({ where: { bookingId: intent.bookingId } });
|
||||
if (ticketCount === 0) {
|
||||
try {
|
||||
await this.ticketsService.generate(intent.bookingId);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
this.logger.warn(
|
||||
`Ticket generation failed on idempotency retry for booking ${intent.bookingId}: ${msg}. Attempting smart seat reassignment.`,
|
||||
);
|
||||
// Only repair for a CONFIRMED booking: a SUCCEEDED intent on a CANCELLED booking is a
|
||||
// recorded orphan payment (booking cancelled, seats possibly reassigned) and must NEVER
|
||||
// generate a ticket.
|
||||
const idempotencyBooking = await this.prisma.booking.findUnique({
|
||||
where: { id: intent.bookingId },
|
||||
select: { status: true },
|
||||
});
|
||||
if (idempotencyBooking?.status === "CONFIRMED") {
|
||||
const ticketCount = await this.prisma.ticket.count({ where: { bookingId: intent.bookingId } });
|
||||
if (ticketCount === 0) {
|
||||
try {
|
||||
await this.ticketsService.smartAssignAndGenerate(intent.bookingId);
|
||||
} catch (retryErr) {
|
||||
this.logger.error(
|
||||
`Error generating ticket on idempotency retry for booking ${intent.bookingId}: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`,
|
||||
await this.ticketsService.generate(intent.bookingId);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
this.logger.warn(
|
||||
`Ticket generation failed on idempotency retry for booking ${intent.bookingId}: ${msg}. Attempting smart seat reassignment.`,
|
||||
);
|
||||
try {
|
||||
await this.ticketsService.smartAssignAndGenerate(intent.bookingId);
|
||||
} catch (retryErr) {
|
||||
this.logger.error(
|
||||
`Error generating ticket on idempotency retry for booking ${intent.bookingId}: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { alreadyFinalized: true };
|
||||
}
|
||||
if (intent.status === PaymentIntentStatus.CANCELLED) {
|
||||
throw new BadRequestException(
|
||||
"PaymentIntent is cancelled; cannot finalize",
|
||||
);
|
||||
}
|
||||
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { id: intent.bookingId },
|
||||
include: { seats: true },
|
||||
@@ -929,7 +1092,24 @@ export class PaymentsService {
|
||||
if (!booking) throw new NotFoundException("Booking not found");
|
||||
|
||||
const paidAt = this.sanitizePaidAt(input.paidAt);
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
|
||||
// Atomic confirm-once. A booking may have many intents (free method changes); only the FIRST
|
||||
// success on a still-PENDING_PAYMENT booking confirms it + generates the ticket. The conditional
|
||||
// update is the race guard: two simultaneous payments both reach here, but exactly one flips
|
||||
// PENDING_PAYMENT→CONFIRMED (count 1) — the other gets count 0 and is register-only (the payment
|
||||
// is already stored on the payment-api ledger; we don't confirm, don't ticket, don't mark this
|
||||
// row SUCCEEDED). `force` (staff) confirms regardless of the current booking status.
|
||||
const confirmed = await this.prisma.$transaction(async (tx) => {
|
||||
const res = input.force
|
||||
? await tx.booking.updateMany({
|
||||
where: { id: booking.id, status: { not: "CONFIRMED" } },
|
||||
data: { status: "CONFIRMED" },
|
||||
})
|
||||
: await tx.booking.updateMany({
|
||||
where: { id: booking.id, status: "PENDING_PAYMENT" },
|
||||
data: { status: "CONFIRMED" },
|
||||
});
|
||||
if (res.count === 0) return 0;
|
||||
await tx.paymentIntent.update({
|
||||
where: { id: intent.id },
|
||||
data: {
|
||||
@@ -937,14 +1117,23 @@ export class PaymentsService {
|
||||
providerTxnId:
|
||||
input.providerTxnId ?? intent.providerTxnId ?? undefined,
|
||||
paidAt,
|
||||
failureCode: null,
|
||||
failureMessage: null,
|
||||
},
|
||||
});
|
||||
await tx.booking.update({
|
||||
where: { id: booking.id },
|
||||
data: { status: "CONFIRMED" },
|
||||
});
|
||||
return res.count;
|
||||
});
|
||||
|
||||
if (confirmed === 0) {
|
||||
// Booking already confirmed by another payment (or not payable and not forced). This capture
|
||||
// is registered on the payment-api ledger; do not confirm, ticket, or touch this row.
|
||||
this.logger.error(
|
||||
`Capture on non-payable booking ${booking.id} (status=${booking.status}), intent ${intent.id} ` +
|
||||
`txn=${input.providerTxnId ?? intent.providerTxnId ?? "n/a"} — registered in payment-api; not confirming`,
|
||||
);
|
||||
return { alreadyFinalized: true };
|
||||
}
|
||||
|
||||
try {
|
||||
await this.seatsService.confirmSeats(booking.seats.map((s) => s.seatId));
|
||||
} catch (err) {
|
||||
@@ -1072,38 +1261,93 @@ export class PaymentsService {
|
||||
return { processed: false, reason: "booking-not-found" };
|
||||
}
|
||||
|
||||
// C-4 guard: a settlement must cover what the passenger was quoted. Compare the provider-settled
|
||||
// amount against the booking's display-currency total (the amount the customer agreed to pay);
|
||||
// a short payment must NOT confirm the booking. Amount-only — the display↔charge currency
|
||||
// divergence is tracked separately under the USD/DJF findings. The 1% tolerance absorbs rounding.
|
||||
const expectedMinor = booking.displayTotalMinor ?? booking.totalMinor;
|
||||
const shortPayTolerance = Math.max(1, Math.round(expectedMinor * 0.01));
|
||||
if (event.amountMinor < expectedMinor - shortPayTolerance) {
|
||||
// C-4 guard: a settlement must cover what the passenger was quoted. `event.amountMinor`
|
||||
// carries the charge amount in MAJOR units (the intent's "real/major price" — what
|
||||
// initiate sent, e.g. 1500.00 ETB), while booking totals are stored in minor units, so
|
||||
// normalize before comparing; a short payment must NOT confirm the booking. Amount-only —
|
||||
// the display↔charge currency divergence is tracked separately under the USD/DJF
|
||||
// findings. The 1% tolerance absorbs rounding.
|
||||
const expectedMajor = (booking.displayTotalMinor ?? booking.totalMinor) / 100;
|
||||
const shortPayTolerance = Math.max(0.01, expectedMajor * 0.01);
|
||||
if (event.amountMinor < expectedMajor - shortPayTolerance) {
|
||||
this.logger.error(
|
||||
`mark-paid: short payment for booking ${booking.id} — settled ${event.amountMinor} ${event.currency} < expected ${expectedMinor} ${booking.displayCurrency}; not confirming`,
|
||||
`mark-paid: short payment for booking ${booking.id} — settled ${event.amountMinor} ${event.currency} < expected ${expectedMajor} ${booking.displayCurrency}; not confirming`,
|
||||
);
|
||||
return { processed: false, reason: "amount-mismatch" };
|
||||
}
|
||||
|
||||
// Local intent row is a projection during the strangler migration: reuse it when the
|
||||
// legacy initiate path created one, otherwise materialize it from the event.
|
||||
let intent = await this.prisma.paymentIntent.findUnique({
|
||||
where: { bookingId: event.referenceId },
|
||||
});
|
||||
if (!intent) {
|
||||
intent = await this.prisma.paymentIntent.create({
|
||||
data: {
|
||||
bookingId: event.referenceId,
|
||||
// Payment on an already-CANCELLED booking: record the success on the passenger projection too
|
||||
// (it is already registered on the payment-api ledger), but NEVER confirm the booking and NEVER
|
||||
// generate a ticket — the seats may already be held by another passenger. Refund is manual.
|
||||
if (booking.status === "CANCELLED") {
|
||||
await this.prisma.paymentIntent.upsert({
|
||||
where: { bookingId: event.referenceId },
|
||||
update: {
|
||||
status: PaymentIntentStatus.SUCCEEDED,
|
||||
method: event.provider as unknown as PaymentMethodType,
|
||||
amountMinor: event.amountMinor,
|
||||
currency: event.currency,
|
||||
method: event.provider as unknown as PaymentMethodType,
|
||||
status: PaymentIntentStatus.PROCESSING,
|
||||
merchantOrderId: event.merchantOrderId,
|
||||
providerTxnId: event.providerTxnId,
|
||||
paidAt: this.sanitizePaidAt(
|
||||
event.paidAt ? new Date(event.paidAt) : undefined,
|
||||
),
|
||||
},
|
||||
create: {
|
||||
bookingId: event.referenceId,
|
||||
status: PaymentIntentStatus.SUCCEEDED,
|
||||
method: event.provider as unknown as PaymentMethodType,
|
||||
amountMinor: event.amountMinor,
|
||||
currency: event.currency,
|
||||
merchantOrderId: event.merchantOrderId,
|
||||
providerTxnId: event.providerTxnId,
|
||||
paidAt: this.sanitizePaidAt(
|
||||
event.paidAt ? new Date(event.paidAt) : undefined,
|
||||
),
|
||||
},
|
||||
});
|
||||
this.logger.error(
|
||||
`Payment on CANCELLED booking ${booking.id} (merchantOrder=${event.merchantOrderId}, ` +
|
||||
`txn=${event.providerTxnId ?? "n/a"}) — recorded on passenger + payment-api; NOT confirming ` +
|
||||
`(seats may be reassigned). Refund required.`,
|
||||
);
|
||||
return { processed: true, alreadyFinalized: true };
|
||||
}
|
||||
|
||||
// Sequential duplicate on an already-CONFIRMED booking: this success is a second payment,
|
||||
// already registered on the payment-api ledger. Do NOT touch the passenger row — it must keep
|
||||
// the confirming payment. (The concurrent-race case is caught atomically in finalizePaymentSuccess.)
|
||||
if (booking.status !== "PENDING_PAYMENT") {
|
||||
this.logger.error(
|
||||
`Duplicate capture on ${booking.status} booking ${booking.id} (merchantOrder=${event.merchantOrderId}, ` +
|
||||
`txn=${event.providerTxnId ?? "n/a"}) — registered in payment-api; not confirming`,
|
||||
);
|
||||
return { processed: true, alreadyFinalized: true };
|
||||
}
|
||||
|
||||
// Booking is payable — point the single passenger projection row at THIS paying session (so the
|
||||
// row reflects the payment that confirms the booking, even if the payer switched methods), then
|
||||
// finalize (which does the atomic confirm-once).
|
||||
const intent = await this.prisma.paymentIntent.upsert({
|
||||
where: { bookingId: event.referenceId },
|
||||
update: {
|
||||
method: event.provider as unknown as PaymentMethodType,
|
||||
amountMinor: event.amountMinor,
|
||||
currency: event.currency,
|
||||
merchantOrderId: event.merchantOrderId,
|
||||
providerTxnId: event.providerTxnId,
|
||||
},
|
||||
create: {
|
||||
bookingId: event.referenceId,
|
||||
amountMinor: event.amountMinor,
|
||||
currency: event.currency,
|
||||
method: event.provider as unknown as PaymentMethodType,
|
||||
status: PaymentIntentStatus.PROCESSING,
|
||||
merchantOrderId: event.merchantOrderId,
|
||||
providerTxnId: event.providerTxnId,
|
||||
},
|
||||
});
|
||||
|
||||
const { alreadyFinalized } = await this.finalizePaymentSuccess({
|
||||
intentId: intent.id,
|
||||
providerTxnId: event.providerTxnId,
|
||||
@@ -1159,6 +1403,7 @@ export class PaymentsService {
|
||||
return this.finalizePaymentSuccess({
|
||||
intentId: intent.id,
|
||||
providerTxnId: dto.paymentReference ?? intent.providerTxnId ?? undefined,
|
||||
force: true,
|
||||
}).then(async (result) => {
|
||||
await this.auditService.log({ action: 'UPDATE', entityType: 'Payment', entityId: intent.id, newData: { status: 'FORCE_CONFIRMED', bookingId, paymentMethod: dto.paymentMethod, paymentReference: dto.paymentReference } });
|
||||
return result;
|
||||
|
||||
@@ -116,11 +116,21 @@ export class SupplementaryChargesService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
async pay(token: string, method: string, platform?: 'web' | 'mobile') {
|
||||
async pay(
|
||||
token: string,
|
||||
method: string,
|
||||
platform?: 'web' | 'mobile',
|
||||
requestOrigin?: string | null,
|
||||
) {
|
||||
const charge = await this.getByToken(token); // validates status/expiry
|
||||
|
||||
const paymentMethod = method as ProviderMethod;
|
||||
const portalUrl = process.env.PORTAL_URL ?? 'http://localhost:5174';
|
||||
// Self-pay links are opened on whichever portal domain the recipient used
|
||||
// (bookingedr.et vs passenger.edrsc.com), so the return pages must live on
|
||||
// that same domain. `requestOrigin` is already allowlist-validated by the
|
||||
// controller; PORTAL_URL is the fallback for non-browser callers.
|
||||
const portalUrl =
|
||||
requestOrigin ?? process.env.PORTAL_URL ?? 'http://localhost:5174';
|
||||
const returnUrl = `${portalUrl}/pay-balance/${token}/success`;
|
||||
const failureUrl = `${portalUrl}/pay-balance/${token}/failed`;
|
||||
|
||||
|
||||
@@ -0,0 +1,641 @@
|
||||
import {
|
||||
assembleReport,
|
||||
AssembleOptions,
|
||||
countSellableSeats,
|
||||
LossBlock,
|
||||
LossCalculatorInput,
|
||||
LossCoach,
|
||||
LossFare,
|
||||
LossSchedule,
|
||||
LossSeat,
|
||||
resolveSeatClass,
|
||||
selectCountedBlocks,
|
||||
soldKey,
|
||||
} from './blocked-seats-loss.calculator';
|
||||
|
||||
// ── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const DEPARTURE = new Date('2026-07-15T08:00:00.000Z');
|
||||
const NOW = new Date('2026-07-20T00:00:00.000Z');
|
||||
|
||||
const ECONOMY_LOCAL = {
|
||||
id: 'sc-econ-local',
|
||||
name: 'Economy',
|
||||
bedPosition: null,
|
||||
nationalityType: 'LOCAL',
|
||||
};
|
||||
const ECONOMY_INTL = {
|
||||
id: 'sc-econ-intl',
|
||||
name: 'Economy (International)',
|
||||
bedPosition: null,
|
||||
nationalityType: 'INTERNATIONAL',
|
||||
};
|
||||
|
||||
function coach(overrides: Partial<LossCoach> = {}): LossCoach {
|
||||
return {
|
||||
id: 'coach-1',
|
||||
number: 'C1',
|
||||
coachTypeType: 'passenger',
|
||||
coachTypeName: 'Economy Coach',
|
||||
seatClasses: [ECONOMY_LOCAL, ECONOMY_INTL],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function seat(overrides: Partial<LossSeat> = {}): LossSeat {
|
||||
return {
|
||||
id: 'seat-1',
|
||||
coachId: 'coach-1',
|
||||
seatNumber: '1',
|
||||
bedPosition: null,
|
||||
premiumFeeMinor: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function schedule(overrides: Partial<LossSchedule> = {}): LossSchedule {
|
||||
return {
|
||||
id: 'sched-1',
|
||||
trainNumber: 'ET-101',
|
||||
routeName: 'Addis Ababa — Dire Dawa',
|
||||
originStation: 'Addis Ababa',
|
||||
destinationStation: 'Dire Dawa',
|
||||
departureAt: DEPARTURE,
|
||||
status: 'SCHEDULED',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function block(overrides: Partial<LossBlock> = {}): LossBlock {
|
||||
return {
|
||||
id: 'block-1',
|
||||
seatId: 'seat-1',
|
||||
scheduleId: null,
|
||||
reason: 'Torn upholstery',
|
||||
reasonCategory: 'MAINTENANCE',
|
||||
blockedBy: 'user-1',
|
||||
blockedByName: 'Abebe Bekele',
|
||||
approvedBy: null,
|
||||
blockedAt: new Date('2026-07-10T00:00:00.000Z'),
|
||||
unblockAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function fare(overrides: Partial<LossFare> = {}): LossFare {
|
||||
return {
|
||||
seatClassId: ECONOMY_LOCAL.id,
|
||||
seatClassName: 'Economy',
|
||||
farePerPassengerMinor: 50_000, // ETB 500.00
|
||||
exchangeRate: 1,
|
||||
currency: 'ETB',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Builds a calculator input from loose parts, wiring up the id→entity maps. */
|
||||
function makeInput(parts: {
|
||||
schedules?: LossSchedule[];
|
||||
seats?: LossSeat[];
|
||||
coaches?: LossCoach[];
|
||||
/** scheduleId → coachIds assigned to it. */
|
||||
assignments?: Record<string, string[]>;
|
||||
sold?: [string, string][];
|
||||
blocks?: LossBlock[];
|
||||
}): LossCalculatorInput {
|
||||
const seats = parts.seats ?? [seat()];
|
||||
const coaches = parts.coaches ?? [coach()];
|
||||
const schedules = parts.schedules ?? [schedule()];
|
||||
const assignments = parts.assignments ?? { 'sched-1': ['coach-1'] };
|
||||
|
||||
return {
|
||||
schedules,
|
||||
seatsById: new Map(seats.map((s) => [s.id, s])),
|
||||
coachesById: new Map(coaches.map((c) => [c.id, c])),
|
||||
coachIdsBySchedule: new Map(
|
||||
Object.entries(assignments).map(([sid, cids]) => [sid, new Set(cids)]),
|
||||
),
|
||||
soldSeatKeys: new Set((parts.sold ?? []).map(([sid, seatId]) => soldKey(sid, seatId))),
|
||||
blocks: parts.blocks ?? [block()],
|
||||
};
|
||||
}
|
||||
|
||||
function makeOptions(overrides: Partial<AssembleOptions> = {}): AssembleOptions {
|
||||
return {
|
||||
faresBySchedule: new Map([['sched-1', new Map([[ECONOMY_LOCAL.id, fare()]])]]),
|
||||
schedulesWithoutFare: new Set<string>(),
|
||||
nationalityType: 'LOCAL',
|
||||
nationalityAssumption: 'Ethiopian',
|
||||
now: NOW,
|
||||
dateFrom: new Date('2026-07-01T00:00:00.000Z'),
|
||||
dateTo: new Date('2026-07-31T23:59:59.999Z'),
|
||||
page: 1,
|
||||
pageSize: 25,
|
||||
sortBy: 'lossMinor',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('blocked-seats-loss calculator', () => {
|
||||
describe('schedule attribution', () => {
|
||||
it('counts a schedule-scoped block against exactly that schedule', () => {
|
||||
const other = schedule({ id: 'sched-2' });
|
||||
const counted = selectCountedBlocks(
|
||||
makeInput({
|
||||
schedules: [schedule(), other],
|
||||
assignments: { 'sched-1': ['coach-1'], 'sched-2': ['coach-1'] },
|
||||
blocks: [block({ scheduleId: 'sched-1' })],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(counted.get('sched-1')).toHaveLength(1);
|
||||
expect(counted.get('sched-1')?.[0].blockType).toBe('SCHEDULE');
|
||||
// Same coach runs on sched-2, but the block named sched-1 only.
|
||||
expect(counted.has('sched-2')).toBe(false);
|
||||
});
|
||||
|
||||
it('counts a global block against every schedule its window covers', () => {
|
||||
const counted = selectCountedBlocks(
|
||||
makeInput({
|
||||
schedules: [schedule(), schedule({ id: 'sched-2' })],
|
||||
assignments: { 'sched-1': ['coach-1'], 'sched-2': ['coach-1'] },
|
||||
blocks: [block({ scheduleId: null })],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(counted.get('sched-1')?.[0].blockType).toBe('GLOBAL');
|
||||
expect(counted.get('sched-2')?.[0].blockType).toBe('GLOBAL');
|
||||
});
|
||||
|
||||
it('ignores a global block that started after departure', () => {
|
||||
const counted = selectCountedBlocks(
|
||||
makeInput({
|
||||
blocks: [block({ blockedAt: new Date('2026-07-16T00:00:00.000Z') })],
|
||||
}),
|
||||
);
|
||||
expect(counted.size).toBe(0);
|
||||
});
|
||||
|
||||
it('ignores a global block that was lifted before departure', () => {
|
||||
const counted = selectCountedBlocks(
|
||||
makeInput({
|
||||
blocks: [
|
||||
block({
|
||||
blockedAt: new Date('2026-07-01T00:00:00.000Z'),
|
||||
unblockAt: new Date('2026-07-10T00:00:00.000Z'),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(counted.size).toBe(0);
|
||||
});
|
||||
|
||||
it('counts a global block still open at departure', () => {
|
||||
const counted = selectCountedBlocks(
|
||||
makeInput({
|
||||
blocks: [
|
||||
block({
|
||||
blockedAt: new Date('2026-07-01T00:00:00.000Z'),
|
||||
unblockAt: new Date('2026-07-20T00:00:00.000Z'),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(counted.get('sched-1')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('counts a seat blocked twice for one schedule only once, at the newer block', () => {
|
||||
const counted = selectCountedBlocks(
|
||||
makeInput({
|
||||
blocks: [
|
||||
block({ id: 'old', blockedAt: new Date('2026-07-01T00:00:00.000Z') }),
|
||||
block({ id: 'new', blockedAt: new Date('2026-07-09T00:00:00.000Z') }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(counted.get('sched-1')).toHaveLength(1);
|
||||
expect(counted.get('sched-1')?.[0].block.id).toBe('new');
|
||||
});
|
||||
|
||||
it('prefers a schedule-scoped block over a global one for the same seat', () => {
|
||||
const counted = selectCountedBlocks(
|
||||
makeInput({
|
||||
blocks: [
|
||||
block({ id: 'global', scheduleId: null }),
|
||||
block({ id: 'scoped', scheduleId: 'sched-1' }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(counted.get('sched-1')).toHaveLength(1);
|
||||
expect(counted.get('sched-1')?.[0].block.id).toBe('scoped');
|
||||
});
|
||||
|
||||
it('skips CANCELLED schedules entirely', () => {
|
||||
const counted = selectCountedBlocks(
|
||||
makeInput({ schedules: [schedule({ status: 'CANCELLED' })] }),
|
||||
);
|
||||
expect(counted.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('coach-assignment gating', () => {
|
||||
it('ignores a global block when the seat\'s coach was not on that train', () => {
|
||||
const counted = selectCountedBlocks(
|
||||
makeInput({ assignments: { 'sched-1': ['coach-other'] } }),
|
||||
);
|
||||
expect(counted.size).toBe(0);
|
||||
});
|
||||
|
||||
it('counts a global block when the coach was assigned', () => {
|
||||
const counted = selectCountedBlocks(
|
||||
makeInput({ assignments: { 'sched-1': ['coach-1'] } }),
|
||||
);
|
||||
expect(counted.get('sched-1')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('gates each schedule independently on its own assignments', () => {
|
||||
const counted = selectCountedBlocks(
|
||||
makeInput({
|
||||
schedules: [schedule(), schedule({ id: 'sched-2' })],
|
||||
assignments: { 'sched-1': ['coach-1'], 'sched-2': ['coach-other'] },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(counted.has('sched-1')).toBe(true);
|
||||
expect(counted.has('sched-2')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dining and placeholder exclusion', () => {
|
||||
it('excludes seats in a dining coach', () => {
|
||||
const counted = selectCountedBlocks(
|
||||
makeInput({ coaches: [coach({ coachTypeType: 'dining' })] }),
|
||||
);
|
||||
expect(counted.size).toBe(0);
|
||||
});
|
||||
|
||||
it('excludes placeholder seats whose number starts with "-"', () => {
|
||||
const counted = selectCountedBlocks(
|
||||
makeInput({ seats: [seat({ seatNumber: '-1' })] }),
|
||||
);
|
||||
expect(counted.size).toBe(0);
|
||||
});
|
||||
|
||||
// Regression: real EDR data puts a display name in CoachType.type — 'Dining Coach '
|
||||
// with a trailing space — rather than the documented 'dining' slug. An exact match
|
||||
// let dining seats into the report and inflated the blocked-seat count.
|
||||
it.each([
|
||||
['dining'],
|
||||
['Dining Coach '],
|
||||
['DINING'],
|
||||
[' dining '],
|
||||
])('excludes a dining coach whose type is %p', (coachTypeType) => {
|
||||
const counted = selectCountedBlocks(
|
||||
makeInput({ coaches: [coach({ coachTypeType, coachTypeName: 'Dining Coach ' })] }),
|
||||
);
|
||||
expect(counted.size).toBe(0);
|
||||
});
|
||||
|
||||
it('excludes a dining coach identified only by its coachType name', () => {
|
||||
const counted = selectCountedBlocks(
|
||||
makeInput({
|
||||
coaches: [coach({ coachTypeType: 'Regular Seat', coachTypeName: 'Dining Coach ' })],
|
||||
}),
|
||||
);
|
||||
expect(counted.size).toBe(0);
|
||||
});
|
||||
|
||||
it('does not mistake a normal coach for a dining one', () => {
|
||||
const counted = selectCountedBlocks(
|
||||
makeInput({
|
||||
coaches: [coach({ coachTypeType: 'Regular Seat', coachTypeName: 'Hard Seat Coach' })],
|
||||
}),
|
||||
);
|
||||
expect(counted.get('sched-1')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps a real seat in a sleeper coach', () => {
|
||||
const counted = selectCountedBlocks(
|
||||
makeInput({ coaches: [coach({ coachTypeType: 'sleeper' })] }),
|
||||
);
|
||||
expect(counted.get('sched-1')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('leaves dining and placeholder seats out of the sellable-seat denominator', () => {
|
||||
const input = makeInput({
|
||||
seats: [
|
||||
seat({ id: 'real-1', coachId: 'coach-1', seatNumber: '1' }),
|
||||
seat({ id: 'real-2', coachId: 'coach-1', seatNumber: '2' }),
|
||||
seat({ id: 'spacer', coachId: 'coach-1', seatNumber: '-1' }),
|
||||
seat({ id: 'diner', coachId: 'coach-dining', seatNumber: '1' }),
|
||||
],
|
||||
coaches: [coach(), coach({ id: 'coach-dining', coachTypeType: 'dining' })],
|
||||
assignments: { 'sched-1': ['coach-1', 'coach-dining'] },
|
||||
});
|
||||
|
||||
expect(countSellableSeats('sched-1', input)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('blocked-after-sale exclusion', () => {
|
||||
it('excludes a blocked seat that was nonetheless sold on that schedule', () => {
|
||||
const counted = selectCountedBlocks(
|
||||
makeInput({ sold: [['sched-1', 'seat-1']] }),
|
||||
);
|
||||
expect(counted.size).toBe(0);
|
||||
});
|
||||
|
||||
it('still counts the block on a schedule where the seat was not sold', () => {
|
||||
const counted = selectCountedBlocks(
|
||||
makeInput({
|
||||
schedules: [schedule(), schedule({ id: 'sched-2' })],
|
||||
assignments: { 'sched-1': ['coach-1'], 'sched-2': ['coach-1'] },
|
||||
sold: [['sched-1', 'seat-1']],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(counted.has('sched-1')).toBe(false);
|
||||
expect(counted.has('sched-2')).toBe(true);
|
||||
});
|
||||
|
||||
it("excludes ticketing's own bookkeeping blocks", () => {
|
||||
const counted = selectCountedBlocks(
|
||||
makeInput({ blocks: [block({ reason: 'Booked in tickets t-1, t-2' })] }),
|
||||
);
|
||||
expect(counted.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('seat-class resolution and per-seat loss', () => {
|
||||
it('picks the seat class variant matching the nationality assumption', () => {
|
||||
expect(resolveSeatClass(seat(), coach(), 'LOCAL')?.id).toBe(ECONOMY_LOCAL.id);
|
||||
expect(resolveSeatClass(seat(), coach(), 'INTERNATIONAL')?.id).toBe(ECONOMY_INTL.id);
|
||||
});
|
||||
|
||||
it('narrows by bed position before nationality in a sleeper coach', () => {
|
||||
const upper = { id: 'sc-upper', name: 'Upper Berth', bedPosition: 'upper', nationalityType: 'LOCAL' };
|
||||
const lower = { id: 'sc-lower', name: 'Lower Berth', bedPosition: 'lower', nationalityType: 'LOCAL' };
|
||||
const sleeper = coach({ seatClasses: [upper, lower] });
|
||||
|
||||
expect(resolveSeatClass(seat({ bedPosition: 'LOWER' }), sleeper, 'LOCAL')?.id).toBe('sc-lower');
|
||||
});
|
||||
|
||||
it("adds the seat's own premium fee to the class fare", () => {
|
||||
const report = assembleReport(
|
||||
makeInput({ seats: [seat({ premiumFeeMinor: 2_500 })] }),
|
||||
selectCountedBlocks(makeInput({ seats: [seat({ premiumFeeMinor: 2_500 })] })),
|
||||
makeOptions(),
|
||||
);
|
||||
|
||||
// 50_000 class fare + 2_500 seat premium
|
||||
expect(report.schedules[0].blocks[0].estimatedLossMinor).toBe(52_500);
|
||||
});
|
||||
|
||||
it('counts the seat but claims no money when no fare could be quoted', () => {
|
||||
const input = makeInput({});
|
||||
const report = assembleReport(input, selectCountedBlocks(input), makeOptions({
|
||||
faresBySchedule: new Map(),
|
||||
schedulesWithoutFare: new Set(['sched-1']),
|
||||
}));
|
||||
|
||||
expect(report.summary.blockedSeatCount).toBe(1);
|
||||
expect(report.schedules[0].estimatedLossMinor).toBe(0);
|
||||
expect(report.meta.schedulesWithoutFare).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('load-factor adjustment', () => {
|
||||
it('scales estimated loss by sold ÷ sellable', () => {
|
||||
const seats = [
|
||||
seat({ id: 'seat-1', seatNumber: '1' }),
|
||||
seat({ id: 'seat-2', seatNumber: '2' }),
|
||||
seat({ id: 'seat-3', seatNumber: '3' }),
|
||||
seat({ id: 'seat-4', seatNumber: '4' }),
|
||||
];
|
||||
// 4 sellable seats, 2 sold ⇒ load factor 0.5.
|
||||
const parts = { seats, sold: [['sched-1', 'seat-2'], ['sched-1', 'seat-3']] as [string, string][] };
|
||||
const input = makeInput(parts);
|
||||
const report = assembleReport(input, selectCountedBlocks(input), makeOptions());
|
||||
|
||||
const row = report.schedules[0];
|
||||
expect(row.sellableSeats).toBe(4);
|
||||
expect(row.soldSeats).toBe(2);
|
||||
expect(row.loadFactorPercent).toBe(50);
|
||||
expect(row.estimatedLossMinor).toBe(50_000);
|
||||
expect(row.adjustedLossMinor).toBe(25_000);
|
||||
});
|
||||
|
||||
it('adjusts to zero on a train that sold nothing', () => {
|
||||
const input = makeInput({});
|
||||
const report = assembleReport(input, selectCountedBlocks(input), makeOptions());
|
||||
|
||||
expect(report.schedules[0].loadFactorPercent).toBe(0);
|
||||
expect(report.schedules[0].estimatedLossMinor).toBe(50_000);
|
||||
expect(report.schedules[0].adjustedLossMinor).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multi-currency grouping', () => {
|
||||
it('groups totals per currency and never sums across them', () => {
|
||||
const schedules = [schedule(), schedule({ id: 'sched-2' })];
|
||||
const seats = [
|
||||
seat({ id: 'seat-1', coachId: 'coach-1', seatNumber: '1' }),
|
||||
seat({ id: 'seat-2', coachId: 'coach-2', seatNumber: '1' }),
|
||||
];
|
||||
const coaches = [coach(), coach({ id: 'coach-2', number: 'C2' })];
|
||||
const blocks = [
|
||||
block({ id: 'b1', seatId: 'seat-1', scheduleId: 'sched-1' }),
|
||||
block({ id: 'b2', seatId: 'seat-2', scheduleId: 'sched-2' }),
|
||||
];
|
||||
const input = makeInput({
|
||||
schedules,
|
||||
seats,
|
||||
coaches,
|
||||
blocks,
|
||||
assignments: { 'sched-1': ['coach-1'], 'sched-2': ['coach-2'] },
|
||||
});
|
||||
|
||||
const report = assembleReport(
|
||||
input,
|
||||
selectCountedBlocks(input),
|
||||
makeOptions({
|
||||
faresBySchedule: new Map([
|
||||
['sched-1', new Map([[ECONOMY_LOCAL.id, fare({ currency: 'ETB' })]])],
|
||||
[
|
||||
'sched-2',
|
||||
new Map([
|
||||
[ECONOMY_LOCAL.id, fare({ currency: 'DJF', exchangeRate: 2, farePerPassengerMinor: 50_000 })],
|
||||
]),
|
||||
],
|
||||
]),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(report.summary.lossByCurrency).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ currency: 'ETB', estimatedLossMinor: 50_000, adjustedLossMinor: 0 },
|
||||
{ currency: 'DJF', estimatedLossMinor: 100_000, adjustedLossMinor: 0 },
|
||||
]),
|
||||
);
|
||||
expect(report.summary.lossByCurrency).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('keeps reason-category and blocker breakdowns split by currency', () => {
|
||||
const input = makeInput({
|
||||
schedules: [schedule(), schedule({ id: 'sched-2' })],
|
||||
seats: [
|
||||
seat({ id: 'seat-1', coachId: 'coach-1', seatNumber: '1' }),
|
||||
seat({ id: 'seat-2', coachId: 'coach-2', seatNumber: '1' }),
|
||||
],
|
||||
coaches: [coach(), coach({ id: 'coach-2', number: 'C2' })],
|
||||
blocks: [
|
||||
block({ id: 'b1', seatId: 'seat-1', scheduleId: 'sched-1' }),
|
||||
block({ id: 'b2', seatId: 'seat-2', scheduleId: 'sched-2' }),
|
||||
],
|
||||
assignments: { 'sched-1': ['coach-1'], 'sched-2': ['coach-2'] },
|
||||
});
|
||||
|
||||
const report = assembleReport(
|
||||
input,
|
||||
selectCountedBlocks(input),
|
||||
makeOptions({
|
||||
faresBySchedule: new Map([
|
||||
['sched-1', new Map([[ECONOMY_LOCAL.id, fare({ currency: 'ETB' })]])],
|
||||
['sched-2', new Map([[ECONOMY_LOCAL.id, fare({ currency: 'USD' })]])],
|
||||
]),
|
||||
}),
|
||||
);
|
||||
|
||||
// Same category, same blocker — but two currencies, so two rows each.
|
||||
expect(report.summary.topReasonCategories).toHaveLength(2);
|
||||
expect(report.summary.topReasonCategories.map((r) => r.currency).sort()).toEqual(['ETB', 'USD']);
|
||||
expect(report.summary.topBlockers).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('legacy rows', () => {
|
||||
it('reports an uncategorized legacy block under UNCATEGORIZED with an Unknown blocker', () => {
|
||||
const input = makeInput({
|
||||
blocks: [block({ reasonCategory: null, blockedByName: null, blockedBy: 'legacy-id' })],
|
||||
});
|
||||
const report = assembleReport(input, selectCountedBlocks(input), makeOptions());
|
||||
|
||||
expect(report.schedules[0].blocks[0].reasonCategory).toBeNull();
|
||||
expect(report.summary.topReasonCategories[0].reasonCategory).toBe('UNCATEGORIZED');
|
||||
expect(report.summary.topBlockers[0].blockedByName).toBe('Unknown');
|
||||
});
|
||||
|
||||
it('names a SYSTEM blocker "System"', () => {
|
||||
const input = makeInput({
|
||||
blocks: [block({ blockedBy: 'SYSTEM', blockedByName: null })],
|
||||
});
|
||||
const report = assembleReport(input, selectCountedBlocks(input), makeOptions());
|
||||
|
||||
expect(report.summary.topBlockers[0].blockedByName).toBe('System');
|
||||
});
|
||||
});
|
||||
|
||||
describe('zero-blocks schedule', () => {
|
||||
it('returns an empty report when nothing is blocked', () => {
|
||||
const input = makeInput({ blocks: [] });
|
||||
const report = assembleReport(input, selectCountedBlocks(input), makeOptions());
|
||||
|
||||
expect(report.schedules).toHaveLength(0);
|
||||
expect(report.summary.schedulesAffected).toBe(0);
|
||||
expect(report.summary.blockedSeatCount).toBe(0);
|
||||
expect(report.summary.lossByCurrency).toEqual([]);
|
||||
expect(report.meta.total).toBe(0);
|
||||
// The methodology and exclusions still travel with the (empty) answer.
|
||||
expect(report.meta.exclusions.length).toBeGreaterThan(0);
|
||||
expect(report.meta.methodology).toContain('counterfactual');
|
||||
});
|
||||
|
||||
it('omits unaffected schedules from a report that has other affected ones', () => {
|
||||
const input = makeInput({
|
||||
schedules: [schedule(), schedule({ id: 'sched-empty' })],
|
||||
assignments: { 'sched-1': ['coach-1'], 'sched-empty': ['coach-1'] },
|
||||
blocks: [block({ scheduleId: 'sched-1' })],
|
||||
});
|
||||
const report = assembleReport(input, selectCountedBlocks(input), makeOptions());
|
||||
|
||||
expect(report.schedules.map((s) => s.scheduleId)).toEqual(['sched-1']);
|
||||
expect(report.meta.total).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('meta and drill-down detail', () => {
|
||||
it('states the nationality assumption it priced at', () => {
|
||||
const input = makeInput({});
|
||||
const report = assembleReport(
|
||||
input,
|
||||
selectCountedBlocks(input),
|
||||
makeOptions({ nationalityAssumption: 'German', nationalityType: 'INTERNATIONAL' }),
|
||||
);
|
||||
|
||||
expect(report.meta.nationalityAssumption).toBe('German');
|
||||
expect(report.meta.methodology).toContain('German');
|
||||
expect(report.meta.methodology).toContain('INTERNATIONAL');
|
||||
});
|
||||
|
||||
it('reports days blocked against now while a block is still open', () => {
|
||||
const input = makeInput({
|
||||
blocks: [block({ blockedAt: new Date('2026-07-10T00:00:00.000Z'), unblockAt: null })],
|
||||
});
|
||||
const report = assembleReport(input, selectCountedBlocks(input), makeOptions());
|
||||
|
||||
const detail = report.schedules[0].blocks[0];
|
||||
expect(detail.stillBlocked).toBe(true);
|
||||
expect(detail.daysBlocked).toBe(10); // 10 Jul → 20 Jul (NOW)
|
||||
});
|
||||
|
||||
it('reports days blocked against unblockAt once a block has ended', () => {
|
||||
const input = makeInput({
|
||||
blocks: [
|
||||
block({
|
||||
blockedAt: new Date('2026-07-10T00:00:00.000Z'),
|
||||
unblockAt: new Date('2026-07-16T00:00:00.000Z'),
|
||||
}),
|
||||
],
|
||||
});
|
||||
const report = assembleReport(input, selectCountedBlocks(input), makeOptions());
|
||||
|
||||
const detail = report.schedules[0].blocks[0];
|
||||
expect(detail.stillBlocked).toBe(false);
|
||||
expect(detail.daysBlocked).toBe(6);
|
||||
});
|
||||
|
||||
it('paginates schedules and reports the unpaginated total', () => {
|
||||
const schedules = [1, 2, 3].map((n) => schedule({ id: `sched-${n}` }));
|
||||
const seats = [1, 2, 3].map((n) => seat({ id: `seat-${n}`, seatNumber: String(n) }));
|
||||
const blocks = [1, 2, 3].map((n) =>
|
||||
block({ id: `b-${n}`, seatId: `seat-${n}`, scheduleId: `sched-${n}` }),
|
||||
);
|
||||
const input = makeInput({
|
||||
schedules,
|
||||
seats,
|
||||
blocks,
|
||||
assignments: { 'sched-1': ['coach-1'], 'sched-2': ['coach-1'], 'sched-3': ['coach-1'] },
|
||||
});
|
||||
|
||||
const report = assembleReport(
|
||||
input,
|
||||
selectCountedBlocks(input),
|
||||
makeOptions({
|
||||
page: 2,
|
||||
pageSize: 2,
|
||||
faresBySchedule: new Map(
|
||||
schedules.map((s) => [s.id, new Map([[ECONOMY_LOCAL.id, fare()]])]),
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(report.meta.total).toBe(3);
|
||||
expect(report.schedules).toHaveLength(1);
|
||||
expect(report.summary.blockedSeatCount).toBe(3); // summary covers all, not the page
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,558 @@
|
||||
/**
|
||||
* Blocked Seat Revenue Loss — the counting rule and the money.
|
||||
*
|
||||
* Deliberately free of Prisma and Nest: `ReportsService` does the fetching, this module
|
||||
* decides which blocks count against which schedule and what each one cost. That split is
|
||||
* what makes the rule testable — every exclusion below has a unit test in
|
||||
* `blocked-seats-loss.calculator.spec.ts`.
|
||||
*
|
||||
* All amounts are integer minor units, always carried with their currency.
|
||||
*/
|
||||
|
||||
import {
|
||||
BlockedSeatBlockType,
|
||||
BlockedSeatLossByBlocker,
|
||||
BlockedSeatLossByCurrency,
|
||||
BlockedSeatLossByReasonCategory,
|
||||
BlockedSeatLossDetail,
|
||||
BlockedSeatLossSchedule,
|
||||
BlockedSeatRevenueLossReport,
|
||||
SeatBlockReasonCategory,
|
||||
UNCATEGORIZED_REASON_CATEGORY,
|
||||
} from '@edr/types';
|
||||
|
||||
// ── Inputs ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface LossSeatClass {
|
||||
id: string;
|
||||
name: string;
|
||||
bedPosition: string | null;
|
||||
nationalityType: string | null;
|
||||
}
|
||||
|
||||
export interface LossCoach {
|
||||
id: string;
|
||||
number: string;
|
||||
/** CoachType.type — 'passenger' | 'sleeper' | 'dining' | 'baggage'. */
|
||||
coachTypeType: string;
|
||||
coachTypeName: string;
|
||||
seatClasses: LossSeatClass[];
|
||||
}
|
||||
|
||||
export interface LossSeat {
|
||||
id: string;
|
||||
coachId: string;
|
||||
seatNumber: string;
|
||||
bedPosition: string | null;
|
||||
premiumFeeMinor: number;
|
||||
}
|
||||
|
||||
export interface LossSchedule {
|
||||
id: string;
|
||||
trainNumber: string;
|
||||
routeName: string | null;
|
||||
originStation: string;
|
||||
destinationStation: string;
|
||||
departureAt: Date;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface LossBlock {
|
||||
id: string;
|
||||
seatId: string;
|
||||
/** Null for a global block — one that applies wherever the seat's coach runs. */
|
||||
scheduleId: string | null;
|
||||
reason: string;
|
||||
reasonCategory: SeatBlockReasonCategory | null;
|
||||
blockedBy: string;
|
||||
blockedByName: string | null;
|
||||
approvedBy: string | null;
|
||||
blockedAt: Date;
|
||||
unblockAt: Date | null;
|
||||
}
|
||||
|
||||
/** One fare quote from the fare engine, per seat class, per schedule. */
|
||||
export interface LossFare {
|
||||
seatClassId: string;
|
||||
seatClassName: string;
|
||||
/** Base + class premium + insurance, in ETB minor units. */
|
||||
farePerPassengerMinor: number;
|
||||
/** ETB → billing currency. 1 when billing in ETB. */
|
||||
exchangeRate: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface LossCalculatorInput {
|
||||
schedules: LossSchedule[];
|
||||
/** Every seat on every coach involved, keyed by seat id. */
|
||||
seatsById: Map<string, LossSeat>;
|
||||
/** Every coach involved, keyed by coach id. */
|
||||
coachesById: Map<string, LossCoach>;
|
||||
/** Coach ids assigned to each schedule, keyed by schedule id. */
|
||||
coachIdsBySchedule: Map<string, Set<string>>;
|
||||
/** `${scheduleId}|${seatId}` for every seat with a CONFIRMED/BOARDED booking. */
|
||||
soldSeatKeys: Set<string>;
|
||||
/** Candidate blocks — schedule-scoped for these schedules, plus overlapping global ones. */
|
||||
blocks: LossBlock[];
|
||||
}
|
||||
|
||||
/** A block that survived every gate, bound to the schedule it cost revenue on. */
|
||||
export interface CountedBlock {
|
||||
block: LossBlock;
|
||||
seat: LossSeat;
|
||||
coach: LossCoach;
|
||||
blockType: BlockedSeatBlockType;
|
||||
}
|
||||
|
||||
// ── Exclusions, stated once so the API can echo them verbatim ────────────────
|
||||
|
||||
export const BLOCKED_SEAT_LOSS_EXCLUSIONS: readonly string[] = [
|
||||
'Dining-coach seats — never sold as passenger seats, so blocking one costs no fare revenue.',
|
||||
'Placeholder seats (seat number starting with "-") — layout spacers, not real seats.',
|
||||
'CANCELLED schedules — the train did not run, so no fare was lost to the block.',
|
||||
'Seats that were nonetheless sold on that schedule (a CONFIRMED or BOARDED booking exists) — blocked after sale, so no revenue was lost.',
|
||||
'System blocks created by ticket issuance ("Booked in tickets …") — bookkeeping for seats that were sold, not withheld inventory.',
|
||||
'A seat blocked more than once for the same schedule is counted once, at its most recent block.',
|
||||
];
|
||||
|
||||
/** Prefix ticket issuance writes into `SeatBlock.reason` for already-sold seats. */
|
||||
export const TICKETING_BLOCK_REASON_PREFIX = 'Booked in tickets';
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
// ── Step 1: which blocks count against which schedule ────────────────────────
|
||||
|
||||
/**
|
||||
* Applies the counting rule.
|
||||
*
|
||||
* A blocked seat counts against a schedule when either:
|
||||
* - a `SeatBlock` row targets that `scheduleId` directly, or
|
||||
* - a global block (no `scheduleId`) was in effect at departure — `blockedAt <=
|
||||
* departureAt` and (`unblockAt IS NULL` or `unblockAt >= departureAt`) — **and** the
|
||||
* seat's coach was actually assigned to that schedule.
|
||||
*
|
||||
* …minus every exclusion in {@link BLOCKED_SEAT_LOSS_EXCLUSIONS}.
|
||||
*
|
||||
* Returns counted blocks keyed by schedule id. Schedules with no counted block are absent.
|
||||
*/
|
||||
export function selectCountedBlocks(
|
||||
input: LossCalculatorInput,
|
||||
): Map<string, CountedBlock[]> {
|
||||
const { schedules, seatsById, coachesById, coachIdsBySchedule, soldSeatKeys, blocks } = input;
|
||||
|
||||
// Per schedule, at most one counted block per seat. A schedule-scoped block beats a
|
||||
// global one (it is the more specific statement); between two of the same kind, the
|
||||
// most recently created wins.
|
||||
const bySchedule = new Map<string, Map<string, CountedBlock>>();
|
||||
|
||||
for (const schedule of schedules) {
|
||||
if (schedule.status === 'CANCELLED') continue;
|
||||
const assignedCoachIds = coachIdsBySchedule.get(schedule.id) ?? new Set<string>();
|
||||
|
||||
for (const block of blocks) {
|
||||
if (block.reason.startsWith(TICKETING_BLOCK_REASON_PREFIX)) continue;
|
||||
|
||||
const seat = seatsById.get(block.seatId);
|
||||
if (!seat) continue;
|
||||
if (isPlaceholderSeat(seat)) continue;
|
||||
|
||||
const coach = coachesById.get(seat.coachId);
|
||||
if (!coach || isDiningCoach(coach)) continue;
|
||||
|
||||
let blockType: BlockedSeatBlockType;
|
||||
if (block.scheduleId !== null) {
|
||||
if (block.scheduleId !== schedule.id) continue;
|
||||
blockType = 'SCHEDULE';
|
||||
} else {
|
||||
if (!assignedCoachIds.has(seat.coachId)) continue;
|
||||
if (!isGlobalBlockInEffectAt(block, schedule.departureAt)) continue;
|
||||
blockType = 'GLOBAL';
|
||||
}
|
||||
|
||||
// Blocked but sold anyway ⇒ the fare was collected, nothing was lost.
|
||||
if (soldSeatKeys.has(soldKey(schedule.id, seat.id))) continue;
|
||||
|
||||
const candidate: CountedBlock = { block, seat, coach, blockType };
|
||||
const seatMap = bySchedule.get(schedule.id) ?? new Map<string, CountedBlock>();
|
||||
const existing = seatMap.get(seat.id);
|
||||
if (!existing || supersedes(candidate, existing)) seatMap.set(seat.id, candidate);
|
||||
bySchedule.set(schedule.id, seatMap);
|
||||
}
|
||||
}
|
||||
|
||||
const result = new Map<string, CountedBlock[]>();
|
||||
for (const [scheduleId, seatMap] of bySchedule) {
|
||||
if (seatMap.size === 0) continue;
|
||||
result.set(scheduleId, [...seatMap.values()]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function supersedes(candidate: CountedBlock, existing: CountedBlock): boolean {
|
||||
if (candidate.blockType !== existing.blockType) return candidate.blockType === 'SCHEDULE';
|
||||
return candidate.block.blockedAt.getTime() > existing.block.blockedAt.getTime();
|
||||
}
|
||||
|
||||
function isGlobalBlockInEffectAt(block: LossBlock, departureAt: Date): boolean {
|
||||
if (block.blockedAt.getTime() > departureAt.getTime()) return false;
|
||||
if (block.unblockAt === null) return true;
|
||||
return block.unblockAt.getTime() >= departureAt.getTime();
|
||||
}
|
||||
|
||||
export function isPlaceholderSeat(seat: Pick<LossSeat, 'seatNumber'>): boolean {
|
||||
return !seat.seatNumber || seat.seatNumber.startsWith('-');
|
||||
}
|
||||
|
||||
/**
|
||||
* `CoachType.type` is documented as a slug ('passenger' | 'sleeper' | 'dining' | 'baggage'),
|
||||
* but real EDR data stores display names there instead — e.g. `'Dining Coach '`, trailing
|
||||
* space included. An exact `=== 'dining'` match therefore lets dining seats through and
|
||||
* inflates the blocked-seat count. Match on a substring of type *or* name so both the
|
||||
* documented convention and the data as it actually exists are covered.
|
||||
*/
|
||||
export function isDiningCoach(
|
||||
coach: Pick<LossCoach, 'coachTypeType' | 'coachTypeName'>,
|
||||
): boolean {
|
||||
const haystack = `${coach.coachTypeType ?? ''} ${coach.coachTypeName ?? ''}`.toLowerCase();
|
||||
return haystack.includes('dining');
|
||||
}
|
||||
|
||||
export function soldKey(scheduleId: string, seatId: string): string {
|
||||
return `${scheduleId}|${seatId}`;
|
||||
}
|
||||
|
||||
// ── Step 2: seats that could have been sold ──────────────────────────────────
|
||||
|
||||
/**
|
||||
* Sellable seats on a schedule: every seat on every assigned coach, minus dining coaches
|
||||
* and placeholder rows. This is the denominator of the load factor, and it deliberately
|
||||
* ignores the `coachId` filter so the percentage stays comparable across filtered views.
|
||||
*/
|
||||
export function countSellableSeats(
|
||||
scheduleId: string,
|
||||
input: Pick<LossCalculatorInput, 'coachIdsBySchedule' | 'coachesById' | 'seatsById'>,
|
||||
): number {
|
||||
const coachIds = input.coachIdsBySchedule.get(scheduleId);
|
||||
if (!coachIds || coachIds.size === 0) return 0;
|
||||
|
||||
let total = 0;
|
||||
for (const seat of input.seatsById.values()) {
|
||||
if (!coachIds.has(seat.coachId)) continue;
|
||||
if (isPlaceholderSeat(seat)) continue;
|
||||
const coach = input.coachesById.get(seat.coachId);
|
||||
if (!coach || isDiningCoach(coach)) continue;
|
||||
total++;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
// ── Step 3: the money ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Picks the seat class a seat is priced under.
|
||||
*
|
||||
* Bed position selects the tier in a sleeper coach; `nationalityType` then picks the
|
||||
* LOCAL or INTERNATIONAL variant of that tier, matching how the fare engine resolves it.
|
||||
*/
|
||||
export function resolveSeatClass(
|
||||
seat: LossSeat,
|
||||
coach: LossCoach,
|
||||
nationalityType: string,
|
||||
): LossSeatClass | null {
|
||||
const classes = coach.seatClasses;
|
||||
if (classes.length === 0) return null;
|
||||
|
||||
const bed = seat.bedPosition?.toLowerCase();
|
||||
const byBed = bed
|
||||
? classes.filter((sc) => sc.bedPosition?.toLowerCase() === bed)
|
||||
: classes.filter((sc) => !sc.bedPosition);
|
||||
const pool = byBed.length > 0 ? byBed : classes;
|
||||
|
||||
return pool.find((sc) => sc.nationalityType === nationalityType) ?? pool[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What one blocked seat would have sold for:
|
||||
*
|
||||
* base fare + class premium + insurance (the fare engine's per-passenger fare)
|
||||
* + the seat's own premium (window/berth surcharge)
|
||||
*
|
||||
* converted into the billing currency implied by the nationality assumption.
|
||||
*
|
||||
* Returns `null` when no fare could be quoted for the seat's class — the seat still
|
||||
* counts as blocked, it just carries no monetary claim.
|
||||
*/
|
||||
export function estimateSeatLoss(
|
||||
seat: LossSeat,
|
||||
fare: LossFare | null,
|
||||
): { estimatedLossMinor: number; currency: string } | null {
|
||||
if (!fare) return null;
|
||||
const etbMinor = fare.farePerPassengerMinor + (seat.premiumFeeMinor ?? 0);
|
||||
return {
|
||||
estimatedLossMinor: Math.round(etbMinor * fare.exchangeRate),
|
||||
currency: fare.currency,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Step 4: assemble ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface AssembleOptions {
|
||||
/** Seat-class fares per schedule, keyed by schedule id then seat class id. */
|
||||
faresBySchedule: Map<string, Map<string, LossFare>>;
|
||||
/** Schedule ids whose fare calculation failed outright. */
|
||||
schedulesWithoutFare: Set<string>;
|
||||
/** 'LOCAL' or 'INTERNATIONAL' — how seat classes were resolved. */
|
||||
nationalityType: string;
|
||||
/** The nationality string the fares were priced at, for `meta`. */
|
||||
nationalityAssumption: string;
|
||||
/** Reference time for "days blocked" on still-blocked seats. Injected for determinism. */
|
||||
now: Date;
|
||||
dateFrom: Date;
|
||||
dateTo: Date;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
sortBy: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns counted blocks + fares into the wire response.
|
||||
*
|
||||
* Schedules with no counted block are omitted: they carry no loss and no drill-down, and
|
||||
* `meta.total` counts the schedules actually paginated so the two never disagree.
|
||||
*/
|
||||
export function assembleReport(
|
||||
input: LossCalculatorInput,
|
||||
countedBySchedule: Map<string, CountedBlock[]>,
|
||||
options: AssembleOptions,
|
||||
): BlockedSeatRevenueLossReport {
|
||||
const soldCountBySchedule = countSoldSeatsPerSchedule(input.soldSeatKeys);
|
||||
|
||||
const scheduleRows: BlockedSeatLossSchedule[] = [];
|
||||
|
||||
for (const schedule of input.schedules) {
|
||||
const counted = countedBySchedule.get(schedule.id);
|
||||
if (!counted || counted.length === 0) continue;
|
||||
|
||||
const fares = options.faresBySchedule.get(schedule.id) ?? new Map<string, LossFare>();
|
||||
const sellableSeats = countSellableSeats(schedule.id, input);
|
||||
const soldSeats = soldCountBySchedule.get(schedule.id) ?? 0;
|
||||
const loadFactor = sellableSeats > 0 ? Math.min(1, soldSeats / sellableSeats) : 0;
|
||||
|
||||
const blocks: BlockedSeatLossDetail[] = counted
|
||||
.map((c) => toDetail(c, fares, options))
|
||||
.sort(byCoachThenSeat);
|
||||
|
||||
// One schedule prices in exactly one currency (the nationality assumption fixes it),
|
||||
// so a plain sum here never crosses currencies.
|
||||
const estimatedLossMinor = blocks.reduce((sum, b) => sum + b.estimatedLossMinor, 0);
|
||||
const currency = blocks.find((b) => b.currency)?.currency ?? 'ETB';
|
||||
|
||||
scheduleRows.push({
|
||||
scheduleId: schedule.id,
|
||||
trainNumber: schedule.trainNumber,
|
||||
routeName: schedule.routeName,
|
||||
originStation: schedule.originStation,
|
||||
destinationStation: schedule.destinationStation,
|
||||
departureAt: schedule.departureAt.toISOString(),
|
||||
status: schedule.status,
|
||||
sellableSeats,
|
||||
soldSeats,
|
||||
loadFactorPercent: +(loadFactor * 100).toFixed(1),
|
||||
blockedSeatCount: blocks.length,
|
||||
estimatedLossMinor,
|
||||
adjustedLossMinor: Math.round(estimatedLossMinor * loadFactor),
|
||||
currency,
|
||||
blocks,
|
||||
});
|
||||
}
|
||||
|
||||
sortSchedules(scheduleRows, options.sortBy);
|
||||
|
||||
const summary = {
|
||||
schedulesAffected: scheduleRows.length,
|
||||
blockedSeatCount: scheduleRows.reduce((sum, s) => sum + s.blockedSeatCount, 0),
|
||||
lossByCurrency: groupLossByCurrency(scheduleRows),
|
||||
topReasonCategories: groupByReasonCategory(scheduleRows),
|
||||
topBlockers: groupByBlocker(scheduleRows),
|
||||
};
|
||||
|
||||
const page = Math.max(1, options.page);
|
||||
const pageSize = Math.max(1, options.pageSize);
|
||||
const paged = scheduleRows.slice((page - 1) * pageSize, page * pageSize);
|
||||
|
||||
return {
|
||||
summary,
|
||||
schedules: paged,
|
||||
meta: {
|
||||
total: scheduleRows.length,
|
||||
page,
|
||||
pageSize,
|
||||
dateFrom: options.dateFrom.toISOString(),
|
||||
dateTo: options.dateTo.toISOString(),
|
||||
nationalityAssumption: options.nationalityAssumption,
|
||||
methodology: buildMethodology(options),
|
||||
exclusions: [...BLOCKED_SEAT_LOSS_EXCLUSIONS],
|
||||
schedulesWithoutFare: options.schedulesWithoutFare.size,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toDetail(
|
||||
counted: CountedBlock,
|
||||
fares: Map<string, LossFare>,
|
||||
options: AssembleOptions,
|
||||
): BlockedSeatLossDetail {
|
||||
const { block, seat, coach, blockType } = counted;
|
||||
const seatClass = resolveSeatClass(seat, coach, options.nationalityType);
|
||||
const fare = lookupFare(seatClass, coach, fares);
|
||||
const loss = estimateSeatLoss(seat, fare);
|
||||
const endedAt = block.unblockAt ?? options.now;
|
||||
|
||||
return {
|
||||
blockId: block.id,
|
||||
seatId: seat.id,
|
||||
coachNumber: coach.number,
|
||||
seatNumber: seat.seatNumber,
|
||||
seatClassName: seatClass?.name ?? coach.coachTypeName ?? null,
|
||||
reason: block.reason,
|
||||
reasonCategory: block.reasonCategory,
|
||||
blockType,
|
||||
blockedBy: block.blockedBy,
|
||||
blockedByName: block.blockedByName,
|
||||
approvedBy: block.approvedBy,
|
||||
blockedAt: block.blockedAt.toISOString(),
|
||||
unblockAt: block.unblockAt ? block.unblockAt.toISOString() : null,
|
||||
stillBlocked: block.unblockAt === null,
|
||||
daysBlocked: Math.max(
|
||||
0,
|
||||
Math.floor((endedAt.getTime() - block.blockedAt.getTime()) / MS_PER_DAY),
|
||||
),
|
||||
estimatedLossMinor: loss?.estimatedLossMinor ?? 0,
|
||||
currency: loss?.currency ?? 'ETB',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The fare engine keys its quotes by the *nationality-resolved* seat class, which may not
|
||||
* be the class the seat nominally belongs to. Try the exact class, then any sibling class
|
||||
* on the same coach type that was quoted.
|
||||
*/
|
||||
function lookupFare(
|
||||
seatClass: LossSeatClass | null,
|
||||
coach: LossCoach,
|
||||
fares: Map<string, LossFare>,
|
||||
): LossFare | null {
|
||||
if (fares.size === 0) return null;
|
||||
if (seatClass) {
|
||||
const exact = fares.get(seatClass.id);
|
||||
if (exact) return exact;
|
||||
const sibling = coach.seatClasses.find(
|
||||
(sc) => sc.bedPosition === seatClass.bedPosition && fares.has(sc.id),
|
||||
);
|
||||
if (sibling) return fares.get(sibling.id) ?? null;
|
||||
}
|
||||
const anyOnCoach = coach.seatClasses.find((sc) => fares.has(sc.id));
|
||||
return anyOnCoach ? (fares.get(anyOnCoach.id) ?? null) : null;
|
||||
}
|
||||
|
||||
function countSoldSeatsPerSchedule(soldSeatKeys: Set<string>): Map<string, number> {
|
||||
const counts = new Map<string, number>();
|
||||
for (const key of soldSeatKeys) {
|
||||
const scheduleId = key.slice(0, key.indexOf('|'));
|
||||
counts.set(scheduleId, (counts.get(scheduleId) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
function byCoachThenSeat(a: BlockedSeatLossDetail, b: BlockedSeatLossDetail): number {
|
||||
const coach = (a.coachNumber ?? '').localeCompare(b.coachNumber ?? '', undefined, {
|
||||
numeric: true,
|
||||
});
|
||||
if (coach !== 0) return coach;
|
||||
return (a.seatNumber ?? '').localeCompare(b.seatNumber ?? '', undefined, { numeric: true });
|
||||
}
|
||||
|
||||
function sortSchedules(rows: BlockedSeatLossSchedule[], sortBy: string): void {
|
||||
switch (sortBy) {
|
||||
case 'lossMinorAsc':
|
||||
rows.sort((a, b) => a.estimatedLossMinor - b.estimatedLossMinor);
|
||||
break;
|
||||
case 'blockedSeatCount':
|
||||
rows.sort((a, b) => b.blockedSeatCount - a.blockedSeatCount);
|
||||
break;
|
||||
case 'departureAt':
|
||||
rows.sort((a, b) => a.departureAt.localeCompare(b.departureAt));
|
||||
break;
|
||||
default:
|
||||
rows.sort((a, b) => b.estimatedLossMinor - a.estimatedLossMinor);
|
||||
}
|
||||
}
|
||||
|
||||
function groupLossByCurrency(rows: BlockedSeatLossSchedule[]): BlockedSeatLossByCurrency[] {
|
||||
const byCurrency = new Map<string, BlockedSeatLossByCurrency>();
|
||||
for (const row of rows) {
|
||||
const entry = byCurrency.get(row.currency) ?? {
|
||||
currency: row.currency,
|
||||
estimatedLossMinor: 0,
|
||||
adjustedLossMinor: 0,
|
||||
};
|
||||
entry.estimatedLossMinor += row.estimatedLossMinor;
|
||||
entry.adjustedLossMinor += row.adjustedLossMinor;
|
||||
byCurrency.set(row.currency, entry);
|
||||
}
|
||||
return [...byCurrency.values()].sort((a, b) => b.estimatedLossMinor - a.estimatedLossMinor);
|
||||
}
|
||||
|
||||
function groupByReasonCategory(
|
||||
rows: BlockedSeatLossSchedule[],
|
||||
): BlockedSeatLossByReasonCategory[] {
|
||||
const groups = new Map<string, BlockedSeatLossByReasonCategory>();
|
||||
for (const row of rows) {
|
||||
for (const block of row.blocks) {
|
||||
const category = block.reasonCategory ?? UNCATEGORIZED_REASON_CATEGORY;
|
||||
const key = `${category}|${block.currency}`;
|
||||
const entry = groups.get(key) ?? {
|
||||
reasonCategory: category,
|
||||
count: 0,
|
||||
estimatedLossMinor: 0,
|
||||
currency: block.currency,
|
||||
};
|
||||
entry.count++;
|
||||
entry.estimatedLossMinor += block.estimatedLossMinor;
|
||||
groups.set(key, entry);
|
||||
}
|
||||
}
|
||||
return [...groups.values()].sort((a, b) => b.estimatedLossMinor - a.estimatedLossMinor);
|
||||
}
|
||||
|
||||
function groupByBlocker(rows: BlockedSeatLossSchedule[]): BlockedSeatLossByBlocker[] {
|
||||
const groups = new Map<string, BlockedSeatLossByBlocker>();
|
||||
for (const row of rows) {
|
||||
for (const block of row.blocks) {
|
||||
const key = `${block.blockedBy}|${block.currency}`;
|
||||
const entry = groups.get(key) ?? {
|
||||
blockedBy: block.blockedBy,
|
||||
// Legacy rows carry no name; 'SYSTEM' blocks are not a person.
|
||||
blockedByName:
|
||||
block.blockedByName ?? (block.blockedBy === 'SYSTEM' ? 'System' : 'Unknown'),
|
||||
count: 0,
|
||||
estimatedLossMinor: 0,
|
||||
currency: block.currency,
|
||||
};
|
||||
entry.count++;
|
||||
entry.estimatedLossMinor += block.estimatedLossMinor;
|
||||
groups.set(key, entry);
|
||||
}
|
||||
}
|
||||
return [...groups.values()].sort((a, b) => b.estimatedLossMinor - a.estimatedLossMinor);
|
||||
}
|
||||
|
||||
function buildMethodology(options: AssembleOptions): string {
|
||||
return [
|
||||
'Estimated loss is a counterfactual: it is the fare each blocked seat would have sold for, not money that left the business.',
|
||||
'Per blocked seat: estimatedLoss = base fare (distance × seat-class per-km tariff × insurance factor) + seat-class premium + insurance fee + the seat\'s own premium fee, priced for the schedule\'s full origin→destination journey.',
|
||||
`Fares are priced at nationality "${options.nationalityAssumption}" (${options.nationalityType} tariff), which also fixes the billing currency. Totals are grouped per currency and never summed across them.`,
|
||||
'estimatedLossAtFullOccupancy assumes every blocked seat would have sold. adjustedLoss = estimatedLoss × load factor (sold ÷ sellable seats on that schedule), because a blocked seat on a half-empty train did not really cost a full fare. The true figure sits between the two.',
|
||||
'A blocked seat counts against a schedule when a SeatBlock names that schedule directly, or when a global block was in effect at departure and the seat\'s coach was assigned to that schedule.',
|
||||
].join(' ');
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common";
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from "@nestjs/swagger";
|
||||
import { Body, Controller, Get, Param, Post, Query, Res } from "@nestjs/common";
|
||||
import type { Response } from "express";
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiBearerAuth,
|
||||
ApiOkResponse,
|
||||
ApiProduces,
|
||||
} from "@nestjs/swagger";
|
||||
import { ReportsService } from "./reports.service";
|
||||
import { GenerateReportDto } from "./reports.dto";
|
||||
import { BlockedSeatsRevenueLossQueryDto, GenerateReportDto } from "./reports.dto";
|
||||
import { PassengerStaff } from "../../common/passenger-guards";
|
||||
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
||||
|
||||
@@ -76,6 +83,54 @@ export class ReportsController {
|
||||
return this.service.getPaymentDiscrepancyBySchedule(scheduleId, { search, seatClass, sort });
|
||||
}
|
||||
|
||||
// ── Blocked Seat Revenue Loss ──────────────────────────────────────────────
|
||||
|
||||
@Get("blocked-seats-revenue-loss")
|
||||
@ApiOperation({
|
||||
summary: "Potential revenue lost to blocked seats, per schedule",
|
||||
description:
|
||||
"For every train schedule in the window, the fare revenue that could never be earned because seats were " +
|
||||
"blocked out of sale — with per-seat drill-down showing who blocked each seat and why.\n\n" +
|
||||
"**A blocked seat counts against a schedule when** a `SeatBlock` row names that `scheduleId` directly, " +
|
||||
"**or** a global block (no `scheduleId`) was in effect at departure — `blockedAt <= departureAt` and " +
|
||||
"(`unblockAt IS NULL` or `unblockAt >= departureAt`) — and the seat's coach was assigned to that schedule " +
|
||||
"via `CoachAssignment`.\n\n" +
|
||||
"**Excluded** (echoed in `meta.exclusions`): dining-coach seats, placeholder seats, CANCELLED schedules, " +
|
||||
"seats that were sold anyway, and ticketing's own bookkeeping blocks.\n\n" +
|
||||
"**This is a counterfactual.** `estimatedLossMinor` assumes every blocked seat would have sold; " +
|
||||
"`adjustedLossMinor` scales it by the schedule's load factor. The real figure sits between the two — " +
|
||||
"`meta.methodology` states the formula and the nationality assumption in full.\n\n" +
|
||||
"All amounts are integer minor units, grouped per currency and never summed across currencies.",
|
||||
})
|
||||
@ApiOkResponse({ description: "Blocked-seat revenue loss report" })
|
||||
getBlockedSeatsRevenueLoss(@Query() query: BlockedSeatsRevenueLossQueryDto) {
|
||||
return this.service.getBlockedSeatsRevenueLoss(query);
|
||||
}
|
||||
|
||||
@Get("blocked-seats-revenue-loss/export")
|
||||
@ApiOperation({
|
||||
summary: "Blocked-seat revenue loss as CSV",
|
||||
description:
|
||||
"Same filters as the JSON report, flattened to one row per blocked seat. Not paginated — the whole " +
|
||||
"filtered result is returned.",
|
||||
})
|
||||
@ApiProduces("text/csv")
|
||||
@ApiOkResponse({ description: "CSV export", schema: { type: "string" } })
|
||||
// `@Res()` without passthrough so the global ResponseTransformInterceptor does not wrap
|
||||
// the CSV in a `{ success, data }` envelope — same approach as the attachment stream.
|
||||
async exportBlockedSeatsRevenueLoss(
|
||||
@Query() query: BlockedSeatsRevenueLossQueryDto,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const csv = await this.service.exportBlockedSeatsRevenueLossCsv(query);
|
||||
res.setHeader("Content-Type", "text/csv; charset=utf-8");
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="blocked-seats-revenue-loss-${new Date().toISOString().split("T")[0]}.csv"`,
|
||||
);
|
||||
res.send(csv);
|
||||
}
|
||||
|
||||
@Get(":reportId")
|
||||
@ApiOperation({ summary: "Get report by ID" })
|
||||
getReport(@Param("reportId") reportId: string) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { IsString, IsDateString, IsOptional, IsEnum } from 'class-validator';
|
||||
import { IsString, IsDateString, IsOptional, IsEnum, IsInt, Min, Max } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { SeatBlockReasonCategory } from '../seats/seats.dto';
|
||||
|
||||
export enum ReportType {
|
||||
REVENUE = 'REVENUE',
|
||||
@@ -27,3 +29,77 @@ export class ExportReportDto {
|
||||
@ApiProperty() @IsString() reportId: string;
|
||||
@ApiProperty({ enum: ExportFormat }) @IsEnum(ExportFormat) format: ExportFormat;
|
||||
}
|
||||
|
||||
// ── Blocked Seat Revenue Loss ────────────────────────────────────────────────
|
||||
|
||||
export enum BlockedSeatsLossSortBy {
|
||||
/** Largest estimated loss first (default). */
|
||||
LOSS_DESC = 'lossMinor',
|
||||
/** Smallest estimated loss first. */
|
||||
LOSS_ASC = 'lossMinorAsc',
|
||||
/** Most blocked seats first. */
|
||||
BLOCKED_SEATS = 'blockedSeatCount',
|
||||
/** Soonest departure first. */
|
||||
DEPARTURE = 'departureAt',
|
||||
}
|
||||
|
||||
export class BlockedSeatsRevenueLossQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
example: '2026-07-01',
|
||||
description: 'Start of the window, inclusive, matched on TrainSchedule.departureAt. Defaults to 30 days ago.',
|
||||
})
|
||||
@IsOptional() @IsDateString() dateFrom?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: '2026-07-31',
|
||||
description: 'End of the window, inclusive, matched on TrainSchedule.departureAt. Defaults to today.',
|
||||
})
|
||||
@IsOptional() @IsDateString() dateTo?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Restrict to a single TrainSchedule.' })
|
||||
@IsOptional() @IsString() scheduleId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Restrict to schedules running this route.' })
|
||||
@IsOptional() @IsString() routeId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Restrict to schedules operated by this train.' })
|
||||
@IsOptional() @IsString() trainId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Restrict to blocks on seats in this coach. Load factor still reflects the whole train, so the percentage stays comparable.',
|
||||
})
|
||||
@IsOptional() @IsString() coachId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: SeatBlockReasonCategory,
|
||||
description: 'Restrict to blocks in this reporting bucket. Legacy uncategorized blocks are excluded when set.',
|
||||
})
|
||||
@IsOptional() @IsEnum(SeatBlockReasonCategory) reasonCategory?: SeatBlockReasonCategory;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Blocker filter — matches the IAM user id exactly, or the recorded name case-insensitively.",
|
||||
})
|
||||
@IsOptional() @IsString() blockedBy?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 'Ethiopian',
|
||||
default: 'Ethiopian',
|
||||
description:
|
||||
'Nationality the counterfactual fares are priced at. Drives both the seat-class tariff variant (LOCAL vs INTERNATIONAL) and the billing currency. Defaults to Ethiopian — the local tariff in ETB.',
|
||||
})
|
||||
@IsOptional() @IsString() nationality?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: 1, minimum: 1, description: 'Page of schedules, 1-based.' })
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1) page?: number;
|
||||
|
||||
@ApiPropertyOptional({ default: 25, minimum: 1, maximum: 200, description: 'Schedules per page.' })
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(200) pageSize?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: BlockedSeatsLossSortBy,
|
||||
default: BlockedSeatsLossSortBy.LOSS_DESC,
|
||||
description: 'Schedule ordering. Defaults to largest estimated loss first.',
|
||||
})
|
||||
@IsOptional() @IsEnum(BlockedSeatsLossSortBy) sortBy?: BlockedSeatsLossSortBy;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,12 @@ import { Module } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { ReportsController } from './reports.controller';
|
||||
import { ReportsService } from './reports.service';
|
||||
import { FareEngineModule } from '../fare-engine/fare-engine.module';
|
||||
|
||||
@Module({
|
||||
imports: [HttpModule],
|
||||
// FareEngineModule supplies the counterfactual fares the blocked-seat revenue
|
||||
// loss report prices blocked seats against — never re-implemented here.
|
||||
imports: [HttpModule, FareEngineModule],
|
||||
controllers: [ReportsController],
|
||||
providers: [ReportsService],
|
||||
exports: [ReportsService]
|
||||
|
||||
@@ -1,8 +1,105 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { InjectDataSource } from "@nestjs/typeorm";
|
||||
import { DataSource } from "typeorm";
|
||||
import {
|
||||
BlockedSeatRevenueLossReport,
|
||||
UNCATEGORIZED_REASON_CATEGORY,
|
||||
} from "@edr/types";
|
||||
import { PrismaService } from "../../common/prisma.service";
|
||||
import { GenerateReportDto, ReportType } from "./reports.dto";
|
||||
import { FareEngineService } from "../fare-engine/fare-engine.service";
|
||||
import {
|
||||
BlockedSeatsLossSortBy,
|
||||
BlockedSeatsRevenueLossQueryDto,
|
||||
GenerateReportDto,
|
||||
ReportType,
|
||||
} from "./reports.dto";
|
||||
import {
|
||||
assembleReport,
|
||||
LossCalculatorInput,
|
||||
LossCoach,
|
||||
LossFare,
|
||||
LossSeat,
|
||||
selectCountedBlocks,
|
||||
soldKey,
|
||||
} from "./blocked-seats-loss.calculator";
|
||||
|
||||
/** Fares are quoted at the local tariff unless the caller asks otherwise. */
|
||||
const DEFAULT_LOSS_NATIONALITY = "Ethiopian";
|
||||
/** Window used when the caller supplies neither `dateFrom` nor `dateTo`. */
|
||||
const DEFAULT_LOSS_WINDOW_DAYS = 30;
|
||||
const DEFAULT_LOSS_PAGE_SIZE = 25;
|
||||
/** How many schedules are priced in parallel. Keeps the DB from being flooded. */
|
||||
const FARE_QUOTE_CONCURRENCY = 4;
|
||||
/** CSV export is not paginated, but still needs an upper bound. */
|
||||
const CSV_EXPORT_MAX_SCHEDULES = 5000;
|
||||
|
||||
const EMPTY_LOSS_INPUT: LossCalculatorInput = {
|
||||
schedules: [],
|
||||
seatsById: new Map(),
|
||||
coachesById: new Map(),
|
||||
coachIdsBySchedule: new Map(),
|
||||
soldSeatKeys: new Set(),
|
||||
blocks: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves the reporting window. Both bounds are inclusive and snap to whole local days,
|
||||
* matching `generateReport`. Defaults to the last 30 days of departures.
|
||||
*/
|
||||
function resolveWindow(
|
||||
query: Pick<BlockedSeatsRevenueLossQueryDto, "dateFrom" | "dateTo">,
|
||||
now: Date,
|
||||
): { dateFrom: Date; dateTo: Date } {
|
||||
const dateTo = query.dateTo ? new Date(query.dateTo) : new Date(now);
|
||||
dateTo.setHours(23, 59, 59, 999);
|
||||
|
||||
const dateFrom = query.dateFrom
|
||||
? new Date(query.dateFrom)
|
||||
: new Date(dateTo.getTime() - DEFAULT_LOSS_WINDOW_DAYS * 24 * 60 * 60 * 1000);
|
||||
dateFrom.setHours(0, 0, 0, 0);
|
||||
|
||||
return { dateFrom, dateTo };
|
||||
}
|
||||
|
||||
/** Mirrors the fare engine's own LOCAL/INTERNATIONAL split. */
|
||||
function resolveNationalityType(nationality: string): string {
|
||||
const upper = nationality.toUpperCase();
|
||||
return upper === "ETHIOPIAN" || upper === "DJIBOUTIAN" ? "LOCAL" : "INTERNATIONAL";
|
||||
}
|
||||
|
||||
/**
|
||||
* The fare engine returns two shapes: a full distance-based calculation, and a thinner
|
||||
* FareRule fallback for schedules with no route. Both are reduced to the fields the loss
|
||||
* calculator needs, or dropped if neither shape is present.
|
||||
*/
|
||||
function normalizeFareQuote(quote: unknown): LossFare | null {
|
||||
if (typeof quote !== "object" || quote === null) return null;
|
||||
const q = quote as Record<string, unknown>;
|
||||
|
||||
const seatClassId = q.seatClassId;
|
||||
if (typeof seatClassId !== "string") return null;
|
||||
|
||||
const fareMinor =
|
||||
typeof q.farePerPassengerMinor === "number"
|
||||
? q.farePerPassengerMinor
|
||||
: typeof q.totalMinor === "number"
|
||||
? q.totalMinor
|
||||
: null;
|
||||
if (fareMinor === null) return null;
|
||||
|
||||
return {
|
||||
seatClassId,
|
||||
seatClassName: typeof q.seatClassName === "string" ? q.seatClassName : "Unknown",
|
||||
farePerPassengerMinor: fareMinor,
|
||||
exchangeRate: typeof q.exchangeRate === "number" ? q.exchangeRate : 1,
|
||||
currency: typeof q.billingCurrency === "string" ? q.billingCurrency : "ETB",
|
||||
};
|
||||
}
|
||||
|
||||
/** RFC 4180 cell: always quoted, embedded quotes doubled. */
|
||||
function toCsvCell(value: string | number): string {
|
||||
return `"${String(value).replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ReportsService {
|
||||
@@ -10,6 +107,7 @@ export class ReportsService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
@InjectDataSource() private dataSource: DataSource,
|
||||
private fareEngine: FareEngineService,
|
||||
) {}
|
||||
|
||||
async generateReport(dto: GenerateReportDto) {
|
||||
@@ -1135,6 +1233,318 @@ export class ReportsService {
|
||||
};
|
||||
}
|
||||
|
||||
// ── Blocked Seat Revenue Loss ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Potential revenue lost to seats that were blocked and therefore never sellable.
|
||||
*
|
||||
* The counting rule and the money live in `blocked-seats-loss.calculator.ts`; this method
|
||||
* is the fetch plan. Query count is bounded and independent of the number of schedules:
|
||||
* schedules → coach assignments → seats → booking seats → seat blocks, plus one fare
|
||||
* calculation per *affected* schedule (schedules with no blocked seat need no fare).
|
||||
*/
|
||||
async getBlockedSeatsRevenueLoss(
|
||||
query: BlockedSeatsRevenueLossQueryDto,
|
||||
): Promise<BlockedSeatRevenueLossReport> {
|
||||
const now = new Date();
|
||||
const { dateFrom, dateTo } = resolveWindow(query, now);
|
||||
const nationalityAssumption = query.nationality?.trim() || DEFAULT_LOSS_NATIONALITY;
|
||||
const nationalityType = resolveNationalityType(nationalityAssumption);
|
||||
|
||||
// 1 — schedules in the window. CANCELLED trains never ran, so nothing was lost on them.
|
||||
const schedules = await this.prisma.trainSchedule.findMany({
|
||||
where: {
|
||||
departureAt: { gte: dateFrom, lte: dateTo },
|
||||
status: { not: 'CANCELLED' },
|
||||
...(query.scheduleId ? { id: query.scheduleId } : {}),
|
||||
...(query.routeId ? { routeId: query.routeId } : {}),
|
||||
...(query.trainId ? { trainId: query.trainId } : {}),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
departureAt: true,
|
||||
status: true,
|
||||
train: { select: { number: true } },
|
||||
route: { select: { name: true } },
|
||||
originStation: { select: { name: true } },
|
||||
destinationStation: { select: { name: true } },
|
||||
},
|
||||
orderBy: { departureAt: 'desc' },
|
||||
});
|
||||
|
||||
const emptyOptions = {
|
||||
faresBySchedule: new Map<string, Map<string, LossFare>>(),
|
||||
schedulesWithoutFare: new Set<string>(),
|
||||
nationalityType,
|
||||
nationalityAssumption,
|
||||
now,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
page: query.page ?? 1,
|
||||
pageSize: query.pageSize ?? DEFAULT_LOSS_PAGE_SIZE,
|
||||
sortBy: query.sortBy ?? BlockedSeatsLossSortBy.LOSS_DESC,
|
||||
};
|
||||
|
||||
if (schedules.length === 0) {
|
||||
return assembleReport(EMPTY_LOSS_INPUT, new Map(), emptyOptions);
|
||||
}
|
||||
|
||||
const scheduleIds = schedules.map((s) => s.id);
|
||||
const departures = schedules.map((s) => s.departureAt.getTime());
|
||||
const earliestDeparture = new Date(Math.min(...departures));
|
||||
const latestDeparture = new Date(Math.max(...departures));
|
||||
|
||||
// 2 — coach assignments. Unfiltered by `coachId` on purpose: the load factor must
|
||||
// describe the whole train even when the block list is narrowed to one coach.
|
||||
const assignments = await this.prisma.coachAssignment.findMany({
|
||||
where: { scheduleId: { in: scheduleIds } },
|
||||
select: {
|
||||
scheduleId: true,
|
||||
coachId: true,
|
||||
coach: {
|
||||
select: {
|
||||
id: true,
|
||||
number: true,
|
||||
coachType: {
|
||||
select: {
|
||||
name: true,
|
||||
type: true,
|
||||
seatClasses: {
|
||||
select: { id: true, name: true, bedPosition: true, nationalityType: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const coachesById = new Map<string, LossCoach>();
|
||||
const coachIdsBySchedule = new Map<string, Set<string>>();
|
||||
for (const assignment of assignments) {
|
||||
const coachIds = coachIdsBySchedule.get(assignment.scheduleId) ?? new Set<string>();
|
||||
coachIds.add(assignment.coachId);
|
||||
coachIdsBySchedule.set(assignment.scheduleId, coachIds);
|
||||
|
||||
if (!coachesById.has(assignment.coachId)) {
|
||||
coachesById.set(assignment.coachId, {
|
||||
id: assignment.coach.id,
|
||||
number: assignment.coach.number,
|
||||
coachTypeType: assignment.coach.coachType?.type ?? 'passenger',
|
||||
coachTypeName: assignment.coach.coachType?.name ?? 'Unknown',
|
||||
seatClasses: assignment.coach.coachType?.seatClasses ?? [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3 — seats on those coaches. Bounded by fleet size, not by schedule count.
|
||||
const coachIds = [...coachesById.keys()];
|
||||
const seatRows = coachIds.length
|
||||
? await this.prisma.seat.findMany({
|
||||
where: { coachId: { in: coachIds } },
|
||||
select: {
|
||||
id: true,
|
||||
coachId: true,
|
||||
seatNumber: true,
|
||||
bedPosition: true,
|
||||
premiumFeeMinor: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
const seatsById = new Map<string, LossSeat>(seatRows.map((s) => [s.id, s]));
|
||||
|
||||
// 4 — seats actually sold on these schedules. Same tri-branch shape the other
|
||||
// schedule reports use: outbound leg, return leg, and legacy rows with a null
|
||||
// scheduleId that inherit the booking's schedule.
|
||||
const bookingSeats = await this.prisma.bookingSeat.findMany({
|
||||
where: {
|
||||
booking: { status: { in: ['CONFIRMED', 'BOARDED'] } },
|
||||
OR: [
|
||||
{ scheduleId: { in: scheduleIds } },
|
||||
{ leg: 2, booking: { returnScheduleId: { in: scheduleIds } } },
|
||||
{ scheduleId: null, leg: 1, booking: { scheduleId: { in: scheduleIds } } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
seatId: true,
|
||||
scheduleId: true,
|
||||
leg: true,
|
||||
booking: { select: { scheduleId: true, returnScheduleId: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const scheduleIdSet = new Set(scheduleIds);
|
||||
const soldSeatKeys = new Set<string>();
|
||||
for (const bs of bookingSeats) {
|
||||
const effectiveScheduleId =
|
||||
bs.scheduleId ?? (bs.leg === 2 ? bs.booking.returnScheduleId : bs.booking.scheduleId);
|
||||
if (!effectiveScheduleId || !scheduleIdSet.has(effectiveScheduleId)) continue;
|
||||
soldSeatKeys.add(soldKey(effectiveScheduleId, bs.seatId));
|
||||
}
|
||||
|
||||
// 5 — candidate blocks: schedule-scoped ones for these schedules, plus global ones
|
||||
// whose active window overlaps the departure range at all. Per-schedule precision
|
||||
// is applied in the calculator against each schedule's own departureAt.
|
||||
const blockRows = await this.prisma.seatBlock.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
OR: [
|
||||
{ scheduleId: { in: scheduleIds } },
|
||||
{
|
||||
scheduleId: null,
|
||||
blockedAt: { lte: latestDeparture },
|
||||
OR: [{ unblockAt: null }, { unblockAt: { gte: earliestDeparture } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
...(query.reasonCategory ? [{ reasonCategory: query.reasonCategory }] : []),
|
||||
...(query.coachId ? [{ seat: { coachId: query.coachId } }] : []),
|
||||
...(query.blockedBy
|
||||
? [
|
||||
{
|
||||
OR: [
|
||||
{ blockedBy: query.blockedBy },
|
||||
{
|
||||
blockedByName: {
|
||||
contains: query.blockedBy,
|
||||
mode: 'insensitive' as const,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
seatId: true,
|
||||
scheduleId: true,
|
||||
reason: true,
|
||||
reasonCategory: true,
|
||||
blockedBy: true,
|
||||
blockedByName: true,
|
||||
approvedBy: true,
|
||||
blockedAt: true,
|
||||
unblockAt: true,
|
||||
},
|
||||
orderBy: { blockedAt: 'desc' },
|
||||
});
|
||||
|
||||
const input: LossCalculatorInput = {
|
||||
schedules: schedules.map((s) => ({
|
||||
id: s.id,
|
||||
trainNumber: s.train?.number ?? '—',
|
||||
routeName: s.route?.name ?? null,
|
||||
originStation: s.originStation?.name ?? '—',
|
||||
destinationStation: s.destinationStation?.name ?? '—',
|
||||
departureAt: s.departureAt,
|
||||
status: s.status,
|
||||
})),
|
||||
seatsById,
|
||||
coachesById,
|
||||
coachIdsBySchedule,
|
||||
soldSeatKeys,
|
||||
blocks: blockRows,
|
||||
};
|
||||
|
||||
const countedBySchedule = selectCountedBlocks(input);
|
||||
|
||||
// 6 — one fare calculation per affected schedule, never per seat.
|
||||
const { faresBySchedule, schedulesWithoutFare } = await this.quoteFaresForSchedules(
|
||||
[...countedBySchedule.keys()],
|
||||
nationalityAssumption,
|
||||
);
|
||||
|
||||
return assembleReport(input, countedBySchedule, {
|
||||
...emptyOptions,
|
||||
faresBySchedule,
|
||||
schedulesWithoutFare,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Quotes every active seat class on each affected schedule, in small concurrent batches
|
||||
* so a wide date range does not open hundreds of simultaneous fare calculations.
|
||||
*/
|
||||
private async quoteFaresForSchedules(
|
||||
scheduleIds: string[],
|
||||
nationality: string,
|
||||
): Promise<{
|
||||
faresBySchedule: Map<string, Map<string, LossFare>>;
|
||||
schedulesWithoutFare: Set<string>;
|
||||
}> {
|
||||
const faresBySchedule = new Map<string, Map<string, LossFare>>();
|
||||
const schedulesWithoutFare = new Set<string>();
|
||||
|
||||
for (let i = 0; i < scheduleIds.length; i += FARE_QUOTE_CONCURRENCY) {
|
||||
const batch = scheduleIds.slice(i, i + FARE_QUOTE_CONCURRENCY);
|
||||
await Promise.all(
|
||||
batch.map(async (scheduleId) => {
|
||||
try {
|
||||
const quotes = await this.fareEngine.calculateAllForSchedule(scheduleId, nationality);
|
||||
const bySeatClass = new Map<string, LossFare>();
|
||||
for (const quote of quotes) {
|
||||
const fare = normalizeFareQuote(quote);
|
||||
if (fare) bySeatClass.set(fare.seatClassId, fare);
|
||||
}
|
||||
if (bySeatClass.size === 0) {
|
||||
schedulesWithoutFare.add(scheduleId);
|
||||
return;
|
||||
}
|
||||
faresBySchedule.set(scheduleId, bySeatClass);
|
||||
} catch (err) {
|
||||
// A schedule with no route and no fare rules cannot be priced. Its blocked
|
||||
// seats still show up in the report; they just carry no monetary claim.
|
||||
this.logger.warn(
|
||||
`Blocked-seat loss: no fare for schedule ${scheduleId} — ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
schedulesWithoutFare.add(scheduleId);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return { faresBySchedule, schedulesWithoutFare };
|
||||
}
|
||||
|
||||
/** CSV of the same report, one row per blocked seat, honouring the same filters. */
|
||||
async exportBlockedSeatsRevenueLossCsv(
|
||||
query: BlockedSeatsRevenueLossQueryDto,
|
||||
): Promise<string> {
|
||||
// Export is the whole filtered result, not the caller's page.
|
||||
const report = await this.getBlockedSeatsRevenueLoss({
|
||||
...query,
|
||||
page: 1,
|
||||
pageSize: CSV_EXPORT_MAX_SCHEDULES,
|
||||
});
|
||||
|
||||
const headers = [
|
||||
'Train', 'Route', 'Origin', 'Destination', 'Departure', 'Schedule Status',
|
||||
'Sellable Seats', 'Sold Seats', 'Load Factor %', 'Coach', 'Seat', 'Seat Class',
|
||||
'Block Type', 'Reason Category', 'Reason', 'Blocked By', 'Blocked By Name',
|
||||
'Approved By', 'Blocked At', 'Unblock At', 'Still Blocked', 'Days Blocked',
|
||||
'Estimated Loss (minor)', 'Currency',
|
||||
];
|
||||
|
||||
const rows = report.schedules.flatMap((s) =>
|
||||
s.blocks.map((b) => [
|
||||
s.trainNumber, s.routeName ?? '', s.originStation, s.destinationStation,
|
||||
s.departureAt, s.status, s.sellableSeats, s.soldSeats, s.loadFactorPercent,
|
||||
b.coachNumber ?? '', b.seatNumber ?? '', b.seatClassName ?? '',
|
||||
b.blockType, b.reasonCategory ?? UNCATEGORIZED_REASON_CATEGORY, b.reason,
|
||||
b.blockedBy, b.blockedByName ?? '', b.approvedBy ?? '',
|
||||
b.blockedAt, b.unblockAt ?? '', b.stillBlocked ? 'YES' : 'NO', b.daysBlocked,
|
||||
b.estimatedLossMinor, b.currency,
|
||||
]),
|
||||
);
|
||||
|
||||
return [headers, ...rows].map((row) => row.map(toCsvCell).join(',')).join('\n');
|
||||
}
|
||||
|
||||
async getReport(reportId: string) {
|
||||
return this.prisma.operationalReport.findUnique({
|
||||
where: { id: reportId },
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, ParseInt
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||
import { SchedulesService } from './schedules.service';
|
||||
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto, TripStatus } from './schedules.dto';
|
||||
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto, TripStatus, ApplyDelayDto } from './schedules.dto';
|
||||
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
|
||||
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||
|
||||
@@ -177,6 +177,22 @@ export class SchedulesController {
|
||||
@Body() dto: UpdateStopTimeDto,
|
||||
) { return this.service.updateStop(id, sequence, dto); }
|
||||
|
||||
@Post(':id/delay')
|
||||
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Report a delay — pushes every downstream stop\'s planned times (and check-in cutoffs) back by the same amount',
|
||||
description: `Shifts plannedArrivalAt/plannedDepartureAt on every stop not yet BOARDED/COMPLETED (or from fromSequence
|
||||
onward, if given) by delayMinutes. Since check-in cutoffs are derived directly from these planned
|
||||
times, this is the only action needed for booking closure to reflect the delay — no separate cutoff
|
||||
update. Also shifts the schedule's own departureAt/arrivalAt when the origin stop is included, and
|
||||
records the accumulated delay on the schedule's live status. Does not change schedule/stop status.`,
|
||||
})
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Schedule with shifted stop times' })
|
||||
applyDelay(@Param('id') id: string, @Body() dto: ApplyDelayDto) {
|
||||
return this.service.applyDelay(id, dto);
|
||||
}
|
||||
|
||||
@Put(':scheduleId/fares/:seatClassId')
|
||||
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({
|
||||
|
||||
@@ -113,6 +113,14 @@ export class UpdateScheduleStatusDto {
|
||||
@ApiProperty({ enum: TripStatus, example: TripStatus.EN_ROUTE }) @IsEnum(TripStatus) status: TripStatus;
|
||||
}
|
||||
|
||||
export class ApplyDelayDto {
|
||||
@ApiProperty({ example: 60, description: 'Minutes to shift downstream stop times by. Negative to correct an over-reported delay.' })
|
||||
@IsInt() delayMinutes: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 3, description: 'Only shift stops from this sequence onward. Omit to default to every stop not yet BOARDED/COMPLETED.' })
|
||||
@IsOptional() @IsInt() @Min(1) fromSequence?: number;
|
||||
}
|
||||
|
||||
export class BulkCreateSchedulesDto {
|
||||
@ApiProperty({ example: 'train-uuid', description: 'Train UUID' }) @IsString() trainId: string;
|
||||
@ApiProperty({ example: 'route-uuid', description: 'Route UUID' }) @IsString() routeId: string;
|
||||
|
||||
@@ -5,9 +5,10 @@ import { RoutesController } from './routes.controller';
|
||||
import { RoutesService } from './routes.service';
|
||||
import { FareEngineModule } from '../fare-engine/fare-engine.module';
|
||||
import { AuditModule } from '../../common/audit.module';
|
||||
import { LiveModule } from '../live/live.module';
|
||||
|
||||
@Module({
|
||||
imports: [FareEngineModule, AuditModule],
|
||||
imports: [FareEngineModule, AuditModule, LiveModule],
|
||||
controllers: [RoutesController, SchedulesController],
|
||||
providers: [RoutesService, SchedulesService],
|
||||
exports: [RoutesService, SchedulesService],
|
||||
|
||||
@@ -2,10 +2,11 @@ import { Injectable, Logger, NotFoundException, BadRequestException } from '@nes
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { RoutesService } from './routes.service';
|
||||
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
||||
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto } from './schedules.dto';
|
||||
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, ApplyDelayDto } from './schedules.dto';
|
||||
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
|
||||
import { parseEthiopianTime, startOfDayEAT, startOfNextDayEAT } from '../../common/utils/timezone.utils';
|
||||
import { AuditService } from '../../common/audit.service';
|
||||
import { LiveService } from '../live/live.service';
|
||||
import { computePlannedStopTimes } from '../../common/utils/schedule-times.utils';
|
||||
|
||||
@Injectable()
|
||||
@@ -17,8 +18,47 @@ export class SchedulesService {
|
||||
private routesService: RoutesService,
|
||||
private fareEngine: FareEngineService,
|
||||
private auditService: AuditService,
|
||||
private liveService: LiveService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Computes each stop's planned arrival/departure time by walking the route in sequence
|
||||
* order and accumulating `RouteStop.travelMinutesToStop` (minutes of travel from the
|
||||
* previous stop). Falls back to distance-proportional interpolation over `distanceKm` for
|
||||
* any stop missing `travelMinutesToStop`. The last stop is always locked to the confirmed
|
||||
* overall `arr` regardless of the accumulated cursor, so schedule.arrivalAt stays
|
||||
* authoritative even if per-stop estimates drift.
|
||||
*/
|
||||
private computePlannedTimes(
|
||||
route: { id: string; stops: { sequence: number; distanceKm: number | null; travelMinutesToStop: number | null }[] },
|
||||
dep: Date,
|
||||
arr: Date,
|
||||
) {
|
||||
const totalDuration = arr.getTime() - dep.getTime();
|
||||
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
|
||||
|
||||
let cursor = dep;
|
||||
return route.stops.map((stop, index) => {
|
||||
if (index === 0) {
|
||||
cursor = dep;
|
||||
} else if (index === route.stops.length - 1) {
|
||||
cursor = arr;
|
||||
} else if (stop.travelMinutesToStop != null) {
|
||||
cursor = new Date(cursor.getTime() + stop.travelMinutesToStop * 60_000);
|
||||
} else {
|
||||
const stopDistance = stop.distanceKm || 0;
|
||||
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
|
||||
cursor = new Date(dep.getTime() + totalDuration * progress);
|
||||
this.logger.warn(`Route ${route.id} stop seq ${stop.sequence} missing travelMinutesToStop; falling back to distance interpolation`);
|
||||
}
|
||||
return {
|
||||
sequence: stop.sequence,
|
||||
plannedArrivalAt: index === 0 ? undefined : cursor.toISOString(),
|
||||
plannedDepartureAt: index === route.stops.length - 1 ? undefined : cursor.toISOString(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async bulkGenerateSchedules(dto: BulkCreateSchedulesDto) {
|
||||
const startDate = parseEthiopianTime(dto.startDateTime);
|
||||
const endDate = new Date(startDate.getTime() + dto.forNextDays * 24 * 60 * 60 * 1000);
|
||||
@@ -89,6 +129,7 @@ export class SchedulesService {
|
||||
include: { coach: true },
|
||||
orderBy: { positionNumber: 'asc' },
|
||||
},
|
||||
liveStatus: { select: { delayMinutes: true } },
|
||||
_count: { select: { coachAssignments: true, bookings: true } },
|
||||
},
|
||||
orderBy: { departureAt: 'asc' },
|
||||
@@ -132,7 +173,7 @@ export class SchedulesService {
|
||||
|
||||
let plannedTimes = dto.plannedTimes;
|
||||
if (!plannedTimes || plannedTimes.length === 0) {
|
||||
plannedTimes = computePlannedStopTimes(route, dep, arr);
|
||||
plannedTimes = this.computePlannedTimes(route, dep, arr);
|
||||
}
|
||||
|
||||
const providedSeqs = new Set((plannedTimes ?? []).map(t => t.sequence));
|
||||
@@ -209,6 +250,7 @@ export class SchedulesService {
|
||||
orderBy: { positionNumber: 'asc' },
|
||||
},
|
||||
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
|
||||
liveStatus: { select: { delayMinutes: true } },
|
||||
},
|
||||
});
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
@@ -303,7 +345,7 @@ export class SchedulesService {
|
||||
|
||||
let plannedTimes = dto.plannedTimes;
|
||||
if (!plannedTimes || plannedTimes.length === 0) {
|
||||
plannedTimes = computePlannedStopTimes(route, dep, arr);
|
||||
plannedTimes = this.computePlannedTimes(route, dep, arr);
|
||||
}
|
||||
|
||||
const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
|
||||
@@ -425,6 +467,70 @@ export class SchedulesService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shifts stored planned times additively rather than reusing updateSchedulePartial's
|
||||
* recompute-from-route-interpolation path — that path also guards `departureAt must be in the
|
||||
* future`, which a delay report for an already-departed/EN_ROUTE train would legitimately
|
||||
* fail. Check-in cutoffs (resolveCheckinCutoff, SeatsService.holdSeats) are both derived
|
||||
* directly from TripStopTime.plannedArrivalAt/plannedDepartureAt at read time, so shifting the
|
||||
* stored values here is the entire fix — neither of those needs to change.
|
||||
*/
|
||||
async applyDelay(scheduleId: string, dto: ApplyDelayDto) {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId } });
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
|
||||
const stopWhere: any = { scheduleId };
|
||||
if (dto.fromSequence != null) {
|
||||
stopWhere.sequence = { gte: dto.fromSequence };
|
||||
} else {
|
||||
// Default: only stops the train hasn't reached yet — a delay report must not retroactively
|
||||
// move a stop that's already BOARDED/COMPLETED.
|
||||
stopWhere.status = { notIn: ['BOARDED', 'COMPLETED'] };
|
||||
}
|
||||
|
||||
const stopsToShift = await this.prisma.tripStopTime.findMany({ where: stopWhere });
|
||||
const shiftMs = dto.delayMinutes * 60_000;
|
||||
const includesOrigin = stopsToShift.some((s) => s.sequence === 1);
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
for (const stop of stopsToShift) {
|
||||
await tx.tripStopTime.update({
|
||||
where: { id: stop.id },
|
||||
data: {
|
||||
plannedArrivalAt: stop.plannedArrivalAt ? new Date(stop.plannedArrivalAt.getTime() + shiftMs) : undefined,
|
||||
plannedDepartureAt: stop.plannedDepartureAt ? new Date(stop.plannedDepartureAt.getTime() + shiftMs) : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Origin stop shifted → the schedule's own departureAt/arrivalAt drive search's day-window
|
||||
// queries and the displayed departure time, so they must move too (both together, so
|
||||
// durationMinutes stays correct).
|
||||
if (includesOrigin) {
|
||||
await tx.trainSchedule.update({
|
||||
where: { id: scheduleId },
|
||||
data: {
|
||||
departureAt: new Date(schedule.departureAt.getTime() + shiftMs),
|
||||
arrivalAt: new Date(schedule.arrivalAt.getTime() + shiftMs),
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const currentLive = await this.prisma.tripLiveStatus.findUnique({ where: { scheduleId } });
|
||||
const accumulatedDelayMinutes = Math.max(0, (currentLive?.delayMinutes ?? 0) + dto.delayMinutes);
|
||||
await this.liveService.updateLiveStatus(scheduleId, { delayMinutes: accumulatedDelayMinutes });
|
||||
|
||||
await this.auditService.log({
|
||||
action: 'UPDATE',
|
||||
entityType: 'Schedule',
|
||||
entityId: scheduleId,
|
||||
newData: { delayMinutes: dto.delayMinutes, fromSequence: dto.fromSequence, accumulatedDelayMinutes },
|
||||
});
|
||||
|
||||
return this.getSchedule(scheduleId);
|
||||
}
|
||||
|
||||
async upsertScheduleFare(
|
||||
scheduleId: string,
|
||||
seatClassId: string,
|
||||
@@ -681,7 +787,7 @@ export class SchedulesService {
|
||||
include: { stops: { orderBy: { sequence: 'asc' } } },
|
||||
});
|
||||
if (route && route.stops.length >= 2) {
|
||||
const plannedTimes = computePlannedStopTimes(route, dep, arr);
|
||||
const plannedTimes = this.computePlannedTimes(route, dep, arr);
|
||||
const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
|
||||
await this.routesService.applyRouteToSchedule(schedule.routeId, id, plannedTimesMap);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Body, Controller, Post, Get, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger';
|
||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||
import { SearchService } from './search.service';
|
||||
import { SearchTripsDto, FareQuoteDto, FareBreakdownRequestDto } from './search.dto';
|
||||
import { SearchTripsDto, FareQuoteDto, FareBreakdownRequestDto, AvailableDatesQueryDto } from './search.dto';
|
||||
|
||||
@ApiTags('Search')
|
||||
@Controller('search')
|
||||
@@ -67,6 +67,21 @@ Nationality-Based:
|
||||
return this.service.getFareQuote(dto);
|
||||
}
|
||||
|
||||
@Get('available-dates')
|
||||
@ApiOperation({
|
||||
summary: 'Which dates in a range have a bookable schedule for an origin/destination pair',
|
||||
description: `Used to disable schedule-less dates on the search date picker before the user submits a search.
|
||||
|
||||
For each date in the (server-clamped, max 90-day) range, a date is "available" if at least one
|
||||
schedule exists for the origin→destination pair whose status/package/coach state is bookable and
|
||||
whose check-in cutoff has not yet passed. This does not check seat-level availability — a date
|
||||
can be marked available and still turn out fully booked when actually searched.`,
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'routeExists flag plus a per-date availability list' })
|
||||
getAvailableDates(@Query() dto: AvailableDatesQueryDto) {
|
||||
return this.service.getAvailableDates(dto);
|
||||
}
|
||||
|
||||
@Get('fare-breakdown')
|
||||
@ApiOperation({
|
||||
summary: 'Per-passenger fare breakdown for booking review page',
|
||||
|
||||
@@ -29,6 +29,20 @@ export class SearchTripsDto {
|
||||
@IsOptional() @IsDateString() returnDate?: string;
|
||||
}
|
||||
|
||||
export class AvailableDatesQueryDto {
|
||||
@ApiProperty({ example: 'station-uuid', description: 'Origin station UUID' })
|
||||
@IsString() originStationId: string;
|
||||
|
||||
@ApiProperty({ example: 'station-uuid', description: 'Destination station UUID' })
|
||||
@IsString() destinationStationId: string;
|
||||
|
||||
@ApiProperty({ example: '2026-06-15', description: 'Start of the date range (YYYY-MM-DD)' })
|
||||
@IsDateString() from: string;
|
||||
|
||||
@ApiProperty({ example: '2026-09-13', description: 'End of the date range (YYYY-MM-DD), inclusive — server clamps to a max 90-day span' })
|
||||
@IsDateString() to: string;
|
||||
}
|
||||
|
||||
export class FareQuoteDto {
|
||||
@ApiProperty({ example: 'schedule-uuid', description: 'TrainSchedule UUID from search results' })
|
||||
@IsString() scheduleId: string;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
FareQuoteDto,
|
||||
FareBreakdownRequestDto,
|
||||
FareBreakdownPassengerDto,
|
||||
AvailableDatesQueryDto,
|
||||
} from "./search.dto";
|
||||
import { CurrencyService } from "../currency/currency.service";
|
||||
import { FareEngineService } from "../fare-engine/fare-engine.service";
|
||||
@@ -12,6 +13,7 @@ import { SegmentsService } from "../segments/segments.service";
|
||||
import { resolveCurrencyFromNationality } from "../fare-engine/fare-engine.dto";
|
||||
import { resolveCheckinCutoff } from "../../common/utils/checkin-cutoff.utils";
|
||||
import { Currency, Prisma } from "@prisma/client";
|
||||
import { Passenger } from "@edr/types";
|
||||
|
||||
const POINTS_TO_MINOR = 10;
|
||||
|
||||
@@ -102,19 +104,23 @@ export class SearchService {
|
||||
const outbound = [...direct, ...transit];
|
||||
|
||||
if (outbound.length === 0 && dto.journeyType !== "ROUND_TRIP") {
|
||||
const alternativesOutbound = await this.searchAlternatives(
|
||||
dto.originStationId,
|
||||
dto.destinationStationId,
|
||||
dto.date,
|
||||
dto.adultCount,
|
||||
dto.childCount,
|
||||
dto.nationality,
|
||||
);
|
||||
const [alternativesOutbound, outboundReason] = await Promise.all([
|
||||
this.searchAlternatives(
|
||||
dto.originStationId,
|
||||
dto.destinationStationId,
|
||||
dto.date,
|
||||
dto.adultCount,
|
||||
dto.childCount,
|
||||
dto.nationality,
|
||||
),
|
||||
this.classifyEmptySearch(dto.originStationId, dto.destinationStationId, dto.date),
|
||||
]);
|
||||
return {
|
||||
journeyType: "ONE_WAY",
|
||||
outbound: [],
|
||||
alternativeOutbound: alternativesOutbound,
|
||||
requestedDate: dto.date,
|
||||
outboundReason,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -157,7 +163,7 @@ export class SearchService {
|
||||
const returnDate = dto.returnDate ?? dto.date;
|
||||
|
||||
if (outbound.length === 0 || inbound.length === 0) {
|
||||
const [alternativeOutbound, alternativeInbound] = await Promise.all([
|
||||
const [alternativeOutbound, alternativeInbound, outboundReason, inboundReason] = await Promise.all([
|
||||
outbound.length === 0
|
||||
? this.searchAlternatives(
|
||||
dto.originStationId,
|
||||
@@ -178,6 +184,12 @@ export class SearchService {
|
||||
dto.nationality,
|
||||
)
|
||||
: Promise.resolve([]),
|
||||
outbound.length === 0
|
||||
? this.classifyEmptySearch(dto.originStationId, dto.destinationStationId, dto.date)
|
||||
: Promise.resolve(undefined),
|
||||
inbound.length === 0
|
||||
? this.classifyEmptySearch(dto.destinationStationId, dto.originStationId, returnDate)
|
||||
: Promise.resolve(undefined),
|
||||
]);
|
||||
return {
|
||||
journeyType: "ROUND_TRIP",
|
||||
@@ -187,6 +199,8 @@ export class SearchService {
|
||||
alternativeInbound,
|
||||
requestedDate: dto.date,
|
||||
requestedReturnDate: returnDate,
|
||||
outboundReason,
|
||||
inboundReason,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -210,13 +224,10 @@ export class SearchService {
|
||||
childCount?: number,
|
||||
nationality?: string,
|
||||
) {
|
||||
const [y, m, d] = dateStr.split("-").map(Number);
|
||||
const requestedDate = new Date(
|
||||
`${String(y)}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}T00:00:00+03:00`,
|
||||
);
|
||||
const requestedNextDay = new Date(
|
||||
`${String(y)}-${String(m).padStart(2, "0")}-${String(d + 1).padStart(2, "0")}T00:00:00+03:00`,
|
||||
);
|
||||
// Real millisecond arithmetic, not string-padded day-of-month increment — the latter
|
||||
// produces an invalid date (e.g. "2026-07-32") for any search on the last day of a month.
|
||||
const requestedDate = new Date(`${dateStr}T00:00:00+03:00`);
|
||||
const requestedNextDay = new Date(requestedDate.getTime() + 24 * 60 * 60 * 1000);
|
||||
const now = new Date();
|
||||
const totalPassengers = adultCount + (childCount ?? 0);
|
||||
const NEEDED = 3;
|
||||
@@ -300,13 +311,10 @@ export class SearchService {
|
||||
childCount?: number,
|
||||
nationality?: string,
|
||||
) {
|
||||
const [y, m, d] = dateStr.split("-").map(Number);
|
||||
const date = new Date(
|
||||
`${String(y)}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}T00:00:00+03:00`,
|
||||
);
|
||||
const nextDay = new Date(
|
||||
`${String(y)}-${String(m).padStart(2, "0")}-${String(d + 1).padStart(2, "0")}T00:00:00+03:00`,
|
||||
);
|
||||
// Real millisecond arithmetic, not string-padded day-of-month increment — the latter
|
||||
// produces an invalid date (e.g. "2026-07-32") for any search on the last day of a month.
|
||||
const date = new Date(`${dateStr}T00:00:00+03:00`);
|
||||
const nextDay = new Date(date.getTime() + 24 * 60 * 60 * 1000);
|
||||
const totalPassengers = adultCount + (childCount ?? 0);
|
||||
|
||||
// Match on the schedule's own departure DATE only — do NOT use `now` as a lower bound here.
|
||||
@@ -344,6 +352,224 @@ export class SearchService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only called when searchSchedules/searchTransitOptions found zero bookable results for a
|
||||
* leg — classifies WHY, cheaply, by re-querying without the filters that already excluded
|
||||
* everything. Priority order (most specific/actionable first): a station pair EDR never
|
||||
* connects at all beats "nothing on this exact date", which beats "something exists but
|
||||
* every option is cancelled/package-only/past cutoff/full" — see SearchEmptyReasonCode.
|
||||
*/
|
||||
private async classifyEmptySearch(
|
||||
originStationId: string,
|
||||
destinationStationId: string,
|
||||
dateStr: string,
|
||||
): Promise<Passenger.ISearchEmptyReason> {
|
||||
const [origin, destination] = await Promise.all([
|
||||
this.prisma.station.findUnique({ where: { id: originStationId }, select: { name: true } }),
|
||||
this.prisma.station.findUnique({ where: { id: destinationStationId }, select: { name: true } }),
|
||||
]);
|
||||
const originStationName = origin?.name ?? "the origin station";
|
||||
const destinationStationName = destination?.name ?? "the destination station";
|
||||
const withCode = (code: Passenger.SearchEmptyReasonCode) => ({
|
||||
code,
|
||||
originStationName,
|
||||
destinationStationName,
|
||||
});
|
||||
|
||||
// 1. Does any active route connect these two stations, in this direction, at all —
|
||||
// ignoring date entirely?
|
||||
if (!(await this.routeExistsForPair(originStationId, destinationStationId))) {
|
||||
return withCode(Passenger.SearchEmptyReasonCode.NoRoute);
|
||||
}
|
||||
|
||||
// 2. A route exists — is there any schedule at all on the requested date for this pair
|
||||
// (regardless of status/package/coach/cutoff — those are checked next)?
|
||||
// Real millisecond arithmetic, not string-padded day-of-month increment — the latter
|
||||
// produces an invalid date (e.g. "2026-07-32") for any search on the last day of a month.
|
||||
const date = new Date(`${dateStr}T00:00:00+03:00`);
|
||||
const nextDay = new Date(date.getTime() + 24 * 60 * 60 * 1000);
|
||||
const dayCandidates = await this.prisma.trainSchedule.findMany({
|
||||
where: { departureAt: { gte: date, lt: nextDay }, stopTimes: { some: { stationId: originStationId } } },
|
||||
select: {
|
||||
status: true,
|
||||
isPackageOnly: true,
|
||||
departureAt: true,
|
||||
route: {
|
||||
select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } },
|
||||
},
|
||||
stopTimes: { select: { stationId: true, sequence: true, plannedArrivalAt: true, plannedDepartureAt: true } },
|
||||
coachAssignments: { select: { id: true } },
|
||||
},
|
||||
});
|
||||
const sameDayForPair = dayCandidates.filter((s) => {
|
||||
const o = s.stopTimes.find((st) => st.stationId === originStationId);
|
||||
const dst = s.stopTimes.find((st) => st.stationId === destinationStationId);
|
||||
return !!o && !!dst && o.sequence < dst.sequence;
|
||||
});
|
||||
if (sameDayForPair.length === 0) return withCode(Passenger.SearchEmptyReasonCode.NoScheduleOnDate);
|
||||
|
||||
// 3. Schedules exist that date — narrow to ones that would otherwise be bookable
|
||||
// (right status, not package-only, has at least one coach assigned).
|
||||
const bookable = sameDayForPair.filter((s) => this.isBookableSchedule(s));
|
||||
if (bookable.length === 0) {
|
||||
if (sameDayForPair.every((s) => s.status === "CANCELLED"))
|
||||
return withCode(Passenger.SearchEmptyReasonCode.Cancelled);
|
||||
if (sameDayForPair.every((s) => s.isPackageOnly))
|
||||
return withCode(Passenger.SearchEmptyReasonCode.PackageOnly);
|
||||
return withCode(Passenger.SearchEmptyReasonCode.NoScheduleOnDate);
|
||||
}
|
||||
|
||||
// 4. Bookable schedules exist — did every one of them already pass its check-in cutoff
|
||||
// for this origin stop?
|
||||
const allCutoffPassed = bookable.every((s) => {
|
||||
const originStop = s.stopTimes.find((st) => st.stationId === originStationId) ?? null;
|
||||
return Date.now() >= resolveCheckinCutoff(s, originStop, originStationId).cutoffAt.getTime();
|
||||
});
|
||||
if (allCutoffPassed) return withCode(Passenger.SearchEmptyReasonCode.CheckinClosed);
|
||||
|
||||
// 5. A bookable, still-open schedule exists for this pair/date — the only remaining reason
|
||||
// searchSchedules dropped it is zero/insufficient seat availability.
|
||||
return withCode(Passenger.SearchEmptyReasonCode.FullyBooked);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any active route connects originStationId → destinationStationId in this
|
||||
* direction, ignoring date/schedule state entirely. Shared by classifyEmptySearch and
|
||||
* getAvailableDates.
|
||||
*/
|
||||
private async routeExistsForPair(originStationId: string, destinationStationId: string): Promise<boolean> {
|
||||
const candidateRoutes = await this.prisma.route.findMany({
|
||||
where: { active: true, stops: { some: { stationId: originStationId } } },
|
||||
select: { stops: { select: { stationId: true, sequence: true } } },
|
||||
});
|
||||
const onRouteDefinition = candidateRoutes.some((r) => {
|
||||
const o = r.stops.find((s) => s.stationId === originStationId);
|
||||
const d = r.stops.find((s) => s.stationId === destinationStationId);
|
||||
return !!o && !!d && o.sequence < d.sequence;
|
||||
});
|
||||
if (onRouteDefinition) return true;
|
||||
|
||||
// Fallback: a schedule whose own stop times connect the pair in order.
|
||||
//
|
||||
// A return leg is modelled by reusing the outbound Route while laying its TripStopTimes
|
||||
// in the opposite order (see test/fixtures/seed-ui.ts). The RouteStop check above cannot
|
||||
// see that — it only knows A→B→C — so it reports "no route" for C→A even though
|
||||
// searchSchedules finds and sells that trip, because searchSchedules resolves
|
||||
// connectivity from TripStopTime.sequence, exactly like the availability loop below.
|
||||
// Without this fallback the endpoint contradicts the search it is meant to preview, and
|
||||
// the portal would disable the date picker for a pair that is genuinely bookable.
|
||||
const schedules = await this.prisma.trainSchedule.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
{ stopTimes: { some: { stationId: originStationId } } },
|
||||
{ stopTimes: { some: { stationId: destinationStationId } } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
stopTimes: {
|
||||
where: { stationId: { in: [originStationId, destinationStationId] } },
|
||||
select: { stationId: true, sequence: true },
|
||||
},
|
||||
},
|
||||
take: this.ROUTE_EXISTS_SCHEDULE_SCAN_LIMIT,
|
||||
});
|
||||
return schedules.some((s) => {
|
||||
const o = s.stopTimes.find((st) => st.stationId === originStationId);
|
||||
const d = s.stopTimes.find((st) => st.stationId === destinationStationId);
|
||||
return !!o && !!d && o.sequence < d.sequence;
|
||||
});
|
||||
}
|
||||
|
||||
/** Bounds the stop-time fallback scan — connectivity is a yes/no, not a survey. */
|
||||
private readonly ROUTE_EXISTS_SCHEDULE_SCAN_LIMIT = 200;
|
||||
|
||||
/** Status/package/coach bookability only — ignores date, cutoff, and seat-level availability. */
|
||||
private isBookableSchedule(s: { status: string; isPackageOnly: boolean; coachAssignments: { id: string }[] }): boolean {
|
||||
return (
|
||||
(["SCHEDULED", "BOARDING", "EN_ROUTE"] as string[]).includes(s.status) &&
|
||||
!s.isPackageOnly &&
|
||||
s.coachAssignments.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
private readonly MAX_AVAILABLE_DATES_SPAN_DAYS = 90;
|
||||
private readonly ADDIS_OFFSET_MS = 3 * 60 * 60 * 1000;
|
||||
private readonly ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Converts an absolute instant to its calendar date string in Africa/Addis_Ababa (fixed UTC+3, no DST). */
|
||||
private toAddisDateStr(d: Date): string {
|
||||
return new Date(d.getTime() + this.ADDIS_OFFSET_MS).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* For each date in the (server-clamped) range, whether at least one bookable schedule exists
|
||||
* for originStationId → destinationStationId — used to disable schedule-less dates on the
|
||||
* search date picker before the user submits a search. Reuses the same route-existence and
|
||||
* bookability checks as classifyEmptySearch, plus the same check-in cutoff resolution used
|
||||
* throughout this service, but does not compute seat-level availability (see buildScheduleResult)
|
||||
* — a date can be marked available and still turn out fully booked when actually searched.
|
||||
*/
|
||||
async getAvailableDates(dto: AvailableDatesQueryDto) {
|
||||
const { originStationId, destinationStationId } = dto;
|
||||
|
||||
const todayStr = this.toAddisDateStr(new Date());
|
||||
const from = dto.from > todayStr ? dto.from : todayStr;
|
||||
const fromDate = new Date(`${from}T00:00:00+03:00`);
|
||||
|
||||
const maxToDate = new Date(fromDate.getTime() + this.MAX_AVAILABLE_DATES_SPAN_DAYS * this.ONE_DAY_MS);
|
||||
const requestedToDate = new Date(`${dto.to}T00:00:00+03:00`);
|
||||
const toDate = requestedToDate < maxToDate ? requestedToDate : maxToDate;
|
||||
const to = this.toAddisDateStr(toDate);
|
||||
|
||||
if (!(await this.routeExistsForPair(originStationId, destinationStationId))) {
|
||||
return {
|
||||
originStationId,
|
||||
destinationStationId,
|
||||
from,
|
||||
to,
|
||||
routeExists: false,
|
||||
dates: [] as { date: string; available: boolean }[],
|
||||
};
|
||||
}
|
||||
|
||||
const rangeEnd = new Date(toDate.getTime() + this.ONE_DAY_MS);
|
||||
const schedules = await this.prisma.trainSchedule.findMany({
|
||||
where: {
|
||||
departureAt: { gte: fromDate, lt: rangeEnd },
|
||||
stopTimes: { some: { stationId: originStationId } },
|
||||
},
|
||||
select: {
|
||||
departureAt: true,
|
||||
status: true,
|
||||
isPackageOnly: true,
|
||||
route: {
|
||||
select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } },
|
||||
},
|
||||
stopTimes: { select: { stationId: true, sequence: true, plannedArrivalAt: true, plannedDepartureAt: true } },
|
||||
coachAssignments: { select: { id: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const now = Date.now();
|
||||
const availableDays = new Set<string>();
|
||||
for (const s of schedules) {
|
||||
const originStop = s.stopTimes.find((st) => st.stationId === originStationId);
|
||||
const destinationStop = s.stopTimes.find((st) => st.stationId === destinationStationId);
|
||||
if (!originStop || !destinationStop || originStop.sequence >= destinationStop.sequence) continue;
|
||||
if (!this.isBookableSchedule(s)) continue;
|
||||
if (now >= resolveCheckinCutoff(s, originStop, originStationId).cutoffAt.getTime()) continue;
|
||||
availableDays.add(this.toAddisDateStr(s.departureAt));
|
||||
}
|
||||
|
||||
const dates: { date: string; available: boolean }[] = [];
|
||||
for (let cursor = fromDate; cursor <= toDate; cursor = new Date(cursor.getTime() + this.ONE_DAY_MS)) {
|
||||
const dateStr = this.toAddisDateStr(cursor);
|
||||
dates.push({ date: dateStr, available: availableDays.has(dateStr) });
|
||||
}
|
||||
|
||||
return { originStationId, destinationStationId, from, to, routeExists: true, dates };
|
||||
}
|
||||
|
||||
// ── Transit search ─────────────────────────────────────────────────────────
|
||||
private readonly MIN_CONNECTION_MINUTES = 30;
|
||||
private readonly MAX_CONNECTION_MINUTES = 360;
|
||||
@@ -356,13 +582,10 @@ export class SearchService {
|
||||
childCount?: number,
|
||||
nationality?: string,
|
||||
) {
|
||||
const [y, m, d] = dateStr.split("-").map(Number);
|
||||
const dayStart = new Date(
|
||||
`${String(y)}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}T00:00:00+03:00`,
|
||||
);
|
||||
const dayEnd = new Date(
|
||||
`${String(y)}-${String(m).padStart(2, "0")}-${String(d + 1).padStart(2, "0")}T00:00:00+03:00`,
|
||||
);
|
||||
// Real millisecond arithmetic, not string-padded day-of-month increment — the latter
|
||||
// produces an invalid date (e.g. "2026-07-32") for any search on the last day of a month.
|
||||
const dayStart = new Date(`${dateStr}T00:00:00+03:00`);
|
||||
const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000);
|
||||
const leg2WindowEnd = new Date(
|
||||
dayEnd.getTime() + this.MAX_CONNECTION_MINUTES * 60_000,
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Post,
|
||||
Patch,
|
||||
Query,
|
||||
Req,
|
||||
SetMetadata,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
@@ -20,7 +21,8 @@ import {
|
||||
ApiBody,
|
||||
} from "@nestjs/swagger";
|
||||
import { SeatsService } from "./seats.service";
|
||||
import { HoldSeatsDto, ReleaseHoldDto } from "./seats.dto";
|
||||
import { BlockSeatDto, HoldSeatsDto, ReleaseHoldDto, SetMaintenanceDto } from "./seats.dto";
|
||||
import { resolveActingUser, RequestWithActingUser } from "../../common/acting-user";
|
||||
import { GetDuplicateSeatsQuery, ResolveDuplicatesDto } from "./duplicate-seats.dto";
|
||||
import { JwtGuard } from "../../common/jwt.guard";
|
||||
import { PassengerStaff } from "../../common/passenger-guards";
|
||||
@@ -220,11 +222,22 @@ This makes it clear which segment of the route each seat is held for, enabling s
|
||||
@Post(":seatId/block")
|
||||
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({ summary: "Block a seat (e.g., maintenance, damage)" })
|
||||
@ApiOperation({
|
||||
summary: "Block a seat (e.g., maintenance, damage)",
|
||||
description:
|
||||
"The authenticated staff member is recorded as the blocker — their IAM id in `blockedBy` and their " +
|
||||
"display name in `blockedByName` — so the Blocked Seat Revenue Loss report can attribute the block " +
|
||||
"without a cross-service lookup.",
|
||||
})
|
||||
@ApiParam({ name: "seatId", description: "Seat UUID" })
|
||||
@ApiBody({ type: BlockSeatDto })
|
||||
@ApiResponse({ status: 200, description: "Seat blocked" })
|
||||
blockSeat(@Param("seatId") seatId: string, @Body() body: { reason: string; scheduleId?: string }) {
|
||||
return this.service.blockSeat(seatId, body.reason, body.scheduleId);
|
||||
blockSeat(
|
||||
@Param("seatId") seatId: string,
|
||||
@Body() body: BlockSeatDto,
|
||||
@Req() req: RequestWithActingUser,
|
||||
) {
|
||||
return this.service.blockSeat(seatId, body, resolveActingUser(req));
|
||||
}
|
||||
|
||||
@Delete(":seatId/block")
|
||||
@@ -243,9 +256,14 @@ This makes it clear which segment of the route each seat is held for, enabling s
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({ summary: "Set seat status to Under Maintenance" })
|
||||
@ApiParam({ name: "seatId", description: "Seat UUID" })
|
||||
@ApiBody({ type: SetMaintenanceDto })
|
||||
@ApiResponse({ status: 200, description: "Seat set to under maintenance" })
|
||||
setMaintenance(@Param("seatId") seatId: string, @Body() body: { reason: string }) {
|
||||
return this.service.setMaintenance(seatId, body.reason);
|
||||
setMaintenance(
|
||||
@Param("seatId") seatId: string,
|
||||
@Body() body: SetMaintenanceDto,
|
||||
@Req() req: RequestWithActingUser,
|
||||
) {
|
||||
return this.service.setMaintenance(seatId, body.reason, resolveActingUser(req));
|
||||
}
|
||||
|
||||
@Delete(":seatId/maintenance")
|
||||
|
||||
@@ -53,3 +53,43 @@ export class ReleaseHoldDto {
|
||||
@ApiProperty({ example: 'hold-uuid', description: 'SeatHold UUID to release' })
|
||||
@IsString() holdId: string;
|
||||
}
|
||||
|
||||
/** Coarse bucket for *why* a seat was pulled out of sale — mirrors the Prisma
|
||||
* `SeatBlockReasonCategory` enum. The free-text `reason` stays the detail. */
|
||||
export enum SeatBlockReasonCategory {
|
||||
MAINTENANCE = 'MAINTENANCE',
|
||||
VIP_RESERVED = 'VIP_RESERVED',
|
||||
SAFETY = 'SAFETY',
|
||||
OPERATIONAL = 'OPERATIONAL',
|
||||
OTHER = 'OTHER',
|
||||
}
|
||||
|
||||
export class BlockSeatDto {
|
||||
@ApiProperty({
|
||||
example: 'Torn upholstery — awaiting replacement',
|
||||
description: 'Free-text detail explaining the block. Shown verbatim in the revenue-loss report.',
|
||||
})
|
||||
@IsString() reason: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 'schedule-uuid',
|
||||
description:
|
||||
'When set, the block applies only to this schedule. Omit for a global block that pulls the seat out of sale on every schedule its coach runs on.',
|
||||
})
|
||||
@IsOptional() @IsString() scheduleId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: SeatBlockReasonCategory,
|
||||
default: SeatBlockReasonCategory.OTHER,
|
||||
description:
|
||||
'Reporting bucket for this block. Defaults to OTHER. Drives the reason-category breakdown in the Blocked Seat Revenue Loss report.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(SeatBlockReasonCategory)
|
||||
reasonCategory?: SeatBlockReasonCategory;
|
||||
}
|
||||
|
||||
export class SetMaintenanceDto {
|
||||
@ApiProperty({ example: 'Seat recline mechanism jammed' })
|
||||
@IsString() reason: string;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, ConflictException, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { HoldSeatsDto, JourneyDirection } from './seats.dto';
|
||||
import { BlockSeatDto, HoldSeatsDto, JourneyDirection, SeatBlockReasonCategory } from './seats.dto';
|
||||
import { ActingUser } from '../../common/acting-user';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { SegmentsService } from '../segments/segments.service';
|
||||
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
|
||||
@@ -45,13 +46,16 @@ export class SeatsService {
|
||||
});
|
||||
|
||||
const allSeatIds = assignments.flatMap((a: any) => a.coach.seats.map((s: any) => s.id));
|
||||
const effectiveStatuses = await this.resolveEffectiveStatuses(
|
||||
scheduleId,
|
||||
allSeatIds,
|
||||
originStationId ?? schedule.originStationId,
|
||||
destinationStationId ?? schedule.destinationStationId,
|
||||
journeyDirection
|
||||
);
|
||||
const [effectiveStatuses, reservations] = await Promise.all([
|
||||
this.resolveEffectiveStatuses(
|
||||
scheduleId,
|
||||
allSeatIds,
|
||||
originStationId ?? schedule.originStationId,
|
||||
destinationStationId ?? schedule.destinationStationId,
|
||||
journeyDirection
|
||||
),
|
||||
this.resolveActiveReservations(allSeatIds, scheduleId),
|
||||
]);
|
||||
|
||||
return {
|
||||
coaches: assignments.map((a) => {
|
||||
@@ -70,6 +74,7 @@ export class SeatsService {
|
||||
? this.resolveBedPosition(s.col, s.bedPosition)
|
||||
: s.bedPosition;
|
||||
const effectiveStatus = effectiveStatuses.get(s.id) ?? (s.status === 'BLOCKED' || s.status === 'BOOKED' ? s.status : 'AVAILABLE');
|
||||
const reservation = reservations.get(s.id);
|
||||
return {
|
||||
id: s.id,
|
||||
seatNumber: s.seatNumber,
|
||||
@@ -88,6 +93,15 @@ export class SeatsService {
|
||||
position: this.colToPosition(s.col, a.coach.arrangement),
|
||||
bed_type: this.bedPositionToType(resolvedBedPosition),
|
||||
} : {}),
|
||||
// Backoffice-issued reservation covering this seat, if any — lets staff see who's
|
||||
// paying/ticketed for a HELD (awaiting payment) or BLOCKED (ticketed) seat without
|
||||
// leaving the seat map. See resolveActiveReservations.
|
||||
...(reservation ? {
|
||||
bookingRef: reservation.bookingRef,
|
||||
reservationStatus: reservation.status,
|
||||
reservationPassengerName: reservation.passengerName,
|
||||
reservationContactPhone: reservation.contactPhone,
|
||||
} : {}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -259,6 +273,46 @@ export class SeatsService {
|
||||
return statusMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-resolves the backoffice-issued reservation (if any) covering each of these seats on
|
||||
* this schedule — a booking created via GuestBookingService.issueBookingFromReservation
|
||||
* (`source: 'BACKOFFICE_RESERVATION'`), still PENDING_PAYMENT (payment link sent, not yet
|
||||
* paid) or already CONFIRMED (ticketed). Used to surface the booking reference on the
|
||||
* backoffice seat map so staff can see who's paying/ticketed for a given seat without
|
||||
* looking it up separately.
|
||||
*/
|
||||
private async resolveActiveReservations(
|
||||
seatIds: string[],
|
||||
scheduleId: string,
|
||||
): Promise<Map<string, { bookingRef: string; status: string; passengerName: string | null; contactPhone: string | null }>> {
|
||||
const map = new Map<string, { bookingRef: string; status: string; passengerName: string | null; contactPhone: string | null }>();
|
||||
if (seatIds.length === 0) return map;
|
||||
|
||||
const bookingSeats = await this.prisma.bookingSeat.findMany({
|
||||
where: {
|
||||
seatId: { in: seatIds },
|
||||
scheduleId,
|
||||
booking: { source: 'BACKOFFICE_RESERVATION', status: { in: ['PENDING_PAYMENT', 'CONFIRMED'] } },
|
||||
},
|
||||
select: {
|
||||
seatId: true,
|
||||
passengerName: true,
|
||||
booking: { select: { bookingRef: true, status: true, contactPhone: true } },
|
||||
},
|
||||
});
|
||||
|
||||
for (const bs of bookingSeats) {
|
||||
if (!bs.seatId) continue;
|
||||
map.set(bs.seatId, {
|
||||
bookingRef: bs.booking.bookingRef,
|
||||
status: bs.booking.status,
|
||||
passengerName: bs.passengerName,
|
||||
contactPhone: bs.booking.contactPhone,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async holdSeats(dto: HoldSeatsDto) {
|
||||
const passengerIds = dto.passengers.map(p => p.passengerId);
|
||||
const seatIds = dto.passengers.map(p => p.seatId);
|
||||
@@ -295,10 +349,9 @@ export class SeatsService {
|
||||
|
||||
// Stop-level override wins; falls back to route-level; then to 30 min.
|
||||
const checkinMinutes = originRouteStop?.checkinMinutesBefore ?? schedule.route?.checkinMinutesBefore ?? 30;
|
||||
// Departure basis: plannedDepartureAt = arrival + dwell. For the origin there is no
|
||||
// arrival so plannedDepartureAt = schedule.departureAt. cutoffAt = departure - dwell = arrival,
|
||||
// so holding closes the moment the train reaches the boarding stop.
|
||||
const segmentDepartureAt = originStopTime?.plannedDepartureAt ?? originStopTime?.plannedArrivalAt ?? schedule.departureAt;
|
||||
// Arrival basis: the origin stop's own estimated arrival, not its departure. The first
|
||||
// stop of a route has no arrival (nothing to arrive at), so it falls back to its departure.
|
||||
const segmentDepartureAt = originStopTime?.plannedArrivalAt ?? originStopTime?.plannedDepartureAt ?? schedule.departureAt;
|
||||
const msUntilDeparture = segmentDepartureAt.getTime() - Date.now();
|
||||
if (msUntilDeparture <= checkinMinutes * 60 * 1000) {
|
||||
throw new BadRequestException(
|
||||
@@ -644,7 +697,10 @@ export class SeatsService {
|
||||
coachNumber: b.seat.coach.number,
|
||||
scheduleId: b.scheduleId,
|
||||
reason: b.reason,
|
||||
// Blocks written before the reason-category column existed report as uncategorized.
|
||||
reasonCategory: b.reasonCategory,
|
||||
blockedBy: b.blockedBy,
|
||||
blockedByName: b.blockedByName,
|
||||
blockedAt: b.blockedAt,
|
||||
unblockAt: b.unblockAt,
|
||||
}));
|
||||
@@ -846,20 +902,42 @@ export class SeatsService {
|
||||
return { imported, errors: errors.slice(0, 10) };
|
||||
}
|
||||
|
||||
async blockSeat(seatId: string, reason: string, scheduleId?: string) {
|
||||
/**
|
||||
* Pulls a seat out of sale.
|
||||
*
|
||||
* `actor` is the authenticated staff member from the request. Their IAM id lands in
|
||||
* `blockedBy` and their display name is denormalized into `blockedByName`, so the
|
||||
* Blocked Seat Revenue Loss report can attribute the block without a cross-service
|
||||
* lookup. System-initiated blocks (no authenticated user) fall back to `SYSTEM`.
|
||||
*/
|
||||
async blockSeat(seatId: string, dto: BlockSeatDto, actor: ActingUser | null) {
|
||||
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
|
||||
if (!seat) throw new NotFoundException('Seat not found');
|
||||
|
||||
const { reason, scheduleId } = dto;
|
||||
const reasonCategory = dto.reasonCategory ?? SeatBlockReasonCategory.OTHER;
|
||||
const blockedBy = actor?.id ?? 'SYSTEM';
|
||||
const blockedByName = actor?.name ?? 'System';
|
||||
|
||||
// Schedule-scoped block: only affects this schedule, not all schedules
|
||||
// Global block (no scheduleId): sets Seat.status = BLOCKED for all schedules
|
||||
if (scheduleId) {
|
||||
await this.prisma.seatBlock.create({ data: { seatId, scheduleId, reason, blockedBy: 'system' } });
|
||||
await this.prisma.seatBlock.create({
|
||||
data: { seatId, scheduleId, reason, reasonCategory, blockedBy, blockedByName },
|
||||
});
|
||||
} else {
|
||||
await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'BLOCKED' } });
|
||||
await this.prisma.seatBlock.create({ data: { seatId, reason, blockedBy: 'system' } });
|
||||
await this.prisma.seatBlock.create({
|
||||
data: { seatId, reason, reasonCategory, blockedBy, blockedByName },
|
||||
});
|
||||
}
|
||||
await this.auditService.log({ action: 'UPDATE', entityType: 'Seat', entityId: seatId, newData: { status: 'BLOCKED', reason, scheduleId } });
|
||||
return { blocked: true, seatId, reason, scheduleId };
|
||||
await this.auditService.log({
|
||||
action: 'UPDATE',
|
||||
entityType: 'Seat',
|
||||
entityId: seatId,
|
||||
newData: { status: 'BLOCKED', reason, reasonCategory, scheduleId, blockedBy },
|
||||
});
|
||||
return { blocked: true, seatId, reason, reasonCategory, scheduleId, blockedBy, blockedByName };
|
||||
}
|
||||
|
||||
async unblockSeat(seatId: string, scheduleId?: string) {
|
||||
@@ -876,12 +954,20 @@ export class SeatsService {
|
||||
return { unblocked: true, seatId, scheduleId };
|
||||
}
|
||||
|
||||
async setMaintenance(seatId: string, reason: string) {
|
||||
async setMaintenance(seatId: string, reason: string, actor: ActingUser | null) {
|
||||
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
|
||||
if (!seat) throw new NotFoundException('Seat not found');
|
||||
if (seat.status === 'BOOKED') throw new BadRequestException('Cannot set a booked seat to maintenance');
|
||||
await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'UNDER_MAINTENANCE' as any } });
|
||||
await this.prisma.seatBlock.create({ data: { seatId, reason: `MAINTENANCE: ${reason}`, blockedBy: 'system' } });
|
||||
await this.prisma.seatBlock.create({
|
||||
data: {
|
||||
seatId,
|
||||
reason: `MAINTENANCE: ${reason}`,
|
||||
reasonCategory: SeatBlockReasonCategory.MAINTENANCE,
|
||||
blockedBy: actor?.id ?? 'SYSTEM',
|
||||
blockedByName: actor?.name ?? 'System',
|
||||
},
|
||||
});
|
||||
return { maintenance: true, seatId, reason };
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,11 @@ 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],
|
||||
imports: [PrismaModule, NotificationsModule, CurrencyModule, PaymentsModule],
|
||||
providers: [TasksService],
|
||||
})
|
||||
export class TasksModule {}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
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 { MAX_PAYMENT_HOURS, CUTOFF_MINUTES, computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
|
||||
|
||||
// Retention windows
|
||||
@@ -28,6 +30,10 @@ export class TasksService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly sms: SmsClientService,
|
||||
private readonly currencyService: CurrencyService,
|
||||
// ModuleRef (NOT direct injection): PaymentsService is request-scoped (AuditService injects
|
||||
// REQUEST), and injecting a request-scoped provider here would make TasksService request-scoped
|
||||
// too — which silently stops all its @Cron methods from firing. Resolve it per-tick instead.
|
||||
private readonly moduleRef: ModuleRef,
|
||||
) {}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -290,6 +296,14 @@ export class TasksService {
|
||||
|
||||
let cancelledCount = 0;
|
||||
|
||||
// resolve() (not direct injection) because PaymentsService is request-scoped — same pattern
|
||||
// as PaymentSyncService. strict:false resolves it from the app context.
|
||||
const paymentsService = await this.moduleRef.resolve(
|
||||
PaymentsService,
|
||||
undefined,
|
||||
{ strict: false },
|
||||
);
|
||||
|
||||
for (const booking of expiredBookings) {
|
||||
try {
|
||||
// Re-verify exact deadline to avoid racing with a concurrent payment confirmation.
|
||||
@@ -308,6 +322,18 @@ export class TasksService {
|
||||
const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes);
|
||||
if (now < paymentDeadline) continue;
|
||||
|
||||
// Deadline passed — but NEVER cancel a booking that is actually paid. The payment.succeeded
|
||||
// event may have been lost (RabbitMQ down) or arrived late, leaving a paid booking stuck
|
||||
// PENDING_PAYMENT. Ask the payment service over HTTP; it confirms the booking synchronously
|
||||
// if paid. Only proceed to cancel when settlement is VERIFIED unpaid.
|
||||
const settlement = await paymentsService.reconcileAndConfirmIfPaid(booking.id);
|
||||
if (settlement.paid || !settlement.verified) {
|
||||
this.logger.log(
|
||||
`Skip auto-cancel ${booking.bookingRef}: ${settlement.paid ? 'PAID → confirmed' : 'unverifiable → deferred'}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 1a. Release held seats (Journey rows are the occupancy source of truth once paid)
|
||||
await this.prisma.journey.deleteMany({ where: { bookingId: booking.id } as any });
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Get()
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.view)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'List all tickets with optional filters' })
|
||||
@ApiQuery({ name: 'search', required: false })
|
||||
@@ -99,7 +99,7 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Get('by-order/:merchantOrderId')
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.view)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Get ticket by merchant order ID',
|
||||
@@ -117,7 +117,7 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Post('scan-board/:qrCodeOrRef')
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.manage)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Scan QR code or booking ref and automatically board ticket',
|
||||
@@ -142,7 +142,7 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Post(':bookingRef/validate')
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.manage)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Validate ticket at gate with audit logging',
|
||||
@@ -173,7 +173,7 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Get(':ticketId/validation-logs')
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.view)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Get validation logs for ticket' })
|
||||
getValidationLogs(@Param('ticketId') ticketId: string) {
|
||||
@@ -181,7 +181,7 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Get('offline/export')
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.view)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Export tickets for offline validation' })
|
||||
exportOfflineData(@Query('scheduleId') scheduleId: string) {
|
||||
@@ -189,7 +189,7 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Post('validate/offline')
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.manage)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Batch import offline validations',
|
||||
@@ -232,7 +232,7 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Patch(':id/restore')
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.manage)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Restore a cancelled ticket by resetting its status to ACTIVE' })
|
||||
restore(@Param('id') id: string) {
|
||||
|
||||
@@ -512,17 +512,30 @@ export class VerifaydaService {
|
||||
gender: { am: normalized.genderAm ?? '', en: normalized.genderEn ?? '' },
|
||||
name: { am: normalized.nameAm ?? '', en: normalized.nameEn ?? '' },
|
||||
phoneNumber: normalized.rawPhoneNumber ?? '',
|
||||
// Persist the identity fields the passenger booking form needs. Fayda returns
|
||||
// these on every verification but they were previously dropped, leaving the
|
||||
// logged-in/verified form with nothing to prefill. birthdate arrives as
|
||||
// YYYY/MM/DD — store it as the ISO YYYY-MM-DD the form expects. A Fayda-verified
|
||||
// holder is an Ethiopian national ID holder, so nationality is always Ethiopian.
|
||||
dateOfBirth: normalized.birthdate ? normalized.birthdate.replace(/\//g, '-') : '',
|
||||
nationality: 'ETHIOPIAN',
|
||||
};
|
||||
|
||||
// Step 1 — already linked to this Fayda sub; ensure verified_by is set
|
||||
// Step 1 — already linked to this Fayda sub; refresh metadata (backfills the newly
|
||||
// persisted dateOfBirth/nationality for users linked before this change) and ensure
|
||||
// verified_by is set.
|
||||
const bySub = await this.dataSource.query<{ id: string }[]>(
|
||||
`SELECT id FROM iam.users WHERE metadata->>'sub' = $1 LIMIT 1`,
|
||||
[normalized.sub],
|
||||
);
|
||||
if (bySub.length > 0) {
|
||||
await this.dataSource.query(
|
||||
`UPDATE iam.users SET verified_by = 'fayda', updated_at = NOW() WHERE id = $1`,
|
||||
[bySub[0].id],
|
||||
`UPDATE iam.users
|
||||
SET metadata = COALESCE(metadata, '{}'::jsonb) || $1::jsonb,
|
||||
verified_by = 'fayda',
|
||||
updated_at = NOW()
|
||||
WHERE id = $2`,
|
||||
[JSON.stringify(iamMetadata), bySub[0].id],
|
||||
);
|
||||
return { iamUserId: bySub[0].id, userDataSaved: true };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user