Merge pull request #1451 from Tria-plc/dev

Merge dev to alpha
This commit is contained in:
Abubeker Yasin
2026-08-29 09:56:25 +03:00
committed by GitHub
698 changed files with 60811 additions and 8977 deletions

View File

@@ -64,6 +64,7 @@ import { ConfigurableFareModule } from "./modules/configurable-fare/configurable
import { SegmentFareSeeder } from "./seed/segment-fare.seeder";
import { EOtpType } from "@tria-plc/iamapi-common";
import { RescheduleModule } from './modules/reschedule/reschedule.module';
@Module({
imports: [
@@ -164,6 +165,7 @@ import { EOtpType } from "@tria-plc/iamapi-common";
TasksModule,
AppReleasesModule,
ConfigurableFareModule,
RescheduleModule,
],
providers: [
{ provide: APP_FILTER, useClass: DeleteExceptionFilter },

View File

@@ -86,6 +86,8 @@ export const AUDIT_ENTITIES = {
// Operations
Ticket: 'Ticket',
Booking: 'Booking',
BookingReschedule: 'BookingReschedule',
ReschedulePolicy: 'ReschedulePolicy',
} as const;
export type AuditEntity = (typeof AUDIT_ENTITIES)[keyof typeof AUDIT_ENTITIES];

View File

@@ -28,7 +28,7 @@ type EmployeeLike = {
delegatedPositions?: PositionLike[];
};
type MeLikeUser = {
export type MeLikeUser = {
roles?: { key?: string }[];
permissions?: PermissionLike[];
employee?: EmployeeLike | EmployeeLike[] | null;

View File

@@ -1,15 +1,3 @@
/**
* Phone normalisation shared by any lookup that has to match a number a customer typed
* against one already stored. Ethiopian numbers reach us in three interchangeable shapes
* (+2519…, 2519…, 09…) depending on whether they came from IAM, a guest booking form or a
* saved profile, so an exact-string match silently misses.
*/
/**
* Returns all plausible normalised variants of a raw phone string so that the
* DB query matches regardless of how the number was stored (local 09… vs international +251…).
* Returns an empty array when the input is clearly invalid (< 7 digits).
*/
export function normalizePhoneVariants(raw: string): string[] {
// Strip whitespace, dashes, dots, parentheses — keep digits and a leading +
const stripped = raw.replace(/[^\d+]/g, '');
@@ -38,13 +26,6 @@ export function normalizePhoneVariants(raw: string): string[] {
return [...variants];
}
/**
* A sign-in identifier is a single free-text field: the passenger types either an email
* address or a phone number and the server works out which. Phone is the default reading —
* an email must contain an `@` with something either side of it, everything else is treated
* as a number so that malformed emails don't silently fall through to a phone lookup that
* can never match.
*/
export type ResolvedIdentifier = {
kind: 'email' | 'phone';
/** Lower-cased email, or null when the input is a phone number. */
@@ -75,3 +56,27 @@ export function maskPhone(phone: string): string {
const tail = stripped.slice(-3);
return `${head}${'*'.repeat(4)}${tail}`;
}
/**
* Collapses a number to a single canonical E.164 form so two values can be compared directly.
* Mirrors `PassengerAuthService.standardizePhone`, plus the bare-9-digit case the passenger
* form produces (its input sits behind a fixed `+251` prefix control).
*
*/
export function normalizePhone(phone?: string | null): string | null {
if (!phone) return null;
const digits = phone.replace(/\D/g, '');
if (!digits) return null;
if (digits.startsWith('251')) return `+${digits}`;
if (digits.startsWith('0')) return `+251${digits.slice(1)}`;
// A bare local subscriber number, e.g. "912345678" from the +251-prefixed input.
if (digits.length === 9) return `+251${digits}`;
return `+${digits}`;
}
/** True only when both numbers are present and resolve to the same E.164 form. */
export function samePhone(a?: string | null, b?: string | null): boolean {
const left = normalizePhone(a);
const right = normalizePhone(b);
return !!left && !!right && left === right;
}

View File

@@ -4,9 +4,11 @@
import "dotenv/config";
import "reflect-metadata";
import { NestFactory } from "@nestjs/core";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Logger, ValidationPipe, VersioningType } from "@nestjs/common";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import helmet from "helmet";
import { join } from "path";
import { AppModule } from "./app.module";
import { HttpExceptionFilter } from "./common/filters/http-exception.filter";
import { ResponseTransformInterceptor } from "./common/interceptors/response-transform.interceptor";
@@ -23,10 +25,20 @@ if (process.env.NODE_ENV === 'production' && process.env.WAAFI_INSECURE_TLS ===
async function bootstrap() {
// rawBody: true buffers the unparsed request body onto req.rawBody so webhook handlers
// (e.g. Waafi HMAC verification) can sign over the exact bytes the provider signed.
const app = await NestFactory.create(AppModule, { rawBody: true });
const app = await NestFactory.create<NestExpressApplication>(AppModule, { rawBody: true });
// Security headers
app.use(helmet());
// Security headers. crossOriginResourcePolicy defaults to 'same-origin' in helmet, which
// would make browsers refuse to actually render package images (served from this origin)
// inside <img> tags on the portal/backoffice (different origins) even though the request
// itself succeeds — relaxed to 'cross-origin' since this API already serves all its JSON to
// those exact same origins per the CORS allowlist below; nothing new is being exposed.
app.use(helmet({ crossOriginResourcePolicy: { policy: "cross-origin" } }));
// Serves apps/edr-passenger-api/public/* at the site root — package images live at
// public/uploads/packages/<file>, reachable as GET /uploads/packages/<file>. Local-disk
// storage is a deliberate, explicit stopgap (see packages.service.ts's uploadImage) rather
// than this app's usual MinIO-backed upload pattern (see modules/support's attachments).
app.useStaticAssets(join(__dirname, "..", "public"));
// URI versioning: the @tria-plc IAM controllers declare `version: "1"` so they register under
// `/v1/...` (e.g. /v1/auth/login). Passenger controllers declare no version, so they stay

View File

@@ -26,7 +26,6 @@ import { BookingsService } from "./bookings.service";
import { GuestBookingService } from "./guest-booking.service";
import {
CreateBookingDto,
ModifyBookingDto,
CancelBookingDto,
} from "./bookings.dto";
import {
@@ -37,6 +36,7 @@ import {
import { JwtGuard } from "../../common/jwt.guard";
import { PassengerAdmin, PassengerStaff, PassengerStaffStrict } from "../../common/passenger-guards";
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
import { SeatsService } from "../seats/seats.service";
@ApiTags("Booking")
@Controller("bookings")
@@ -45,6 +45,7 @@ export class BookingsController {
constructor(
private service: BookingsService,
private guestService: GuestBookingService,
private seatsService: SeatsService,
) {}
@Get("my")
@@ -359,6 +360,33 @@ export class BookingsController {
return this.guestService.createGuestBooking(dto, req);
}
@Post("group")
@PassengerStaff([PASSENGER_PERMS.bookings.manage])
@ApiBearerAuth("IAM-auth")
@ApiOperation({
summary: "Create a group booking — staff bulk/group reservation, one PNR for the whole group",
description: `Staff-only entry point for bulk/group bookings (e.g. tour groups booked via an uploaded passenger list and auto-assigned seats from POST /seats/auto-assign-hold).
Same body shape as POST /bookings/guest (CreateGuestBookingDto) and the same underlying pipeline — fare engine, ADULT/CHILD age pricing — just gated to staff. Supports ONE_WAY (default) and ROUND_TRIP via \`bookingType\`; for ROUND_TRIP, supply \`returnScheduleId\`/\`returnHoldId\`/\`returnOriginStationId\`/\`returnDestinationStationId\`/\`returnSeatClassId\` and each passenger's \`returnSeatId\`, exactly as POST /bookings/guest does.
Skips Verifayda national-ID verification: the roster comes from a staff-uploaded spreadsheet, not a live Fayda identity flow, so there is nothing to verify an ID number against. Passenger fields (name, DOB, nationality) are trusted exactly as uploaded.
Deliberately does NOT forward the staff caller's identity into booking creation: the acting staff member is not a Passenger, so the underlying guest-booking flow (which tries to resolve an authenticated caller as an existing Passenger profile) would reject the request. The booking is created exactly like a guest booking — a fresh passenger record, contact info from the first passenger in the list — with staff authorization enforced only at this route.
If booking creation fails after the seats were already held, every hold involved (outbound and, for ROUND_TRIP, return) is released immediately so the seats don't sit locked for the rest of the hold TTL.`,
})
@ApiResponse({ status: 201, description: "Group booking created successfully with fareBreakdown" })
@ApiResponse({ status: 400, description: "Missing required seat IDs" })
async createGroup(@Body() dto: CreateGuestBookingDto) {
try {
return await this.guestService.createGuestBooking({ ...dto, bookingType: dto.bookingType || "ONE_WAY", skipIdentityVerification: true });
} catch (err) {
const holdIdsToRelease = [dto.holdId, dto.returnHoldId].filter((id): id is string => !!id);
await Promise.allSettled(holdIdsToRelease.map((id) => this.seatsService.releaseHold(id)));
throw err;
}
}
@Post("reservations/:seatId/issue")
@PassengerStaffStrict(PASSENGER_PERMS.tickets.generate)
@ApiBearerAuth("IAM-auth")
@@ -663,22 +691,6 @@ Results are ordered most-recent first. Use the returned \`bookingRef\` to open b
return this.service.getByRef(ref);
}
@Patch(":bookingRef/modify")
@UseGuards(JwtGuard)
@ApiBearerAuth("JWT-auth")
@ApiOperation({
summary: "Modify booking seats or trip",
description: "Allows modification of confirmed bookings before departure",
})
@ApiResponse({ status: 200, description: "Booking modified successfully" })
@ApiResponse({
status: 400,
description: "Cannot modify cancelled or past bookings",
})
modify(@Req() req: any, @Body() dto: ModifyBookingDto) {
return this.service.modify(dto, req.user?.id);
}
@Delete(":id")
@PassengerAdmin()
@ApiBearerAuth("IAM-auth")

View File

@@ -214,13 +214,6 @@ export class CreateBookingDto {
@IsOptional() @IsString() returnLeg2SeatClassId?: string;
}
export class ModifyBookingDto {
@ApiProperty() @IsString() bookingRef: string;
@ApiProperty({ example: 'schedule-uuid' }) @IsString() newScheduleId: string;
@ApiProperty({ type: [String] }) @IsArray() newSeatIds: string[];
@ApiPropertyOptional() @IsOptional() @IsString() reason?: string;
}
export class CancelBookingDto {
@ApiProperty() @IsString() bookingRef: string;
@ApiPropertyOptional() @IsOptional() @IsString() reason?: string;

View File

@@ -5,8 +5,9 @@ import { PrismaService } from '../../common/prisma.service';
import { SeatsService } from '../seats/seats.service';
import { TicketsService } from '../tickets/tickets.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { CreateBookingDto, ModifyBookingDto } from './bookings.dto';
import { CreateBookingDto } from './bookings.dto';
import { assertIdentitiesNotAlreadyBooked, resolveIdentityRef } from './booking-identity.util';
import { buildPaymentBreakdown } from './payment-breakdown.util';
import { Cron, CronExpression } from '@nestjs/schedule';
import { VerifaydaService } from '../verifayda/verifayda.service';
import { CurrencyService } from '../currency/currency.service';
@@ -1873,7 +1874,7 @@ export class BookingsService {
};
}
private async getBaseFare(
async getBaseFare(
scheduleId: string,
seatClassId: string,
segmentRoute?: string,
@@ -2005,6 +2006,8 @@ export class BookingsService {
returnSchedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } },
paymentIntent: true, tickets: true,
// Money collected after the original payment (reschedule fees, underpayments).
supplementaryCharges: { orderBy: { createdAt: 'asc' } },
priceTier: { select: { priceMinor: true } },
},
});
@@ -2098,6 +2101,8 @@ export class BookingsService {
returnSchedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } },
paymentIntent: true, tickets: true,
// Money collected after the original payment (reschedule fees, underpayments).
supplementaryCharges: { orderBy: { createdAt: 'asc' } },
priceTier: { select: { priceMinor: true } },
},
});
@@ -2176,12 +2181,20 @@ export class BookingsService {
},
};
}),
// `amountMinor` stays exactly as it was — the original intent, in the major units that
// column actually stores — so existing callers keep working. Everything collected since
// (reschedule fees and fare differences) lives in `breakdown`, whose `totalPaidMinor` is
// the number to show as "Total paid". See payment-breakdown.util.ts.
payment: (booking as any).paymentIntent
? {
method: (booking as any).paymentIntent.method,
status: (booking as any).paymentIntent.status,
amountMinor: (booking as any).paymentIntent.amountMinor,
currency: (booking as any).paymentIntent.currency,
breakdown: buildPaymentBreakdown(
(booking as any).paymentIntent,
(booking as any).supplementaryCharges ?? [],
),
}
: undefined,
// One ticket per passenger per leg (round trips have a separate ticket — and
@@ -2199,22 +2212,6 @@ export class BookingsService {
};
}
async modify(dto: ModifyBookingDto, iamUserId?: string) {
const booking = await this.prisma.booking.findUnique({ where: { bookingRef: dto.bookingRef }, include: { seats: true, schedule: true } });
if (!booking) throw new NotFoundException('Booking not found');
if (booking.status !== 'CONFIRMED') throw new BadRequestException('Only confirmed bookings can be modified');
if (booking.schedule.departureAt < new Date()) throw new BadRequestException('Cannot modify past bookings');
const oldSeats = booking.seats.map(s => s.seatId);
await this.prisma.bookingModification.create({
data: { bookingId: booking.id, modifiedBy: booking.passengerId, modificationType: 'SEAT_CHANGE', oldData: { scheduleId: booking.scheduleId, seatIds: oldSeats }, newData: { scheduleId: dto.newScheduleId, seatIds: dto.newSeatIds }, fareAdjustment: 0, reason: dto.reason },
});
await this.seatsService.releaseSeats(booking.id);
await this.seatsService.confirmSeats(dto.newSeatIds);
await this.auditService.log({ userId: iamUserId ?? booking.passengerId, action: 'UPDATE', entityType: 'Booking', entityId: booking.id, oldData: { seatIds: oldSeats }, newData: { seatIds: dto.newSeatIds, reason: dto.reason } });
return { modified: true, bookingRef: dto.bookingRef };
}
async cancel(bookingRef: string, reason?: string, iamUserId?: string) {
const booking = await this.prisma.booking.findUnique({ where: { bookingRef }, include: { seats: true, paymentIntent: true } });
if (!booking) throw new NotFoundException('Booking not found');

View File

@@ -168,6 +168,15 @@ export class CreateGuestBookingDto {
@ApiPropertyOptional({ description: 'Total amount in minor units (ETB) as computed and displayed on the review page. When provided, overrides the fare engine total — use to pass the exact berth-specific amount the user saw.' })
@IsOptional() @IsNumber() reviewedTotalMinor?: number;
@ApiPropertyOptional({
description:
'Skip Verifayda national-ID verification and trust passenger fields as given (name, DOB, nationality). ' +
'For staff-entered/bulk-uploaded rosters (e.g. group bookings) where there is no live Fayda identity ' +
'flow to verify against — calling Verifayda for typed-in ID numbers either returns dev-mode mock data ' +
'(overwriting the real name) or, once configured, would reject the whole booking on a non-match.',
})
@IsOptional() @IsBoolean() skipIdentityVerification?: boolean;
}
export class SavedPassengerProfileDto {

View File

@@ -196,7 +196,7 @@ export class GuestBookingService {
passenger.idDocumentType === IdDocumentType.NATIONAL_ID;
if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) {
if (passenger.idDocumentNumber) {
if (passenger.idDocumentNumber && !dto.skipIdentityVerification) {
const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber);
if (!verification.verified) {
throw new BadRequestException(
@@ -392,7 +392,10 @@ export class GuestBookingService {
contactEmail: contact.contactEmail,
contactPhone: contact.contactPhone,
seats: {
create: passengersWithFares.map((p) => ({
// A free child (no seatId — see passengersWithFares above) has nothing to connect to;
// `connect: { id: undefined }` throws PrismaClientValidationError immediately if this
// filter is missing, so it's never optional here despite the map below looking safe.
create: passengersWithFares.filter((p) => p.seatId).map((p) => ({
seat: { connect: { id: p.seatId } },
scheduleId: dto.scheduleId,
passengerName: p.passengerName,
@@ -763,7 +766,7 @@ export class GuestBookingService {
passenger.idDocumentType === IdDocumentType.NATIONAL_ID;
if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) {
if (passenger.idDocumentNumber) {
if (passenger.idDocumentNumber && !dto.skipIdentityVerification) {
const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber);
if (!verification.verified) throw new BadRequestException(`Verifayda verification failed for ${passenger.passengerName}: ${verification.failureReason}`);
passengerName = verification.passengerData?.fullName || passengerName;

View File

@@ -0,0 +1,101 @@
export type PaymentLineKind = 'BOOKING' | 'SUPPLEMENTARY';
export interface PaymentLine {
kind: PaymentLineKind;
/** Human label — "Original booking", "Reschedule", "Excess baggage". */
label: string;
/** Raw reason for supplementary lines (RESCHEDULE, UNDERPAYMENT, …); null for the booking line. */
reason: string | null;
/** Always true minor units, whatever the source column stored. */
amountMinor: number;
currency: string;
status: string;
paidAt: string | null;
/** Whether this line represents money actually collected. */
settled: boolean;
}
export interface PaymentBreakdown {
lines: PaymentLine[];
/** Sum of settled lines, or null when they are not all in one currency. */
totalPaidMinor: number | null;
totalPaidCurrency: string | null;
/** True when something is still owed (a charge raised but not yet paid). */
hasOutstanding: boolean;
outstandingMinor: number;
}
/** `PaymentIntent.amountMinor` is a Float in major units — bring it onto the minor-unit scale. */
export function intentAmountToMinor(amount: number | null | undefined): number {
if (amount == null) return 0;
return Math.round(amount * 100);
}
const SUPPLEMENTARY_LABELS: Record<string, string> = {
RESCHEDULE: 'Reschedule',
UNDERPAYMENT: 'Underpayment',
FARE_CORRECTION: 'Fare correction',
EXCESS_BAGGAGE: 'Excess baggage',
};
function labelFor(reason: string): string {
return (
SUPPLEMENTARY_LABELS[reason] ??
// "SOME_OTHER_REASON" → "Some other reason"
reason.charAt(0).toUpperCase() + reason.slice(1).toLowerCase().replace(/_/g, ' ')
);
}
export function buildPaymentBreakdown(
paymentIntent: { status?: string | null; amountMinor?: number | null; currency?: string | null } | null | undefined,
supplementaryCharges: Array<{
reason: string;
amountMinor: number;
currency: string;
status: string;
paidAt: Date | string | null;
}> = [],
): PaymentBreakdown {
const lines: PaymentLine[] = [];
if (paymentIntent) {
lines.push({
kind: 'BOOKING',
label: 'Original booking',
reason: null,
amountMinor: intentAmountToMinor(paymentIntent.amountMinor),
currency: paymentIntent.currency ?? 'ETB',
status: paymentIntent.status ?? 'UNKNOWN',
paidAt: null,
settled: paymentIntent.status === 'SUCCEEDED',
});
}
for (const charge of supplementaryCharges) {
if (charge.status === 'WAIVED' || charge.status === 'EXPIRED') continue;
lines.push({
kind: 'SUPPLEMENTARY',
label: labelFor(charge.reason),
reason: charge.reason,
amountMinor: charge.amountMinor,
currency: charge.currency ?? 'ETB',
status: charge.status,
paidAt: charge.paidAt ? new Date(charge.paidAt).toISOString() : null,
settled: charge.status === 'PAID',
});
}
const settled = lines.filter((l) => l.settled);
const currencies = new Set(settled.map((l) => l.currency));
const singleCurrency = currencies.size === 1 ? [...currencies][0] : null;
const outstanding = lines.filter((l) => l.status === 'PENDING');
return {
lines,
totalPaidMinor: singleCurrency ? settled.reduce((sum, l) => sum + l.amountMinor, 0) : null,
totalPaidCurrency: singleCurrency,
hasOutstanding: outstanding.length > 0,
outstandingMinor: outstanding.reduce((sum, l) => sum + l.amountMinor, 0),
};
}

View File

@@ -731,6 +731,24 @@ export class NotificationsService {
);
}
@OnEvent('booking.rescheduled')
async onBookingRescheduled(payload: any) {
const { booking, reschedule } = payload;
await this.send(
'booking.rescheduled',
booking.passengerId,
{
bookingRef: booking.bookingRef,
leg: reschedule?.leg === 2 ? 'return' : 'outbound',
feeAmount: ((reschedule?.feeMinor ?? 0) / 100).toFixed(2),
currency: 'ETB',
category: 'BOOKING',
deepLink: `edr://bookings/${booking.bookingRef}`,
},
['IN_APP', 'EMAIL', 'SMS'],
);
}
@OnEvent('booking.cancelled')
async onBookingCancelled(payload: any) {
const booking = payload.booking;

View File

@@ -0,0 +1,44 @@
import { BadRequestException } from '@nestjs/common';
import { diskStorage } from 'multer';
import { randomUUID } from 'crypto';
import { extname, join } from 'path';
import { mkdirSync } from 'fs';
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
/** Multipart field name carrying the package image file. */
export const PACKAGE_IMAGE_FIELD = 'image';
/** Local-disk stopgap (see packages.service.ts) — not this app's usual MinIO-backed upload pattern. */
export const PACKAGE_IMAGE_UPLOAD_DIR = join(__dirname, '..', '..', '..', 'public', 'uploads', 'packages');
export const PACKAGE_IMAGE_MAX_BYTES = 5 * 1024 * 1024; // 5MB
const ALLOWED_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
export const packageImageMulterOptions: MulterOptions = {
storage: diskStorage({
destination: (_req, _file, cb) => {
// mkdir on every request rather than once at module load — this directory is
// gitignored (see public/uploads/packages/.gitignore) so a fresh checkout/deploy
// won't have it yet, and recursive mkdir on an already-existing dir is a no-op.
mkdirSync(PACKAGE_IMAGE_UPLOAD_DIR, { recursive: true });
cb(null, PACKAGE_IMAGE_UPLOAD_DIR);
},
// Unique filename so two packages (or two uploads for the same package) never collide —
// never trust or reuse the original filename.
filename: (_req, file, cb) => {
cb(null, `${randomUUID()}${extname(file.originalname).toLowerCase()}`);
},
}),
limits: {
fileSize: PACKAGE_IMAGE_MAX_BYTES,
files: 1,
},
fileFilter: (_req, file, cb) => {
if (!ALLOWED_MIME_TYPES.has(file.mimetype)) {
cb(new BadRequestException('Image must be JPEG, PNG, WEBP, or GIF.'), false);
return;
}
cb(null, true);
},
};

View File

@@ -1,8 +1,10 @@
import { Body, Controller, Get, Param, Post, Patch, Delete, UseGuards, Request, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { Body, Controller, Get, Param, Post, Patch, Delete, UseGuards, UseInterceptors, UploadedFile, Request, Query, BadRequestException } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiConsumes, ApiBody } from '@nestjs/swagger';
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { PackagesService } from './packages.service';
import { CreatePackageDto, BookPackageDto, CreatePriceTierDto, UpdatePriceTierDto, CreateInquiryDto, UpdateInquiryStatusDto, PackageBookingContextDto } from './packages.dto';
import { PACKAGE_IMAGE_FIELD, packageImageMulterOptions } from './package-image-upload.options';
import { JwtGuard } from '../../common/jwt.guard';
import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard';
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
@@ -148,6 +150,29 @@ export class PackagesController {
return this.service.remove(id, cascade === 'true');
}
@Post(':id/image')
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@UseInterceptors(FileInterceptor(PACKAGE_IMAGE_FIELD, packageImageMulterOptions))
@ApiConsumes('multipart/form-data')
@ApiBody({ schema: { type: 'object', properties: { [PACKAGE_IMAGE_FIELD]: { type: 'string', format: 'binary' } } } })
@ApiOperation({
summary: 'Upload or replace a package image (admin)',
description: 'JPEG/PNG/WEBP/GIF, max 5MB. Replaces and deletes the previous image file if one exists — works the same whether the package currently has an image or not, so this one route covers both the initial upload and later replacement.',
})
uploadImage(@Param('id') id: string, @UploadedFile() file?: Express.Multer.File) {
if (!file) throw new BadRequestException('No image file provided.');
return this.service.uploadImage(id, file);
}
@Delete(':id/image')
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Remove a package image without deleting the package (admin)' })
removeImage(@Param('id') id: string) {
return this.service.removeImage(id);
}
@Patch(':id/activate')
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')

View File

@@ -1,4 +1,4 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CurrencyService } from '../currency/currency.service';
import { CreatePackageDto, BookPackageDto, UpdatePriceTierDto, CreatePriceTierDto, CreateInquiryDto } from './packages.dto';
@@ -7,6 +7,9 @@ import { BookingsService } from '../bookings/bookings.service';
import { GuestBookingService } from '../bookings/guest-booking.service';
import { AuditService } from '../../common/audit.service';
import { computePaymentDeadline, CUTOFF_MINUTES } from '../../common/utils/payment-deadline.utils';
import { PACKAGE_IMAGE_UPLOAD_DIR } from './package-image-upload.options';
import { join } from 'path';
import { unlink } from 'fs/promises';
/** Package-specific fare rules */
const PKG_MAX_ADULTS = 5;
@@ -46,6 +49,8 @@ function generateRef(): string {
@Injectable()
export class PackagesService {
private readonly logger = new Logger(PackagesService.name);
constructor(
private readonly prisma: PrismaService,
private readonly currencyService: CurrencyService,
@@ -325,6 +330,55 @@ export class PackagesService {
return updated;
}
/** Public URL prefix main.ts's app.useStaticAssets serves public/uploads/packages under. */
private readonly PACKAGE_IMAGE_URL_PREFIX = '/uploads/packages/';
private packageImagePublicUrl(filename: string): string {
const base = (process.env.APP_PUBLIC_URL || `http://localhost:${process.env.PORT || 4000}`).replace(/\/$/, '');
return `${base}${this.PACKAGE_IMAGE_URL_PREFIX}${filename}`;
}
/** Best-effort delete of the file backing a package's current imageUrl — never throws, since a
* missing file (already deleted, moved, or from before this feature existed) shouldn't block
* the DB update that's actually replacing/clearing the field. */
private async deletePackageImageFile(imageUrl: string | null): Promise<void> {
if (!imageUrl) return;
const idx = imageUrl.indexOf(this.PACKAGE_IMAGE_URL_PREFIX);
if (idx === -1) return; // not a file this app manages (e.g. an external URL) — nothing to delete
const filename = imageUrl.slice(idx + this.PACKAGE_IMAGE_URL_PREFIX.length);
if (!filename || filename.includes('/') || filename.includes('..')) return; // defensive: never touch paths outside the uploads dir
try {
await unlink(join(PACKAGE_IMAGE_UPLOAD_DIR, filename));
} catch (err: any) {
if (err?.code !== 'ENOENT') this.logger.warn(`Failed to delete package image file ${filename}: ${err?.message ?? err}`);
}
}
/** Multer's diskStorage has already written the file to PACKAGE_IMAGE_UPLOAD_DIR by the time
* this runs (see package-image-upload.options.ts) — this just points the package at it and
* cleans up whatever it's replacing. */
async uploadImage(id: string, file: Express.Multer.File) {
const pkg = await this.prisma.travelPackage.findUnique({ where: { id }, select: { imageUrl: true } });
if (!pkg) throw new NotFoundException('Package not found');
const imageUrl = this.packageImagePublicUrl(file.filename);
const updated = await this.prisma.travelPackage.update({ where: { id }, data: { imageUrl } });
await this.deletePackageImageFile(pkg.imageUrl);
await this.auditService.log({ action: 'UPDATE', entityType: 'Package', entityId: id, newData: { imageUrl } });
return updated;
}
async removeImage(id: string) {
const pkg = await this.prisma.travelPackage.findUnique({ where: { id }, select: { imageUrl: true } });
if (!pkg) throw new NotFoundException('Package not found');
if (!pkg.imageUrl) return this.prisma.travelPackage.findUnique({ where: { id } });
const updated = await this.prisma.travelPackage.update({ where: { id }, data: { imageUrl: null } });
await this.deletePackageImageFile(pkg.imageUrl);
await this.auditService.log({ action: 'UPDATE', entityType: 'Package', entityId: id, newData: { imageUrl: null } });
return updated;
}
async addTier(packageId: string, dto: CreatePriceTierDto) {
const pkg = await this.prisma.travelPackage.findUnique({ where: { id: packageId } });
if (!pkg) throw new NotFoundException('Package not found');
@@ -369,6 +423,7 @@ export class PackagesService {
await this.prisma.packageInquiry.deleteMany({ where: { packageId: id } });
await this.prisma.packagePriceTier.deleteMany({ where: { packageId: id } });
await this.prisma.travelPackage.delete({ where: { id } });
await this.deletePackageImageFile(pkg.imageUrl);
await this.auditService.log({ action: 'DELETE', entityType: 'Package', entityId: id });
return { deleted: true };
}

View File

@@ -74,6 +74,6 @@ function rabbitMQImport(): DynamicModule[] {
PaymentSyncService,
ServiceAuthGuard,
],
exports: [PaymentClientService, PaymentsService],
exports: [PaymentClientService, PaymentsService, SupplementaryChargesService],
})
export class PaymentsModule {}

View File

@@ -1619,6 +1619,7 @@ export class PaymentsService {
settledCurrency: event.currency,
},
});
this.eventEmitter.emit("supplementary-charge.paid", { chargeId: charge.id });
}
return { processed: true, alreadyFinalized: count === 0 };
}
@@ -1996,7 +1997,7 @@ export class PaymentsService {
});
}
private async createJourneySegments(
async createJourneySegments(
booking: Prisma.BookingGetPayload<{ include: { seats: true } }>,
) {
const b = booking as any;

View File

@@ -52,7 +52,7 @@ describe('SupplementaryChargesService — audit', () => {
};
audit = { log: jest.fn().mockResolvedValue(undefined) };
// Constructor order: prisma, audit, sms, email, paymentClient, currency.
// Constructor order: prisma, audit, sms, email, paymentClient, currency, eventEmitter.
service = new SupplementaryChargesService(
prisma as any,
audit as any,
@@ -60,6 +60,7 @@ describe('SupplementaryChargesService — audit', () => {
{ sendEmail: jest.fn() } as any,
{} as any,
{} as any,
{ emit: jest.fn() } as any,
);
return row;
};

View File

@@ -14,6 +14,7 @@ import {
} from '@edr/types';
import { PaymentPlatformDto } from './payments.dto';
import { PaymentMethodType } from '@prisma/client';
import { EventEmitter2 } from '@nestjs/event-emitter';
const CHARGE_TTL_MS = 72 * 60 * 60 * 1000; // 72 hours
@@ -45,6 +46,7 @@ export class SupplementaryChargesService {
private emailClient: EmailClientService,
private paymentClient: PaymentClientService,
private currencyService: CurrencyService,
private eventEmitter: EventEmitter2,
) {}
async create(dto: {
@@ -55,6 +57,8 @@ export class SupplementaryChargesService {
contactPhone?: string;
contactEmail?: string;
createdBy: string;
/** Overrides the default 72h link lifetime (a reschedule charge must die with its seat hold). */
expiresAt?: Date;
}) {
const booking = await this.prisma.booking.findUnique({
where: { bookingRef: dto.bookingRef },
@@ -66,7 +70,7 @@ export class SupplementaryChargesService {
}
if (dto.amountMinor <= 0) throw new BadRequestException('Amount must be positive');
const expiresAt = new Date(Date.now() + CHARGE_TTL_MS);
const expiresAt = dto.expiresAt ?? new Date(Date.now() + CHARGE_TTL_MS);
const charge = await this.prisma.supplementaryCharge.create({
data: {
bookingId: booking.id,
@@ -172,6 +176,7 @@ export class SupplementaryChargesService {
providerTxnId: providerTxnId ?? null,
},
});
this.eventEmitter.emit('supplementary-charge.paid', { chargeId: id });
}
return updated!;

View File

@@ -69,6 +69,7 @@ describe('SupplementaryChargesService — payment methods', () => {
{} as any,
paymentClient as any,
new CurrencyService(prisma as any),
{ emit: jest.fn() } as any,
);
};

View File

@@ -0,0 +1,82 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtGuard } from '../../common/jwt.guard';
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
import { RescheduleService } from './reschedule.service';
import {
CreateReschedulePolicyDto,
CreateRescheduleDto,
RescheduleQuoteDto,
UpdateReschedulePolicyDto,
} from './reschedule.dto';
@ApiTags('Reschedule')
@Controller()
export class RescheduleController {
constructor(private service: RescheduleService) {}
@Get('reschedule/policies')
@PassengerStaff(PASSENGER_PERMS.bookings.view)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Every reschedule policy, each with its coach type (fare class)' })
listPolicies() {
return this.service.listPolicies();
}
@Get('reschedule/policies/available-coach-types')
@PassengerStaff(PASSENGER_PERMS.bookings.view)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Coach types that do not have a reschedule policy yet (add-dialog dropdown)' })
listUnconfiguredCoachTypes() {
return this.service.listUnconfiguredCoachTypes();
}
@Post('reschedule/policies')
@PassengerAdmin()
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Create a reschedule policy for a coach type (admin)' })
createPolicy(@Req() req: any, @Body() dto: CreateReschedulePolicyDto) {
return this.service.createPolicy(dto, req.user?.id);
}
@Patch('reschedule/policies/:coachTypeId')
@PassengerAdmin()
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Update the reschedule policy of a coach type (admin)' })
updatePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string, @Body() dto: UpdateReschedulePolicyDto) {
return this.service.updatePolicy(coachTypeId, dto, req.user?.id);
}
@Delete('reschedule/policies/:coachTypeId')
@PassengerAdmin()
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Delete a reschedule policy — rescheduling is then refused for that fare class (admin)' })
deletePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string) {
return this.service.deletePolicy(coachTypeId, req.user?.id);
}
@Get('bookings/:bookingRef/reschedule')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Reschedule eligibility per leg, pending request, history' })
options(@Req() req: any, @Param('bookingRef') bookingRef: string) {
return this.service.getOptions(bookingRef, req.user);
}
@Post('bookings/:bookingRef/reschedule/quote')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Itemised quote (fee, fare difference, amount due) for a proposed change' })
quote(@Req() req: any, @Param('bookingRef') bookingRef: string, @Body() dto: RescheduleQuoteDto) {
return this.service.quote(bookingRef, dto, req.user);
}
@Post('bookings/:bookingRef/reschedule')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Reschedule a leg. Applies immediately when nothing is due, otherwise returns a payment token' })
create(@Req() req: any, @Param('bookingRef') bookingRef: string, @Body() dto: CreateRescheduleDto) {
return this.service.create(bookingRef, dto, req.user);
}
}

View File

@@ -0,0 +1,77 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMinSize,
IsArray,
IsBoolean,
IsInt,
IsOptional,
IsString,
Max,
Min,
} from 'class-validator';
export class UpdateReschedulePolicyDto {
@ApiPropertyOptional({ example: 30, description: '% of the leg fare charged as a change fee' })
@IsOptional() @Type(() => Number) @IsInt() @Min(0) @Max(100)
feePercent?: number;
@ApiPropertyOptional({ example: 50000, description: 'Fee floor in ETB minor units (500 ETB = 50000)' })
@IsOptional() @Type(() => Number) @IsInt() @Min(0)
feeMinMinor?: number;
@ApiPropertyOptional({ example: false })
@IsOptional() @IsBoolean()
routeChangeAllowed?: boolean;
@ApiPropertyOptional({ example: false })
@IsOptional() @IsBoolean()
sameDayAllowed?: boolean;
@ApiPropertyOptional({ example: 15, description: 'Same-day change: % of the leg fare (replaces feePercent)' })
@IsOptional() @Type(() => Number) @IsInt() @Min(0) @Max(100)
sameDayFeePercent?: number;
@ApiPropertyOptional({ example: 25000, description: 'Same-day change: fee floor in ETB minor units' })
@IsOptional() @Type(() => Number) @IsInt() @Min(0)
sameDayFeeMinMinor?: number;
@ApiPropertyOptional({ example: 120, description: 'Changes are refused this many minutes before departure' })
@IsOptional() @Type(() => Number) @IsInt() @Min(0)
cutoffMinutes?: number;
@ApiPropertyOptional({ example: true })
@IsOptional() @IsBoolean()
isActive?: boolean;
}
/** Same fields as the update DTO, plus the fare class the new policy attaches to. */
export class CreateReschedulePolicyDto extends UpdateReschedulePolicyDto {
@ApiProperty({ example: 'coach-type-uuid', description: 'CoachType the policy applies to (one policy per fare class)' })
@IsString()
coachTypeId: string;
}
export class RescheduleQuoteDto {
@ApiPropertyOptional({ example: 1, description: '1 = outbound (default), 2 = return leg of a round trip' })
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(2)
leg?: number;
@ApiProperty({ example: 'schedule-uuid' })
@IsString() newScheduleId: string;
@ApiProperty({ example: 'station-uuid' })
@IsString() newOriginStationId: string;
@ApiProperty({ example: 'station-uuid' })
@IsString() newDestinationStationId: string;
@ApiProperty({ type: [String], description: 'One seat per seated passenger of the leg, in BookingSeat order' })
@IsArray() @ArrayMinSize(1) @IsString({ each: true })
newSeatIds: string[];
}
export class CreateRescheduleDto extends RescheduleQuoteDto {
@ApiProperty({ example: 'hold-uuid', description: 'SeatHold on the new schedule covering newSeatIds (POST /seats/hold)' })
@IsString() holdId: string;
}

View File

@@ -0,0 +1,40 @@
import { Injectable, Logger, Module } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { OnEvent } from '@nestjs/event-emitter';
import { AuditModule } from '../../common/audit.module';
import { BookingsModule } from '../bookings/bookings.module';
import { SeatsModule } from '../seats/seats.module';
import { TicketsModule } from '../tickets/tickets.module';
import { PaymentsModule } from '../payments/payments.module';
import { CurrencyModule } from '../currency/currency.module';
import { RescheduleController } from './reschedule.controller';
import { RescheduleService, SUPPLEMENTARY_CHARGE_PAID_EVENT } from './reschedule.service';
/**
* RescheduleService is request-scoped by transitivity (AuditService injects REQUEST), and Nest
* never fires @OnEvent on request-scoped providers — so the listener lives on this singleton and
* resolves the service per event, the same way TasksService reaches PaymentsService.
*/
@Injectable()
export class RescheduleEventsListener {
private readonly logger = new Logger(RescheduleEventsListener.name);
constructor(private readonly moduleRef: ModuleRef) {}
@OnEvent(SUPPLEMENTARY_CHARGE_PAID_EVENT, { async: true })
async onChargePaid(payload: { chargeId: string }) {
try {
const service = await this.moduleRef.resolve(RescheduleService, undefined, { strict: false });
await service.applyForCharge(payload.chargeId);
} catch (err) {
this.logger.error(`Failed to apply reschedule for charge ${payload.chargeId}: ${err instanceof Error ? err.message : err}`);
}
}
}
@Module({
imports: [AuditModule, BookingsModule, SeatsModule, TicketsModule, PaymentsModule, CurrencyModule],
controllers: [RescheduleController],
providers: [RescheduleService, RescheduleEventsListener],
exports: [RescheduleService],
})
export class RescheduleModule {}

View File

@@ -0,0 +1,37 @@
import { addisDay, computeRescheduleAmounts } from './reschedule.service';
const standard = { feePercent: 30, feeMinMinor: 50000, sameDayFeePercent: 0, sameDayFeeMinMinor: 0 };
const flex = { feePercent: 0, feeMinMinor: 0, sameDayFeePercent: 15, sameDayFeeMinMinor: 25000 };
const premium = { feePercent: 0, feeMinMinor: 0, sameDayFeePercent: 0, sameDayFeeMinMinor: 0 };
describe('computeRescheduleAmounts (policy §3)', () => {
it('Standard: 30% of fare, floored at 500 ETB, plus positive fare difference', () => {
// 1000 ETB fare → 30% = 300 < 500 floor
expect(computeRescheduleAmounts(standard, 100000, 120000, false)).toEqual({ feeMinor: 50000, fareDifferenceMinor: 20000, amountDueMinor: 70000 });
// 3000 ETB fare → 30% = 900 > floor
expect(computeRescheduleAmounts(standard, 300000, 300000, false)).toEqual({ feeMinor: 90000, fareDifferenceMinor: 0, amountDueMinor: 90000 });
});
it('negative fare difference is recorded but never paid out', () => {
expect(computeRescheduleAmounts(standard, 300000, 200000, false)).toEqual({ feeMinor: 90000, fareDifferenceMinor: -100000, amountDueMinor: 90000 });
expect(computeRescheduleAmounts(flex, 300000, 200000, false)).toEqual({ feeMinor: 0, fareDifferenceMinor: -100000, amountDueMinor: 0 });
});
it('Flex: free, fare difference only; same-day is 15% min 250 ETB', () => {
expect(computeRescheduleAmounts(flex, 100000, 150000, false)).toEqual({ feeMinor: 0, fareDifferenceMinor: 50000, amountDueMinor: 50000 });
expect(computeRescheduleAmounts(flex, 100000, 100000, true)).toEqual({ feeMinor: 25000, fareDifferenceMinor: 0, amountDueMinor: 25000 });
expect(computeRescheduleAmounts(flex, 400000, 400000, true)).toEqual({ feeMinor: 60000, fareDifferenceMinor: 0, amountDueMinor: 60000 });
});
it('Premium: always free, even same-day', () => {
expect(computeRescheduleAmounts(premium, 500000, 500000, true).amountDueMinor).toBe(0);
expect(computeRescheduleAmounts(premium, 500000, 560000, true).amountDueMinor).toBe(60000);
});
});
describe('addisDay', () => {
it('compares calendar days in Africa/Addis_Ababa (UTC+3), not UTC', () => {
expect(addisDay(new Date('2026-09-01T21:30:00Z'))).toBe('2026-09-02');
expect(addisDay(new Date('2026-09-01T20:30:00Z'))).toBe('2026-09-01');
});
});

View File

@@ -0,0 +1,643 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma.service';
import { AuditService } from '../../common/audit.service';
import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions';
import { MeLikeUser } from '../../common/passenger-permission.util';
import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
import { normalizePhone, samePhone } from '../../common/utils/phone.utils';
import { BookingsService } from '../bookings/bookings.service';
import { SeatsService } from '../seats/seats.service';
import { TicketsService } from '../tickets/tickets.service';
import { PaymentsService } from '../payments/payments.service';
import { SupplementaryChargesService } from '../payments/supplementary-charges.service';
import { CurrencyService } from '../currency/currency.service';
import {
CreateReschedulePolicyDto,
CreateRescheduleDto,
RescheduleQuoteDto,
UpdateReschedulePolicyDto,
} from './reschedule.dto';
export const RESCHEDULE_CHARGE_REASON = 'RESCHEDULE';
export const SUPPLEMENTARY_CHARGE_PAID_EVENT = 'supplementary-charge.paid';
/**
* Coaches nobody buys a seat in, so they can never carry a reschedule policy.
*
* Matched loosely on purpose: `CoachType.type` is documented as 'passenger' | 'sleeper' |
* 'dining' | 'baggage', but the live data holds display labels ('Dining Coach ', trailing space
* included). A `notIn: ['dining','baggage']` filter therefore matches nothing and offers the
* dining coach as a fare class. This mirrors the portal's own test (`/dining|dpc/i`,
* booking/seats/page.tsx) and checks `code` as well as `type`.
*/
const NON_FARE_COACH_TERMS = ['dining', 'dpc', 'baggage'];
const NOT_A_FARE_CLASS = {
NOT: NON_FARE_COACH_TERMS.flatMap((term) => [
{ type: { contains: term, mode: 'insensitive' as const } },
{ code: { contains: term, mode: 'insensitive' as const } },
]),
};
type PolicyNumbers = {
feePercent: number;
feeMinMinor: number;
sameDayFeePercent: number;
sameDayFeeMinMinor: number;
};
/**
* Pure fee arithmetic — policy §3. Negative fare differences are recorded but NOT paid out
* (credit/refund handling is a later step), so amountDue never goes below the fee.
*/
export function computeRescheduleAmounts(
policy: PolicyNumbers,
oldFareMinor: number,
newFareMinor: number,
isSameDay: boolean,
): { feeMinor: number; fareDifferenceMinor: number; amountDueMinor: number } {
const pct = isSameDay ? policy.sameDayFeePercent : policy.feePercent;
const min = isSameDay ? policy.sameDayFeeMinMinor : policy.feeMinMinor;
const feeMinor = pct > 0 || min > 0 ? Math.max(Math.round((oldFareMinor * pct) / 100), min) : 0;
const fareDifferenceMinor = newFareMinor - oldFareMinor;
return { feeMinor, fareDifferenceMinor, amountDueMinor: feeMinor + Math.max(0, fareDifferenceMinor) };
}
export function addisDay(d: Date): string {
return d.toLocaleDateString('en-CA', { timeZone: 'Africa/Addis_Ababa' });
}
type ActingUser = MeLikeUser & { id?: string; sub?: string; phoneNumber?: string };
type LegView = {
leg: number;
scheduleId: string;
originStationId: string | null;
destinationStationId: string | null;
departureAt: Date;
seats: Array<{ id: string; seatId: string; passengerName: string; fareMinor: number | null; passengerCategory: string }>;
coachTypeId: string;
};
// Seats are ordered by passenger name so getOptions(), quote() and create() all see the same
// sequence — the client submits newSeatIds in that order (BookingSeat has no creation order).
const bookingInclude: Prisma.BookingInclude = {
schedule: { select: { id: true, departureAt: true, arrivalAt: true, originStationId: true, destinationStationId: true } },
returnSchedule: { select: { id: true, departureAt: true, arrivalAt: true, originStationId: true, destinationStationId: true } },
seats: { include: { seat: { include: { coach: { select: { coachTypeId: true } } } } }, orderBy: [{ passengerName: 'asc' }, { id: 'asc' }] },
};
@Injectable()
export class RescheduleService {
private readonly logger = new Logger(RescheduleService.name);
constructor(
private prisma: PrismaService,
private bookingsService: BookingsService,
private seatsService: SeatsService,
private ticketsService: TicketsService,
private paymentsService: PaymentsService,
private supplementaryCharges: SupplementaryChargesService,
private currencyService: CurrencyService,
private auditService: AuditService,
private eventEmitter: EventEmitter2,
) {}
// ── Policy admin ─────────────────────────────────────────────────────────
/** The policies that exist, each carrying its fare class. A coach type with no policy is simply absent. */
async listPolicies() {
return this.prisma.reschedulePolicy.findMany({
include: { coachType: { select: { id: true, code: true, name: true, type: true } } },
orderBy: { coachType: { code: 'asc' } },
});
}
/** Fare classes still available to attach a policy to — the "add" dialog's dropdown. */
async listUnconfiguredCoachTypes() {
return this.prisma.coachType.findMany({
where: { ...NOT_A_FARE_CLASS, reschedulePolicy: { is: null } },
select: { id: true, code: true, name: true, type: true },
orderBy: { code: 'asc' },
});
}
async createPolicy(dto: CreateReschedulePolicyDto, actorId?: string) {
const { coachTypeId, ...values } = dto;
const coachType = await this.prisma.coachType.findUnique({ where: { id: coachTypeId } });
if (!coachType) throw new NotFoundException('Coach type not found');
if (NON_FARE_COACH_TERMS.some((t) => `${coachType.type} ${coachType.code}`.toLowerCase().includes(t))) {
throw new BadRequestException(`${coachType.code} is not a fare class — no seats are sold in it.`);
}
const existing = await this.prisma.reschedulePolicy.findUnique({ where: { coachTypeId } });
if (existing) throw new ConflictException(`${coachType.code} already has a reschedule policy — edit it instead.`);
const policy = await this.prisma.reschedulePolicy.create({ data: { coachTypeId, ...values } });
await this.auditService.log({
userId: actorId,
action: AUDIT_ACTIONS.CREATE,
entityType: AUDIT_ENTITIES.ReschedulePolicy,
entityId: policy.id,
newData: { coachTypeCode: coachType.code, ...values },
});
return policy;
}
async deletePolicy(coachTypeId: string, actorId?: string) {
const policy = await this.prisma.reschedulePolicy.findUnique({
where: { coachTypeId },
include: { coachType: { select: { code: true } } },
});
if (!policy) throw new NotFoundException('Reschedule policy not found');
await this.prisma.reschedulePolicy.delete({ where: { coachTypeId } });
await this.auditService.log({
userId: actorId,
action: AUDIT_ACTIONS.DELETE,
entityType: AUDIT_ENTITIES.ReschedulePolicy,
entityId: policy.id,
oldData: policy,
});
// Rescheduling for this fare class is now refused outright (legBlockers treats a missing
// policy the same as an inactive one), which is the intended effect of deleting it.
return { deleted: true, coachTypeId };
}
async updatePolicy(coachTypeId: string, dto: UpdateReschedulePolicyDto, actorId?: string) {
const coachType = await this.prisma.coachType.findUnique({ where: { id: coachTypeId } });
if (!coachType) throw new NotFoundException('Coach type not found');
const before = await this.prisma.reschedulePolicy.findUnique({ where: { coachTypeId } });
const policy = await this.prisma.reschedulePolicy.upsert({
where: { coachTypeId },
update: dto,
create: { coachTypeId, ...dto },
});
await this.auditService.log({
userId: actorId,
action: AUDIT_ACTIONS.UPDATE,
entityType: AUDIT_ENTITIES.ReschedulePolicy,
entityId: policy.id,
oldData: before ?? undefined,
newData: { coachTypeCode: coachType.code, ...dto },
});
return policy;
}
// ── Reads ────────────────────────────────────────────────────────────────
/** What the portal needs before picking a new schedule: per-leg eligibility + the rule set. */
async getOptions(bookingRef: string, user: ActingUser) {
const booking = await this.loadOwnedBooking(bookingRef, user);
const legs = this.legsOf(booking);
const out = [];
for (const leg of legs) {
const policy = await this.prisma.reschedulePolicy.findUnique({ where: { coachTypeId: leg.coachTypeId } });
const blockers = this.legBlockers(booking, leg, policy);
out.push({
leg: leg.leg,
scheduleId: leg.scheduleId,
originStationId: leg.originStationId,
destinationStationId: leg.destinationStationId,
departureAt: leg.departureAt,
coachTypeId: leg.coachTypeId,
seatCount: leg.seats.length,
passengerNames: leg.seats.map((s) => s.passengerName),
oldFareMinor: this.legFare(booking, leg),
policy: policy && {
feePercent: policy.feePercent,
feeMinMinor: policy.feeMinMinor,
routeChangeAllowed: policy.routeChangeAllowed,
sameDayAllowed: policy.sameDayAllowed,
sameDayFeePercent: policy.sameDayFeePercent,
sameDayFeeMinMinor: policy.sameDayFeeMinMinor,
cutoffMinutes: policy.cutoffMinutes,
},
canReschedule: blockers.length === 0,
blockers,
});
}
const reschedules = await this.prisma.bookingReschedule.findMany({
where: { bookingId: booking.id },
orderBy: { createdAt: 'desc' },
});
const pending = reschedules.find((r) => r.status === 'PENDING_PAYMENT');
const charge = pending?.supplementaryChargeId
? await this.prisma.supplementaryCharge.findUnique({
where: { id: pending.supplementaryChargeId },
select: { paymentToken: true, status: true, expiresAt: true },
})
: null;
return {
bookingRef: booking.bookingRef,
bookingType: booking.bookingType,
legs: out,
pending: pending ? { ...pending, paymentToken: charge?.paymentToken ?? null } : null,
history: reschedules.filter((r) => r.status !== 'PENDING_PAYMENT'),
};
}
// ── Quote / create / apply ───────────────────────────────────────────────
async quote(bookingRef: string, dto: RescheduleQuoteDto, user: ActingUser) {
const booking = await this.loadOwnedBooking(bookingRef, user);
return this.buildQuote(booking, dto);
}
async create(bookingRef: string, dto: CreateRescheduleDto, user: ActingUser) {
const booking = await this.loadOwnedBooking(bookingRef, user);
const q = await this.buildQuote(booking, dto);
if (!q.allowed) throw new BadRequestException(q.blockers.join(' '));
const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } });
if (!hold || hold.expiresAt < new Date()) throw new BadRequestException('Seat hold expired');
if (hold.scheduleId !== dto.newScheduleId) throw new BadRequestException('Seat hold is for a different schedule');
const held = new Set(hold.seatIds);
if (!dto.newSeatIds.every((id) => held.has(id))) throw new BadRequestException('Selected seats are not covered by the hold');
// Availability was enforced when the hold was taken (holdSeats checks holds + booked
// segments for the leg); tickets.generate() re-checks at apply time.
const requestedBy = user.id ?? user.sub ?? booking.passengerId;
const newDeparture = q.newDepartureAt;
const expiresAt = computePaymentDeadline(new Date(), newDeparture);
const reschedule = await this.prisma.bookingReschedule.create({
data: {
bookingId: booking.id,
leg: q.leg,
status: 'PENDING_PAYMENT',
requestedBy,
oldScheduleId: q.oldScheduleId,
newScheduleId: dto.newScheduleId,
oldOriginStationId: q.oldOriginStationId,
oldDestinationStationId: q.oldDestinationStationId,
newOriginStationId: dto.newOriginStationId,
newDestinationStationId: dto.newDestinationStationId,
oldSeatIds: q.oldSeatIds,
newSeatIds: dto.newSeatIds,
holdId: dto.holdId,
oldFareMinor: q.oldFareMinor,
newFareMinor: q.newFareMinor,
fareDifferenceMinor: q.fareDifferenceMinor,
feeMinor: q.feeMinor,
amountDueMinor: q.amountDueMinor,
isSameDay: q.isSameDay,
isRouteChange: q.isRouteChange,
expiresAt: q.amountDueMinor > 0 ? expiresAt : null,
},
});
if (q.amountDueMinor === 0) {
await this.apply(reschedule.id);
return { rescheduleId: reschedule.id, status: 'APPLIED', amountDueMinor: 0, paymentToken: null, quote: q };
}
// Money owed: raise a supplementary charge (pay page /pay-balance/:token, SMS+email link) and
// keep the new seats held until the same deadline the charge carries.
const charge = await this.supplementaryCharges.create({
bookingRef: booking.bookingRef,
amountMinor: q.amountDueMinor,
reason: RESCHEDULE_CHARGE_REASON,
notes: `Reschedule leg ${q.leg} → schedule ${dto.newScheduleId}`,
createdBy: requestedBy,
expiresAt,
});
await this.prisma.bookingReschedule.update({
where: { id: reschedule.id },
data: { supplementaryChargeId: charge.id },
});
await this.seatsService.confirmSeats(dto.newSeatIds);
await this.auditService.log({
userId: requestedBy,
action: AUDIT_ACTIONS.CREATE,
entityType: AUDIT_ENTITIES.BookingReschedule,
entityId: reschedule.id,
newData: { bookingRef: booking.bookingRef, leg: q.leg, amountDueMinor: q.amountDueMinor, chargeId: charge.id },
});
return { rescheduleId: reschedule.id, status: 'PENDING_PAYMENT', amountDueMinor: q.amountDueMinor, paymentToken: charge.paymentToken, expiresAt, quote: q };
}
/** Entry point for the paid-charge event. Idempotent: only a PENDING_PAYMENT row is applied. */
async applyForCharge(supplementaryChargeId: string) {
const r = await this.prisma.bookingReschedule.findUnique({ where: { supplementaryChargeId } });
if (!r || r.status !== 'PENDING_PAYMENT') return;
await this.apply(r.id);
}
/** Moves the booking leg: booking fields, seats, journey segments, tickets. */
async apply(rescheduleId: string) {
const r = await this.prisma.bookingReschedule.findUnique({ where: { id: rescheduleId } });
if (!r) throw new NotFoundException('Reschedule not found');
if (r.status !== 'PENDING_PAYMENT') return r;
const booking = await this.prisma.booking.findUnique({ where: { id: r.bookingId }, include: bookingInclude });
if (!booking) throw new NotFoundException('Booking not found');
const leg = this.legsOf(booking).find((l) => l.leg === r.leg);
if (!leg) throw new BadRequestException('Leg no longer exists on booking');
if (leg.seats.length !== r.newSeatIds.length) throw new BadRequestException('Seat count changed since quote');
const newTotal = Math.max(0, booking.totalMinor + r.fareDifferenceMinor);
const displayTotal =
booking.displayCurrency && booking.displayCurrency !== 'ETB'
? await this.currencyService.convertAmount(newTotal, 'ETB' as any, booking.displayCurrency as any)
: newTotal;
const perSeatNew = this.splitFare(r.newFareMinor, leg.seats);
await this.prisma.$transaction(async (tx) => {
await tx.booking.update({
where: { id: booking.id },
data: {
...(r.leg === 1
? { scheduleId: r.newScheduleId, originStationId: r.newOriginStationId, destinationStationId: r.newDestinationStationId }
: { returnScheduleId: r.newScheduleId, returnOriginStationId: r.newOriginStationId, returnDestinationStationId: r.newDestinationStationId }),
totalMinor: newTotal,
displayTotalMinor: displayTotal,
},
});
// Two passes so the (scheduleId, seatId) unique key never collides mid-update when a
// passenger takes a seat another passenger of the same booking is leaving.
for (const s of leg.seats) {
await tx.bookingSeat.update({ where: { id: s.id }, data: { scheduleId: `moving-${s.id}` } });
}
for (let i = 0; i < leg.seats.length; i++) {
await tx.bookingSeat.update({
where: { id: leg.seats[i].id },
data: { seatId: r.newSeatIds[i], scheduleId: r.newScheduleId, fareMinor: perSeatNew[i], seatLabelSnapshot: null },
});
}
await tx.bookingModification.create({
data: {
bookingId: booking.id,
modifiedBy: r.requestedBy,
modificationType: 'RESCHEDULE',
oldData: { leg: r.leg, scheduleId: r.oldScheduleId, originStationId: r.oldOriginStationId, destinationStationId: r.oldDestinationStationId, seatIds: r.oldSeatIds, fareMinor: r.oldFareMinor },
newData: { leg: r.leg, scheduleId: r.newScheduleId, originStationId: r.newOriginStationId, destinationStationId: r.newDestinationStationId, seatIds: r.newSeatIds, fareMinor: r.newFareMinor, feeMinor: r.feeMinor },
fareAdjustment: r.fareDifferenceMinor,
},
});
await tx.bookingReschedule.update({ where: { id: r.id }, data: { status: 'APPLIED', appliedAt: new Date() } });
});
// Occupancy + tickets are rebuilt from the (now updated) booking, outside the transaction.
const fresh = await this.prisma.booking.findUnique({ where: { id: booking.id }, include: { seats: true, tickets: { select: { id: true } } } });
if (fresh) {
try {
await this.seatsService.releaseSeats(fresh.id);
await this.paymentsService.createJourneySegments(fresh as any);
} catch (err) {
this.logger.error(`Reschedule ${r.id}: journey segments failed: ${err instanceof Error ? err.message : err}`);
}
// Old tickets' SYSTEM seat blocks reference ticket ids that generate() is about to delete.
for (const t of fresh.tickets) {
await this.prisma.seatBlock.deleteMany({ where: { reason: { contains: t.id }, blockedBy: 'SYSTEM' } });
}
try { await this.ticketsService.generate(fresh.id); } catch (err) {
this.logger.error(`Reschedule ${r.id}: ticket generation failed: ${err instanceof Error ? err.message : err}`);
}
}
// The new seats are owned by the journey now; the old seats' own booking-time hold (holds
// outlive confirmation until the payment deadline) would otherwise keep them HELD on the old
// schedule. Nobody else can hold a booked seat, so any hold there is this booking's.
await this.prisma.seatHold.deleteMany({
where: { OR: [{ id: r.holdId ?? '' }, { scheduleId: r.oldScheduleId, seatIds: { hasSome: r.oldSeatIds } }] },
});
await this.auditService.log({
userId: r.requestedBy,
action: AUDIT_ACTIONS.UPDATE,
entityType: AUDIT_ENTITIES.Booking,
entityId: booking.id,
oldData: { leg: r.leg, scheduleId: r.oldScheduleId, seatIds: r.oldSeatIds },
newData: { leg: r.leg, scheduleId: r.newScheduleId, seatIds: r.newSeatIds, feeMinor: r.feeMinor, fareDifferenceMinor: r.fareDifferenceMinor, rescheduleId: r.id },
});
this.eventEmitter.emit('booking.rescheduled', { booking: fresh ?? booking, reschedule: r });
return { ...r, status: 'APPLIED' };
}
/** Cron hook: unpaid reschedules past their payment deadline. The seat hold lapses by itself. */
async expireStale(now = new Date()): Promise<number> {
const stale = await this.prisma.bookingReschedule.findMany({
where: { status: 'PENDING_PAYMENT', expiresAt: { lt: now } },
select: { id: true, supplementaryChargeId: true },
});
for (const r of stale) {
await this.prisma.bookingReschedule.update({ where: { id: r.id }, data: { status: 'EXPIRED' } });
if (r.supplementaryChargeId) {
await this.prisma.supplementaryCharge.updateMany({
where: { id: r.supplementaryChargeId, status: 'PENDING' },
data: { status: 'EXPIRED' },
});
}
}
return stale.length;
}
// ── Internals ────────────────────────────────────────────────────────────
/**
* Who may act on this booking: only the person who made it, proven by their account's phone
* number matching the booking's `contactPhone`. Being merely *named* on the booking is not
* enough — a passenger travelling on someone else's booking cannot move it.
*
* There is deliberately no staff override. The `bookings:reschedule` permission still exists in
* the registry (and on the stationMaster preset) but is not honoured here, so a station master
* cannot reschedule on a customer's behalf yet. To restore it, re-import
* `hasPassengerPermission` / `PASSENGER_PERMS` and return the booking early when the caller
* holds `PASSENGER_PERMS.bookings.reschedule`.
*/
private async loadOwnedBooking(bookingRef: string, user: ActingUser) {
const booking = await this.prisma.booking.findUnique({ where: { bookingRef }, include: bookingInclude });
if (!booking) throw new NotFoundException('Booking not found');
const iamUserId = user.id ?? user.sub;
if (!iamUserId) throw new ForbiddenException();
if (booking.contactPhone) {
const callerPhone = await this.resolveUserPhone(iamUserId, user);
if (samePhone(callerPhone, booking.contactPhone)) return booking;
throw new ForbiddenException(
'Only the person who made this booking can reschedule it. Sign in with the phone number used to book.',
);
}
// ~0.3% of bookings (72 of 24.7k on dev) carry no contactPhone at all, so there is nothing to
// match against. Fall back to the account link rather than locking their owner out entirely.
const passenger = await this.prisma.passenger.findUnique({ where: { iamUserId }, select: { id: true } });
if (!passenger || passenger.id !== booking.passengerId) throw new ForbiddenException('Not your booking');
return booking;
}
/**
* The signed-in user's phone. The session snapshot (`userInfo.phoneNumber`) is frequently an
* empty string, so `iam.users` is the source of truth — and reading it live also means a user
* who changed their number does not have to sign out before the new one counts.
*/
private async resolveUserPhone(iamUserId: string, user: ActingUser): Promise<string | null> {
const fromSession = normalizePhone(user.phoneNumber);
if (fromSession) return fromSession;
const rows = await this.prisma.$queryRaw<{ phone_number: string | null }[]>`
SELECT phone_number FROM iam.users WHERE id = ${iamUserId}::uuid LIMIT 1
`;
return normalizePhone(rows[0]?.phone_number);
}
private legsOf(booking: any): LegView[] {
const legs: LegView[] = [];
const seatsOf = (n: number) =>
(booking.seats as any[])
.filter((s) => (s.leg ?? 1) === n)
.map((s) => ({ id: s.id, seatId: s.seatId, passengerName: s.passengerName, fareMinor: s.fareMinor, passengerCategory: s.passengerCategory, coachTypeId: s.seat?.coach?.coachTypeId }));
const l1 = seatsOf(1);
if (l1.length && booking.schedule) {
legs.push({ leg: 1, scheduleId: booking.scheduleId, originStationId: booking.originStationId, destinationStationId: booking.destinationStationId, departureAt: booking.schedule.departureAt, seats: l1, coachTypeId: l1[0].coachTypeId });
}
const l2 = seatsOf(2);
if (booking.bookingType === 'ROUND_TRIP' && l2.length && booking.returnSchedule) {
legs.push({ leg: 2, scheduleId: booking.returnScheduleId, originStationId: booking.returnOriginStationId, destinationStationId: booking.returnDestinationStationId, departureAt: booking.returnSchedule.departureAt, seats: l2, coachTypeId: l2[0].coachTypeId });
}
return legs;
}
/** The leg's original fare: per-seat amounts when recorded, else the whole booking (one-way). */
private legFare(booking: any, leg: LegView): number {
const recorded = leg.seats.reduce((sum, s) => sum + (s.fareMinor ?? 0), 0);
if (recorded > 0) return recorded;
return booking.bookingType === 'ONE_WAY' ? booking.totalMinor : Math.round(booking.totalMinor / 2);
}
private legBlockers(booking: any, leg: LegView, policy: any, now = new Date()): string[] {
const blockers: string[] = [];
if (!['ONE_WAY', 'ROUND_TRIP'].includes(booking.bookingType)) blockers.push('Only one-way and round-trip bookings can be rescheduled.');
if (booking.status !== 'CONFIRMED') blockers.push('Only confirmed bookings can be rescheduled.');
// ponytail: a boarded leg can't be moved and tickets.generate() rebuilds every leg, so a
// round trip whose outbound was already used can't change its return yet — needs leg-scoped
// ticket regeneration.
if (booking.outboundBoardedAt || booking.returnBoardedAt) blockers.push('This booking has already been used for travel.');
if (!policy || !policy.isActive) blockers.push('Rescheduling is not available for this fare class.');
else if (leg.departureAt.getTime() - now.getTime() < policy.cutoffMinutes * 60_000) {
blockers.push(`Changes must be made at least ${policy.cutoffMinutes} minutes before departure.`);
}
return blockers;
}
private async buildQuote(booking: any, dto: RescheduleQuoteDto) {
const legNo = dto.leg ?? 1;
const leg = this.legsOf(booking).find((l) => l.leg === legNo);
if (!leg) throw new BadRequestException(`Booking has no leg ${legNo}`);
const policy = await this.prisma.reschedulePolicy.findUnique({ where: { coachTypeId: leg.coachTypeId } });
const blockers = this.legBlockers(booking, leg, policy);
const pending = await this.prisma.bookingReschedule.findFirst({ where: { bookingId: booking.id, status: 'PENDING_PAYMENT' } });
if (pending) blockers.push('A reschedule is already awaiting payment for this booking.');
if (dto.newSeatIds.length !== leg.seats.length) blockers.push(`Select exactly ${leg.seats.length} seat(s).`);
if (new Set(dto.newSeatIds).size !== dto.newSeatIds.length) blockers.push('Duplicate seats selected.');
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: dto.newScheduleId },
include: { stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
});
if (!schedule) throw new NotFoundException('New schedule not found');
const now = new Date();
if (schedule.departureAt <= now || schedule.status !== 'SCHEDULED') blockers.push('The selected departure is no longer bookable.');
if (schedule.id === leg.scheduleId && dto.newOriginStationId === leg.originStationId && dto.newDestinationStationId === leg.destinationStationId) {
blockers.push('Pick a different departure, route or date.');
}
const originStop = schedule.stopTimes.find((s) => s.stationId === dto.newOriginStationId);
const destStop = schedule.stopTimes.find((s) => s.stationId === dto.newDestinationStationId);
if (!originStop || !destStop || originStop.sequence >= destStop.sequence) blockers.push('Origin/destination are not valid for this schedule.');
const isRouteChange = dto.newOriginStationId !== leg.originStationId || dto.newDestinationStationId !== leg.destinationStationId;
if (isRouteChange && policy && !policy.routeChangeAllowed) blockers.push('Route changes are not permitted for this fare class.');
const isSameDay = addisDay(schedule.departureAt) === addisDay(leg.departureAt);
if (isSameDay && policy && !policy.sameDayAllowed) blockers.push('Same-day changes are not permitted for this fare class.');
// Keep the round trip chronologically sane.
if (booking.bookingType === 'ROUND_TRIP') {
if (legNo === 1 && booking.returnSchedule && schedule.arrivalAt >= booking.returnSchedule.departureAt) blockers.push('New outbound must arrive before the return departs.');
if (legNo === 2 && booking.schedule && schedule.departureAt <= booking.schedule.arrivalAt) blockers.push('New return must depart after the outbound arrives.');
}
// New seats: same coach type as booked (no class change in this step), priced per seat.
const seats = await this.prisma.seat.findMany({
where: { id: { in: dto.newSeatIds } },
include: { coach: { include: { coachType: { include: { seatClasses: { where: { isActive: true } } } } } } },
});
const seatById = new Map(seats.map((s) => [s.id, s]));
let newFareMinor = 0;
if (originStop && destStop && seats.length === dto.newSeatIds.length) {
const nationalityType = booking.displayCurrency === 'USD' ? 'INTERNATIONAL' : 'LOCAL';
// ponytail: passenger nationality isn't stored on the booking; currency is the proxy the
// search/fare code already uses (ETB/DJF = local, USD = international).
const nationality = booking.displayCurrency === 'DJF' ? 'Djiboutian' : booking.displayCurrency === 'ETB' ? 'Ethiopian' : undefined;
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
for (let i = 0; i < dto.newSeatIds.length; i++) {
const seat = seatById.get(dto.newSeatIds[i])!;
if (seat.coach.coachTypeId !== leg.coachTypeId) { blockers.push('New seats must be in the same class as the original booking.'); break; }
const oldSeat = leg.seats[i];
if (oldSeat.fareMinor === 0) continue; // free child keeps riding free
const seatClass = this.pickSeatClass(seat.coach.coachType.seatClasses, seat.bedPosition, nationalityType);
if (!seatClass) { blockers.push('No fare is configured for the selected seat.'); break; }
newFareMinor += await this.bookingsService.getBaseFare(
schedule.id, seatClass.id, segmentRoute, undefined, nationality,
originStop.sequence, destStop.sequence, originStop.stationId, destStop.stationId,
);
}
} else if (seats.length !== dto.newSeatIds.length) {
blockers.push('One or more selected seats do not exist.');
}
const oldFareMinor = this.legFare(booking, leg);
const amounts = policy
? computeRescheduleAmounts(policy, oldFareMinor, newFareMinor, isSameDay)
: { feeMinor: 0, fareDifferenceMinor: newFareMinor - oldFareMinor, amountDueMinor: 0 };
return {
allowed: blockers.length === 0,
blockers: Array.from(new Set(blockers)),
leg: legNo,
oldScheduleId: leg.scheduleId,
oldOriginStationId: leg.originStationId,
oldDestinationStationId: leg.destinationStationId,
oldSeatIds: leg.seats.map((s) => s.seatId),
newScheduleId: schedule.id,
newDepartureAt: schedule.departureAt,
isSameDay,
isRouteChange,
oldFareMinor,
newFareMinor,
...amounts,
currency: 'ETB',
cutoffAt: policy ? new Date(leg.departureAt.getTime() - policy.cutoffMinutes * 60_000) : null,
policy: policy && { feePercent: policy.feePercent, feeMinMinor: policy.feeMinMinor, sameDayFeePercent: policy.sameDayFeePercent, sameDayFeeMinMinor: policy.sameDayFeeMinMinor, routeChangeAllowed: policy.routeChangeAllowed, sameDayAllowed: policy.sameDayAllowed, cutoffMinutes: policy.cutoffMinutes },
};
}
/** Mirrors SearchService's class matching: nationality filter, then bed position. */
private pickSeatClass(classes: any[], bedPosition: string | null, nationalityType: string) {
const byNat = classes.filter((c) => !c.nationalityType || c.nationalityType === nationalityType);
const pool = byNat.length ? byNat : classes;
const bed = bedPosition?.toLowerCase() ?? null;
const exact = pool.find((c) => (c.bedPosition?.toLowerCase() ?? null) === bed);
return exact ?? pool.find((c) => !c.bedPosition) ?? pool[0] ?? null;
}
/** Distributes the leg fare over seats, free children (fare 0) stay 0; rounding lands on the last paid seat. */
private splitFare(total: number, seats: LegView['seats']): number[] {
const paid = seats.map((s) => s.fareMinor !== 0);
const n = paid.filter(Boolean).length || 1;
const each = Math.floor(total / n);
let remaining = total;
let lastPaid = -1;
const out = seats.map((_, i) => { if (!paid[i]) return 0; lastPaid = i; remaining -= each; return each; });
if (lastPaid >= 0) out[lastPaid] += remaining;
return out;
}
}

View File

@@ -1,4 +1,4 @@
import { IsString, IsDateString, IsInt, IsOptional, IsEnum, IsArray, ValidateNested, IsObject, Min } from 'class-validator';
import { IsString, IsDateString, IsInt, IsOptional, IsEnum, IsArray, ValidateNested, IsObject, IsBoolean, Min } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
@@ -56,6 +56,9 @@ export class CreateScheduleDto {
@ApiPropertyOptional({ type: [String], description: 'Coach UUIDs to assign, in consist order. Overrides the route coach template if provided. A schedule must end up with at least one coach.' })
@IsOptional() @IsArray() @IsString({ each: true })
coachIds?: string[];
@ApiPropertyOptional({ example: false, description: 'Exclude from public search (reserved for packages)' }) @IsOptional() @IsBoolean() isPackageOnly?: boolean;
@ApiPropertyOptional({ example: false, description: 'Exclude from public search (reserved for staff group bookings)' }) @IsOptional() @IsBoolean() isGroupBookingOnly?: boolean;
}
export class UpdateScheduleDto {
@@ -64,6 +67,7 @@ export class UpdateScheduleDto {
@ApiPropertyOptional({ enum: TripStatus, example: TripStatus.SCHEDULED }) @IsOptional() @IsEnum(TripStatus) status?: TripStatus;
@ApiPropertyOptional({ type: Array, description: 'List of coaches to assign' }) @IsOptional() @IsArray() coaches?: Array<{ coachId: string; positionNumber: number }>;
@ApiPropertyOptional({ example: false, description: 'Exclude from public search (reserved for packages)' }) @IsOptional() isPackageOnly?: boolean;
@ApiPropertyOptional({ example: false, description: 'Exclude from public search (reserved for staff group bookings)' }) @IsOptional() @IsBoolean() isGroupBookingOnly?: boolean;
}
export class UpdateStopTimeDto {

View File

@@ -263,6 +263,8 @@ export class SchedulesService {
arrivalAt: arr,
durationMinutes: Math.round((arr.getTime() - dep.getTime()) / 60_000),
stopsCount: Math.max(0, route.stops.length - 2),
isPackageOnly: dto.isPackageOnly ?? false,
isGroupBookingOnly: dto.isGroupBookingOnly ?? false,
},
include: { train: true, originStation: true, destinationStation: true },
});
@@ -1000,6 +1002,7 @@ export class SchedulesService {
if (dto.status) updateData.status = dto.status;
if (dto.isPackageOnly !== undefined) updateData.isPackageOnly = dto.isPackageOnly;
if (dto.isGroupBookingOnly !== undefined) updateData.isGroupBookingOnly = dto.isGroupBookingOnly;
if (Object.keys(updateData).length > 0) {
await this.prisma.trainSchedule.update({ where: { id }, data: updateData });

View File

@@ -27,6 +27,13 @@ export class SearchTripsDto {
@ApiPropertyOptional({ example: '2026-06-20', description: 'Return date (YYYY-MM-DD) — required for ROUND_TRIP, must be after outbound date' })
@IsOptional() @IsDateString() returnDate?: string;
@ApiPropertyOptional({
example: 'PORTAL',
enum: ['PORTAL', 'GROUP_BOOKING'],
description: 'Calling surface. Omit or PORTAL for normal ticket search (default) — only sees schedules with isGroupBookingOnly=false. GROUP_BOOKING sees only schedules with isGroupBookingOnly=true — the two are an exclusive partition, not additive; each channel sees a disjoint set of schedules.',
})
@IsOptional() @IsEnum(['PORTAL', 'GROUP_BOOKING']) channel?: string;
}
export class AvailableDatesQueryDto {

View File

@@ -82,6 +82,10 @@ export class SearchService {
) {}
async searchTrips(dto: SearchTripsDto) {
// GROUP_BOOKING is the staff-only bulk-booking wizard's own calling surface — isGroupBookingOnly
// is an exclusive partition, not additive: this channel sees ONLY schedules explicitly created
// for group booking, and the normal ticket channel (the default, PORTAL) sees only the rest.
const forGroupBooking = dto.channel === "GROUP_BOOKING";
const [direct, transit] = await Promise.all([
this.searchSchedules(
dto.originStationId,
@@ -90,6 +94,7 @@ export class SearchService {
dto.adultCount,
dto.childCount,
dto.nationality,
forGroupBooking,
),
this.searchTransitOptions(
dto.originStationId,
@@ -98,6 +103,7 @@ export class SearchService {
dto.adultCount,
dto.childCount,
dto.nationality,
forGroupBooking,
),
]);
@@ -112,8 +118,9 @@ export class SearchService {
dto.adultCount,
dto.childCount,
dto.nationality,
forGroupBooking,
),
this.classifyEmptySearch(dto.originStationId, dto.destinationStationId, dto.date),
this.classifyEmptySearch(dto.originStationId, dto.destinationStationId, dto.date, forGroupBooking),
]);
return {
journeyType: "ONE_WAY",
@@ -133,6 +140,7 @@ export class SearchService {
dto.adultCount,
dto.childCount,
dto.nationality,
forGroupBooking,
),
this.searchTransitOptions(
dto.destinationStationId,
@@ -141,6 +149,7 @@ export class SearchService {
dto.adultCount,
dto.childCount,
dto.nationality,
forGroupBooking,
),
]);
@@ -172,6 +181,7 @@ export class SearchService {
dto.adultCount,
dto.childCount,
dto.nationality,
forGroupBooking,
)
: Promise.resolve([]),
inbound.length === 0
@@ -182,13 +192,14 @@ export class SearchService {
dto.adultCount,
dto.childCount,
dto.nationality,
forGroupBooking,
)
: Promise.resolve([]),
outbound.length === 0
? this.classifyEmptySearch(dto.originStationId, dto.destinationStationId, dto.date)
? this.classifyEmptySearch(dto.originStationId, dto.destinationStationId, dto.date, forGroupBooking)
: Promise.resolve(undefined),
inbound.length === 0
? this.classifyEmptySearch(dto.destinationStationId, dto.originStationId, returnDate)
? this.classifyEmptySearch(dto.destinationStationId, dto.originStationId, returnDate, forGroupBooking)
: Promise.resolve(undefined),
]);
return {
@@ -223,6 +234,7 @@ export class SearchService {
adultCount: number,
childCount?: number,
nationality?: string,
forGroupBooking = false,
) {
// 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.
@@ -241,6 +253,10 @@ export class SearchService {
const baseWhere: Prisma.TrainScheduleWhereInput = {
status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] },
isPackageOnly: false,
// Group Booking's search is exclusive, not additive: staff only ever see schedules
// explicitly created for group booking, never the normal passenger-facing ones, and the
// portal never sees group-only ones. Each channel is a strict partition of the other.
isGroupBookingOnly: forGroupBooking,
stopTimes: { some: { stationId: originStationId } },
coachAssignments: { some: {} },
};
@@ -310,6 +326,7 @@ export class SearchService {
adultCount: number,
childCount?: number,
nationality?: string,
forGroupBooking = false,
) {
// 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.
@@ -329,6 +346,8 @@ export class SearchService {
// statuses, not booking-closed signals (see comment on searchAlternatives' baseWhere).
status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] },
isPackageOnly: false,
// Exclusive partition — see the comment on searchAlternatives' baseWhere.
isGroupBookingOnly: forGroupBooking,
departureAt: { gte: date, lt: nextDay },
stopTimes: { some: { stationId: originStationId } },
coachAssignments: { some: {} },
@@ -363,6 +382,7 @@ export class SearchService {
originStationId: string,
destinationStationId: string,
dateStr: string,
forGroupBooking = false,
): Promise<Passenger.ISearchEmptyReason> {
const [origin, destination] = await Promise.all([
this.prisma.station.findUnique({ where: { id: originStationId }, select: { name: true } }),
@@ -393,6 +413,7 @@ export class SearchService {
select: {
status: true,
isPackageOnly: true,
isGroupBookingOnly: true,
departureAt: true,
route: {
select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } },
@@ -409,13 +430,20 @@ export class SearchService {
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));
// (right status, not package-only, not group-booking-only unless this IS a group-booking
// search, has at least one coach assigned).
const bookable = sameDayForPair.filter((s) => this.isBookableSchedule(s, forGroupBooking));
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);
// isGroupBookingOnly is an exclusive partition (see isBookableSchedule) — this same reason
// code covers both directions: the portal finding only group-reserved schedules, and Group
// Booking finding only normal ones (nothing set up for it on this date). The frontend picks
// the right copy per caller.
if (sameDayForPair.every((s) => s.isGroupBookingOnly !== forGroupBooking))
return withCode(Passenger.SearchEmptyReasonCode.GroupBookingOnly);
return withCode(Passenger.SearchEmptyReasonCode.NoScheduleOnDate);
}
@@ -483,11 +511,22 @@ export class SearchService {
/** 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 {
/**
* Status/package/group-booking/coach bookability only — ignores date, cutoff, and seat-level
* availability. `forGroupBooking` defaults false so existing single-arg callers (e.g.
* getAvailableDates, the portal's calendar) keep hiding group-booking-only schedules.
* isGroupBookingOnly is an exclusive partition, not an additive one: a schedule is bookable
* for a given channel only when its flag exactly matches that channel (normal schedules for
* the portal, group-only schedules for Group Booking — never both from one channel).
*/
private isBookableSchedule(
s: { status: string; isPackageOnly: boolean; isGroupBookingOnly: boolean; coachAssignments: { id: string }[] },
forGroupBooking = false,
): boolean {
return (
(["SCHEDULED", "BOARDING", "EN_ROUTE"] as string[]).includes(s.status) &&
!s.isPackageOnly &&
s.isGroupBookingOnly === forGroupBooking &&
s.coachAssignments.length > 0
);
}
@@ -542,6 +581,7 @@ export class SearchService {
departureAt: true,
status: true,
isPackageOnly: true,
isGroupBookingOnly: true,
route: {
select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } },
},
@@ -581,6 +621,7 @@ export class SearchService {
adultCount: number,
childCount?: number,
nationality?: string,
forGroupBooking = false,
) {
// 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.
@@ -599,6 +640,8 @@ export class SearchService {
// BOARDING included alongside SCHEDULED — see comment on searchAlternatives' baseWhere.
status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] },
isPackageOnly: false,
// Exclusive partition — see the comment on searchAlternatives' baseWhere.
isGroupBookingOnly: forGroupBooking,
departureAt: { gte: dayStart, lt: dayEnd },
stopTimes: { some: { stationId: originStationId } },
coachAssignments: { some: {} },
@@ -609,6 +652,7 @@ export class SearchService {
where: {
status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] },
isPackageOnly: false,
isGroupBookingOnly: forGroupBooking,
departureAt: { gte: dayStart, lt: leg2WindowEnd },
coachAssignments: { some: {} },
},

View File

@@ -21,7 +21,7 @@ import {
ApiBody,
} from "@nestjs/swagger";
import { SeatsService } from "./seats.service";
import { BlockSeatDto, HoldSeatsDto, ReleaseHoldDto, SetMaintenanceDto } from "./seats.dto";
import { AutoAssignHoldDto, 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";
@@ -186,6 +186,33 @@ This makes it clear which segment of the route each seat is held for, enabling s
return this.service.holdSeats(dto);
}
@Post("auto-assign-hold")
@PassengerStaff([PASSENGER_PERMS.bookings.manage])
@ApiBearerAuth("IAM-auth")
@ApiOperation({
summary: "Auto-assign and hold N seats of a class — staff bulk/group booking only",
description: `Picks the requested number of available seats of the given class (filling Lower berths first, then Middle, then Upper, ascending seat number within each tier) and holds them in one step, so the caller never shows an assignment it could lose to a race before the passenger data is submitted.
No manual seat selection — this is for bulk/group booking flows where staff upload a passenger list rather than picking seats on a seat map. Returns the same hold shape as POST /seats/hold.
For a round-trip group booking, call this twice — once per leg — passing \`journeyDirection: 'OUTBOUND'\`/\`'RETURN'\` so a same-schedule turnaround round trip isn't mistaken for a double-hold conflict.
Throws 409 with no partial hold created if fewer than the requested seats are available in that class.`,
})
@ApiResponse({ status: 201, description: "Seats auto-assigned and held" })
@ApiResponse({ status: 409, description: "Not enough seats available in the requested class" })
autoAssignHold(@Body() dto: AutoAssignHoldDto) {
const passengerCount = dto.adultCount + (dto.childCount ?? 0);
return this.service.autoAssignAndHold(
dto.scheduleId,
dto.originStationId,
dto.destinationStationId,
dto.seatClassName,
passengerCount,
dto.journeyDirection,
);
}
@Delete("hold/:holdId")
@UseGuards(JwtGuard)
@ApiBearerAuth("JWT-auth")

View File

@@ -1,4 +1,4 @@
import { IsString, IsArray, ValidateNested, IsOptional, IsEnum } from 'class-validator';
import { IsString, IsArray, ValidateNested, IsOptional, IsEnum, IsInt, Min } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
@@ -49,6 +49,33 @@ export class HoldSeatsDto {
passengers: PassengerSeatDto[];
}
export class AutoAssignHoldDto {
@ApiProperty({ example: 'schedule-uuid', description: 'TrainSchedule UUID' })
@IsString() scheduleId: string;
@ApiProperty({ example: 'station-uuid', description: 'Origin station UUID for this leg' })
@IsString() originStationId: string;
@ApiProperty({ example: 'station-uuid', description: 'Destination station UUID for this leg' })
@IsString() destinationStationId: string;
@ApiProperty({ example: 'Economy Regular', description: 'Seat class name to auto-assign from — must match a class returned by POST /search for this schedule.' })
@IsString() seatClassName: string;
@ApiProperty({ example: 4, minimum: 1, description: 'Number of adult passengers to assign seats for.' })
@IsInt() @Min(0) adultCount: number;
@ApiPropertyOptional({ example: 1, minimum: 0, description: 'Number of child passengers to assign seats for.' })
@IsOptional() @IsInt() @Min(0) childCount?: number;
@ApiPropertyOptional({
enum: JourneyDirection,
example: JourneyDirection.OUTBOUND,
description: 'Round-trip leg direction — OUTBOUND or RETURN. Omit for a plain one-way group booking (the hold defaults to ONE_WAY), preserving current behavior.',
})
@IsOptional() @IsEnum(JourneyDirection) journeyDirection?: JourneyDirection;
}
export class ReleaseHoldDto {
@ApiProperty({ example: 'hold-uuid', description: 'SeatHold UUID to release' })
@IsString() holdId: string;

View File

@@ -18,6 +18,10 @@ describe('SeatsService - Auto Assign', () => {
findMany: jest.fn(),
updateMany: jest.fn(),
},
seatClass: {
findFirst: jest.fn(),
findMany: jest.fn(),
},
tripStopTime: {
findMany: jest.fn(),
},
@@ -72,6 +76,14 @@ describe('SeatsService - Auto Assign', () => {
]);
mockPrisma.seatBlock.findMany.mockResolvedValue([]);
mockSegmentsService.getSeatAvailabilityMap.mockResolvedValue(new Map());
mockPrisma.seatClass.findFirst.mockResolvedValue({
coachTypeId: 'coach-type-1',
nationalityType: 'INTERNATIONAL',
coachType: { name: 'Hard Seat Coach' },
});
mockPrisma.seatClass.findMany.mockResolvedValue([
{ bedPosition: null },
]);
});
describe('assertNoRouteSeatConflict', () => {
@@ -114,25 +126,27 @@ describe('SeatsService - Auto Assign', () => {
});
describe('autoAssignSeats', () => {
it('should assign contiguous seats in same row', async () => {
it('should assign seats in ascending seat-number order (not row/insertion order)', async () => {
// Deliberately out of order and non-contiguous-by-row to prove the sort is driven by
// seatNumber, not by the order seats came back from the query or their row grouping.
const mockSeats = [
{ id: 'seat-1', coachId: 'coach-1', row: 1, col: 'A' },
{ id: 'seat-2', coachId: 'coach-1', row: 1, col: 'B' },
{ id: 'seat-3', coachId: 'coach-1', row: 1, col: 'C' },
{ id: 'seat-4', coachId: 'coach-1', row: 2, col: 'A' },
{ id: 'seat-3', seatNumber: '3', coachId: 'coach-1', row: 2, col: 'A' },
{ id: 'seat-1', seatNumber: '1', coachId: 'coach-1', row: 1, col: 'A' },
{ id: 'seat-2', seatNumber: '2', coachId: 'coach-1', row: 1, col: 'B' },
{ id: 'seat-10', seatNumber: '10', coachId: 'coach-1', row: 3, col: 'A' },
];
mockPrisma.seat.findMany.mockResolvedValue(mockSeats);
const result = await service.autoAssignSeats('trip-1', 2, 'ECONOMY_REGULAR');
expect(result).toHaveLength(2);
// Numeric order (1, 2) — a lexicographic sort would have put '10' before '2'.
expect(result).toEqual(['seat-1', 'seat-2']);
});
it('should throw error if not enough seats available', async () => {
mockPrisma.seat.findMany.mockResolvedValue([
{ id: 'seat-1', coachId: 'coach-1', row: 1, col: 'A' },
{ id: 'seat-1', seatNumber: '1', coachId: 'coach-1', row: 1, col: 'A' },
]);
await expect(
@@ -140,22 +154,9 @@ describe('SeatsService - Auto Assign', () => {
).rejects.toThrow(ConflictException);
});
it('should respect eligibility filter', async () => {
const mockSeats = [
{ id: 'seat-1', coachId: 'coach-1', row: 1, col: 'A', eligibility: 'ACCESSIBLE' },
{ id: 'seat-2', coachId: 'coach-1', row: 1, col: 'B', eligibility: 'ACCESSIBLE' },
];
mockPrisma.seat.findMany.mockResolvedValue(mockSeats);
const result = await service.autoAssignSeats('trip-1', 2, 'ECONOMY_REGULAR');
expect(result).toHaveLength(2);
});
it('should assign single seat', async () => {
const mockSeats = [
{ id: 'seat-1', coachId: 'coach-1', row: 1, col: 'A' },
{ id: 'seat-1', seatNumber: '1', coachId: 'coach-1', row: 1, col: 'A' },
];
mockPrisma.seat.findMany.mockResolvedValue(mockSeats);
@@ -164,5 +165,75 @@ describe('SeatsService - Auto Assign', () => {
expect(result).toEqual(['seat-1']);
});
it('should fill Lower, then Middle, then Upper — a fixed physical order, not fare order', async () => {
mockPrisma.seatClass.findFirst.mockResolvedValue({
coachTypeId: 'coach-type-hbc',
nationalityType: 'INTERNATIONAL',
coachType: { name: 'Hard Berth Coach' },
});
mockPrisma.seatClass.findMany.mockResolvedValue([
{ bedPosition: 'UPPER' },
{ bedPosition: 'MIDDLE' },
{ bedPosition: 'LOWER' },
]);
// Upper is the cheapest tier in the seed data (4000 vs 5500 Middle vs 6000 Lower) — this
// deliberately picks seats so a fare-order algorithm and a lower-first algorithm disagree.
const mockSeats = [
{ id: 'upper-1', seatNumber: '16', coachId: 'coach-1', row: 4, col: 'A', bedPosition: 'UPPER' },
{ id: 'upper-2', seatNumber: '17', coachId: 'coach-1', row: 4, col: 'B', bedPosition: 'UPPER' },
{ id: 'middle-1', seatNumber: '11', coachId: 'coach-1', row: 3, col: 'A', bedPosition: 'MIDDLE' },
{ id: 'lower-1', seatNumber: '6', coachId: 'coach-1', row: 2, col: 'A', bedPosition: 'LOWER' },
{ id: 'lower-2', seatNumber: '7', coachId: 'coach-1', row: 2, col: 'B', bedPosition: 'LOWER' },
];
mockPrisma.seat.findMany.mockResolvedValue(mockSeats);
const result = await service.autoAssignSeats('trip-1', 3, 'Economy Bed Upper (Intl)');
// Both Lower seats first, then spill into Middle — Upper is untouched even though it's cheaper.
expect(result).toEqual(['lower-1', 'lower-2', 'middle-1']);
});
it('should count all fare tiers toward availability, not just one tier', async () => {
mockPrisma.seatClass.findFirst.mockResolvedValue({
coachTypeId: 'coach-type-hbc',
nationalityType: 'INTERNATIONAL',
coachType: { name: 'Hard Berth Coach' },
});
mockPrisma.seatClass.findMany.mockResolvedValue([
{ bedPosition: 'UPPER' },
{ bedPosition: 'LOWER' },
]);
mockPrisma.seat.findMany.mockResolvedValue([
{ id: 'upper-1', seatNumber: '16', coachId: 'coach-1', row: 4, col: 'A', bedPosition: 'UPPER' },
{ id: 'lower-1', seatNumber: '6', coachId: 'coach-1', row: 2, col: 'A', bedPosition: 'LOWER' },
]);
const result = await service.autoAssignSeats('trip-1', 2, 'VIP Bed Upper (Intl)');
expect(result).toHaveLength(2);
});
it('should match bed-tier seats regardless of case (SeatClass.bedPosition is seeded uppercase, Seat.bedPosition is stored lowercase in production data)', async () => {
mockPrisma.seatClass.findFirst.mockResolvedValue({
coachTypeId: 'coach-type-sbc',
nationalityType: 'INTERNATIONAL',
coachType: { name: 'Soft Berth Coach' },
});
mockPrisma.seatClass.findMany.mockResolvedValue([
{ bedPosition: 'UPPER' },
{ bedPosition: 'LOWER' },
]);
mockPrisma.seat.findMany.mockResolvedValue([
{ id: 'upper-1', seatNumber: '16', coachId: 'coach-1', row: 4, col: 'A', bedPosition: 'upper' },
{ id: 'lower-1', seatNumber: '6', coachId: 'coach-1', row: 3, col: 'A', bedPosition: 'lower' },
]);
const result = await service.autoAssignSeats('trip-1', 2, 'VIP Bed Upper (Intl)');
expect(result).toHaveLength(2);
// Lower fills before Upper regardless of case.
expect(result).toEqual(['lower-1', 'upper-1']);
});
});
});

View File

@@ -1,4 +1,5 @@
import { Injectable, ConflictException, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { PrismaService } from '../../common/prisma.service';
import { BlockSeatDto, HoldSeatsDto, JourneyDirection, SeatBlockReasonCategory } from './seats.dto';
import { ActingUser } from '../../common/acting-user';
@@ -738,9 +739,11 @@ export class SeatsService {
}
}
// Delete the Journey (and its JourneySegments) scoped to this booking.
// Delete the Journey (and its JourneySegments) scoped to this booking. The segment FK has no
// ON DELETE CASCADE, so segments go first or the journey delete fails on a ticketed booking.
async releaseSeats(bookingId: string) {
await this.prisma.journey.deleteMany({ where: { bookingId } as any });
await this.prisma.journeySegment.deleteMany({ where: { journey: { bookingId } } });
await this.prisma.journey.deleteMany({ where: { bookingId } });
}
async getBlockedSeats() {
@@ -824,14 +827,56 @@ export class SeatsService {
});
if (!schedule) throw new NotFoundException('Schedule not found');
const seats = await this.prisma.seat.findMany({
where: {
coach: { assignments: { some: { scheduleId } } },
seatNumber: { not: '' },
NOT: [{ seatNumber: { startsWith: '-' } }, { status: 'BLOCKED' }, { status: 'UNDER_MAINTENANCE' as any }],
},
orderBy: [{ coach: { number: 'asc' } }, { row: 'asc' }, { col: 'asc' }],
// Resolve the requested class to its actual SeatClass row, then pool seats across every
// fare tier (bed position) that shares its coachTypeId + nationalityType. "Economy"/"VIP"
// are coach categories, not one physical seat pool — Upper/Middle/Lower berths are
// genuinely different seats priced differently — but the whole group is billed one uniform
// rate (the cheapest tier, which is what callers pass as seatClassName; see
// bookings.controller's group endpoint). A coach type with no bed split (e.g. Economy
// Regular) has exactly one tier, so this collapses to plain seat-number order for it.
const seatClass = await this.prisma.seatClass.findFirst({
where: { name: seatClassName },
select: { coachTypeId: true, nationalityType: true, coachType: { select: { name: true } } },
});
if (!seatClass) throw new NotFoundException(`Seat class "${seatClassName}" not found`);
const siblingClasses = await this.prisma.seatClass.findMany({
where: { coachTypeId: seatClass.coachTypeId, nationalityType: seatClass.nationalityType },
select: { bedPosition: true },
});
// SeatClass.bedPosition is seeded uppercase ('UPPER'), but Seat.bedPosition is stored
// lowercase ('upper') — normalize both sides or every bed-tier seat silently fails to match.
const validBedPositions = new Set(siblingClasses.map((sc) => (sc.bedPosition ?? '').toLowerCase()));
// Fixed physical fill order — lower berths first, then middle, then upper — not fare-driven.
const BED_POSITION_ORDER: Record<string, number> = { lower: 0, middle: 1, upper: 2 };
const allSeatsOnSchedule = await this.prisma.seat.findMany({
where: {
coach: {
coachTypeId: seatClass.coachTypeId,
assignments: { some: { scheduleId } },
},
seatNumber: { not: '' },
// Only 'BLOCKED' is a real SeatStatus value (AVAILABLE|HELD|BOOKED|BLOCKED) — this
// method never wrote 'UNDER_MAINTENANCE' before, and Prisma validates enum values at
// the query level regardless of an `as any` cast, so that clause would throw at
// runtime the moment this method was ever actually called. Maintenance-blocked seats
// are still excluded below via the schedule-scoped SeatBlock check.
NOT: [{ seatNumber: { startsWith: '-' } }, { status: 'BLOCKED' }],
},
orderBy: [{ coach: { number: 'asc' } }],
});
// Lower → Middle → Upper, then ascending seat number within a tier (seatNumber is a string
// column, so DB/lexicographic ordering would sort "10" before "2" — compare numerically here).
const seats = allSeatsOnSchedule
.filter((s) => validBedPositions.has((s.bedPosition ?? '').toLowerCase()))
.sort((a, b) => {
const tierDiff = (BED_POSITION_ORDER[(a.bedPosition ?? '').toLowerCase()] ?? 0)
- (BED_POSITION_ORDER[(b.bedPosition ?? '').toLowerCase()] ?? 0);
if (tierDiff !== 0) return tierDiff;
return parseInt(a.seatNumber, 10) - parseInt(b.seatNumber, 10);
});
const allSeatIds = seats.map(s => s.id);
const stopTimes = await this.prisma.tripStopTime.findMany({
@@ -856,30 +901,44 @@ export class SeatsService {
const availableSeats = seats.filter(s => !unavailable.has(s.id) && !scheduleBlockedIds.has(s.id));
if (availableSeats.length < count) {
throw new ConflictException(`Only ${availableSeats.length} seats available, requested ${count}`);
throw new ConflictException(`Only ${availableSeats.length} seats available in ${seatClass.coachType.name} (across all fare tiers), requested ${count}`);
}
const assigned = this.findContiguousSeats(availableSeats, count);
return assigned.map((s) => s.id);
// availableSeats is already ordered lower→middle→upper, ascending seat number within a
// tier — take the first `count` in that order, spilling into the next tier once one runs out.
return availableSeats.slice(0, count).map((s) => s.id);
}
private findContiguousSeats(seats: any[], count: number): any[] {
if (count === 1) return [seats[0]];
const grouped = new Map<string, any[]>();
for (const seat of seats) {
const key = `${seat.coachId}-${seat.row}`;
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key)!.push(seat);
}
for (const rowSeats of grouped.values()) {
if (rowSeats.length >= count) {
return rowSeats.slice(0, count);
}
}
return seats.slice(0, count);
/**
* Auto-assigns `count` seats of `seatClassName` and immediately holds them in one request,
* for callers (like bulk/group booking) that must never show an assignment the caller could
* lose to a race before confirming it. Reuses `holdSeats` as-is — a single hold already
* supports many seats/passengers in one row (see `SeatHold.seatIds: String[]`), so this is
* pure orchestration, not a new hold mechanism.
*/
async autoAssignAndHold(
scheduleId: string,
originStationId: string,
destinationStationId: string,
seatClassName: string,
passengerCount: number,
journeyDirection?: JourneyDirection,
) {
const seatIds = await this.autoAssignSeats(scheduleId, passengerCount, seatClassName);
// Scope the synthetic passengerId to this attempt (not just its row index) — a fixed
// "group-1", "group-2"... would collide with any other still-active group-booking hold on
// the same schedule (e.g. an abandoned/retried attempt, or two staff members booking the
// same train within the hold TTL), tripping holdSeats' "passenger already holds a seat on
// this journey leg" conflict check for two entirely unrelated bookings.
const attemptId = randomUUID();
const passengers = seatIds.map((seatId, i) => ({ passengerId: `group-${attemptId}-${i + 1}`, seatId }));
return this.holdSeats({
scheduleId,
originStationId,
destinationStationId,
journeyDirection,
passengers,
} as HoldSeatsDto);
}
async exportSeatsCSV(scheduleId: string): Promise<string> {

View File

@@ -5,6 +5,7 @@ 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 { RescheduleService } from '../reschedule/reschedule.service';
import { MAX_PAYMENT_HOURS, CUTOFF_MINUTES, computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
// Retention windows
@@ -510,6 +511,21 @@ export class TasksService {
// ─────────────────────────────────────────────────────────────────────────
// Daily at 02:00 EAT: purge expired/stale records to enforce data retention.
// ─────────────────────────────────────────────────────────────────────────
// ─────────────────────────────────────────────────────────────────────────
// Every 1 min: reschedule requests whose payment deadline passed → EXPIRED
// (their supplementary charge too). The new-seat hold lapses on its own.
// ─────────────────────────────────────────────────────────────────────────
@Cron('*/1 * * * *')
async expireStaleReschedules() {
try {
const reschedule = await this.moduleRef.resolve(RescheduleService, undefined, { strict: false });
const n = await reschedule.expireStale();
if (n > 0) this.logger.log(`Expired ${n} unpaid reschedule request(s)`);
} catch (err) {
this.logger.error(`expireStaleReschedules failed: ${err instanceof Error ? err.message : err}`);
}
}
@Cron('0 2 * * *')
async purgeExpiredData() {
const now = new Date();

View File

@@ -18,6 +18,7 @@ export const PASSENGER_PERMISSIONS: PassengerPermissionSeed[] = [
perm('40f1b49c-c33d-4563-a6bb-9373eabbde9b', 'edr_passenger_app:bookings:view', 'View bookings'),
perm('62810ae5-315e-4ae5-8ed1-33cead51b95a', 'edr_passenger_app:bookings:manage', 'Manage bookings'),
perm('b593adf3-2060-48b0-b35d-ff9ff5d72bc4', 'edr_passenger_app:bookings:cancel', 'Cancel bookings'),
perm('0c5e7a2b-9d41-4f7e-8b36-2a1c6d9e4f50', 'edr_passenger_app:bookings:reschedule', 'Reschedule bookings'),
perm('566c968f-71f1-462d-9824-4b7cd33cecbb', 'edr_passenger_app:passengers:view', 'View passengers'),
perm('ff5d33a0-0fe7-427f-a065-46dd14ac1da0', 'edr_passenger_app:passengers:manage', 'Manage passengers'),
perm('326ec767-1da8-4c7e-b557-d4d2f9dd6d2c', 'edr_passenger_app:tickets:view', 'View tickets'),
@@ -77,6 +78,7 @@ export const PASSENGER_PERMS = {
view: 'edr_passenger_app:bookings:view',
manage: 'edr_passenger_app:bookings:manage',
cancel: 'edr_passenger_app:bookings:cancel',
reschedule: 'edr_passenger_app:bookings:reschedule',
},
passengers: {
view: 'edr_passenger_app:passengers:view',
@@ -181,6 +183,7 @@ export const ROLE_PERMISSION_PRESETS = {
stationMaster: [
PASSENGER_PERMS.bookings.view,
PASSENGER_PERMS.bookings.manage,
PASSENGER_PERMS.bookings.reschedule,
PASSENGER_PERMS.tickets.view,
PASSENGER_PERMS.tickets.manage,
PASSENGER_PERMS.tickets.generate,