mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Adding ticket generation and booking for staff employees logic
This commit is contained in:
@@ -0,0 +1,10 @@
|
|||||||
|
-- Adds Booking.payToken/payTokenExpiresAt: an opaque, expiring token used by the
|
||||||
|
-- reserve-seat "issue booking (passenger)" flow to let a traveler pay via an SMS'd
|
||||||
|
-- link without needing a portal session (see GuestBookingService.issueBookingFromReservation
|
||||||
|
-- and BookingsService.getByPayToken). Nullable — only set for reservation-issued bookings
|
||||||
|
-- awaiting passenger payment; a normal guest/portal booking never sets it.
|
||||||
|
|
||||||
|
ALTER TABLE "passenger"."Booking" ADD COLUMN IF NOT EXISTS "payToken" TEXT;
|
||||||
|
ALTER TABLE "passenger"."Booking" ADD COLUMN IF NOT EXISTS "payTokenExpiresAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "Booking_payToken_key" ON "passenger"."Booking"("payToken");
|
||||||
@@ -550,6 +550,8 @@ model Booking {
|
|||||||
paidAt DateTime?
|
paidAt DateTime?
|
||||||
paymentReminderSentAt DateTime?
|
paymentReminderSentAt DateTime?
|
||||||
packageDepartureStationId String?
|
packageDepartureStationId String?
|
||||||
|
payToken String? @unique
|
||||||
|
payTokenExpiresAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
passenger Passenger @relation(fields: [passengerId], references: [id])
|
passenger Passenger @relation(fields: [passengerId], references: [id])
|
||||||
|
|||||||
@@ -32,9 +32,11 @@ import {
|
|||||||
import {
|
import {
|
||||||
CreateGuestBookingDto,
|
CreateGuestBookingDto,
|
||||||
GetSavedPassengersDto,
|
GetSavedPassengersDto,
|
||||||
|
IssueReservationBookingDto,
|
||||||
} from "./guest-booking.dto";
|
} from "./guest-booking.dto";
|
||||||
import { JwtGuard } from "../../common/jwt.guard";
|
import { JwtGuard } from "../../common/jwt.guard";
|
||||||
import { PassengerAdmin } from "../../common/passenger-guards";
|
import { PassengerAdmin, PassengerStaff } from "../../common/passenger-guards";
|
||||||
|
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
||||||
|
|
||||||
@ApiTags("Booking")
|
@ApiTags("Booking")
|
||||||
@Controller("bookings")
|
@Controller("bookings")
|
||||||
@@ -349,6 +351,35 @@ export class BookingsController {
|
|||||||
return this.guestService.createGuestBooking(dto, req);
|
return this.guestService.createGuestBooking(dto, req);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post("reservations/:seatId/issue")
|
||||||
|
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.bookings.manage, PASSENGER_PERMS.admin])
|
||||||
|
@ApiBearerAuth("IAM-auth")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Issue a booking from a reserved (blocked) seat",
|
||||||
|
description:
|
||||||
|
"Converts an admin-reserved seat into a real booking for one traveler. bookingKind STAFF waives the fee and issues the ticket immediately; bookingKind PASSENGER creates the booking as PENDING_PAYMENT and texts a payment link to the traveler's phone.",
|
||||||
|
})
|
||||||
|
@ApiBody({ type: IssueReservationBookingDto })
|
||||||
|
issueBookingFromReservation(
|
||||||
|
@Param("seatId") seatId: string,
|
||||||
|
@Body() dto: IssueReservationBookingDto,
|
||||||
|
@Req() req: any,
|
||||||
|
) {
|
||||||
|
const actingUserId = req.user?.id ?? req.user?.sub ?? null;
|
||||||
|
return this.guestService.issueBookingFromReservation(seatId, dto, actingUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("pay/:token")
|
||||||
|
@SetMetadata("isPublic", true)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Resolve a reservation booking by its pay token (public)",
|
||||||
|
description:
|
||||||
|
"Used by the portal's standalone pay-by-link page for a reservation booking awaiting passenger payment — no login required.",
|
||||||
|
})
|
||||||
|
getByPayToken(@Param("token") token: string) {
|
||||||
|
return this.service.getByPayToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
@Get("saved-passengers")
|
@Get("saved-passengers")
|
||||||
@SetMetadata("isPublic", true)
|
@SetMetadata("isPublic", true)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ import { CurrencyModule } from '../currency/currency.module';
|
|||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { FareEngineModule } from '../fare-engine/fare-engine.module';
|
import { FareEngineModule } from '../fare-engine/fare-engine.module';
|
||||||
import { TicketsModule } from '../tickets/tickets.module';
|
import { TicketsModule } from '../tickets/tickets.module';
|
||||||
|
import { PaymentsModule } from '../payments/payments.module';
|
||||||
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule, AuthModule, TicketsModule],
|
imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule, AuthModule, TicketsModule, PaymentsModule, NotificationsModule],
|
||||||
controllers: [BookingsController],
|
controllers: [BookingsController],
|
||||||
providers: [BookingsService, GuestBookingService],
|
providers: [BookingsService, GuestBookingService],
|
||||||
exports: [BookingsService, GuestBookingService]
|
exports: [BookingsService, GuestBookingService]
|
||||||
|
|||||||
@@ -1860,6 +1860,52 @@ export class BookingsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a reservation booking by its standalone payToken — the public entry point for
|
||||||
|
* the portal's pay-by-link page (a traveler who never had a portal session, texted a link
|
||||||
|
* by a reservation-issuing staff member). Payment itself still goes through the already-
|
||||||
|
* public /payments/* endpoints (initiate/methods/booking-amount/status), keyed by the
|
||||||
|
* booking id this returns — no change needed there.
|
||||||
|
*/
|
||||||
|
async getByPayToken(token: string) {
|
||||||
|
const booking = await this.prisma.booking.findUnique({
|
||||||
|
where: { payToken: token },
|
||||||
|
include: {
|
||||||
|
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
|
||||||
|
seats: { include: { seat: { include: { coach: true } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!booking) throw new NotFoundException('Payment link not found');
|
||||||
|
if (booking.status !== 'PENDING_PAYMENT') throw new BadRequestException('This booking is no longer awaiting payment');
|
||||||
|
if ((booking as any).payTokenExpiresAt && (booking as any).payTokenExpiresAt < new Date()) {
|
||||||
|
throw new BadRequestException('This payment link has expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
const segment = resolveBookingSegment((booking as any).schedule, (booking as any).originStationId, (booking as any).destinationStationId);
|
||||||
|
return {
|
||||||
|
id: booking.id,
|
||||||
|
bookingRef: booking.bookingRef,
|
||||||
|
status: booking.status,
|
||||||
|
totalMinor: booking.totalMinor,
|
||||||
|
currency: booking.currency,
|
||||||
|
displayCurrency: booking.displayCurrency,
|
||||||
|
displayTotalMinor: booking.displayTotalMinor,
|
||||||
|
schedule: {
|
||||||
|
trainNumber: (booking as any).schedule.train.number,
|
||||||
|
trainName: (booking as any).schedule.train.name,
|
||||||
|
origin: { id: segment.origin.id, name: segment.origin.name, code: segment.origin.code },
|
||||||
|
destination: { id: segment.destination.id, name: segment.destination.name, code: segment.destination.code },
|
||||||
|
departureAt: segment.departureAt,
|
||||||
|
arrivalAt: segment.arrivalAt,
|
||||||
|
},
|
||||||
|
seats: (booking as any).seats.map((s: any) => ({
|
||||||
|
passengerName: s.passengerName,
|
||||||
|
seatNumber: s.seat?.seatNumber ?? null,
|
||||||
|
coach: s.seat?.coach?.number ?? null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async getByRef(bookingRefOrId: string) {
|
async getByRef(bookingRefOrId: string) {
|
||||||
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(bookingRefOrId);
|
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(bookingRefOrId);
|
||||||
const booking = await this.prisma.booking.findUnique({
|
const booking = await this.prisma.booking.findUnique({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsString, IsArray, ValidateNested, IsOptional, IsEnum, IsDateString, IsBoolean, IsInt, IsNumber } from 'class-validator';
|
import { IsString, IsArray, ValidateNested, IsOptional, IsEnum, IsDateString, IsBoolean, IsInt, IsNumber, IsIn } from 'class-validator';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Currency, IdDocumentType } from '@prisma/client';
|
import { Currency, IdDocumentType } from '@prisma/client';
|
||||||
@@ -174,6 +174,67 @@ export class SavedPassengerProfileDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class GetSavedPassengersDto {
|
export class GetSavedPassengersDto {
|
||||||
@ApiPropertyOptional({ example: 'device-uuid-12345', description: 'Device ID to retrieve saved passengers' })
|
@ApiPropertyOptional({ example: 'device-uuid-12345', description: 'Device ID to retrieve saved passengers' })
|
||||||
@IsOptional() @IsString() deviceId?: string;
|
@IsOptional() @IsString() deviceId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum ReservationBookingKind {
|
||||||
|
STAFF = 'STAFF',
|
||||||
|
PASSENGER = 'PASSENGER',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RESERVATION_NATIONALITIES = ['Ethiopian', 'Djiboutian', 'Other'] as const;
|
||||||
|
export type ReservationNationality = (typeof RESERVATION_NATIONALITIES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issues a real booking against a seat an admin/staff previously reserved (SeatBlock) —
|
||||||
|
* one traveler per seat, fare always server-computed (no seatFareMinor/reviewedTotalMinor
|
||||||
|
* override: unlike the guest DTO, there's no untrusted client-displayed price to defend
|
||||||
|
* against here). seatId comes from the route param, not the body.
|
||||||
|
*
|
||||||
|
* No seatClassId — the seat (and therefore its coach/class) is already fixed by the
|
||||||
|
* reservation being converted; the service resolves the correct seat class itself from the
|
||||||
|
* seat's own coach type and nationality (LOCAL vs INTERNATIONAL pricing tier), the same
|
||||||
|
* matching search results already use. No passportCountry — nationality alone is what
|
||||||
|
* drives both fare-tier selection and passport-vs-national-ID validation.
|
||||||
|
*/
|
||||||
|
export class IssueReservationBookingDto {
|
||||||
|
@ApiProperty({ example: 'schedule-uuid' })
|
||||||
|
@IsString() scheduleId: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'station-uuid' })
|
||||||
|
@IsString() originStationId: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'station-uuid' })
|
||||||
|
@IsString() destinationStationId: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: ReservationBookingKind, description: 'STAFF: no fee, ticket issued immediately. PASSENGER: a payment link is sent to phone.' })
|
||||||
|
@IsEnum(ReservationBookingKind) bookingKind: ReservationBookingKind;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'Abebe Kebede' })
|
||||||
|
@IsString() passengerName: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '1990-05-15', description: 'Date of birth (YYYY-MM-DD) for age calculation' })
|
||||||
|
@IsDateString() dateOfBirth: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType })
|
||||||
|
@IsEnum(IdDocumentType) idDocumentType: IdDocumentType;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'ET123456789' })
|
||||||
|
@IsOptional() @IsString() idDocumentNumber?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'P1234567' })
|
||||||
|
@IsOptional() @IsString() passportNumber?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'Ethiopian', enum: RESERVATION_NATIONALITIES, description: 'Drives both the LOCAL/INTERNATIONAL fare tier and passport-vs-national-ID validation.' })
|
||||||
|
@IsIn(RESERVATION_NATIONALITIES) nationality: ReservationNationality;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: '+251912345678', description: 'Required when bookingKind is PASSENGER — the payment link is sent here.' })
|
||||||
|
@IsOptional() @IsString() phone?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'abebe@email.com' })
|
||||||
|
@IsOptional() @IsString() email?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'ETB', enum: Currency })
|
||||||
|
@IsOptional() @IsEnum(Currency) displayCurrency?: Currency;
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,9 +6,14 @@ import { CurrencyService } from '../currency/currency.service';
|
|||||||
import { PassengerAuthService } from '../auth/passenger-auth.service';
|
import { PassengerAuthService } from '../auth/passenger-auth.service';
|
||||||
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import { CreateGuestBookingDto, SavedPassengerProfileDto } from './guest-booking.dto';
|
import { PaymentsService } from '../payments/payments.service';
|
||||||
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
|
import { AuditService } from '../../common/audit.service';
|
||||||
|
import { SmsClientService } from '../notifications/sms-client.service';
|
||||||
|
import { CreateGuestBookingDto, SavedPassengerProfileDto, IssueReservationBookingDto, ReservationBookingKind } from './guest-booking.dto';
|
||||||
|
import { Currency, PassengerCategory, IdDocumentType, PaymentMethodType, PaymentIntentStatus } from '@prisma/client';
|
||||||
import { resolveCheckinCutoff } from '../../common/utils/checkin-cutoff.utils';
|
import { resolveCheckinCutoff } from '../../common/utils/checkin-cutoff.utils';
|
||||||
|
import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Throws if the given boarding stop's own configurable check-in cutoff (route/stop
|
* Throws if the given boarding stop's own configurable check-in cutoff (route/stop
|
||||||
@@ -25,6 +30,29 @@ function assertWithinCheckinCutoff(schedule: any, stopTime: any, stationId: stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the seat class for a specific, already-known seat (its coach type only ever
|
||||||
|
* offers a fixed set of classes) given the traveler's nationality tier — mirrors
|
||||||
|
* search.service.ts's own LOCAL/INTERNATIONAL + bed-position matching so the price a
|
||||||
|
* reservation-issued booking charges is the exact same "already configured price setup"
|
||||||
|
* search results would have quoted, without asking the admin to redundantly re-pick a class
|
||||||
|
* for a seat whose class is already fixed.
|
||||||
|
*/
|
||||||
|
function resolveSeatClassForSeat(seat: any, nationality: string): { id: string; name: string } {
|
||||||
|
const nationalityUpper = (nationality ?? '').toUpperCase();
|
||||||
|
const resolvedNationalityType = nationalityUpper === 'ETHIOPIAN' || nationalityUpper === 'DJIBOUTIAN' ? 'LOCAL' : 'INTERNATIONAL';
|
||||||
|
const candidates = (seat.coach?.coachType?.seatClasses ?? []).filter(
|
||||||
|
(sc: any) => !sc.nationalityType || sc.nationalityType === resolvedNationalityType,
|
||||||
|
);
|
||||||
|
const matchingClass = seat.bedPosition
|
||||||
|
? candidates.find((sc: any) => sc.bedPosition?.toLowerCase() === seat.bedPosition)
|
||||||
|
: candidates[0];
|
||||||
|
if (!matchingClass) {
|
||||||
|
throw new BadRequestException('No seat class is configured for this seat and nationality — set up seat classes for this coach type first.');
|
||||||
|
}
|
||||||
|
return matchingClass;
|
||||||
|
}
|
||||||
|
|
||||||
function generateRef(): string {
|
function generateRef(): string {
|
||||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||||
return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
|
return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
|
||||||
@@ -65,6 +93,9 @@ export class GuestBookingService {
|
|||||||
private passengerAuthService: PassengerAuthService,
|
private passengerAuthService: PassengerAuthService,
|
||||||
private fareEngine: FareEngineService,
|
private fareEngine: FareEngineService,
|
||||||
private eventEmitter: EventEmitter2,
|
private eventEmitter: EventEmitter2,
|
||||||
|
private paymentsService: PaymentsService,
|
||||||
|
private auditService: AuditService,
|
||||||
|
private smsClient: SmsClientService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -384,6 +415,228 @@ export class GuestBookingService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts an admin-reserved seat (SeatBlock) into a real booking for one traveler —
|
||||||
|
* no SeatHold involved (the seat was already set aside), so this mirrors
|
||||||
|
* createGuestOneWayBooking's schedule/fare/passenger resolution but skips the hold check
|
||||||
|
* and instead validates+releases the SeatBlock. STAFF bookings are fee-waived and
|
||||||
|
* finalized immediately via the same PaymentsService.finalizePaymentSuccess() path every
|
||||||
|
* real payment webhook uses; PASSENGER bookings are left PENDING_PAYMENT with a payToken
|
||||||
|
* texted to the traveler so they can pay via the existing, already-public /payments/*
|
||||||
|
* endpoints without a portal session.
|
||||||
|
*/
|
||||||
|
async issueBookingFromReservation(
|
||||||
|
seatId: string,
|
||||||
|
dto: IssueReservationBookingDto,
|
||||||
|
actingUserId: string | null,
|
||||||
|
): Promise<{ booking: any; payUrl?: string }> {
|
||||||
|
if (dto.bookingKind === ReservationBookingKind.PASSENGER && !dto.phone) {
|
||||||
|
throw new BadRequestException('Phone number is required for a passenger booking');
|
||||||
|
}
|
||||||
|
if (dto.idDocumentType === IdDocumentType.NATIONAL_ID && dto.nationality !== 'Ethiopian') {
|
||||||
|
throw new BadRequestException('National ID is only valid for Ethiopian nationality — use a passport instead');
|
||||||
|
}
|
||||||
|
|
||||||
|
const seatBlock = await this.prisma.seatBlock.findFirst({
|
||||||
|
where: { seatId, OR: [{ scheduleId: dto.scheduleId }, { scheduleId: null }] },
|
||||||
|
});
|
||||||
|
if (!seatBlock) throw new NotFoundException('Seat is not reserved');
|
||||||
|
|
||||||
|
// The seat (and therefore its coach) is already fixed by the reservation — resolve the
|
||||||
|
// seat class from the seat's own coach type + the traveler's nationality tier, the same
|
||||||
|
// LOCAL/INTERNATIONAL + bed-position matching search results already use, instead of
|
||||||
|
// asking the admin to redundantly pick a class.
|
||||||
|
const seat = await this.prisma.seat.findUnique({
|
||||||
|
where: { id: seatId },
|
||||||
|
include: { coach: { include: { coachType: { include: { seatClasses: true } } } } },
|
||||||
|
});
|
||||||
|
if (!seat) throw new NotFoundException('Seat not found');
|
||||||
|
const resolvedSeatClass = resolveSeatClassForSeat(seat, dto.nationality);
|
||||||
|
|
||||||
|
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||||
|
where: { id: dto.scheduleId },
|
||||||
|
include: {
|
||||||
|
originStation: true,
|
||||||
|
destinationStation: true,
|
||||||
|
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
|
||||||
|
route: { include: { stops: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||||
|
|
||||||
|
const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId)
|
||||||
|
?? (schedule.stopTimes.length === 0 ? { stationId: schedule.originStationId, sequence: 0, station: schedule.originStation } : undefined);
|
||||||
|
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId)
|
||||||
|
?? (schedule.stopTimes.length === 0 ? { stationId: schedule.destinationStationId, sequence: 1, station: schedule.destinationStation } : undefined);
|
||||||
|
if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found');
|
||||||
|
|
||||||
|
assertWithinCheckinCutoff(schedule, originStop, dto.originStationId);
|
||||||
|
|
||||||
|
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
|
||||||
|
const fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`;
|
||||||
|
|
||||||
|
// Single-traveler passenger processing. nationality is a fixed dropdown value (Ethiopian/
|
||||||
|
// Djiboutian/Other), so — unlike the guest-booking loop this mirrors — there's no need to
|
||||||
|
// infer it from document type/country; only the Verifayda check (NATIONAL_ID) and the
|
||||||
|
// passport-number requirement (PASSPORT) still depend on the chosen document type.
|
||||||
|
const dateOfBirth = new Date(dto.dateOfBirth);
|
||||||
|
const age = calculateAge(dateOfBirth);
|
||||||
|
const category: PassengerCategory = age < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT;
|
||||||
|
|
||||||
|
let passengerName = dto.passengerName;
|
||||||
|
let verifaydaVerified = false;
|
||||||
|
let verifaydaData: Record<string, any> | undefined;
|
||||||
|
|
||||||
|
if (dto.idDocumentType === IdDocumentType.NATIONAL_ID) {
|
||||||
|
if (dto.idDocumentNumber) {
|
||||||
|
const verification = await this.verifaydaService.verifyNationalId(dto.idDocumentNumber);
|
||||||
|
if (!verification.verified) {
|
||||||
|
throw new BadRequestException(`Verifayda verification failed for ${dto.passengerName}: ${verification.failureReason}`);
|
||||||
|
}
|
||||||
|
passengerName = verification.passengerData?.fullName || passengerName;
|
||||||
|
verifaydaVerified = true;
|
||||||
|
verifaydaData = verification.passengerData?.profileData;
|
||||||
|
}
|
||||||
|
} else if (dto.idDocumentType === IdDocumentType.PASSPORT) {
|
||||||
|
if (!dto.passportNumber) {
|
||||||
|
throw new BadRequestException(`Passport number required for ${dto.passengerName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const passengerData = {
|
||||||
|
passengerName,
|
||||||
|
dateOfBirth,
|
||||||
|
category,
|
||||||
|
verifaydaVerified,
|
||||||
|
verifaydaData,
|
||||||
|
nationality: dto.nationality,
|
||||||
|
idDocumentType: dto.idDocumentType,
|
||||||
|
idDocumentNumber: dto.idDocumentNumber,
|
||||||
|
passportNumber: dto.passportNumber,
|
||||||
|
phone: dto.phone,
|
||||||
|
email: dto.email,
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseFareMinor = await this.getBaseFare(
|
||||||
|
dto.scheduleId,
|
||||||
|
resolvedSeatClass.id,
|
||||||
|
segmentRoute,
|
||||||
|
fullRoute,
|
||||||
|
dto.nationality,
|
||||||
|
dto.originStationId,
|
||||||
|
dto.destinationStationId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const isStaff = dto.bookingKind === ReservationBookingKind.STAFF;
|
||||||
|
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||||
|
const totalMinor = isStaff ? 0 : baseFareMinor;
|
||||||
|
if (!isStaff) {
|
||||||
|
// Defense-in-depth — there's no client-forgeable price on this DTO, but keep the
|
||||||
|
// same authoritative-fare floor every other booking path enforces.
|
||||||
|
this.assertTotalNotUnderAuthoritative(totalMinor, baseFareMinor, 'issueBookingFromReservation');
|
||||||
|
}
|
||||||
|
const displayTotalMinor = displayCurrency !== Currency.ETB
|
||||||
|
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||||||
|
: totalMinor;
|
||||||
|
|
||||||
|
const { guestPassengerId } = await this.resolveGuestPassenger({}, passengerData);
|
||||||
|
|
||||||
|
const payToken = isStaff ? undefined : randomUUID();
|
||||||
|
const payTokenExpiresAt = isStaff ? undefined : computePaymentDeadline(new Date(), schedule.departureAt);
|
||||||
|
|
||||||
|
const booking = await this.prisma.booking.create({
|
||||||
|
data: {
|
||||||
|
bookingRef: generateRef(),
|
||||||
|
passengerId: guestPassengerId,
|
||||||
|
scheduleId: dto.scheduleId,
|
||||||
|
originStationId: dto.originStationId,
|
||||||
|
destinationStationId: dto.destinationStationId,
|
||||||
|
status: 'PENDING_PAYMENT',
|
||||||
|
totalMinor,
|
||||||
|
currency: Currency.ETB,
|
||||||
|
adultCount: category === PassengerCategory.ADULT ? 1 : 0,
|
||||||
|
childCount: category === PassengerCategory.CHILD ? 1 : 0,
|
||||||
|
displayCurrency,
|
||||||
|
displayTotalMinor,
|
||||||
|
bookingType: 'ONE_WAY',
|
||||||
|
source: 'BACKOFFICE_RESERVATION',
|
||||||
|
contactEmail: dto.email || null,
|
||||||
|
contactPhone: dto.phone || null,
|
||||||
|
payToken,
|
||||||
|
payTokenExpiresAt,
|
||||||
|
seats: {
|
||||||
|
create: [{
|
||||||
|
seat: { connect: { id: seatId } },
|
||||||
|
scheduleId: dto.scheduleId,
|
||||||
|
passengerName: passengerData.passengerName,
|
||||||
|
dateOfBirth: passengerData.dateOfBirth,
|
||||||
|
passengerCategory: passengerData.category,
|
||||||
|
idDocumentType: passengerData.idDocumentType,
|
||||||
|
passportNumber: passengerData.passportNumber,
|
||||||
|
verifaydaVerified: passengerData.verifaydaVerified,
|
||||||
|
verifaydaData: passengerData.verifaydaData || undefined,
|
||||||
|
fareMinor: totalMinor,
|
||||||
|
displayCurrency,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
seats: { include: { seat: { include: { coach: true } } } },
|
||||||
|
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.createTravelerProfiles(guestPassengerId, [passengerData]);
|
||||||
|
|
||||||
|
// Release the reservation using the SAME scope it was created with (global vs
|
||||||
|
// schedule-scoped) — unblockSeat already correctly resets Seat.status for a global
|
||||||
|
// block; reimplementing that here would risk missing that reset.
|
||||||
|
await this.seatsService.unblockSeat(seatId, seatBlock.scheduleId ?? undefined);
|
||||||
|
await this.seatsService.confirmSeats([seatId]);
|
||||||
|
this.eventEmitter.emit('booking.created', { booking });
|
||||||
|
|
||||||
|
if (isStaff) {
|
||||||
|
await this.auditService.log({
|
||||||
|
userId: actingUserId ?? undefined,
|
||||||
|
action: 'CREATE',
|
||||||
|
entityType: 'Booking',
|
||||||
|
entityId: booking.id,
|
||||||
|
newData: { feeWaived: true, waivedBy: actingUserId, originalFareMinor: baseFareMinor },
|
||||||
|
});
|
||||||
|
|
||||||
|
const intent = await this.prisma.paymentIntent.create({
|
||||||
|
data: {
|
||||||
|
bookingId: booking.id,
|
||||||
|
amountMinor: 0,
|
||||||
|
currency: 'ETB',
|
||||||
|
// WALLET is an internal-only method that never leaves this app (see
|
||||||
|
// payments.service.ts) — safe, inert placeholder for a zero-charge waiver.
|
||||||
|
method: PaymentMethodType.WALLET,
|
||||||
|
status: PaymentIntentStatus.REQUIRES_ACTION,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.paymentsService.finalizePaymentSuccess({ intentId: intent.id });
|
||||||
|
// finalizePaymentSuccess mutates the booking (status -> CONFIRMED) in the DB —
|
||||||
|
// re-fetch so the caller sees the actual outcome, not the pre-finalization snapshot.
|
||||||
|
const confirmedBooking = await this.prisma.booking.findUnique({ where: { id: booking.id } });
|
||||||
|
return { booking: confirmedBooking };
|
||||||
|
}
|
||||||
|
|
||||||
|
const portalUrl = process.env.PORTAL_URL ?? 'http://localhost:5174';
|
||||||
|
const payUrl = `${portalUrl}/reserve/pay/${payToken}`;
|
||||||
|
const amountStr = (totalMinor / 100).toFixed(2);
|
||||||
|
try {
|
||||||
|
await this.smsClient.sendSms({
|
||||||
|
to: dto.phone!,
|
||||||
|
message: `EDR: Your seat is reserved. Pay ${amountStr} ETB to confirm your ticket: ${payUrl}`,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Reservation payment-link SMS failed for booking ${booking.bookingRef}: ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { booking, payUrl };
|
||||||
|
}
|
||||||
|
|
||||||
private async createGuestRoundTripBooking(dto: CreateGuestBookingDto, req?: any) {
|
private async createGuestRoundTripBooking(dto: CreateGuestBookingDto, req?: any) {
|
||||||
if (!dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId) {
|
if (!dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId) {
|
||||||
throw new BadRequestException('returnScheduleId, returnHoldId, returnOriginStationId and returnDestinationStationId are required for ROUND_TRIP');
|
throw new BadRequestException('returnScheduleId, returnHoldId, returnOriginStationId and returnDestinationStationId are required for ROUND_TRIP');
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Auth/authorization gaps (matrix Suite J), via route guard metadata — no boot needed.
|
* Auth/authorization gaps (matrix Suite J), via route guard metadata — no boot needed.
|
||||||
*
|
*
|
||||||
* C-8 🔴 The exchange-rate write routes (PUT upsert, PATCH update) carry no METHOD-LEVEL guard, so
|
* C-8 ✅ FIXED (was 🔴 "The exchange-rate write routes (PUT upsert, PATCH update) carry no
|
||||||
* they get only the global JwtGuard (authentication) and NOT @PassengerAdmin (authorization)
|
* METHOD-LEVEL guard, so they get only the global JwtGuard, not @PassengerAdmin —
|
||||||
* — unlike DELETE, which is admin-gated. Net effect (verified live in
|
* unlike DELETE, which was admin-gated. Net effect: any authenticated user incl. a
|
||||||
* e2e-ui .../pb-config-propagation.spec.ts BC-11): anonymous → 401, but ANY authenticated
|
* passenger could rewrite live FX rates."): currency.controller.ts now decorates
|
||||||
* user incl. a passenger → 200 rewrites live FX. fare-engine/currency.controller.ts:25,32,42
|
* upsert/update/remove all with @PassengerAdmin() — confirmed by reading the source.
|
||||||
*
|
* Updated below to assert all three routes are admin-gated, not just DELETE.
|
||||||
* NOTE: this metadata check proves the missing ADMIN guard, NOT "unauthenticated" — a global
|
|
||||||
* APP_GUARD=JwtGuard (SharedAuthModule) still requires a valid token. The earlier "unauthenticated
|
|
||||||
* FX write" reading was a false positive corrected by the live BC-11 test.
|
|
||||||
*/
|
*/
|
||||||
import "reflect-metadata";
|
import "reflect-metadata";
|
||||||
import { CurrencyController } from "../src/modules/fare-engine/currency.controller";
|
import { CurrencyController } from "../src/modules/fare-engine/currency.controller";
|
||||||
@@ -20,15 +17,15 @@ function guardsOn(handler: unknown): unknown[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("Auth gaps (Suite J)", () => {
|
describe("Auth gaps (Suite J)", () => {
|
||||||
it("C-8 🔴 PUT upsert exchange-rate has NO admin guard (only the global JwtGuard applies)", () => {
|
it("C-8 ✅ PUT upsert exchange-rate IS admin-gated", () => {
|
||||||
expect(guardsOn(CurrencyController.prototype.upsert)).toHaveLength(0);
|
expect(guardsOn(CurrencyController.prototype.upsert).length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("C-8 🔴 PATCH update exchange-rate has NO admin guard (only the global JwtGuard applies)", () => {
|
it("C-8 ✅ PATCH update exchange-rate IS admin-gated", () => {
|
||||||
expect(guardsOn(CurrencyController.prototype.update)).toHaveLength(0);
|
expect(guardsOn(CurrencyController.prototype.update).length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("C-8 control: DELETE exchange-rate IS admin-gated — proving writes should be too", () => {
|
it("C-8 control: DELETE exchange-rate IS admin-gated too — all three writes consistently guarded", () => {
|
||||||
expect(guardsOn(CurrencyController.prototype.remove).length).toBeGreaterThan(0);
|
expect(guardsOn(CurrencyController.prototype.remove).length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ describe("Authenticated booking passengerId resolution (regression)", () => {
|
|||||||
prisma as any,
|
prisma as any,
|
||||||
{ query: async () => [] } as any, // dataSource (resolveIamContact raw SQL → [])
|
{ query: async () => [] } as any, // dataSource (resolveIamContact raw SQL → [])
|
||||||
asyncStub(), // seatsService
|
asyncStub(), // seatsService
|
||||||
|
asyncStub(), // ticketsService — constructor gained this param since this test was written
|
||||||
{ emit: () => true } as any,
|
{ emit: () => true } as any,
|
||||||
asyncStub(), // verifaydaService (PASSPORT skips)
|
asyncStub(), // verifaydaService (PASSPORT skips)
|
||||||
asyncStub(), // currencyService (ETB skips)
|
asyncStub(), // currencyService (ETB skips)
|
||||||
|
|||||||
365
apps/edr-passenger-api/test/booking-types.e2e-spec.ts
Normal file
365
apps/edr-passenger-api/test/booking-types.e2e-spec.ts
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
/**
|
||||||
|
* Booking-type coverage — ROUND_TRIP, TRANSIT, ROUND_TRIP_TRANSIT end-to-end through
|
||||||
|
* GuestBookingService.createGuestBooking(). ONE_WAY is already covered by
|
||||||
|
* stop-based-booking-segment.e2e-spec.ts / checkin-cutoff.e2e-spec.ts and is not repeated
|
||||||
|
* here.
|
||||||
|
*
|
||||||
|
* TRANSIT/ROUND_TRIP_TRANSIT use seed-core's single A(seq1)->B(seq2)->C(seq3) route for the
|
||||||
|
* outbound/leg2 direction (transit at B). ROUND_TRIP/ROUND_TRIP_TRANSIT additionally need a
|
||||||
|
* genuine return direction — createGuestRoundTripBooking does NOT validate stop-sequence
|
||||||
|
* ordering between origin/destination (confirmed by reading the method), so it would silently
|
||||||
|
* accept a "return" leg on the same forward route, but that's not what a real round trip is.
|
||||||
|
* A local reverse route (C(seq1)->B(seq2)->A(seq3)) is created per-test instead, keeping
|
||||||
|
* seed-core.ts itself untouched (this reverse route isn't a general-purpose fixture need).
|
||||||
|
*
|
||||||
|
* Uses the same harness/Tier-2 pattern as reserve-seat-issue-booking.e2e-spec.ts and
|
||||||
|
* stop-based-booking-segment.e2e-spec.ts.
|
||||||
|
*/
|
||||||
|
import { IdDocumentType, PassengerCategory } from "@prisma/client";
|
||||||
|
import { SchedulesService } from "../src/modules/schedules/schedules.service";
|
||||||
|
import { SeatsService } from "../src/modules/seats/seats.service";
|
||||||
|
import { CurrencyService } from "../src/modules/currency/currency.service";
|
||||||
|
import { FareEngineService } from "../src/modules/fare-engine/fare-engine.service";
|
||||||
|
import { SystemConfigService } from "../src/modules/system-config/system-config.service";
|
||||||
|
import { GuestBookingService } from "../src/modules/bookings/guest-booking.service";
|
||||||
|
import { createServiceHarness, ServiceHarness } from "./setup/slim-app";
|
||||||
|
import { IDS, DISTANCE, resetAndSeedCore } from "./fixtures/seed-core";
|
||||||
|
|
||||||
|
function asyncStub(): any {
|
||||||
|
return new Proxy({}, { get: () => async () => undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Booking types — ROUND_TRIP / TRANSIT / ROUND_TRIP_TRANSIT", () => {
|
||||||
|
let harness: ServiceHarness;
|
||||||
|
let schedulesService: SchedulesService;
|
||||||
|
let seatsService: SeatsService;
|
||||||
|
let guestBookingService: GuestBookingService;
|
||||||
|
let reverseRouteId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
harness = await createServiceHarness();
|
||||||
|
schedulesService = await harness.moduleRef.resolve(SchedulesService);
|
||||||
|
const currencyService = harness.moduleRef.get(CurrencyService);
|
||||||
|
const fareEngine = harness.moduleRef.get(FareEngineService);
|
||||||
|
const systemConfig = new SystemConfigService(harness.prisma as any);
|
||||||
|
|
||||||
|
seatsService = new SeatsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub());
|
||||||
|
guestBookingService = new GuestBookingService(
|
||||||
|
harness.prisma as any,
|
||||||
|
seatsService,
|
||||||
|
asyncStub(), // verifaydaService — test passengers use PASSPORT, not NATIONAL_ID
|
||||||
|
currencyService,
|
||||||
|
asyncStub(), // passengerAuthService — no createAccount in these DTOs
|
||||||
|
fareEngine,
|
||||||
|
{ emit: () => true } as any, // eventEmitter
|
||||||
|
asyncStub(), // paymentsService — not reached by createGuestBooking
|
||||||
|
asyncStub(), // auditService
|
||||||
|
asyncStub(), // smsClient
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await harness?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await resetAndSeedCore(harness.prisma);
|
||||||
|
reverseRouteId = await createReverseRoute();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Mirrors seed-core's A->B->C route but reversed (C->B->A), for a genuine return leg. */
|
||||||
|
async function createReverseRoute(): Promise<string> {
|
||||||
|
const route = await harness.prisma.route.create({
|
||||||
|
data: {
|
||||||
|
code: `RT-REV-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||||
|
name: "Reverse Line",
|
||||||
|
effectiveFrom: new Date("2020-01-01T00:00:00.000Z"),
|
||||||
|
active: true,
|
||||||
|
stops: {
|
||||||
|
create: [
|
||||||
|
{ stationId: IDS.stationC, sequence: 1, distanceKm: 0 },
|
||||||
|
{ stationId: IDS.stationB, sequence: 2, distanceKm: DISTANCE.C - DISTANCE.B },
|
||||||
|
{ stationId: IDS.stationA, sequence: 3, distanceKm: DISTANCE.C - DISTANCE.A },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return route.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Coach assigned via coachIds at creation time — createSchedule rejects zero coaches. */
|
||||||
|
async function createTestSchedule(opts: { trainNumber: string; routeId: string; departureAt: Date; arrivalAt: Date }) {
|
||||||
|
const train = await harness.prisma.train.create({
|
||||||
|
data: { number: opts.trainNumber, name: `Test ${opts.trainNumber}` },
|
||||||
|
});
|
||||||
|
const coach = await harness.prisma.coach.create({
|
||||||
|
data: { coachTypeId: IDS.coachType, number: `${opts.trainNumber}-C1`, capacity: 6, sequence: 1, status: "ACTIVE" },
|
||||||
|
});
|
||||||
|
const seats = await Promise.all(
|
||||||
|
["1A", "1B", "1C", "1D", "1E", "1F"].map((seatNumber, i) =>
|
||||||
|
harness.prisma.seat.create({
|
||||||
|
data: { coachId: coach.id, seatNumber, row: 1, col: seatNumber.slice(-1), isWindow: i === 0, isAisle: i === 1 },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const schedule = await schedulesService.createSchedule({
|
||||||
|
trainId: train.id,
|
||||||
|
routeId: opts.routeId,
|
||||||
|
departureAt: opts.departureAt.toISOString(),
|
||||||
|
arrivalAt: opts.arrivalAt.toISOString(),
|
||||||
|
coachIds: [coach.id],
|
||||||
|
} as any);
|
||||||
|
return { schedule, seats };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hold(scheduleId: string, originStationId: string, destinationStationId: string, seatId: string, passengerId: string) {
|
||||||
|
return seatsService.holdSeats({
|
||||||
|
scheduleId,
|
||||||
|
originStationId,
|
||||||
|
destinationStationId,
|
||||||
|
passengers: [{ passengerId, seatId }],
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Holds several seats on the SAME leg in one SeatHold row (one holdId covers them all) —
|
||||||
|
* required when a single booking carries multiple travelers on one leg: holdSeats rejects a
|
||||||
|
* second call for the same passengerId+leg as a conflict, so multi-passenger holds must be
|
||||||
|
* one call with distinct passengerIds, not N sequential single-seat calls. */
|
||||||
|
async function holdMany(scheduleId: string, originStationId: string, destinationStationId: string, seatIds: string[]) {
|
||||||
|
return seatsService.holdSeats({
|
||||||
|
scheduleId,
|
||||||
|
originStationId,
|
||||||
|
destinationStationId,
|
||||||
|
passengers: seatIds.map((seatId, i) => ({ passengerId: `44444444-4444-4444-8444-40000000000${i}`, seatId })),
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
function passenger(overrides: Partial<Record<string, any>> = {}) {
|
||||||
|
return {
|
||||||
|
passengerName: "Test Traveler",
|
||||||
|
dateOfBirth: "1990-01-01",
|
||||||
|
idDocumentType: IdDocumentType.PASSPORT,
|
||||||
|
passportNumber: "X123456",
|
||||||
|
passportCountry: "Djibouti",
|
||||||
|
nationality: "Djiboutian",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const future = (mins: number) => new Date(Date.now() + mins * 60_000);
|
||||||
|
|
||||||
|
describe("ROUND_TRIP", () => {
|
||||||
|
it("creates a booking with correct fields for both legs", async () => {
|
||||||
|
const dep = future(180);
|
||||||
|
const arr = future(280);
|
||||||
|
const { schedule: outbound, seats: outSeats } = await createTestSchedule({ trainNumber: `RT-OB-${Date.now()}`, routeId: IDS.route, departureAt: dep, arrivalAt: arr });
|
||||||
|
const { schedule: ret, seats: retSeats } = await createTestSchedule({ trainNumber: `RT-RET-${Date.now()}`, routeId: reverseRouteId, departureAt: future(400), arrivalAt: future(480) });
|
||||||
|
|
||||||
|
const outHold = await hold(outbound.id, IDS.stationA, IDS.stationC, outSeats[0].id, "11111111-1111-4111-8111-100000000001");
|
||||||
|
const retHold = await hold(ret.id, IDS.stationC, IDS.stationA, retSeats[0].id, "11111111-1111-4111-8111-100000000001");
|
||||||
|
|
||||||
|
const result: any = await guestBookingService.createGuestBooking({
|
||||||
|
bookingType: "ROUND_TRIP",
|
||||||
|
scheduleId: outbound.id,
|
||||||
|
holdId: (outHold as any).holdId,
|
||||||
|
originStationId: IDS.stationA,
|
||||||
|
destinationStationId: IDS.stationC,
|
||||||
|
seatClassId: IDS.seatClassLocal,
|
||||||
|
returnScheduleId: ret.id,
|
||||||
|
returnHoldId: (retHold as any).holdId,
|
||||||
|
returnOriginStationId: IDS.stationC,
|
||||||
|
returnDestinationStationId: IDS.stationA,
|
||||||
|
passengers: [passenger({ seatId: outSeats[0].id, returnSeatId: retSeats[0].id })],
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(result.bookingType).toBe("ROUND_TRIP");
|
||||||
|
expect(result.returnScheduleId).toBe(ret.id);
|
||||||
|
expect(result.returnOriginStationId).toBe(IDS.stationC);
|
||||||
|
expect(result.returnDestinationStationId).toBe(IDS.stationA);
|
||||||
|
expect(result.totalMinor).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const seatRows = await harness.prisma.bookingSeat.findMany({ where: { bookingId: result.id } });
|
||||||
|
expect(seatRows).toHaveLength(2); // one BookingSeat per leg
|
||||||
|
expect(seatRows.map((s) => s.scheduleId).sort()).toEqual([outbound.id, ret.id].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a ROUND_TRIP missing return fields with the exact message", async () => {
|
||||||
|
const { schedule: outbound, seats } = await createTestSchedule({ trainNumber: `RT-MISS-${Date.now()}`, routeId: IDS.route, departureAt: future(180), arrivalAt: future(280) });
|
||||||
|
const outHold = await hold(outbound.id, IDS.stationA, IDS.stationC, seats[0].id, "11111111-1111-4111-8111-100000000002");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
guestBookingService.createGuestBooking({
|
||||||
|
bookingType: "ROUND_TRIP",
|
||||||
|
scheduleId: outbound.id,
|
||||||
|
holdId: (outHold as any).holdId,
|
||||||
|
originStationId: IDS.stationA,
|
||||||
|
destinationStationId: IDS.stationC,
|
||||||
|
seatClassId: IDS.seatClassLocal,
|
||||||
|
passengers: [passenger({ seatId: seats[0].id })],
|
||||||
|
} as any),
|
||||||
|
).rejects.toThrow(/returnScheduleId, returnHoldId, returnOriginStationId and returnDestinationStationId are required/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
// NOTE: a "first child travels free" test was attempted here and dropped — the
|
||||||
|
// fareMinor-by-seatId-presence branch (`p.seatId ? childUnitFare : 0`, commented "Free
|
||||||
|
// children have no seat (frontend excludes them from the DTO)") is UNREACHABLE in
|
||||||
|
// practice: every passenger row unconditionally goes through
|
||||||
|
// `seat: { connect: { id: p.seatId } } }` when the booking is created (confirmed in
|
||||||
|
// ONE_WAY at line ~367, ROUND_TRIP at ~874, TRANSIT at ~1075) — a passenger with no
|
||||||
|
// `seatId` makes that Prisma `connect` throw, so a truly seat-less "free child" can never
|
||||||
|
// reach the booking-creation step at all. FLAGGED, not fixed — see the final report.
|
||||||
|
|
||||||
|
it("two adults on the same booking are each charged the full per-leg fare", async () => {
|
||||||
|
const dep = future(180);
|
||||||
|
const { schedule: outbound, seats: outSeats } = await createTestSchedule({ trainNumber: `RT-2ADULT-OB-${Date.now()}`, routeId: IDS.route, departureAt: dep, arrivalAt: future(280) });
|
||||||
|
const { schedule: ret, seats: retSeats } = await createTestSchedule({ trainNumber: `RT-2ADULT-RET-${Date.now()}`, routeId: reverseRouteId, departureAt: future(400), arrivalAt: future(480) });
|
||||||
|
|
||||||
|
const outHold = await holdMany(outbound.id, IDS.stationA, IDS.stationC, [outSeats[0].id, outSeats[1].id]);
|
||||||
|
const retHold = await holdMany(ret.id, IDS.stationC, IDS.stationA, [retSeats[0].id, retSeats[1].id]);
|
||||||
|
|
||||||
|
const result: any = await guestBookingService.createGuestBooking({
|
||||||
|
bookingType: "ROUND_TRIP",
|
||||||
|
scheduleId: outbound.id,
|
||||||
|
holdId: (outHold as any).holdId,
|
||||||
|
originStationId: IDS.stationA,
|
||||||
|
destinationStationId: IDS.stationC,
|
||||||
|
seatClassId: IDS.seatClassLocal,
|
||||||
|
returnScheduleId: ret.id,
|
||||||
|
returnHoldId: (retHold as any).holdId,
|
||||||
|
returnOriginStationId: IDS.stationC,
|
||||||
|
returnDestinationStationId: IDS.stationA,
|
||||||
|
passengers: [
|
||||||
|
passenger({ seatId: outSeats[0].id, returnSeatId: retSeats[0].id, passengerName: "Adult One" }),
|
||||||
|
passenger({ seatId: outSeats[1].id, returnSeatId: retSeats[1].id, passengerName: "Adult Two" }),
|
||||||
|
],
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(result.adultCount).toBe(2);
|
||||||
|
expect(result.childCount).toBe(0);
|
||||||
|
const seatFares = await harness.prisma.bookingSeat.findMany({ where: { bookingId: result.id } });
|
||||||
|
expect(seatFares).toHaveLength(4); // 2 passengers x 2 legs
|
||||||
|
const fareMinors = new Set(seatFares.map((s) => s.fareMinor));
|
||||||
|
expect(fareMinors.size).toBe(1); // every seat on every leg is the same base fare
|
||||||
|
expect([...fareMinors][0]).toBeGreaterThan(0);
|
||||||
|
const total = seatFares.reduce((sum, s) => sum + s.fareMinor, 0);
|
||||||
|
expect(result.totalMinor).toBe(total);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TRANSIT", () => {
|
||||||
|
it("creates a single booking spanning leg-1 and leg-2 via the transit station", async () => {
|
||||||
|
const dep = future(180);
|
||||||
|
const { schedule: leg1, seats: leg1Seats } = await createTestSchedule({ trainNumber: `TR-L1-${Date.now()}`, routeId: IDS.route, departureAt: dep, arrivalAt: future(220) });
|
||||||
|
const { schedule: leg2, seats: leg2Seats } = await createTestSchedule({ trainNumber: `TR-L2-${Date.now()}`, routeId: IDS.route, departureAt: future(260), arrivalAt: future(320) });
|
||||||
|
|
||||||
|
const leg1Hold = await hold(leg1.id, IDS.stationA, IDS.stationB, leg1Seats[0].id, "22222222-2222-4222-8222-200000000001");
|
||||||
|
const leg2Hold = await hold(leg2.id, IDS.stationB, IDS.stationC, leg2Seats[0].id, "22222222-2222-4222-8222-200000000001");
|
||||||
|
|
||||||
|
const result: any = await guestBookingService.createGuestBooking({
|
||||||
|
bookingType: "TRANSIT",
|
||||||
|
scheduleId: leg1.id,
|
||||||
|
holdId: (leg1Hold as any).holdId,
|
||||||
|
originStationId: IDS.stationA,
|
||||||
|
destinationStationId: IDS.stationB,
|
||||||
|
seatClassId: IDS.seatClassLocal,
|
||||||
|
leg2ScheduleId: leg2.id,
|
||||||
|
leg2HoldId: (leg2Hold as any).holdId,
|
||||||
|
transitStationId: IDS.stationB,
|
||||||
|
leg2DestinationStationId: IDS.stationC,
|
||||||
|
passengers: [passenger({ seatId: leg1Seats[0].id, leg2SeatId: leg2Seats[0].id })],
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(result.bookingType).toBe("TRANSIT");
|
||||||
|
expect(result.originStationId).toBe(IDS.stationA);
|
||||||
|
expect(result.destinationStationId).toBe(IDS.stationC); // full journey span, not just leg-1
|
||||||
|
expect(result.leg2ScheduleId).toBe(leg2.id);
|
||||||
|
expect(result.leg2OriginStationId).toBe(IDS.stationB);
|
||||||
|
expect(result.leg2DestinationStationId).toBe(IDS.stationC);
|
||||||
|
|
||||||
|
const seatRows = await harness.prisma.bookingSeat.findMany({ where: { bookingId: result.id } });
|
||||||
|
expect(seatRows).toHaveLength(2);
|
||||||
|
expect(seatRows.map((s) => s.scheduleId).sort()).toEqual([leg1.id, leg2.id].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a TRANSIT missing leg-2 fields with the exact message", async () => {
|
||||||
|
const { schedule: leg1, seats } = await createTestSchedule({ trainNumber: `TR-MISS-${Date.now()}`, routeId: IDS.route, departureAt: future(180), arrivalAt: future(220) });
|
||||||
|
const leg1Hold = await hold(leg1.id, IDS.stationA, IDS.stationB, seats[0].id, "22222222-2222-4222-8222-200000000002");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
guestBookingService.createGuestBooking({
|
||||||
|
bookingType: "TRANSIT",
|
||||||
|
scheduleId: leg1.id,
|
||||||
|
holdId: (leg1Hold as any).holdId,
|
||||||
|
originStationId: IDS.stationA,
|
||||||
|
destinationStationId: IDS.stationB,
|
||||||
|
seatClassId: IDS.seatClassLocal,
|
||||||
|
passengers: [passenger({ seatId: seats[0].id })],
|
||||||
|
} as any),
|
||||||
|
).rejects.toThrow(/leg2ScheduleId, leg2HoldId, transitStationId and leg2DestinationStationId are required/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ROUND_TRIP_TRANSIT", () => {
|
||||||
|
it("creates a single booking spanning all 4 legs (outbound x2, return x2)", async () => {
|
||||||
|
const { schedule: obL1, seats: obL1Seats } = await createTestSchedule({ trainNumber: `RTT-OB1-${Date.now()}`, routeId: IDS.route, departureAt: future(180), arrivalAt: future(220) });
|
||||||
|
const { schedule: obL2, seats: obL2Seats } = await createTestSchedule({ trainNumber: `RTT-OB2-${Date.now()}`, routeId: IDS.route, departureAt: future(260), arrivalAt: future(320) });
|
||||||
|
const { schedule: retL1, seats: retL1Seats } = await createTestSchedule({ trainNumber: `RTT-RET1-${Date.now()}`, routeId: reverseRouteId, departureAt: future(500), arrivalAt: future(560) });
|
||||||
|
const { schedule: retL2, seats: retL2Seats } = await createTestSchedule({ trainNumber: `RTT-RET2-${Date.now()}`, routeId: reverseRouteId, departureAt: future(600), arrivalAt: future(660) });
|
||||||
|
|
||||||
|
const pid = "33333333-3333-4333-8333-300000000001";
|
||||||
|
const obL1Hold = await hold(obL1.id, IDS.stationA, IDS.stationB, obL1Seats[0].id, pid);
|
||||||
|
const obL2Hold = await hold(obL2.id, IDS.stationB, IDS.stationC, obL2Seats[0].id, pid);
|
||||||
|
const retL1Hold = await hold(retL1.id, IDS.stationC, IDS.stationB, retL1Seats[0].id, pid);
|
||||||
|
const retL2Hold = await hold(retL2.id, IDS.stationB, IDS.stationA, retL2Seats[0].id, pid);
|
||||||
|
|
||||||
|
const result: any = await guestBookingService.createGuestBooking({
|
||||||
|
bookingType: "ROUND_TRIP_TRANSIT",
|
||||||
|
scheduleId: obL1.id,
|
||||||
|
holdId: (obL1Hold as any).holdId,
|
||||||
|
originStationId: IDS.stationA,
|
||||||
|
destinationStationId: IDS.stationB,
|
||||||
|
seatClassId: IDS.seatClassLocal,
|
||||||
|
leg2ScheduleId: obL2.id,
|
||||||
|
leg2HoldId: (obL2Hold as any).holdId,
|
||||||
|
transitStationId: IDS.stationB,
|
||||||
|
leg2DestinationStationId: IDS.stationC,
|
||||||
|
returnScheduleId: retL1.id,
|
||||||
|
returnHoldId: (retL1Hold as any).holdId,
|
||||||
|
returnOriginStationId: IDS.stationC,
|
||||||
|
returnDestinationStationId: IDS.stationB,
|
||||||
|
returnLeg2ScheduleId: retL2.id,
|
||||||
|
returnLeg2HoldId: (retL2Hold as any).holdId,
|
||||||
|
returnTransitStationId: IDS.stationB,
|
||||||
|
returnLeg2DestinationStationId: IDS.stationA,
|
||||||
|
passengers: [passenger({
|
||||||
|
seatId: obL1Seats[0].id,
|
||||||
|
leg2SeatId: obL2Seats[0].id,
|
||||||
|
returnSeatId: retL1Seats[0].id,
|
||||||
|
returnLeg2SeatId: retL2Seats[0].id,
|
||||||
|
})],
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(result.bookingType).toBe("ROUND_TRIP_TRANSIT");
|
||||||
|
const seatRows = await harness.prisma.bookingSeat.findMany({ where: { bookingId: result.id } });
|
||||||
|
expect(seatRows).toHaveLength(4);
|
||||||
|
expect(seatRows.map((s) => s.scheduleId).sort()).toEqual([obL1.id, obL2.id, retL1.id, retL2.id].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a ROUND_TRIP_TRANSIT missing any leg's fields with the exact message", async () => {
|
||||||
|
const { schedule: obL1, seats } = await createTestSchedule({ trainNumber: `RTT-MISS-${Date.now()}`, routeId: IDS.route, departureAt: future(180), arrivalAt: future(220) });
|
||||||
|
const obL1Hold = await hold(obL1.id, IDS.stationA, IDS.stationB, seats[0].id, "33333333-3333-4333-8333-300000000002");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
guestBookingService.createGuestBooking({
|
||||||
|
bookingType: "ROUND_TRIP_TRANSIT",
|
||||||
|
scheduleId: obL1.id,
|
||||||
|
holdId: (obL1Hold as any).holdId,
|
||||||
|
originStationId: IDS.stationA,
|
||||||
|
destinationStationId: IDS.stationB,
|
||||||
|
seatClassId: IDS.seatClassLocal,
|
||||||
|
passengers: [passenger({ seatId: seats[0].id })],
|
||||||
|
} as any),
|
||||||
|
).rejects.toThrow(/ROUND_TRIP_TRANSIT requires all 4 holds and all transit\/return station fields/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,10 +3,13 @@
|
|||||||
* enforces exactly these class-validator DTOs, so validating the DTOs directly reproduces what a
|
* enforces exactly these class-validator DTOs, so validating the DTOs directly reproduces what a
|
||||||
* raw API call (bypassing the HTML-only frontend checks) would be allowed to submit.
|
* raw API call (bypassing the HTML-only frontend checks) would be allowed to submit.
|
||||||
* H1 🔴 CreateFareRuleDto.baseFareMinor accepts NEGATIVE (no @Min) — while the sibling
|
* H1 🔴 CreateFareRuleDto.baseFareMinor accepts NEGATIVE (no @Min) — while the sibling
|
||||||
* CreateSegmentFareDto.baseFareMinor has @Min(0) (inconsistent).
|
* CreateSegmentFareDto.baseFareMinor has @Min(0) (inconsistent). Still unfixed.
|
||||||
* H2 🔴 CreateSeatClassDto.basePrice accepts negative/zero (no @Min) — drives every distance fare.
|
* H2 ✅ FIXED (was 🔴 "CreateSeatClassDto.basePrice accepts negative/zero, no @Min"):
|
||||||
* H4 🔴 CreatePromotionDto.percentOff accepts 200 (no @Max(100)) → discount > subtotal.
|
* seat-classes.dto.ts now has @Min(0) on basePrice. Test updated to assert the fix.
|
||||||
|
* H4 ✅ FIXED (was 🔴 "CreatePromotionDto.percentOff accepts 200, no @Max(100)"):
|
||||||
|
* promos.dto.ts now has @Min(0) @Max(100) on percentOff. Test updated to assert the fix.
|
||||||
* H5 🔴 CreatePromotionDto.validUntil is @IsString (not @IsDateString) → accepts non-dates.
|
* H5 🔴 CreatePromotionDto.validUntil is @IsString (not @IsDateString) → accepts non-dates.
|
||||||
|
* Still unfixed.
|
||||||
*/
|
*/
|
||||||
import "reflect-metadata";
|
import "reflect-metadata";
|
||||||
import { plainToInstance } from "class-transformer";
|
import { plainToInstance } from "class-transformer";
|
||||||
@@ -44,23 +47,23 @@ describe("Backoffice config validation (Suite H)", () => {
|
|||||||
expect(await erroredProps(dto)).toContain("baseFareMinor");
|
expect(await erroredProps(dto)).toContain("baseFareMinor");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("H2 🔴 CreateSeatClassDto accepts a negative basePrice (no @Min)", async () => {
|
it("H2 ✅ CreateSeatClassDto rejects a negative basePrice (@Min(0))", async () => {
|
||||||
const dto = plainToInstance(CreateSeatClassDto, {
|
const dto = plainToInstance(CreateSeatClassDto, {
|
||||||
coachTypeId: "ct-1",
|
coachTypeId: "ct-1",
|
||||||
name: "Economy",
|
name: "Economy",
|
||||||
basePrice: -5000,
|
basePrice: -5000,
|
||||||
});
|
});
|
||||||
expect(await erroredProps(dto)).not.toContain("basePrice");
|
expect(await erroredProps(dto)).toContain("basePrice");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("H4 🔴 CreatePromotionDto accepts percentOff = 200 (no @Max(100))", async () => {
|
it("H4 ✅ CreatePromotionDto rejects percentOff = 200 (@Max(100))", async () => {
|
||||||
const dto = plainToInstance(CreatePromotionDto, {
|
const dto = plainToInstance(CreatePromotionDto, {
|
||||||
code: "OVER",
|
code: "OVER",
|
||||||
title: "Overshoot",
|
title: "Overshoot",
|
||||||
percentOff: 200,
|
percentOff: 200,
|
||||||
validUntil: "2026-12-31T23:59:59Z",
|
validUntil: "2026-12-31T23:59:59Z",
|
||||||
});
|
});
|
||||||
expect(await erroredProps(dto)).not.toContain("percentOff");
|
expect(await erroredProps(dto)).toContain("percentOff");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("H5 🔴 CreatePromotionDto.validUntil accepts a non-date string (@IsString, not @IsDateString)", async () => {
|
it("H5 🔴 CreatePromotionDto.validUntil accepts a non-date string (@IsString, not @IsDateString)", async () => {
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
* Executable reproducers for the highest-severity findings that were previously inspection-only.
|
* Executable reproducers for the highest-severity findings that were previously inspection-only.
|
||||||
* All Tier-2 (direct instantiation, real Prisma + stubbed collaborators).
|
* All Tier-2 (direct instantiation, real Prisma + stubbed collaborators).
|
||||||
*
|
*
|
||||||
* C-1 🔴 BookingsService trusts client `reviewedTotalMinor`: a booking is stored with totalMinor=1
|
* C-1 ✅ FIXED (was 🔴 "BookingsService trusts client reviewedTotalMinor: a booking is
|
||||||
* while the server fare engine computed ~30000.
|
* stored with totalMinor=1 while the server fare engine computed ~30000") —
|
||||||
|
* createOneWayBooking now runs assertTotalNotUnderAuthoritative before persisting;
|
||||||
|
* a forged low total is rejected, not stored. Test below updated to assert this.
|
||||||
* C-4 🔴 finalizePaymentSuccess confirms a booking without comparing the paid amount: an intent for
|
* C-4 🔴 finalizePaymentSuccess confirms a booking without comparing the paid amount: an intent for
|
||||||
* 1 minor confirms a 30000 booking.
|
* 1 minor confirms a 30000 booking.
|
||||||
* C-6 🔴 Concurrent WALLET payments double-spend one balance (no row lock): a wallet funded for one
|
* C-6 🔴 Concurrent WALLET payments double-spend one balance (no row lock): a wallet funded for one
|
||||||
@@ -164,14 +166,16 @@ describe("Critical reproducers (Tier-2)", () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const result: any = await (bookings as any).createOneWayBooking(dto);
|
// FIXED (was 🔴): createOneWayBooking now runs the forged reviewedTotalMinor through
|
||||||
|
// assertTotalNotUnderAuthoritative before ever writing a Booking row — a client total
|
||||||
|
// below the server-computed fare (minus a 1% rounding tolerance) is rejected outright,
|
||||||
|
// not silently persisted. Confirmed by reading bookings.service.ts's C-1 guard comment.
|
||||||
|
await expect((bookings as any).createOneWayBooking(dto)).rejects.toThrow(
|
||||||
|
/Booking total does not match the authoritative fare/i,
|
||||||
|
);
|
||||||
|
|
||||||
// The server engine computed the real fare…
|
const stored = await prisma.booking.findFirst({ where: { scheduleId: schedule.id, passengerId: passenger.id } });
|
||||||
expect(result.fareBreakdown.totalMinor).toBeGreaterThanOrEqual(30_000);
|
expect(stored).toBeNull(); // no under-priced booking left behind
|
||||||
// …but the booking was stored at the client's forged 1 minor.
|
|
||||||
expect(result.totalMinor).toBe(1);
|
|
||||||
const stored = await prisma.booking.findUnique({ where: { id: result.id } });
|
|
||||||
expect(stored?.totalMinor).toBe(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── C-4 ──────────────────────────────────────────────────────────────────
|
// ── C-4 ──────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
146
apps/edr-passenger-api/test/currency-through-booking.e2e-spec.ts
Normal file
146
apps/edr-passenger-api/test/currency-through-booking.e2e-spec.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* Currency conversion through a REAL booking — CurrencyService/FareEngineService are only
|
||||||
|
* exercised in isolation elsewhere (pricing-currency.e2e-spec.ts, pricing-fare-engine.e2e-spec.ts);
|
||||||
|
* nothing previously drove an actual GuestBookingService.createGuestBooking() call in a
|
||||||
|
* non-ETB displayCurrency and asserted the converted amount.
|
||||||
|
*
|
||||||
|
* Also covers the consequence of the C2 fix documented in pricing-currency.e2e-spec.ts:
|
||||||
|
* CurrencyService.getExchangeRate() now fails closed (throws) on a missing rate instead of
|
||||||
|
* silently pricing at parity — confirms that failure mode actually propagates out of booking
|
||||||
|
* creation as a real rejection, not a silently wrong charge.
|
||||||
|
*/
|
||||||
|
import { IdDocumentType, Currency } from "@prisma/client";
|
||||||
|
import { SchedulesService } from "../src/modules/schedules/schedules.service";
|
||||||
|
import { SeatsService } from "../src/modules/seats/seats.service";
|
||||||
|
import { CurrencyService } from "../src/modules/currency/currency.service";
|
||||||
|
import { FareEngineService } from "../src/modules/fare-engine/fare-engine.service";
|
||||||
|
import { SystemConfigService } from "../src/modules/system-config/system-config.service";
|
||||||
|
import { GuestBookingService } from "../src/modules/bookings/guest-booking.service";
|
||||||
|
import { createServiceHarness, ServiceHarness } from "./setup/slim-app";
|
||||||
|
import { IDS, resetAndSeedCore, USD_TO_ETB, ETB_TO_DJF } from "./fixtures/seed-core";
|
||||||
|
|
||||||
|
function asyncStub(): any {
|
||||||
|
return new Proxy({}, { get: () => async () => undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Currency conversion through a real booking", () => {
|
||||||
|
let harness: ServiceHarness;
|
||||||
|
let schedulesService: SchedulesService;
|
||||||
|
let seatsService: SeatsService;
|
||||||
|
let guestBookingService: GuestBookingService;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
harness = await createServiceHarness();
|
||||||
|
schedulesService = await harness.moduleRef.resolve(SchedulesService);
|
||||||
|
const currencyService = harness.moduleRef.get(CurrencyService);
|
||||||
|
const fareEngine = harness.moduleRef.get(FareEngineService);
|
||||||
|
const systemConfig = new SystemConfigService(harness.prisma as any);
|
||||||
|
|
||||||
|
seatsService = new SeatsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub());
|
||||||
|
guestBookingService = new GuestBookingService(
|
||||||
|
harness.prisma as any,
|
||||||
|
seatsService,
|
||||||
|
asyncStub(),
|
||||||
|
currencyService,
|
||||||
|
asyncStub(),
|
||||||
|
fareEngine,
|
||||||
|
{ emit: () => true } as any,
|
||||||
|
asyncStub(),
|
||||||
|
asyncStub(),
|
||||||
|
asyncStub(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await harness?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await resetAndSeedCore(harness.prisma);
|
||||||
|
});
|
||||||
|
|
||||||
|
const future = (mins: number) => new Date(Date.now() + mins * 60_000);
|
||||||
|
|
||||||
|
async function createTestSchedule(trainNumber: string) {
|
||||||
|
const train = await harness.prisma.train.create({ data: { number: trainNumber, name: "Test" } });
|
||||||
|
const coach = await harness.prisma.coach.create({
|
||||||
|
data: { coachTypeId: IDS.coachType, number: `${trainNumber}-C1`, capacity: 4, sequence: 1, status: "ACTIVE" },
|
||||||
|
});
|
||||||
|
const seats = await Promise.all(
|
||||||
|
["1A", "1B", "1C"].map((seatNumber, i) =>
|
||||||
|
harness.prisma.seat.create({ data: { coachId: coach.id, seatNumber, row: 1, col: seatNumber.slice(-1), isWindow: i === 0, isAisle: i === 1 } }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const schedule = await schedulesService.createSchedule({
|
||||||
|
trainId: train.id, routeId: IDS.route,
|
||||||
|
departureAt: future(180).toISOString(), arrivalAt: future(220).toISOString(),
|
||||||
|
coachIds: [coach.id],
|
||||||
|
} as any);
|
||||||
|
return { schedule, seats };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bookInCurrency(scheduleId: string, seatId: string, displayCurrency: Currency, passengerId: string, nationality: "Djiboutian" | "Ethiopian" = "Djiboutian") {
|
||||||
|
const hold = await seatsService.holdSeats({
|
||||||
|
scheduleId, originStationId: IDS.stationA, destinationStationId: IDS.stationB,
|
||||||
|
passengers: [{ passengerId, seatId }],
|
||||||
|
} as any);
|
||||||
|
return guestBookingService.createGuestBooking({
|
||||||
|
scheduleId,
|
||||||
|
holdId: (hold as any).holdId,
|
||||||
|
originStationId: IDS.stationA,
|
||||||
|
destinationStationId: IDS.stationB,
|
||||||
|
seatClassId: IDS.seatClassLocal,
|
||||||
|
displayCurrency,
|
||||||
|
passengers: [{
|
||||||
|
seatId, passengerName: "Test Traveler", dateOfBirth: "1990-01-01",
|
||||||
|
idDocumentType: IdDocumentType.PASSPORT, passportNumber: "X123456", passportCountry: "Djibouti", nationality,
|
||||||
|
}],
|
||||||
|
} as any) as Promise<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("charges the SAME ETB amount regardless of displayCurrency, but displays it converted by the seeded rate", async () => {
|
||||||
|
const { schedule, seats } = await createTestSchedule(`CUR-${Date.now()}`);
|
||||||
|
|
||||||
|
const etbBooking = await bookInCurrency(schedule.id, seats[0].id, Currency.ETB, "66666666-6666-4666-8666-600000000001");
|
||||||
|
const usdBooking = await bookInCurrency(schedule.id, seats[1].id, Currency.USD, "66666666-6666-4666-8666-600000000002");
|
||||||
|
const djfBooking = await bookInCurrency(schedule.id, seats[2].id, Currency.DJF, "66666666-6666-4666-8666-600000000003");
|
||||||
|
|
||||||
|
// ETB charge basis (totalMinor) is identical across all three — displayCurrency only
|
||||||
|
// affects what's SHOWN to the passenger, never the authoritative ETB amount collected.
|
||||||
|
expect(usdBooking.totalMinor).toBe(etbBooking.totalMinor);
|
||||||
|
expect(djfBooking.totalMinor).toBe(etbBooking.totalMinor);
|
||||||
|
expect(etbBooking.currency).toBe("ETB");
|
||||||
|
expect(usdBooking.currency).toBe("ETB");
|
||||||
|
expect(djfBooking.currency).toBe("ETB");
|
||||||
|
|
||||||
|
// displayTotalMinor derived from the exact seeded rates (see seed-core.ts).
|
||||||
|
expect(etbBooking.displayTotalMinor).toBe(etbBooking.totalMinor);
|
||||||
|
expect(usdBooking.displayTotalMinor).toBe(etbBooking.totalMinor * (1 / USD_TO_ETB));
|
||||||
|
expect(djfBooking.displayTotalMinor).toBe(etbBooking.totalMinor * ETB_TO_DJF);
|
||||||
|
|
||||||
|
expect(etbBooking.displayCurrency).toBe("ETB");
|
||||||
|
expect(usdBooking.displayCurrency).toBe("USD");
|
||||||
|
expect(djfBooking.displayCurrency).toBe("DJF");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects booking creation when the requested displayCurrency has no configured FX rate (fails closed, doesn't price at parity)", async () => {
|
||||||
|
const { schedule, seats } = await createTestSchedule(`CUR-NORATE-${Date.now()}`);
|
||||||
|
// Remove BOTH directions of the ETB<->DJF rate. Nationality is Ethiopian (not Djiboutian)
|
||||||
|
// specifically so FareEngineService's OWN internal billing-currency conversion (tied to
|
||||||
|
// nationality, resolves to ETB for an Ethiopian passenger — a same-currency no-op that
|
||||||
|
// never touches the DB) doesn't also need this rate; only the OUTER
|
||||||
|
// GuestBookingService.createGuestOneWayBooking's displayCurrency conversion does. This
|
||||||
|
// isolates the failure to that one call rather than fare calculation itself.
|
||||||
|
await harness.prisma.currencyExchangeRate.deleteMany({
|
||||||
|
where: { OR: [{ fromCurrency: "ETB", toCurrency: "DJF" }, { fromCurrency: "DJF", toCurrency: "ETB" }] },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
bookInCurrency(schedule.id, seats[0].id, Currency.DJF, "66666666-6666-4666-8666-600000000004", "Ethiopian"),
|
||||||
|
).rejects.toThrow(/No exchange rate configured/i);
|
||||||
|
|
||||||
|
// No half-created booking left behind by the failed conversion.
|
||||||
|
const orphan = await harness.prisma.booking.findFirst({ where: { scheduleId: schedule.id, displayCurrency: "DJF" } });
|
||||||
|
expect(orphan).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -85,6 +85,7 @@ describe("Money integrity (Tier-2 direct instantiation)", () => {
|
|||||||
prisma as any,
|
prisma as any,
|
||||||
asyncStub(), // dataSource
|
asyncStub(), // dataSource
|
||||||
asyncStub(), // seatsService
|
asyncStub(), // seatsService
|
||||||
|
asyncStub(), // ticketsService — constructor gained this param since this test was written
|
||||||
{ emit: () => true } as any, // eventEmitter
|
{ emit: () => true } as any, // eventEmitter
|
||||||
asyncStub(), // verifaydaService
|
asyncStub(), // verifaydaService
|
||||||
asyncStub(), // currencyService
|
asyncStub(), // currencyService
|
||||||
|
|||||||
249
apps/edr-passenger-api/test/payments-webhook-refund.e2e-spec.ts
Normal file
249
apps/edr-passenger-api/test/payments-webhook-refund.e2e-spec.ts
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
/**
|
||||||
|
* Payments webhook + non-wallet provider flow — PaymentsService.handlePaymentEvent() (the
|
||||||
|
* real "webhook" entry point, invoked by both the RabbitMQ consumer and
|
||||||
|
* InternalPaymentsController — see payment-events.consumer.ts /
|
||||||
|
* internal-payments.controller.ts) had zero e2e coverage before this suite. Also exercises
|
||||||
|
* initiatePayment() for a non-wallet (provider-routed) method, mocking PaymentClientService at
|
||||||
|
* the boundary — no real payment provider is contacted.
|
||||||
|
*
|
||||||
|
* Refund disbursement is deliberately NOT re-tested here: money-integrity.e2e-spec.ts already
|
||||||
|
* covers `cancel()` computing an 80% refund that's never actually disbursed (no PaymentRefund
|
||||||
|
* row, no wallet credit) in detail — re-run that suite rather than duplicating it. Confirmed
|
||||||
|
* still true as of this session (unrelated to the module changed here).
|
||||||
|
*
|
||||||
|
* NOTE: apps/edr-passenger-api/src/modules/payments/payments.e2e-spec.ts (an existing "E2E"
|
||||||
|
* suite that boots the full AppModule over HTTP) is never actually executed by any script —
|
||||||
|
* it lives outside test/jest-e2e.json's rootDir (`test/`) and doesn't match the plain `test`
|
||||||
|
* script's `.spec.ts$` regex either (the filename ends `...e2e-spec.ts`, not `...spec.ts`
|
||||||
|
* immediately preceded by a dot). Flagging as an orphaned test file, not fixed here.
|
||||||
|
*/
|
||||||
|
import { IdDocumentType, PaymentIntentStatus, PaymentMethodType } from "@prisma/client";
|
||||||
|
import { PaymentService as PaymentServiceEnum, PaymentReferenceType, ProviderMethod, ProviderPaymentStatus } from "@edr/types";
|
||||||
|
import { SchedulesService } from "../src/modules/schedules/schedules.service";
|
||||||
|
import { SeatsService } from "../src/modules/seats/seats.service";
|
||||||
|
import { TicketsService } from "../src/modules/tickets/tickets.service";
|
||||||
|
import { PaymentsService } from "../src/modules/payments/payments.service";
|
||||||
|
import { CurrencyService } from "../src/modules/currency/currency.service";
|
||||||
|
import { FareEngineService } from "../src/modules/fare-engine/fare-engine.service";
|
||||||
|
import { SystemConfigService } from "../src/modules/system-config/system-config.service";
|
||||||
|
import { GuestBookingService } from "../src/modules/bookings/guest-booking.service";
|
||||||
|
import { createServiceHarness, ServiceHarness } from "./setup/slim-app";
|
||||||
|
import { IDS, resetAndSeedCore } from "./fixtures/seed-core";
|
||||||
|
|
||||||
|
function asyncStub(): any {
|
||||||
|
return new Proxy({}, { get: () => async () => undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Payments — webhook idempotency and non-wallet initiate flow", () => {
|
||||||
|
let harness: ServiceHarness;
|
||||||
|
let schedulesService: SchedulesService;
|
||||||
|
let seatsService: SeatsService;
|
||||||
|
let ticketsService: TicketsService;
|
||||||
|
let guestBookingService: GuestBookingService;
|
||||||
|
let paymentClient: { initiate: jest.Mock };
|
||||||
|
let paymentsService: PaymentsService;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
harness = await createServiceHarness();
|
||||||
|
schedulesService = await harness.moduleRef.resolve(SchedulesService);
|
||||||
|
const currencyService = harness.moduleRef.get(CurrencyService);
|
||||||
|
const fareEngine = harness.moduleRef.get(FareEngineService);
|
||||||
|
const systemConfig = new SystemConfigService(harness.prisma as any);
|
||||||
|
|
||||||
|
seatsService = new SeatsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub());
|
||||||
|
ticketsService = new TicketsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub());
|
||||||
|
paymentClient = { initiate: jest.fn() };
|
||||||
|
paymentsService = new PaymentsService(
|
||||||
|
harness.prisma as any,
|
||||||
|
seatsService,
|
||||||
|
ticketsService,
|
||||||
|
{ emit: () => true } as any,
|
||||||
|
paymentClient as any,
|
||||||
|
currencyService,
|
||||||
|
asyncStub(),
|
||||||
|
);
|
||||||
|
guestBookingService = new GuestBookingService(
|
||||||
|
harness.prisma as any,
|
||||||
|
seatsService,
|
||||||
|
asyncStub(),
|
||||||
|
currencyService,
|
||||||
|
asyncStub(),
|
||||||
|
fareEngine,
|
||||||
|
{ emit: () => true } as any,
|
||||||
|
paymentsService,
|
||||||
|
asyncStub(),
|
||||||
|
asyncStub(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await harness?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await resetAndSeedCore(harness.prisma);
|
||||||
|
paymentClient.initiate.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
const future = (mins: number) => new Date(Date.now() + mins * 60_000);
|
||||||
|
|
||||||
|
async function createTestSchedule(trainNumber: string) {
|
||||||
|
const train = await harness.prisma.train.create({ data: { number: trainNumber, name: "Test" } });
|
||||||
|
const coach = await harness.prisma.coach.create({
|
||||||
|
data: { coachTypeId: IDS.coachType, number: `${trainNumber}-C1`, capacity: 4, sequence: 1, status: "ACTIVE" },
|
||||||
|
});
|
||||||
|
const seats = await Promise.all(
|
||||||
|
["1A", "1B"].map((seatNumber, i) =>
|
||||||
|
harness.prisma.seat.create({ data: { coachId: coach.id, seatNumber, row: 1, col: seatNumber.slice(-1), isWindow: i === 0, isAisle: i === 1 } }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const schedule = await schedulesService.createSchedule({
|
||||||
|
trainId: train.id, routeId: IDS.route,
|
||||||
|
departureAt: future(180).toISOString(), arrivalAt: future(220).toISOString(),
|
||||||
|
coachIds: [coach.id],
|
||||||
|
} as any);
|
||||||
|
return { schedule, seats };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createOneWayBooking(scheduleId: string, seatId: string, passengerId: string) {
|
||||||
|
const hold = await seatsService.holdSeats({
|
||||||
|
scheduleId, originStationId: IDS.stationA, destinationStationId: IDS.stationB,
|
||||||
|
passengers: [{ passengerId, seatId }],
|
||||||
|
} as any);
|
||||||
|
return guestBookingService.createGuestBooking({
|
||||||
|
scheduleId, holdId: (hold as any).holdId,
|
||||||
|
originStationId: IDS.stationA, destinationStationId: IDS.stationB,
|
||||||
|
seatClassId: IDS.seatClassLocal,
|
||||||
|
passengers: [{
|
||||||
|
seatId, passengerName: "Test Traveler", dateOfBirth: "1990-01-01",
|
||||||
|
idDocumentType: IdDocumentType.PASSPORT, passportNumber: "X123456", passportCountry: "Djibouti", nationality: "Djiboutian",
|
||||||
|
}],
|
||||||
|
} as any) as Promise<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function webhookEvent(booking: any, overrides: Partial<Record<string, any>> = {}) {
|
||||||
|
return {
|
||||||
|
version: 1 as const,
|
||||||
|
eventId: `evt-${Math.random().toString(36).slice(2)}`,
|
||||||
|
eventType: "payment.succeeded" as const,
|
||||||
|
occurredAt: new Date().toISOString(),
|
||||||
|
service: PaymentServiceEnum.PASSENGER,
|
||||||
|
intentId: `remote-intent-${Math.random().toString(36).slice(2)}`,
|
||||||
|
referenceType: PaymentReferenceType.BOOKING,
|
||||||
|
referenceId: booking.id,
|
||||||
|
merchantOrderId: booking.bookingRef,
|
||||||
|
provider: ProviderMethod.TELEBIRR,
|
||||||
|
amountMinor: booking.displayTotalMinor ?? booking.totalMinor,
|
||||||
|
currency: booking.displayCurrency ?? "ETB",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("handlePaymentEvent() — webhook", () => {
|
||||||
|
it("delivering the same success event twice confirms the booking once, not twice", async () => {
|
||||||
|
const { schedule, seats } = await createTestSchedule(`WH-DUP-${Date.now()}`);
|
||||||
|
const booking = await createOneWayBooking(schedule.id, seats[0].id, "77777777-7777-4777-8777-700000000001");
|
||||||
|
|
||||||
|
const first = await paymentsService.handlePaymentEvent(webhookEvent(booking) as any);
|
||||||
|
expect(first.processed).toBe(true);
|
||||||
|
|
||||||
|
const second = await paymentsService.handlePaymentEvent(webhookEvent(booking, { eventId: "evt-redelivered" }) as any);
|
||||||
|
expect(second.processed).toBe(true);
|
||||||
|
|
||||||
|
const refreshedBooking = await harness.prisma.booking.findUnique({ where: { id: booking.id } });
|
||||||
|
expect(refreshedBooking?.status).toBe("CONFIRMED");
|
||||||
|
|
||||||
|
const intents = await harness.prisma.paymentIntent.findMany({ where: { bookingId: booking.id } });
|
||||||
|
expect(intents).toHaveLength(1);
|
||||||
|
expect(intents[0].status).toBe(PaymentIntentStatus.SUCCEEDED);
|
||||||
|
|
||||||
|
const tickets = await harness.prisma.ticket.findMany({ where: { bookingId: booking.id } });
|
||||||
|
expect(tickets).toHaveLength(1); // NOT duplicated on redelivery
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to confirm on a short (underpaid) settlement", async () => {
|
||||||
|
const { schedule, seats } = await createTestSchedule(`WH-SHORT-${Date.now()}`);
|
||||||
|
const booking = await createOneWayBooking(schedule.id, seats[0].id, "77777777-7777-4777-8777-700000000002");
|
||||||
|
const expected = booking.displayTotalMinor ?? booking.totalMinor;
|
||||||
|
|
||||||
|
const result = await paymentsService.handlePaymentEvent(
|
||||||
|
webhookEvent(booking, { amountMinor: Math.round(expected * 0.5) }) as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.processed).toBe(false);
|
||||||
|
expect((result as any).reason).toBe("amount-mismatch");
|
||||||
|
|
||||||
|
const refreshedBooking = await harness.prisma.booking.findUnique({ where: { id: booking.id } });
|
||||||
|
expect(refreshedBooking?.status).toBe("PENDING_PAYMENT");
|
||||||
|
const succeededIntents = await harness.prisma.paymentIntent.count({ where: { bookingId: booking.id, status: PaymentIntentStatus.SUCCEEDED } });
|
||||||
|
expect(succeededIntents).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("payment.failed marks the intent FAILED without confirming the booking", async () => {
|
||||||
|
const { schedule, seats } = await createTestSchedule(`WH-FAILED-${Date.now()}`);
|
||||||
|
const booking = await createOneWayBooking(schedule.id, seats[0].id, "77777777-7777-4777-8777-700000000003");
|
||||||
|
await harness.prisma.paymentIntent.create({
|
||||||
|
data: { bookingId: booking.id, amountMinor: booking.totalMinor, currency: "ETB", method: PaymentMethodType.TELEBIRR, status: PaymentIntentStatus.PROCESSING },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await paymentsService.handlePaymentEvent(
|
||||||
|
webhookEvent(booking, { eventType: "payment.failed", failureCode: "INSUFFICIENT_FUNDS", failureMessage: "Declined" }) as any,
|
||||||
|
);
|
||||||
|
expect(result.processed).toBe(true);
|
||||||
|
|
||||||
|
const intent = await harness.prisma.paymentIntent.findUnique({ where: { bookingId: booking.id } });
|
||||||
|
expect(intent?.status).toBe(PaymentIntentStatus.FAILED);
|
||||||
|
const refreshedBooking = await harness.prisma.booking.findUnique({ where: { id: booking.id } });
|
||||||
|
expect(refreshedBooking?.status).toBe("PENDING_PAYMENT");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("initiatePayment() — non-wallet (provider-routed) method", () => {
|
||||||
|
it("an instantly-settled provider response (e.g. TELEBIRR) converges the booking immediately, same as a webhook would", async () => {
|
||||||
|
const { schedule, seats } = await createTestSchedule(`INIT-NONWALLET-${Date.now()}`);
|
||||||
|
const booking = await createOneWayBooking(schedule.id, seats[0].id, "77777777-7777-4777-8777-700000000004");
|
||||||
|
|
||||||
|
paymentClient.initiate.mockResolvedValue({
|
||||||
|
intentId: "remote-intent-1",
|
||||||
|
status: ProviderPaymentStatus.SUCCEEDED,
|
||||||
|
provider: ProviderMethod.TELEBIRR,
|
||||||
|
merchantOrderId: booking.bookingRef,
|
||||||
|
amountMinor: booking.totalMinor / 100,
|
||||||
|
currency: "ETB",
|
||||||
|
providerTxnId: "TXN-123",
|
||||||
|
paidAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await paymentsService.initiatePayment({ bookingId: booking.id, method: "TELEBIRR", platform: "web" } as any);
|
||||||
|
expect(paymentClient.initiate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(response.status).toBe(PaymentIntentStatus.SUCCEEDED);
|
||||||
|
|
||||||
|
const refreshedBooking = await harness.prisma.booking.findUnique({ where: { id: booking.id } });
|
||||||
|
expect(refreshedBooking?.status).toBe("CONFIRMED");
|
||||||
|
const tickets = await harness.prisma.ticket.findMany({ where: { bookingId: booking.id } });
|
||||||
|
expect(tickets).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a REQUIRES_ACTION provider response leaves the booking PENDING_PAYMENT and surfaces the clientAction", async () => {
|
||||||
|
const { schedule, seats } = await createTestSchedule(`INIT-PENDING-${Date.now()}`);
|
||||||
|
const booking = await createOneWayBooking(schedule.id, seats[0].id, "77777777-7777-4777-8777-700000000005");
|
||||||
|
|
||||||
|
paymentClient.initiate.mockResolvedValue({
|
||||||
|
intentId: "remote-intent-2",
|
||||||
|
status: ProviderPaymentStatus.REQUIRES_ACTION,
|
||||||
|
provider: ProviderMethod.TELEBIRR,
|
||||||
|
merchantOrderId: booking.bookingRef,
|
||||||
|
amountMinor: booking.totalMinor / 100,
|
||||||
|
currency: "ETB",
|
||||||
|
clientAction: { type: "REDIRECT", url: "https://provider.example/pay/abc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await paymentsService.initiatePayment({ bookingId: booking.id, method: "TELEBIRR", platform: "web" } as any);
|
||||||
|
expect(response.status).toBe(PaymentIntentStatus.REQUIRES_ACTION);
|
||||||
|
expect((response as any).clientAction?.type).toBe("REDIRECT");
|
||||||
|
|
||||||
|
const refreshedBooking = await harness.prisma.booking.findUnique({ where: { id: booking.id } });
|
||||||
|
expect(refreshedBooking?.status).toBe("PENDING_PAYMENT");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Currency / FX suite (matrix Suite C). Exercises CurrencyService directly.
|
* Currency / FX suite (matrix Suite C). Exercises CurrencyService directly.
|
||||||
* C2 🔴 missing rate: getExchangeRate() silently returns 1.0 while getRateOrThrow() throws —
|
* C2 ✅ FIXED (was 🔴): getExchangeRate() used to silently return 1.0 on a missing rate —
|
||||||
* the display path degrades but the charge path errors on the SAME condition (divergence).
|
* now fails closed with the same BadRequestException getRateOrThrow() always threw
|
||||||
|
* (see the "H-2: fail closed" comment in currency.service.ts). C2/C2b below were
|
||||||
|
* found still asserting the OLD buggy behavior (`resolves.toBe(1.0)`) and were
|
||||||
|
* themselves failing as a result — updated to assert the current, correct behavior.
|
||||||
|
* Do not revert getExchangeRate to silently return 1.0 to make an old version of
|
||||||
|
* this test pass; that would reintroduce a real underpricing bug.
|
||||||
* C3 🔴 a future-dated rate is applied immediately (no `effectiveDate <= now` filter).
|
* C3 🔴 a future-dated rate is applied immediately (no `effectiveDate <= now` filter).
|
||||||
* C5 🔴 conversion routines disagree on units: displayMinorToChargeMajor / convertMinorToChargeMajor
|
* C5 🔴 conversion routines disagree on units: displayMinorToChargeMajor / convertMinorToChargeMajor
|
||||||
* return MAJOR units, convertEtbMinorToChargeMinor returns MINOR — a 100x unit landmine both
|
* return MAJOR units, convertEtbMinorToChargeMinor returns MINOR — a 100x unit landmine both
|
||||||
@@ -26,23 +31,25 @@ describe("Pricing — CurrencyService (Suite C)", () => {
|
|||||||
await resetAndSeedCore(harness.prisma);
|
await resetAndSeedCore(harness.prisma);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("C2 🔴 same DB state, 100x divergence: getExchangeRate → 1.0, getRateOrThrow → 100 (via inverse)", async () => {
|
it("C2 ✅ getExchangeRate now fails closed on a missing DIRECT rate, same as getRateOrThrow (no more silent 1.0)", async () => {
|
||||||
// Remove only the DIRECT USD→ETB row; the inverse ETB→USD (0.01) from the fixture stays.
|
// Remove only the DIRECT USD→ETB row; the inverse ETB→USD (0.01) from the fixture stays.
|
||||||
|
// getExchangeRate has no inverse-rate fallback at all (unlike getRateOrThrow, which does)
|
||||||
|
// — so it correctly throws here even though a usable inverse rate technically exists.
|
||||||
await harness.prisma.currencyExchangeRate.deleteMany({
|
await harness.prisma.currencyExchangeRate.deleteMany({
|
||||||
where: { fromCurrency: "USD", toCurrency: "ETB" },
|
where: { fromCurrency: "USD", toCurrency: "ETB" },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Display/fare path (getExchangeRate) has NO inverse fallback → silently returns 1.0 (wrong).
|
await expect(currency.getExchangeRate("USD" as any, "ETB" as any)).rejects.toThrow(/No exchange rate configured/i);
|
||||||
await expect(currency.getExchangeRate("USD" as any, "ETB" as any)).resolves.toBe(1.0);
|
|
||||||
|
|
||||||
// Charge path (getRateOrThrow) DOES fall back to the inverse → 1 / 0.01 = 100 (correct).
|
// getRateOrThrow DOES fall back to the inverse → 1 / 0.01 = 100 (correct) — this divergence
|
||||||
|
// (one method has an inverse fallback, the other doesn't) is a separate, real inconsistency
|
||||||
|
// from the old "silent 1.0" bug; flagging it, not fixing it here.
|
||||||
await expect(
|
await expect(
|
||||||
currency.getRateOrThrow("USD" as any, "ETB" as any),
|
currency.getRateOrThrow("USD" as any, "ETB" as any),
|
||||||
).resolves.toBe(USD_TO_ETB);
|
).resolves.toBe(USD_TO_ETB);
|
||||||
// → the display fare and the charged amount for the same trip differ by 100x.
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("C2b 🔴 truly-missing pair: getExchangeRate → 1.0 (silent), getRateOrThrow → throws", async () => {
|
it("C2b ✅ truly-missing pair: both getExchangeRate and getRateOrThrow fail closed", async () => {
|
||||||
await harness.prisma.currencyExchangeRate.deleteMany({
|
await harness.prisma.currencyExchangeRate.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
OR: [
|
OR: [
|
||||||
@@ -51,7 +58,7 @@ describe("Pricing — CurrencyService (Suite C)", () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await expect(currency.getExchangeRate("USD" as any, "ETB" as any)).resolves.toBe(1.0);
|
await expect(currency.getExchangeRate("USD" as any, "ETB" as any)).rejects.toThrow(/No exchange rate configured/i);
|
||||||
await expect(
|
await expect(
|
||||||
currency.getRateOrThrow("USD" as any, "ETB" as any),
|
currency.getRateOrThrow("USD" as any, "ETB" as any),
|
||||||
).rejects.toThrow(/No exchange rate/i);
|
).rejects.toThrow(/No exchange rate/i);
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* Reference pricing suite — proves the slim harness boots and exercises FareEngineService directly.
|
* Reference pricing suite — proves the slim harness boots and exercises FareEngineService directly.
|
||||||
* Also confirms two matrix findings against the running engine:
|
* Also confirms matrix findings against the running engine:
|
||||||
* D1 — a promo with percentOff > 100 drives the total NEGATIVE (no clamp at 0).
|
* D1 — a promo with percentOff > 100 drives the total NEGATIVE (no clamp at 0).
|
||||||
* C1 — a missing USD→ETB FX rate is silently substituted with 1.0 (fares collapse ~100x).
|
* C1 ✅ FIXED (was 🔴 "missing USD→ETB FX rate is silently substituted with 1.0, fares
|
||||||
|
* collapse ~100x"): CurrencyService.getExchangeRate() now fails closed on a missing
|
||||||
|
* rate (see the "H-2: fail closed" comment in currency.service.ts, and
|
||||||
|
* pricing-currency.e2e-spec.ts's C2/C2b) — FareEngineService.calculate() calls it
|
||||||
|
* internally, so a missing rate now correctly rejects the fare calculation instead of
|
||||||
|
* silently underpricing it. Updated below to assert the current, correct behavior.
|
||||||
*/
|
*/
|
||||||
import { FareEngineService } from "../src/modules/fare-engine/fare-engine.service";
|
import { FareEngineService } from "../src/modules/fare-engine/fare-engine.service";
|
||||||
import {
|
import {
|
||||||
@@ -114,18 +119,15 @@ describe("Pricing — FareEngineService (slim harness)", () => {
|
|||||||
expect(withPromo.totalMinor).toBe(base.totalMinor - 5000);
|
expect(withPromo.totalMinor).toBe(base.totalMinor - 5000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("C1 🔴 missing USD→ETB rate silently falls back to 1.0 (fare collapses ~100x)", async () => {
|
it("C1 ✅ a missing USD→ETB rate now rejects the fare calculation instead of silently pricing at parity", async () => {
|
||||||
const withRate = await fareEngine.calculate(baseDto() as any);
|
const withRate = await fareEngine.calculate(baseDto() as any);
|
||||||
|
expect(withRate.totalMinor).toBeGreaterThan(0);
|
||||||
|
|
||||||
// Remove the USD→ETB rate the seat-class formula multiplies by.
|
// Remove the USD→ETB rate the seat-class formula multiplies by.
|
||||||
await harness.prisma.currencyExchangeRate.deleteMany({
|
await harness.prisma.currencyExchangeRate.deleteMany({
|
||||||
where: { fromCurrency: "USD", toCurrency: "ETB" },
|
where: { fromCurrency: "USD", toCurrency: "ETB" },
|
||||||
});
|
});
|
||||||
|
|
||||||
const withoutRate = await fareEngine.calculate(baseDto() as any);
|
await expect(fareEngine.calculate(baseDto() as any)).rejects.toThrow(/No exchange rate configured/i);
|
||||||
|
|
||||||
// Correct behavior would be to reject/flag; instead the fare silently drops by the rate factor.
|
|
||||||
expect(withoutRate.totalMinor).toBe(withRate.totalMinor / USD_TO_ETB);
|
|
||||||
expect(withoutRate.totalMinor).toBeLessThan(withRate.totalMinor);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,374 @@
|
|||||||
|
/**
|
||||||
|
* Reserve seat → release | issue booking — coverage for GuestBookingService.
|
||||||
|
* issueBookingFromReservation(), BookingsService.getByPayToken(), and the
|
||||||
|
* SeatsService.unblockSeat() reuse that releases the reservation.
|
||||||
|
*
|
||||||
|
* STAFF path: fee-waived, finalized immediately via PaymentsService.finalizePaymentSuccess()
|
||||||
|
* (the same all-in-one finalizer every real payment webhook uses) — proves a Ticket is
|
||||||
|
* issued, the booking is CONFIRMED, and the reservation's own SeatBlock is released (not
|
||||||
|
* left stale) before TicketsService.generate() marks the now-ticketed seat BOOKED with its
|
||||||
|
* own fresh block — the correct end state once a real ticket exists, distinct from an
|
||||||
|
* operational reservation that never converts into a booking.
|
||||||
|
*
|
||||||
|
* PASSENGER path: booking stays PENDING_PAYMENT with a payToken texted to the traveler —
|
||||||
|
* proves no PaymentIntent is created yet (the passenger creates one themselves later via the
|
||||||
|
* already-public /payments/initiate, same as a normal guest checkout) and that
|
||||||
|
* BookingsService.getByPayToken() resolves it for the portal's pay-by-link page.
|
||||||
|
*
|
||||||
|
* Uses the slim harness (real Nest DI) for SchedulesService/CurrencyService/FareEngineService,
|
||||||
|
* same as stop-based-booking-segment.e2e-spec.ts. SeatsService/TicketsService/PaymentsService/
|
||||||
|
* GuestBookingService/BookingsService are instantiated directly with a real Prisma +
|
||||||
|
* stubbed collaborators (Tier-2 pattern, money-integrity.e2e-spec.ts) — PaymentsModule/
|
||||||
|
* BookingsModule pull in NotificationsModule → RabbitMQ, which the slim harness avoids.
|
||||||
|
*/
|
||||||
|
import { IdDocumentType, PaymentIntentStatus, PaymentMethodType } from "@prisma/client";
|
||||||
|
import { validateSync } from "class-validator";
|
||||||
|
import { plainToInstance } from "class-transformer";
|
||||||
|
import { SchedulesService } from "../src/modules/schedules/schedules.service";
|
||||||
|
import { SeatsService } from "../src/modules/seats/seats.service";
|
||||||
|
import { TicketsService } from "../src/modules/tickets/tickets.service";
|
||||||
|
import { PaymentsService } from "../src/modules/payments/payments.service";
|
||||||
|
import { CurrencyService } from "../src/modules/currency/currency.service";
|
||||||
|
import { FareEngineService } from "../src/modules/fare-engine/fare-engine.service";
|
||||||
|
import { SystemConfigService } from "../src/modules/system-config/system-config.service";
|
||||||
|
import { BookingsService } from "../src/modules/bookings/bookings.service";
|
||||||
|
import { GuestBookingService } from "../src/modules/bookings/guest-booking.service";
|
||||||
|
import { ReservationBookingKind, IssueReservationBookingDto } from "../src/modules/bookings/guest-booking.dto";
|
||||||
|
import { createServiceHarness, ServiceHarness } from "./setup/slim-app";
|
||||||
|
import { IDS, resetAndSeedCore } from "./fixtures/seed-core";
|
||||||
|
|
||||||
|
/** A Proxy whose every property is an async no-op — satisfies unused collaborator method calls. */
|
||||||
|
function asyncStub(): any {
|
||||||
|
return new Proxy({}, { get: () => async () => undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => {
|
||||||
|
let harness: ServiceHarness;
|
||||||
|
let schedulesService: SchedulesService;
|
||||||
|
let seatsService: SeatsService;
|
||||||
|
let guestBookingService: GuestBookingService;
|
||||||
|
let bookingsService: BookingsService;
|
||||||
|
let smsClient: { sendSms: jest.Mock };
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
harness = await createServiceHarness();
|
||||||
|
schedulesService = await harness.moduleRef.resolve(SchedulesService);
|
||||||
|
const currencyService = harness.moduleRef.get(CurrencyService);
|
||||||
|
const fareEngine = harness.moduleRef.get(FareEngineService);
|
||||||
|
const systemConfig = new SystemConfigService(harness.prisma as any);
|
||||||
|
|
||||||
|
seatsService = new SeatsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub());
|
||||||
|
const ticketsService = new TicketsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub());
|
||||||
|
const paymentsService = new PaymentsService(
|
||||||
|
harness.prisma as any,
|
||||||
|
seatsService,
|
||||||
|
ticketsService,
|
||||||
|
{ emit: () => true } as any, // eventEmitter
|
||||||
|
asyncStub(), // paymentClient — never reached: finalizePaymentSuccess doesn't call it
|
||||||
|
currencyService,
|
||||||
|
asyncStub(), // auditService
|
||||||
|
);
|
||||||
|
smsClient = { sendSms: jest.fn().mockResolvedValue({ queued: true }) };
|
||||||
|
guestBookingService = new GuestBookingService(
|
||||||
|
harness.prisma as any,
|
||||||
|
seatsService,
|
||||||
|
asyncStub(), // verifaydaService — never reached: test passengers use PASSPORT, not NATIONAL_ID
|
||||||
|
currencyService,
|
||||||
|
asyncStub(), // passengerAuthService — never reached: no createAccount in this flow
|
||||||
|
fareEngine,
|
||||||
|
{ emit: () => true } as any, // eventEmitter
|
||||||
|
paymentsService,
|
||||||
|
asyncStub(), // auditService
|
||||||
|
smsClient,
|
||||||
|
);
|
||||||
|
bookingsService = new BookingsService(
|
||||||
|
harness.prisma as any,
|
||||||
|
asyncStub(), // dataSource
|
||||||
|
seatsService,
|
||||||
|
{ emit: () => true } as any,
|
||||||
|
asyncStub(), // verifaydaService
|
||||||
|
currencyService,
|
||||||
|
fareEngine,
|
||||||
|
asyncStub(), // auditService
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await harness?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
smsClient.sendSms.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Creates a fresh Train + TrainSchedule on the seed-core route, coach assigned at creation. */
|
||||||
|
async function createTestSchedule(opts: { trainNumber: string; departureAt: Date; arrivalAt: Date }) {
|
||||||
|
const train = await harness.prisma.train.create({
|
||||||
|
data: { number: opts.trainNumber, name: `Test ${opts.trainNumber}` },
|
||||||
|
});
|
||||||
|
const coach = await harness.prisma.coach.create({
|
||||||
|
data: { coachTypeId: IDS.coachType, number: `${opts.trainNumber}-C1`, capacity: 4, sequence: 1, status: "ACTIVE" },
|
||||||
|
});
|
||||||
|
const seats = await Promise.all(
|
||||||
|
["1A", "1B"].map((seatNumber, i) =>
|
||||||
|
harness.prisma.seat.create({
|
||||||
|
data: { coachId: coach.id, seatNumber, row: 1, col: seatNumber.slice(-1), isWindow: i === 0, isAisle: i === 1 },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const schedule = await schedulesService.createSchedule({
|
||||||
|
trainId: train.id,
|
||||||
|
routeId: IDS.route,
|
||||||
|
departureAt: opts.departureAt.toISOString(),
|
||||||
|
arrivalAt: opts.arrivalAt.toISOString(),
|
||||||
|
coachIds: [coach.id],
|
||||||
|
} as any);
|
||||||
|
return { schedule, seats };
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseDto(overrides: Partial<Record<string, any>> = {}) {
|
||||||
|
return {
|
||||||
|
scheduleId: "", // filled per-test
|
||||||
|
originStationId: IDS.stationA,
|
||||||
|
destinationStationId: IDS.stationB,
|
||||||
|
// No seatClassId — the seat's class is resolved server-side from its coach type +
|
||||||
|
// nationality tier (Djiboutian -> LOCAL, matching seed-core's seatClassLocal).
|
||||||
|
bookingKind: ReservationBookingKind.STAFF,
|
||||||
|
passengerName: "Test Traveler",
|
||||||
|
dateOfBirth: "1990-01-01",
|
||||||
|
idDocumentType: IdDocumentType.PASSPORT,
|
||||||
|
passportNumber: "X123456",
|
||||||
|
nationality: "Djiboutian",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("404s when the seat has no active reservation", async () => {
|
||||||
|
await resetAndSeedCore(harness.prisma);
|
||||||
|
const dep = new Date(Date.now() + 3 * 60 * 60_000);
|
||||||
|
const arr = new Date(dep.getTime() + 100 * 60_000);
|
||||||
|
const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-404-${Date.now()}`, departureAt: dep, arrivalAt: arr });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
guestBookingService.issueBookingFromReservation(
|
||||||
|
seats[0].id,
|
||||||
|
baseDto({ scheduleId: schedule.id }) as any,
|
||||||
|
null,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/not reserved/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves the seat class (and therefore fare) from nationality — LOCAL for Ethiopian/Djiboutian, INTERNATIONAL for Other", async () => {
|
||||||
|
await resetAndSeedCore(harness.prisma);
|
||||||
|
const dep = new Date(Date.now() + 3 * 60 * 60_000);
|
||||||
|
const arr = new Date(dep.getTime() + 100 * 60_000);
|
||||||
|
|
||||||
|
// A->B is 100km (seed-core DISTANCE). LOCAL seatClass is 3.00 ETB/km, INTERNATIONAL is
|
||||||
|
// 5.00 ETB/km, USD_TO_ETB=100 — same formula pricing-fare-engine.e2e-spec.ts verifies.
|
||||||
|
// Both use PASSENGER kind so the computed fare survives unwaived (STAFF always zeroes
|
||||||
|
// totalMinor/fareMinor regardless of the resolved seat class).
|
||||||
|
const localSchedule = await createTestSchedule({ trainNumber: `RES-FARE-LOCAL-${Date.now()}`, departureAt: dep, arrivalAt: arr });
|
||||||
|
await seatsService.blockSeat(localSchedule.seats[0].id, "Reserved", localSchedule.schedule.id);
|
||||||
|
const localBooking: any = await guestBookingService.issueBookingFromReservation(
|
||||||
|
localSchedule.seats[0].id,
|
||||||
|
baseDto({ scheduleId: localSchedule.schedule.id, bookingKind: ReservationBookingKind.PASSENGER, phone: "+251911234567", nationality: "Ethiopian" }) as any,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const localSeat = await harness.prisma.bookingSeat.findFirst({ where: { bookingId: localBooking.booking.id } });
|
||||||
|
expect(localSeat?.fareMinor).toBe(30_000);
|
||||||
|
|
||||||
|
const intlSchedule = await createTestSchedule({ trainNumber: `RES-FARE-INTL-${Date.now()}`, departureAt: dep, arrivalAt: arr });
|
||||||
|
await seatsService.blockSeat(intlSchedule.seats[0].id, "Reserved", intlSchedule.schedule.id);
|
||||||
|
const intlBooking: any = await guestBookingService.issueBookingFromReservation(
|
||||||
|
intlSchedule.seats[0].id,
|
||||||
|
baseDto({ scheduleId: intlSchedule.schedule.id, bookingKind: ReservationBookingKind.PASSENGER, phone: "+253771234567", nationality: "Other" }) as any,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
expect(intlBooking.booking.totalMinor).toBe(50_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a nationality outside Ethiopian/Djiboutian/Other at the DTO level", () => {
|
||||||
|
const dto = plainToInstance(IssueReservationBookingDto, baseDto({ scheduleId: "irrelevant", nationality: "French" }));
|
||||||
|
const errors = validateSync(dto);
|
||||||
|
expect(errors.some((e) => e.property === "nationality")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("STAFF + a GLOBAL block: booking is CONFIRMED, fee-waived, ticket issued, and the old global block is replaced (not left stale)", async () => {
|
||||||
|
await resetAndSeedCore(harness.prisma);
|
||||||
|
const dep = new Date(Date.now() + 3 * 60 * 60_000);
|
||||||
|
const arr = new Date(dep.getTime() + 100 * 60_000);
|
||||||
|
const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-STAFF-GLOBAL-${Date.now()}`, departureAt: dep, arrivalAt: arr });
|
||||||
|
|
||||||
|
await seatsService.blockSeat(seats[0].id, "VIP hold — mayor's office");
|
||||||
|
|
||||||
|
const result: any = await guestBookingService.issueBookingFromReservation(
|
||||||
|
seats[0].id,
|
||||||
|
baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.STAFF }) as any,
|
||||||
|
"staff-user-1",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.booking.status).toBe("CONFIRMED");
|
||||||
|
expect(result.payUrl).toBeUndefined();
|
||||||
|
|
||||||
|
const intent = await harness.prisma.paymentIntent.findUnique({ where: { bookingId: result.booking.id } });
|
||||||
|
expect(intent?.amountMinor).toBe(0);
|
||||||
|
expect(intent?.status).toBe(PaymentIntentStatus.SUCCEEDED);
|
||||||
|
expect(intent?.method).toBe(PaymentMethodType.WALLET);
|
||||||
|
|
||||||
|
const ticketCount = await harness.prisma.ticket.count({ where: { bookingId: result.booking.id } });
|
||||||
|
expect(ticketCount).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// TicketsService.generate() itself creates a fresh global SeatBlock ("Booked in
|
||||||
|
// tickets ...") once a ticket is actually issued — a ticketed seat SHOULD show as
|
||||||
|
// unavailable. The meaningful assertion is that the ORIGINAL reservation's block is
|
||||||
|
// gone (proving unblockSeat ran), replaced by exactly this one new booked-marker block —
|
||||||
|
// not that the seat is left globally BLOCKED under the old reservation's reason forever.
|
||||||
|
const blocks = await harness.prisma.seatBlock.findMany({ where: { seatId: seats[0].id } });
|
||||||
|
expect(blocks).toHaveLength(1);
|
||||||
|
expect(blocks[0].reason).not.toMatch(/VIP hold/);
|
||||||
|
expect(blocks[0].reason).toMatch(/Booked in tickets/);
|
||||||
|
|
||||||
|
const seat = await harness.prisma.seat.findUnique({ where: { id: seats[0].id } });
|
||||||
|
expect(seat?.status).toBe("BOOKED");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("STAFF + a SCHEDULE-SCOPED block: same release path, no leftover reservation block", async () => {
|
||||||
|
await resetAndSeedCore(harness.prisma);
|
||||||
|
const dep = new Date(Date.now() + 3 * 60 * 60_000);
|
||||||
|
const arr = new Date(dep.getTime() + 100 * 60_000);
|
||||||
|
const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-STAFF-SCOPED-${Date.now()}`, departureAt: dep, arrivalAt: arr });
|
||||||
|
|
||||||
|
await seatsService.blockSeat(seats[0].id, "Reserved for inspection", schedule.id);
|
||||||
|
const seatBefore = await harness.prisma.seat.findUnique({ where: { id: seats[0].id } });
|
||||||
|
expect(seatBefore?.status).not.toBe("BLOCKED"); // schedule-scoped block never touches Seat.status
|
||||||
|
|
||||||
|
const result: any = await guestBookingService.issueBookingFromReservation(
|
||||||
|
seats[0].id,
|
||||||
|
baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.STAFF }) as any,
|
||||||
|
"staff-user-2",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.booking.status).toBe("CONFIRMED");
|
||||||
|
// The original schedule-scoped reservation block is gone; only generate()'s own
|
||||||
|
// booked-marker block remains (see the global-block test above for why).
|
||||||
|
const originalScopedBlock = await harness.prisma.seatBlock.count({
|
||||||
|
where: { seatId: seats[0].id, scheduleId: schedule.id, reason: "Reserved for inspection" },
|
||||||
|
});
|
||||||
|
expect(originalScopedBlock).toBe(0);
|
||||||
|
|
||||||
|
// Same as the global-block test — generate() marks the now-ticketed seat BOOKED.
|
||||||
|
const seatAfter = await harness.prisma.seat.findUnique({ where: { id: seats[0].id } });
|
||||||
|
expect(seatAfter?.status).toBe("BOOKED");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PASSENGER path: booking stays PENDING_PAYMENT, payToken is set, no PaymentIntent yet, SMS sent with the pay link", async () => {
|
||||||
|
await resetAndSeedCore(harness.prisma);
|
||||||
|
const dep = new Date(Date.now() + 3 * 60 * 60_000);
|
||||||
|
const arr = new Date(dep.getTime() + 100 * 60_000);
|
||||||
|
const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-PAX-${Date.now()}`, departureAt: dep, arrivalAt: arr });
|
||||||
|
|
||||||
|
await seatsService.blockSeat(seats[0].id, "Reserved pending payment", schedule.id);
|
||||||
|
|
||||||
|
const result: any = await guestBookingService.issueBookingFromReservation(
|
||||||
|
seats[0].id,
|
||||||
|
baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.PASSENGER, phone: "+253771234567" }) as any,
|
||||||
|
"staff-user-3",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.booking.status).toBe("PENDING_PAYMENT");
|
||||||
|
expect(result.booking.payToken).toBeTruthy();
|
||||||
|
expect(result.payUrl).toContain(result.booking.payToken);
|
||||||
|
|
||||||
|
const intent = await harness.prisma.paymentIntent.findUnique({ where: { bookingId: result.booking.id } });
|
||||||
|
expect(intent).toBeNull();
|
||||||
|
|
||||||
|
expect(smsClient.sendSms).toHaveBeenCalledTimes(1);
|
||||||
|
const smsArgs = smsClient.sendSms.mock.calls[0][0];
|
||||||
|
expect(smsArgs.to).toBe("+253771234567");
|
||||||
|
expect(smsArgs.message).toContain(result.booking.payToken);
|
||||||
|
|
||||||
|
// BookingsService.getByPayToken — the portal pay-by-link page's data source.
|
||||||
|
const byToken: any = await bookingsService.getByPayToken(result.booking.payToken);
|
||||||
|
expect(byToken.id).toBe(result.booking.id);
|
||||||
|
expect(byToken.status).toBe("PENDING_PAYMENT");
|
||||||
|
expect(byToken.schedule.origin.id).toBe(IDS.stationA);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires a phone number for a PASSENGER booking", async () => {
|
||||||
|
await resetAndSeedCore(harness.prisma);
|
||||||
|
const dep = new Date(Date.now() + 3 * 60 * 60_000);
|
||||||
|
const arr = new Date(dep.getTime() + 100 * 60_000);
|
||||||
|
const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-PAX-NOPHONE-${Date.now()}`, departureAt: dep, arrivalAt: arr });
|
||||||
|
|
||||||
|
await seatsService.blockSeat(seats[0].id, "Reserved", schedule.id);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
guestBookingService.issueBookingFromReservation(
|
||||||
|
seats[0].id,
|
||||||
|
baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.PASSENGER, phone: undefined }) as any,
|
||||||
|
null,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/phone number is required/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects NATIONAL_ID for a non-Ethiopian nationality", async () => {
|
||||||
|
await resetAndSeedCore(harness.prisma);
|
||||||
|
const dep = new Date(Date.now() + 3 * 60 * 60_000);
|
||||||
|
const arr = new Date(dep.getTime() + 100 * 60_000);
|
||||||
|
const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-NATID-${Date.now()}`, departureAt: dep, arrivalAt: arr });
|
||||||
|
|
||||||
|
await seatsService.blockSeat(seats[0].id, "Reserved", schedule.id);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
guestBookingService.issueBookingFromReservation(
|
||||||
|
seats[0].id,
|
||||||
|
baseDto({
|
||||||
|
scheduleId: schedule.id,
|
||||||
|
nationality: "Djiboutian",
|
||||||
|
idDocumentType: IdDocumentType.NATIONAL_ID,
|
||||||
|
idDocumentNumber: "ET123456789",
|
||||||
|
}) as any,
|
||||||
|
null,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/national id is only valid for ethiopian/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still enforces the check-in cutoff — rejects once the boarding stop is too close to departure", async () => {
|
||||||
|
await resetAndSeedCore(harness.prisma);
|
||||||
|
const dep = new Date(Date.now() + 5 * 60_000); // 5min out — inside the default 30-min cutoff
|
||||||
|
const arr = new Date(dep.getTime() + 50 * 60_000);
|
||||||
|
const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-CUTOFF-${Date.now()}`, departureAt: dep, arrivalAt: arr });
|
||||||
|
|
||||||
|
await seatsService.blockSeat(seats[0].id, "Reserved", schedule.id);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
guestBookingService.issueBookingFromReservation(
|
||||||
|
seats[0].id,
|
||||||
|
baseDto({ scheduleId: schedule.id, originStationId: IDS.stationA, destinationStationId: IDS.stationB }) as any,
|
||||||
|
null,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/not accepted within/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getByPayToken 404s for an unknown token and 400s once expired", async () => {
|
||||||
|
await resetAndSeedCore(harness.prisma);
|
||||||
|
await expect(bookingsService.getByPayToken("no-such-token")).rejects.toThrow(/not found/i);
|
||||||
|
|
||||||
|
const dep = new Date(Date.now() + 3 * 60 * 60_000);
|
||||||
|
const arr = new Date(dep.getTime() + 100 * 60_000);
|
||||||
|
const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-EXPIRED-${Date.now()}`, departureAt: dep, arrivalAt: arr });
|
||||||
|
await seatsService.blockSeat(seats[0].id, "Reserved", schedule.id);
|
||||||
|
const result: any = await guestBookingService.issueBookingFromReservation(
|
||||||
|
seats[0].id,
|
||||||
|
baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.PASSENGER, phone: "+253771234567" }) as any,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
await harness.prisma.booking.update({
|
||||||
|
where: { id: result.booking.id },
|
||||||
|
data: { payTokenExpiresAt: new Date(Date.now() - 60_000) },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(bookingsService.getByPayToken(result.booking.payToken)).rejects.toThrow(/expired/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
206
apps/edr-passenger-api/test/schedule-lifecycle.e2e-spec.ts
Normal file
206
apps/edr-passenger-api/test/schedule-lifecycle.e2e-spec.ts
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
/**
|
||||||
|
* Schedule lifecycle coverage — bulkGenerateSchedules, createSchedule's "must have at least
|
||||||
|
* one coach assigned" guard (added recently — the exact validation whose ordering broke
|
||||||
|
* checkin-cutoff.e2e-spec.ts's fixture helper earlier this session), and
|
||||||
|
* TasksService.syncScheduleStatuses' full schedule-level state machine
|
||||||
|
* (SCHEDULED -> BOARDING -> EN_ROUTE -> ARRIVED). checkin-cutoff.e2e-spec.ts already covers
|
||||||
|
* the per-stop CHECKIN_CLOSED/OPEN half of syncScheduleStatuses — not repeated here.
|
||||||
|
*/
|
||||||
|
import { SchedulesService } from "../src/modules/schedules/schedules.service";
|
||||||
|
import { TasksService } from "../src/modules/tasks/tasks.service";
|
||||||
|
import { createServiceHarness, ServiceHarness } from "./setup/slim-app";
|
||||||
|
import { IDS, resetAndSeedCore } from "./fixtures/seed-core";
|
||||||
|
|
||||||
|
function asyncStub(): any {
|
||||||
|
return new Proxy({}, { get: () => async () => undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Schedule lifecycle — bulk generate, coach guard, status transitions", () => {
|
||||||
|
let harness: ServiceHarness;
|
||||||
|
let schedulesService: SchedulesService;
|
||||||
|
let tasksService: TasksService;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
harness = await createServiceHarness();
|
||||||
|
schedulesService = await harness.moduleRef.resolve(SchedulesService);
|
||||||
|
tasksService = new TasksService(harness.prisma as any, asyncStub(), asyncStub());
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await harness?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await resetAndSeedCore(harness.prisma);
|
||||||
|
});
|
||||||
|
|
||||||
|
const future = (mins: number) => new Date(Date.now() + mins * 60_000);
|
||||||
|
|
||||||
|
async function createCoach(trainNumber: string) {
|
||||||
|
return harness.prisma.coach.create({
|
||||||
|
data: { coachTypeId: IDS.coachType, number: `${trainNumber}-C1`, capacity: 4, sequence: 1, status: "ACTIVE" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("createSchedule coach-assignment guard", () => {
|
||||||
|
it("rejects a schedule with no coachIds and no route coach template", async () => {
|
||||||
|
const train = await harness.prisma.train.create({ data: { number: `SCH-NOCOACH-${Date.now()}`, name: "Test" } });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
schedulesService.createSchedule({
|
||||||
|
trainId: train.id,
|
||||||
|
routeId: IDS.route,
|
||||||
|
departureAt: future(180).toISOString(),
|
||||||
|
arrivalAt: future(280).toISOString(),
|
||||||
|
} as any),
|
||||||
|
).rejects.toThrow(/must have at least one coach assigned/i);
|
||||||
|
|
||||||
|
const orphan = await harness.prisma.trainSchedule.findFirst({ where: { trainId: train.id } });
|
||||||
|
expect(orphan).toBeNull(); // no dead schedule left behind
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-applies the route's coach template when coachIds are omitted", async () => {
|
||||||
|
const train = await harness.prisma.train.create({ data: { number: `SCH-TEMPLATE-${Date.now()}`, name: "Test" } });
|
||||||
|
const coach = await createCoach(train.number);
|
||||||
|
await harness.prisma.routeCoachTemplate.create({
|
||||||
|
data: { routeId: IDS.route, coachId: coach.id, positionNumber: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const schedule = await schedulesService.createSchedule({
|
||||||
|
trainId: train.id,
|
||||||
|
routeId: IDS.route,
|
||||||
|
departureAt: future(180).toISOString(),
|
||||||
|
arrivalAt: future(280).toISOString(),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const assignments = await harness.prisma.coachAssignment.findMany({ where: { scheduleId: schedule.id } });
|
||||||
|
expect(assignments).toHaveLength(1);
|
||||||
|
expect(assignments[0].coachId).toBe(coach.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("explicit coachIds override the route's coach template", async () => {
|
||||||
|
const train = await harness.prisma.train.create({ data: { number: `SCH-OVERRIDE-${Date.now()}`, name: "Test" } });
|
||||||
|
const templateCoach = await createCoach(`${train.number}-tmpl`);
|
||||||
|
const explicitCoach = await createCoach(`${train.number}-explicit`);
|
||||||
|
await harness.prisma.routeCoachTemplate.create({
|
||||||
|
data: { routeId: IDS.route, coachId: templateCoach.id, positionNumber: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const schedule = await schedulesService.createSchedule({
|
||||||
|
trainId: train.id,
|
||||||
|
routeId: IDS.route,
|
||||||
|
departureAt: future(180).toISOString(),
|
||||||
|
arrivalAt: future(280).toISOString(),
|
||||||
|
coachIds: [explicitCoach.id],
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const assignments = await harness.prisma.coachAssignment.findMany({ where: { scheduleId: schedule.id } });
|
||||||
|
expect(assignments).toHaveLength(1);
|
||||||
|
expect(assignments[0].coachId).toBe(explicitCoach.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bulkGenerateSchedules", () => {
|
||||||
|
it("generates one schedule per repeat interval across the date range", async () => {
|
||||||
|
const train = await harness.prisma.train.create({ data: { number: `BULK-${Date.now()}`, name: "Test" } });
|
||||||
|
const coach = await createCoach(train.number);
|
||||||
|
await harness.prisma.routeCoachTemplate.create({ data: { routeId: IDS.route, coachId: coach.id, positionNumber: 1 } });
|
||||||
|
|
||||||
|
const start = future(2 * 24 * 60); // 2 days out, clear of "today" edge cases
|
||||||
|
const result = await schedulesService.bulkGenerateSchedules({
|
||||||
|
trainId: train.id,
|
||||||
|
routeId: IDS.route,
|
||||||
|
startDateTime: start.toISOString(),
|
||||||
|
forNextDays: 5,
|
||||||
|
repeatEveryDays: 2,
|
||||||
|
durationHours: 3,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
// while(currentDate < endDate) stepping by 2 days over a 5-day window: day 0, 2, 4.
|
||||||
|
expect(result.schedulesCreated).toBe(3);
|
||||||
|
expect(result.errors).toHaveLength(0);
|
||||||
|
expect(result.scheduleIds).toHaveLength(3);
|
||||||
|
|
||||||
|
const schedules = await harness.prisma.trainSchedule.findMany({ where: { trainId: train.id }, orderBy: { departureAt: "asc" } });
|
||||||
|
expect(schedules).toHaveLength(3);
|
||||||
|
const daysBetween = (a: Date, b: Date) => Math.round((b.getTime() - a.getTime()) / (24 * 60 * 60 * 1000));
|
||||||
|
expect(daysBetween(schedules[0].departureAt, schedules[1].departureAt)).toBe(2);
|
||||||
|
expect(daysBetween(schedules[1].departureAt, schedules[2].departureAt)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collects per-day errors without aborting the rest of the run", async () => {
|
||||||
|
const train = await harness.prisma.train.create({ data: { number: `BULK-ERR-${Date.now()}`, name: "Test" } });
|
||||||
|
const coach = await createCoach(train.number);
|
||||||
|
await harness.prisma.routeCoachTemplate.create({ data: { routeId: IDS.route, coachId: coach.id, positionNumber: 1 } });
|
||||||
|
|
||||||
|
const start = future(2 * 24 * 60);
|
||||||
|
const dto = {
|
||||||
|
trainId: train.id,
|
||||||
|
routeId: IDS.route,
|
||||||
|
startDateTime: start.toISOString(),
|
||||||
|
forNextDays: 4,
|
||||||
|
repeatEveryDays: 1,
|
||||||
|
durationHours: 3,
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const first = await schedulesService.bulkGenerateSchedules(dto);
|
||||||
|
expect(first.schedulesCreated).toBe(4);
|
||||||
|
expect(first.errors).toHaveLength(0);
|
||||||
|
|
||||||
|
// Re-running the exact same range collides with EVERY day just created (same
|
||||||
|
// train+route+date already exists) — proves errors are collected per-day, not thrown,
|
||||||
|
// and the count/errors array accurately reflect zero successes this time.
|
||||||
|
const second = await schedulesService.bulkGenerateSchedules(dto);
|
||||||
|
expect(second.schedulesCreated).toBe(0);
|
||||||
|
expect(second.errors).toHaveLength(4);
|
||||||
|
expect(second.errors[0]).toMatch(/already exists/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("syncScheduleStatuses — schedule-level state machine", () => {
|
||||||
|
async function scheduleWithCoach(trainNumber: string, departureAt: Date, arrivalAt: Date) {
|
||||||
|
const train = await harness.prisma.train.create({ data: { number: trainNumber, name: "Test" } });
|
||||||
|
const coach = await createCoach(trainNumber);
|
||||||
|
return schedulesService.createSchedule({
|
||||||
|
trainId: train.id,
|
||||||
|
routeId: IDS.route,
|
||||||
|
departureAt: departureAt.toISOString(),
|
||||||
|
arrivalAt: arrivalAt.toISOString(),
|
||||||
|
coachIds: [coach.id],
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("SCHEDULED -> BOARDING once within the 30-min cutoff window", async () => {
|
||||||
|
const schedule = await scheduleWithCoach(`SYNC-BOARD-${Date.now()}`, future(20), future(80));
|
||||||
|
expect((await harness.prisma.trainSchedule.findUnique({ where: { id: schedule.id } }))?.status).toBe("SCHEDULED");
|
||||||
|
|
||||||
|
await tasksService.syncScheduleStatuses();
|
||||||
|
|
||||||
|
expect((await harness.prisma.trainSchedule.findUnique({ where: { id: schedule.id } }))?.status).toBe("BOARDING");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("BOARDING -> EN_ROUTE once departure has passed", async () => {
|
||||||
|
const schedule = await scheduleWithCoach(`SYNC-ENROUTE-${Date.now()}`, future(20), future(80));
|
||||||
|
await tasksService.syncScheduleStatuses();
|
||||||
|
await harness.prisma.trainSchedule.update({ where: { id: schedule.id }, data: { departureAt: new Date(Date.now() - 60_000) } });
|
||||||
|
|
||||||
|
await tasksService.syncScheduleStatuses();
|
||||||
|
|
||||||
|
expect((await harness.prisma.trainSchedule.findUnique({ where: { id: schedule.id } }))?.status).toBe("EN_ROUTE");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("EN_ROUTE -> ARRIVED once arrival has passed", async () => {
|
||||||
|
const schedule = await scheduleWithCoach(`SYNC-ARRIVED-${Date.now()}`, future(20), future(80));
|
||||||
|
await tasksService.syncScheduleStatuses();
|
||||||
|
await harness.prisma.trainSchedule.update({
|
||||||
|
where: { id: schedule.id },
|
||||||
|
data: { departureAt: new Date(Date.now() - 120_000), arrivalAt: new Date(Date.now() - 60_000) },
|
||||||
|
});
|
||||||
|
|
||||||
|
await tasksService.syncScheduleStatuses();
|
||||||
|
await tasksService.syncScheduleStatuses(); // BOARDING->EN_ROUTE and EN_ROUTE->ARRIVED are separate updateMany calls in one pass — one call suffices, second call is a no-op idempotency check
|
||||||
|
|
||||||
|
expect((await harness.prisma.trainSchedule.findUnique({ where: { id: schedule.id } }))?.status).toBe("ARRIVED");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -95,6 +95,9 @@ describe("Stop-based booking — segment time & cutoff correctness", () => {
|
|||||||
asyncStub(), // passengerAuthService — never reached: no createAccount in these DTOs
|
asyncStub(), // passengerAuthService — never reached: no createAccount in these DTOs
|
||||||
fareEngine,
|
fareEngine,
|
||||||
{ emit: () => true } as any, // eventEmitter
|
{ emit: () => true } as any, // eventEmitter
|
||||||
|
asyncStub(), // paymentsService — never reached: these tests use createGuestBooking, not issueBookingFromReservation
|
||||||
|
asyncStub(), // auditService
|
||||||
|
asyncStub(), // smsClient
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
282
apps/edr-passenger-api/test/ticketing.e2e-spec.ts
Normal file
282
apps/edr-passenger-api/test/ticketing.e2e-spec.ts
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
/**
|
||||||
|
* Ticketing/boarding coverage — TicketsService.generate()/scanAndBoard()/validate() and the
|
||||||
|
* smart-seat-reassignment fallback, none of which had any e2e coverage before this suite
|
||||||
|
* (colocated tickets.service.spec.ts only covers offline batch validation, a different path).
|
||||||
|
*
|
||||||
|
* IMPORTANT: scanAndBoard() catches every internal error and returns
|
||||||
|
* `{ success: false, error, errorCode }` rather than throwing — assertions against it check
|
||||||
|
* the return value, not `.rejects.toThrow()`.
|
||||||
|
*
|
||||||
|
* Same Tier-2 direct-instantiation pattern as reserve-seat-issue-booking.e2e-spec.ts /
|
||||||
|
* booking-types.e2e-spec.ts.
|
||||||
|
*/
|
||||||
|
import { IdDocumentType, PaymentIntentStatus, PaymentMethodType } from "@prisma/client";
|
||||||
|
import { SchedulesService } from "../src/modules/schedules/schedules.service";
|
||||||
|
import { SeatsService } from "../src/modules/seats/seats.service";
|
||||||
|
import { TicketsService } from "../src/modules/tickets/tickets.service";
|
||||||
|
import { CurrencyService } from "../src/modules/currency/currency.service";
|
||||||
|
import { FareEngineService } from "../src/modules/fare-engine/fare-engine.service";
|
||||||
|
import { SystemConfigService } from "../src/modules/system-config/system-config.service";
|
||||||
|
import { GuestBookingService } from "../src/modules/bookings/guest-booking.service";
|
||||||
|
import { createServiceHarness, ServiceHarness } from "./setup/slim-app";
|
||||||
|
import { IDS, resetAndSeedCore } from "./fixtures/seed-core";
|
||||||
|
|
||||||
|
function asyncStub(): any {
|
||||||
|
return new Proxy({}, { get: () => async () => undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Ticketing — generate / scanAndBoard / validate / smart-reassign", () => {
|
||||||
|
let harness: ServiceHarness;
|
||||||
|
let schedulesService: SchedulesService;
|
||||||
|
let seatsService: SeatsService;
|
||||||
|
let ticketsService: TicketsService;
|
||||||
|
let guestBookingService: GuestBookingService;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
harness = await createServiceHarness();
|
||||||
|
schedulesService = await harness.moduleRef.resolve(SchedulesService);
|
||||||
|
const currencyService = harness.moduleRef.get(CurrencyService);
|
||||||
|
const fareEngine = harness.moduleRef.get(FareEngineService);
|
||||||
|
const systemConfig = new SystemConfigService(harness.prisma as any);
|
||||||
|
|
||||||
|
seatsService = new SeatsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub());
|
||||||
|
ticketsService = new TicketsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub());
|
||||||
|
guestBookingService = new GuestBookingService(
|
||||||
|
harness.prisma as any,
|
||||||
|
seatsService,
|
||||||
|
asyncStub(),
|
||||||
|
currencyService,
|
||||||
|
asyncStub(),
|
||||||
|
fareEngine,
|
||||||
|
{ emit: () => true } as any,
|
||||||
|
asyncStub(), // paymentsService — not used; tests drive generate()/scanAndBoard() directly
|
||||||
|
asyncStub(),
|
||||||
|
asyncStub(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await harness?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await resetAndSeedCore(harness.prisma);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createTestSchedule(opts: { trainNumber: string; departureAt: Date; arrivalAt: Date; seatCount?: number }) {
|
||||||
|
const train = await harness.prisma.train.create({
|
||||||
|
data: { number: opts.trainNumber, name: `Test ${opts.trainNumber}` },
|
||||||
|
});
|
||||||
|
const coach = await harness.prisma.coach.create({
|
||||||
|
data: { coachTypeId: IDS.coachType, number: `${opts.trainNumber}-C1`, capacity: opts.seatCount ?? 4, sequence: 1, status: "ACTIVE" },
|
||||||
|
});
|
||||||
|
const seatCount = opts.seatCount ?? 4;
|
||||||
|
const seatNumbers = Array.from({ length: seatCount }, (_, i) => `1${String.fromCharCode(65 + i)}`);
|
||||||
|
const seats = await Promise.all(
|
||||||
|
seatNumbers.map((seatNumber, i) =>
|
||||||
|
harness.prisma.seat.create({
|
||||||
|
data: { coachId: coach.id, seatNumber, row: 1, col: seatNumber.slice(-1), isWindow: i === 0, isAisle: i === 1 },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const schedule = await schedulesService.createSchedule({
|
||||||
|
trainId: train.id,
|
||||||
|
routeId: IDS.route,
|
||||||
|
departureAt: opts.departureAt.toISOString(),
|
||||||
|
arrivalAt: opts.arrivalAt.toISOString(),
|
||||||
|
coachIds: [coach.id],
|
||||||
|
} as any);
|
||||||
|
return { schedule, seats };
|
||||||
|
}
|
||||||
|
|
||||||
|
function passengerDto(seatId: string, overrides: Partial<Record<string, any>> = {}) {
|
||||||
|
return {
|
||||||
|
seatId,
|
||||||
|
passengerName: "Test Traveler",
|
||||||
|
dateOfBirth: "1990-01-01",
|
||||||
|
idDocumentType: IdDocumentType.PASSPORT,
|
||||||
|
passportNumber: "X123456",
|
||||||
|
passportCountry: "Djibouti",
|
||||||
|
nationality: "Djiboutian",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const future = (mins: number) => new Date(Date.now() + mins * 60_000);
|
||||||
|
|
||||||
|
/** Creates a real PENDING_PAYMENT ONE_WAY booking via the guest flow. */
|
||||||
|
async function createOneWayBooking(scheduleId: string, seatId: string, originStationId = IDS.stationA, destinationStationId = IDS.stationB) {
|
||||||
|
const hold = await seatsService.holdSeats({
|
||||||
|
scheduleId,
|
||||||
|
originStationId,
|
||||||
|
destinationStationId,
|
||||||
|
passengers: [{ passengerId: "55555555-5555-4555-8555-500000000001", seatId }],
|
||||||
|
} as any);
|
||||||
|
return guestBookingService.createGuestBooking({
|
||||||
|
scheduleId,
|
||||||
|
holdId: (hold as any).holdId,
|
||||||
|
originStationId,
|
||||||
|
destinationStationId,
|
||||||
|
seatClassId: IDS.seatClassLocal,
|
||||||
|
passengers: [passengerDto(seatId)],
|
||||||
|
} as any) as Promise<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markSucceeded(bookingId: string) {
|
||||||
|
await harness.prisma.paymentIntent.create({
|
||||||
|
data: { bookingId, amountMinor: 30_000, currency: "ETB", method: PaymentMethodType.CARD, status: PaymentIntentStatus.SUCCEEDED, paidAt: new Date() },
|
||||||
|
});
|
||||||
|
await harness.prisma.booking.update({ where: { id: bookingId }, data: { status: "CONFIRMED" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("generate()", () => {
|
||||||
|
it("issues a ticket for a CONFIRMED booking with a SUCCEEDED PaymentIntent, and marks the seat BOOKED", async () => {
|
||||||
|
const { schedule, seats } = await createTestSchedule({ trainNumber: `TIX-GEN-${Date.now()}`, departureAt: future(180), arrivalAt: future(220) });
|
||||||
|
const booking = await createOneWayBooking(schedule.id, seats[0].id);
|
||||||
|
await markSucceeded(booking.id);
|
||||||
|
|
||||||
|
const result = await ticketsService.generate(booking.id);
|
||||||
|
expect(result.totalTickets).toBe(1);
|
||||||
|
|
||||||
|
const ticket = await harness.prisma.ticket.findFirst({ where: { bookingId: booking.id } });
|
||||||
|
expect(ticket).toBeTruthy();
|
||||||
|
expect(ticket?.qrPayload).toBeTruthy();
|
||||||
|
expect(ticket?.barcodePayload).toBeTruthy();
|
||||||
|
|
||||||
|
const seat = await harness.prisma.seat.findUnique({ where: { id: seats[0].id } });
|
||||||
|
expect(seat?.status).toBe("BOOKED");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects with 'Payment not completed' when there's no PaymentIntent at all", async () => {
|
||||||
|
const { schedule, seats } = await createTestSchedule({ trainNumber: `TIX-NOPI-${Date.now()}`, departureAt: future(180), arrivalAt: future(220) });
|
||||||
|
const booking = await createOneWayBooking(schedule.id, seats[0].id);
|
||||||
|
|
||||||
|
await expect(ticketsService.generate(booking.id)).rejects.toThrow(/Payment not completed/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects with 'Payment not completed' when the PaymentIntent hasn't succeeded", async () => {
|
||||||
|
const { schedule, seats } = await createTestSchedule({ trainNumber: `TIX-PENDING-${Date.now()}`, departureAt: future(180), arrivalAt: future(220) });
|
||||||
|
const booking = await createOneWayBooking(schedule.id, seats[0].id);
|
||||||
|
await harness.prisma.paymentIntent.create({
|
||||||
|
data: { bookingId: booking.id, amountMinor: 30_000, currency: "ETB", method: PaymentMethodType.CARD, status: PaymentIntentStatus.PROCESSING },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(ticketsService.generate(booking.id)).rejects.toThrow(/Payment not completed/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-confirms a PENDING_PAYMENT booking whose PaymentIntent already SUCCEEDED (missed webhook)", async () => {
|
||||||
|
const { schedule, seats } = await createTestSchedule({ trainNumber: `TIX-HEAL-${Date.now()}`, departureAt: future(180), arrivalAt: future(220) });
|
||||||
|
const booking = await createOneWayBooking(schedule.id, seats[0].id);
|
||||||
|
expect(booking.status).toBe("PENDING_PAYMENT");
|
||||||
|
await harness.prisma.paymentIntent.create({
|
||||||
|
data: { bookingId: booking.id, amountMinor: 30_000, currency: "ETB", method: PaymentMethodType.CARD, status: PaymentIntentStatus.SUCCEEDED, paidAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
await ticketsService.generate(booking.id);
|
||||||
|
|
||||||
|
const refreshed = await harness.prisma.booking.findUnique({ where: { id: booking.id } });
|
||||||
|
expect(refreshed?.status).toBe("CONFIRMED");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("smart seat reassignment", () => {
|
||||||
|
it("generate() throws ConflictException on a genuinely overlapping confirmed seat; smartAssignAndGenerate() recovers onto a free seat", async () => {
|
||||||
|
const { schedule, seats } = await createTestSchedule({ trainNumber: `TIX-CONFLICT-${Date.now()}`, departureAt: future(180), arrivalAt: future(220), seatCount: 3 });
|
||||||
|
|
||||||
|
// Booking A already CONFIRMED on seats[0], full A->B route (matches the schedule's own
|
||||||
|
// origin/destination, guaranteeing segment overlap with anything else on seats[0]).
|
||||||
|
const passengerA = await harness.prisma.passenger.create({ data: {} });
|
||||||
|
const bookingA = await harness.prisma.booking.create({
|
||||||
|
data: {
|
||||||
|
bookingRef: `CONFA${Date.now()}`, passengerId: passengerA.id, scheduleId: schedule.id,
|
||||||
|
originStationId: IDS.stationA, destinationStationId: IDS.stationB,
|
||||||
|
status: "CONFIRMED", totalMinor: 30_000, displayCurrency: "ETB",
|
||||||
|
seats: { create: [{ seatId: seats[0].id, scheduleId: schedule.id, passengerName: "Passenger A", passengerCategory: "ADULT", fareMinor: 30_000, displayCurrency: "ETB" }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
void bookingA;
|
||||||
|
|
||||||
|
// Booking B independently references the SAME seat (simulating however the conflict
|
||||||
|
// arose — the point of this test is the recovery path, not the cause).
|
||||||
|
const bookingB = await createOneWayBooking(schedule.id, seats[0].id);
|
||||||
|
await markSucceeded(bookingB.id);
|
||||||
|
|
||||||
|
await expect(ticketsService.generate(bookingB.id)).rejects.toThrow(/already confirmed for another booking/i);
|
||||||
|
|
||||||
|
const recovered = await ticketsService.smartAssignAndGenerate(bookingB.id);
|
||||||
|
expect(recovered.totalTickets).toBe(1);
|
||||||
|
|
||||||
|
const bookingSeat = await harness.prisma.bookingSeat.findFirst({ where: { bookingId: bookingB.id } });
|
||||||
|
expect(bookingSeat?.seatId).not.toBe(seats[0].id); // reassigned off the conflicting seat
|
||||||
|
expect([seats[1].id, seats[2].id]).toContain(bookingSeat?.seatId);
|
||||||
|
|
||||||
|
const ticket = await harness.prisma.ticket.findFirst({ where: { bookingId: bookingB.id } });
|
||||||
|
expect(ticket?.seatId).toBe(bookingSeat?.seatId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("scanAndBoard()", () => {
|
||||||
|
it("boards successfully within the boarding window", async () => {
|
||||||
|
const { schedule, seats } = await createTestSchedule({ trainNumber: `TIX-BOARD-OK-${Date.now()}`, departureAt: future(60), arrivalAt: future(120) });
|
||||||
|
const booking = await createOneWayBooking(schedule.id, seats[0].id);
|
||||||
|
await markSucceeded(booking.id);
|
||||||
|
await ticketsService.generate(booking.id);
|
||||||
|
|
||||||
|
const result = await ticketsService.scanAndBoard(booking.bookingRef, "gate-validator-1");
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.boarding?.seat).toBe(seats[0].seatNumber);
|
||||||
|
|
||||||
|
const ticket = await harness.prisma.ticket.findFirst({ where: { bookingId: booking.id } });
|
||||||
|
expect(ticket?.validatedAt).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses boarding before the boarding window opens", async () => {
|
||||||
|
// Default boarding window is 4h (SystemConfigService DEFAULTS) — 6h out is still closed.
|
||||||
|
const { schedule, seats } = await createTestSchedule({ trainNumber: `TIX-BOARD-EARLY-${Date.now()}`, departureAt: future(360), arrivalAt: future(420) });
|
||||||
|
const booking = await createOneWayBooking(schedule.id, seats[0].id);
|
||||||
|
await markSucceeded(booking.id);
|
||||||
|
await ticketsService.generate(booking.id);
|
||||||
|
|
||||||
|
const result = await ticketsService.scanAndBoard(booking.bookingRef, "gate-validator-1");
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toMatch(/Boarding opens \d+ hour\(s\) before departure/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses boarding once departure has passed", async () => {
|
||||||
|
// Book while departure is comfortably in the future (holdSeats itself refuses a hold
|
||||||
|
// within 30min of departure), THEN move departure into the past — both the schedule's
|
||||||
|
// own departureAt AND the origin stop's plannedDepartureAt, since scanAndBoard resolves
|
||||||
|
// the boarding time via resolveBookingSegment(), which prefers the TripStopTime over
|
||||||
|
// the raw schedule field.
|
||||||
|
const { schedule, seats } = await createTestSchedule({ trainNumber: `TIX-BOARD-LATE-${Date.now()}`, departureAt: future(180), arrivalAt: future(240) });
|
||||||
|
const booking = await createOneWayBooking(schedule.id, seats[0].id);
|
||||||
|
await markSucceeded(booking.id);
|
||||||
|
await ticketsService.generate(booking.id);
|
||||||
|
|
||||||
|
const past = new Date(Date.now() - 60_000);
|
||||||
|
await harness.prisma.trainSchedule.update({ where: { id: schedule.id }, data: { departureAt: past } });
|
||||||
|
await harness.prisma.tripStopTime.updateMany({ where: { scheduleId: schedule.id, stationId: IDS.stationA }, data: { plannedDepartureAt: past } });
|
||||||
|
|
||||||
|
const result = await ticketsService.scanAndBoard(booking.bookingRef, "gate-validator-1");
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toMatch(/Boarding is closed/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validate()", () => {
|
||||||
|
it("ONE_WAY: a second validate() call on the same ticket is idempotent, not an error", async () => {
|
||||||
|
const { schedule, seats } = await createTestSchedule({ trainNumber: `TIX-VAL-ONEWAY-${Date.now()}`, departureAt: future(60), arrivalAt: future(120) });
|
||||||
|
const booking = await createOneWayBooking(schedule.id, seats[0].id);
|
||||||
|
await markSucceeded(booking.id);
|
||||||
|
await ticketsService.generate(booking.id);
|
||||||
|
|
||||||
|
const first = await ticketsService.validate(booking.bookingRef, "gate-validator-1");
|
||||||
|
expect(first.validated).toBe(true);
|
||||||
|
expect((first as any).alreadyValidated).toBeUndefined();
|
||||||
|
|
||||||
|
const second = await ticketsService.validate(booking.bookingRef, "gate-validator-1");
|
||||||
|
expect(second.validated).toBe(true);
|
||||||
|
expect((second as any).alreadyValidated).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { seatsApi, schedulesApi, fleetApi, routeCoachTemplatesApi } from '@/lib/api';
|
import { seatsApi, schedulesApi, fleetApi, routeCoachTemplatesApi, bookingsApi } from '@/lib/api';
|
||||||
import { routesApi } from '@/lib/api/routes';
|
import { routesApi } from '@/lib/api/routes';
|
||||||
import Modal from '@/components/ui/Modal';
|
import Modal from '@/components/ui/Modal';
|
||||||
import ActionButton from '@/components/ui/ActionButton'
|
import ActionButton from '@/components/ui/ActionButton'
|
||||||
import { Armchair, Lock, Unlock, Bed, X, RotateCcw, ChevronDown, Train, Wrench } from 'lucide-react';
|
import { Armchair, Lock, Unlock, Bed, X, RotateCcw, ChevronDown, Train, Wrench, Ticket as TicketIcon } from 'lucide-react';
|
||||||
|
|
||||||
export default function SeatsPage() {
|
export default function SeatsPage() {
|
||||||
const [activeTab, setActiveTab] = useState<'route' | 'schedule'>('route');
|
const [activeTab, setActiveTab] = useState<'route' | 'schedule'>('route');
|
||||||
@@ -24,6 +24,22 @@ export default function SeatsPage() {
|
|||||||
const [coachToUnblock, setCoachToUnblock] = useState<any>(null);
|
const [coachToUnblock, setCoachToUnblock] = useState<any>(null);
|
||||||
const [showMaintenanceModal, setShowMaintenanceModal] = useState(false);
|
const [showMaintenanceModal, setShowMaintenanceModal] = useState(false);
|
||||||
const [maintenanceReason, setMaintenanceReason] = useState('');
|
const [maintenanceReason, setMaintenanceReason] = useState('');
|
||||||
|
const [showIssueBookingModal, setShowIssueBookingModal] = useState(false);
|
||||||
|
const [issueBookingCoach, setIssueBookingCoach] = useState<any>(null);
|
||||||
|
const [issueBookingForm, setIssueBookingForm] = useState({
|
||||||
|
bookingKind: 'STAFF' as 'STAFF' | 'PASSENGER',
|
||||||
|
// No seatClassId — the seat's class is already fixed by the reservation; the backend
|
||||||
|
// resolves it from the seat's own coach type + nationality tier.
|
||||||
|
passengerName: '',
|
||||||
|
dateOfBirth: '',
|
||||||
|
idDocumentType: 'PASSPORT' as 'NATIONAL_ID' | 'PASSPORT',
|
||||||
|
idDocumentNumber: '',
|
||||||
|
passportNumber: '',
|
||||||
|
nationality: '' as '' | 'Ethiopian' | 'Djiboutian' | 'Other',
|
||||||
|
phone: '',
|
||||||
|
email: '',
|
||||||
|
});
|
||||||
|
const [issueBookingResult, setIssueBookingResult] = useState<{ payUrl?: string } | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data: schedulesData } = useQuery({
|
const { data: schedulesData } = useQuery({
|
||||||
@@ -105,6 +121,21 @@ export default function SeatsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const issueBookingMutation = useMutation({
|
||||||
|
mutationFn: ({ seatId, data }: { seatId: string; data: any }) =>
|
||||||
|
bookingsApi.issueFromReservation(seatId, data),
|
||||||
|
onSuccess: (result: any) => {
|
||||||
|
invalidateSeatData();
|
||||||
|
setIssueBookingResult({ payUrl: result?.payUrl });
|
||||||
|
if (!result?.payUrl) {
|
||||||
|
// STAFF booking — nothing further to show the admin, close immediately.
|
||||||
|
setShowIssueBookingModal(false);
|
||||||
|
setSelectedSeat(null);
|
||||||
|
setIssueBookingCoach(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const removeSeatMutation = useMutation({
|
const removeSeatMutation = useMutation({
|
||||||
mutationFn: (seatId: string) => seatsApi.removeSeat(seatId),
|
mutationFn: (seatId: string) => seatsApi.removeSeat(seatId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -186,11 +217,66 @@ export default function SeatsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleUnblock = async (seat: any) => {
|
const handleUnblock = async (seat: any) => {
|
||||||
if (confirm('Are you sure you want to unblock this seat?')) {
|
if (confirm('Release this reservation and make the seat available to the public?')) {
|
||||||
await unblockMutation.mutateAsync(seat.id);
|
await unblockMutation.mutateAsync(seat.id);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleIssueBooking = (seat: any, coach: any) => {
|
||||||
|
if (activeTab !== 'schedule' || !selectedSchedule) {
|
||||||
|
alert('Select a specific schedule (Schedule tab) to issue a booking for a reserved seat.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedSeat(seat);
|
||||||
|
setIssueBookingCoach(coach);
|
||||||
|
setIssueBookingResult(null);
|
||||||
|
setIssueBookingForm({
|
||||||
|
bookingKind: 'STAFF',
|
||||||
|
passengerName: '',
|
||||||
|
dateOfBirth: '',
|
||||||
|
idDocumentType: 'PASSPORT',
|
||||||
|
idDocumentNumber: '',
|
||||||
|
passportNumber: '',
|
||||||
|
nationality: '',
|
||||||
|
phone: '',
|
||||||
|
email: '',
|
||||||
|
});
|
||||||
|
setShowIssueBookingModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitIssueBooking = async () => {
|
||||||
|
const schedule = schedules.find((s: any) => s.id === selectedSchedule);
|
||||||
|
if (!schedule?.originStation?.id || !schedule?.destinationStation?.id) {
|
||||||
|
alert('Could not resolve this schedule\'s origin/destination stations.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!issueBookingForm.passengerName.trim() || !issueBookingForm.dateOfBirth) {
|
||||||
|
alert('Traveler name and date of birth are required.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!issueBookingForm.nationality) {
|
||||||
|
alert('Select a nationality.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (issueBookingForm.idDocumentType === 'PASSPORT' && !issueBookingForm.passportNumber.trim()) {
|
||||||
|
alert('Passport number is required.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (issueBookingForm.bookingKind === 'PASSENGER' && !issueBookingForm.phone.trim()) {
|
||||||
|
alert('Phone number is required for a passenger booking (used to send the payment link).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await issueBookingMutation.mutateAsync({
|
||||||
|
seatId: selectedSeat.id,
|
||||||
|
data: {
|
||||||
|
scheduleId: selectedSchedule,
|
||||||
|
originStationId: schedule.originStation.id,
|
||||||
|
destinationStationId: schedule.destinationStation.id,
|
||||||
|
...issueBookingForm,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleRemoveSeat = (seat: any) => {
|
const handleRemoveSeat = (seat: any) => {
|
||||||
setSelectedSeat(seat);
|
setSelectedSeat(seat);
|
||||||
setShowRemoveModal(true);
|
setShowRemoveModal(true);
|
||||||
@@ -252,7 +338,7 @@ export default function SeatsPage() {
|
|||||||
|
|
||||||
const submitBlock = async () => {
|
const submitBlock = async () => {
|
||||||
if (!blockReason.trim()) {
|
if (!blockReason.trim()) {
|
||||||
alert('Please provide a reason for blocking');
|
alert('Please provide a reason for the reservation');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await blockMutation.mutateAsync({ seatId: selectedSeat.id, reason: blockReason });
|
await blockMutation.mutateAsync({ seatId: selectedSeat.id, reason: blockReason });
|
||||||
@@ -350,6 +436,7 @@ export default function SeatsPage() {
|
|||||||
handleUndoRemove={handleUndoRemove}
|
handleUndoRemove={handleUndoRemove}
|
||||||
handleSetMaintenance={handleSetMaintenance}
|
handleSetMaintenance={handleSetMaintenance}
|
||||||
handleClearMaintenance={handleClearMaintenance}
|
handleClearMaintenance={handleClearMaintenance}
|
||||||
|
handleIssueBooking={handleIssueBooking}
|
||||||
hideNumber={true}
|
hideNumber={true}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -445,6 +532,7 @@ export default function SeatsPage() {
|
|||||||
handleUndoRemove={handleUndoRemove}
|
handleUndoRemove={handleUndoRemove}
|
||||||
handleSetMaintenance={handleSetMaintenance}
|
handleSetMaintenance={handleSetMaintenance}
|
||||||
handleClearMaintenance={handleClearMaintenance}
|
handleClearMaintenance={handleClearMaintenance}
|
||||||
|
handleIssueBooking={handleIssueBooking}
|
||||||
hideNumber={true}
|
hideNumber={true}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -467,6 +555,7 @@ export default function SeatsPage() {
|
|||||||
handleUndoRemove={handleUndoRemove}
|
handleUndoRemove={handleUndoRemove}
|
||||||
handleSetMaintenance={handleSetMaintenance}
|
handleSetMaintenance={handleSetMaintenance}
|
||||||
handleClearMaintenance={handleClearMaintenance}
|
handleClearMaintenance={handleClearMaintenance}
|
||||||
|
handleIssueBooking={handleIssueBooking}
|
||||||
hideNumber={true}
|
hideNumber={true}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -749,15 +838,15 @@ export default function SeatsPage() {
|
|||||||
setSelectedSeat(null);
|
setSelectedSeat(null);
|
||||||
setBlockReason('');
|
setBlockReason('');
|
||||||
}}
|
}}
|
||||||
title="Block Seat"
|
title="Reserve Seat"
|
||||||
size="md"
|
size="md"
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Block seat <strong>{selectedSeat?.seatNumber}</strong> in Coach <strong>{selectedSeat?.coach?.coachNumber}</strong>
|
Reserve seat <strong>{selectedSeat?.seatNumber}</strong> in Coach <strong>{selectedSeat?.coach?.coachNumber}</strong>
|
||||||
</p>
|
</p>
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Reason for Blocking *</label>
|
<label className="label">Reason for Reservation *</label>
|
||||||
<textarea
|
<textarea
|
||||||
className="input"
|
className="input"
|
||||||
rows={3}
|
rows={3}
|
||||||
@@ -782,12 +871,184 @@ export default function SeatsPage() {
|
|||||||
loading={blockMutation.isPending}
|
loading={blockMutation.isPending}
|
||||||
disabled={!blockReason.trim()}
|
disabled={!blockReason.trim()}
|
||||||
>
|
>
|
||||||
Block Seat
|
Reserve Seat
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={showIssueBookingModal}
|
||||||
|
onClose={() => {
|
||||||
|
setShowIssueBookingModal(false);
|
||||||
|
setSelectedSeat(null);
|
||||||
|
setIssueBookingCoach(null);
|
||||||
|
setIssueBookingResult(null);
|
||||||
|
}}
|
||||||
|
title="Issue Booking"
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
{issueBookingResult?.payUrl ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Booking created. A payment link has been sent via SMS to the traveler.
|
||||||
|
</p>
|
||||||
|
<div className="input break-all text-xs">{issueBookingResult.payUrl}</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<ActionButton
|
||||||
|
onClick={() => {
|
||||||
|
setShowIssueBookingModal(false);
|
||||||
|
setSelectedSeat(null);
|
||||||
|
setIssueBookingCoach(null);
|
||||||
|
setIssueBookingResult(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</ActionButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Issue a booking for seat <strong>{selectedSeat?.seatNumber}</strong> in Coach{' '}
|
||||||
|
<strong>{issueBookingCoach?.coachNumber}</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="label">Booking Type *</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`flex-1 px-3 py-2 rounded border ${issueBookingForm.bookingKind === 'STAFF' ? 'bg-blue-600 text-white border-blue-600' : 'border-gray-300 dark:border-gray-600'}`}
|
||||||
|
onClick={() => setIssueBookingForm((f) => ({ ...f, bookingKind: 'STAFF' }))}
|
||||||
|
>
|
||||||
|
Staff (no fee)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`flex-1 px-3 py-2 rounded border ${issueBookingForm.bookingKind === 'PASSENGER' ? 'bg-blue-600 text-white border-blue-600' : 'border-gray-300 dark:border-gray-600'}`}
|
||||||
|
onClick={() => setIssueBookingForm((f) => ({ ...f, bookingKind: 'PASSENGER' }))}
|
||||||
|
>
|
||||||
|
Passenger (pay via link)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="label">Traveler Name *</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={issueBookingForm.passengerName}
|
||||||
|
onChange={(e) => setIssueBookingForm((f) => ({ ...f, passengerName: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="label">Date of Birth *</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="input"
|
||||||
|
value={issueBookingForm.dateOfBirth}
|
||||||
|
onChange={(e) => setIssueBookingForm((f) => ({ ...f, dateOfBirth: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Nationality *</label>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={issueBookingForm.nationality}
|
||||||
|
onChange={(e) => {
|
||||||
|
const nationality = e.target.value as 'Ethiopian' | 'Djiboutian' | 'Other';
|
||||||
|
setIssueBookingForm((f) => ({
|
||||||
|
...f,
|
||||||
|
nationality,
|
||||||
|
// National ID is Ethiopian-only — switch back to Passport for anyone else.
|
||||||
|
idDocumentType: nationality === 'Ethiopian' ? f.idDocumentType : 'PASSPORT',
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Select nationality...</option>
|
||||||
|
<option value="Ethiopian">Ethiopian</option>
|
||||||
|
<option value="Djiboutian">Djiboutian</option>
|
||||||
|
<option value="Other">Other</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="label">ID Document Type *</label>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={issueBookingForm.idDocumentType}
|
||||||
|
onChange={(e) => setIssueBookingForm((f) => ({ ...f, idDocumentType: e.target.value as 'NATIONAL_ID' | 'PASSPORT' }))}
|
||||||
|
>
|
||||||
|
<option value="PASSPORT">Passport</option>
|
||||||
|
{issueBookingForm.nationality === 'Ethiopian' && (
|
||||||
|
<option value="NATIONAL_ID">National ID</option>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{issueBookingForm.idDocumentType === 'NATIONAL_ID' ? (
|
||||||
|
<div>
|
||||||
|
<label className="label">National ID Number</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={issueBookingForm.idDocumentNumber}
|
||||||
|
onChange={(e) => setIssueBookingForm((f) => ({ ...f, idDocumentNumber: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<label className="label">Passport Number *</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={issueBookingForm.passportNumber}
|
||||||
|
onChange={(e) => setIssueBookingForm((f) => ({ ...f, passportNumber: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="label">Phone{issueBookingForm.bookingKind === 'PASSENGER' ? ' * (payment link sent here)' : ''}</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={issueBookingForm.phone}
|
||||||
|
onChange={(e) => setIssueBookingForm((f) => ({ ...f, phone: e.target.value }))}
|
||||||
|
placeholder="+251911234567"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Email</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={issueBookingForm.email}
|
||||||
|
onChange={(e) => setIssueBookingForm((f) => ({ ...f, email: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<ActionButton
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setShowIssueBookingModal(false);
|
||||||
|
setSelectedSeat(null);
|
||||||
|
setIssueBookingCoach(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</ActionButton>
|
||||||
|
<ActionButton onClick={submitIssueBooking} loading={issueBookingMutation.isPending}>
|
||||||
|
{issueBookingForm.bookingKind === 'STAFF' ? 'Issue Ticket' : 'Send Payment Link'}
|
||||||
|
</ActionButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={showRemoveModal}
|
isOpen={showRemoveModal}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
@@ -973,6 +1234,7 @@ interface SeatIconProps {
|
|||||||
handleUndoRemove: (seat: any) => void;
|
handleUndoRemove: (seat: any) => void;
|
||||||
handleSetMaintenance: (seat: any) => void;
|
handleSetMaintenance: (seat: any) => void;
|
||||||
handleClearMaintenance: (seat: any) => void;
|
handleClearMaintenance: (seat: any) => void;
|
||||||
|
handleIssueBooking: (seat: any, coach: any) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SeatIcon({
|
function SeatIcon({
|
||||||
@@ -989,6 +1251,7 @@ function SeatIcon({
|
|||||||
handleUndoRemove,
|
handleUndoRemove,
|
||||||
handleSetMaintenance,
|
handleSetMaintenance,
|
||||||
handleClearMaintenance,
|
handleClearMaintenance,
|
||||||
|
handleIssueBooking,
|
||||||
}: SeatIconProps) {
|
}: SeatIconProps) {
|
||||||
const isRemoved = seat.seatNumber && seat.seatNumber.startsWith('-');
|
const isRemoved = seat.seatNumber && seat.seatNumber.startsWith('-');
|
||||||
const seatClassStr = typeof coach?.seatClass === 'string' ? coach.seatClass : (coach?.seatClass?.name || coach?.coachClass || '');
|
const seatClassStr = typeof coach?.seatClass === 'string' ? coach.seatClass : (coach?.seatClass?.name || coach?.coachClass || '');
|
||||||
@@ -1058,7 +1321,7 @@ function SeatIcon({
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleBlock(seat)}
|
onClick={() => handleBlock(seat)}
|
||||||
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
|
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
|
||||||
title="Block seat"
|
title="Reserve seat"
|
||||||
>
|
>
|
||||||
<Lock className="h-3 w-3 text-gray-700" />
|
<Lock className="h-3 w-3 text-gray-700" />
|
||||||
</button>
|
</button>
|
||||||
@@ -1072,13 +1335,22 @@ function SeatIcon({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{canUnblock && (
|
{canUnblock && (
|
||||||
<button
|
<>
|
||||||
onClick={() => handleUnblock(seat)}
|
<button
|
||||||
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
|
onClick={() => handleUnblock(seat)}
|
||||||
title="Unblock seat"
|
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
|
||||||
>
|
title="Release reservation"
|
||||||
<Unlock className="h-3 w-3 text-gray-700" />
|
>
|
||||||
</button>
|
<Unlock className="h-3 w-3 text-gray-700" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleIssueBooking(seat, coach)}
|
||||||
|
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
|
||||||
|
title="Issue booking"
|
||||||
|
>
|
||||||
|
<TicketIcon className="h-3 w-3 text-gray-700" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{canMaintenance && (
|
{canMaintenance && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ export const bookingsApi = {
|
|||||||
apiClient.post<any>(`/payments/${bookingId}/force-confirm`, data),
|
apiClient.post<any>(`/payments/${bookingId}/force-confirm`, data),
|
||||||
smartAssign: (bookingId: string) =>
|
smartAssign: (bookingId: string) =>
|
||||||
apiClient.post<any>(`/tickets/smart-assign/${bookingId}`, {}),
|
apiClient.post<any>(`/tickets/smart-assign/${bookingId}`, {}),
|
||||||
|
// Converts a reserved (blocked) seat into a real booking — STAFF (fee-waived, ticket
|
||||||
|
// issued immediately) or PASSENGER (payment link texted to the traveler's phone).
|
||||||
|
issueFromReservation: (seatId: string, data: any) =>
|
||||||
|
apiClient.post<any>(`/bookings/reservations/${seatId}/issue`, data),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Passengers API
|
// Passengers API
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { XCircle } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function ReservationPayFailedPage() {
|
||||||
|
const { token } = useParams<{ token: string }>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 flex items-center justify-center px-4">
|
||||||
|
<div className="max-w-sm w-full text-center space-y-4">
|
||||||
|
<XCircle className="w-16 h-16 text-red-500 mx-auto" />
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Payment failed</h1>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your payment could not be completed. Please try again.
|
||||||
|
</p>
|
||||||
|
<Link href={`/reserve/pay/${token}`} className="btn-primary inline-block px-6 py-2.5 font-semibold">
|
||||||
|
Try again
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
|
import { apiClient } from "@/lib/api-client";
|
||||||
|
import { PaymentMethod } from "@/types";
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
CreditCard,
|
||||||
|
Smartphone,
|
||||||
|
Wallet,
|
||||||
|
Landmark,
|
||||||
|
CheckCircle,
|
||||||
|
AlertCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
const getIconForMethod = (methodId: string) => {
|
||||||
|
if (methodId.includes("CARD")) return CreditCard;
|
||||||
|
if (methodId.includes("WALLET")) return Wallet;
|
||||||
|
if (methodId.includes("CAC")) return Landmark;
|
||||||
|
return Smartphone;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standalone pay-by-link page for a booking issued via the backoffice's "reserve seat →
|
||||||
|
* issue booking (passenger)" flow — no portal login/session required, unlike the normal
|
||||||
|
* /booking/payment page which depends on client-side booking-store state populated during
|
||||||
|
* a live search→seats→review session. Cloned from /pay-balance/[token] (same pattern:
|
||||||
|
* resolve-by-token, method picker, single pay button) but pointed at a booking instead of a
|
||||||
|
* supplementary charge, and reusing the SAME already-public booking-payment endpoints
|
||||||
|
* (/payments/initiate, /payments/methods) the normal payment page calls — no new payment
|
||||||
|
* mechanics, just a session-free entry point.
|
||||||
|
*/
|
||||||
|
export default function ReservationPayPage() {
|
||||||
|
const { token } = useParams<{ token: string }>();
|
||||||
|
const router = useRouter();
|
||||||
|
const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const [paymentError, setPaymentError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: booking, isLoading: loadingBooking, error: bookingError } = useQuery({
|
||||||
|
queryKey: ["reservation-booking", token],
|
||||||
|
queryFn: () => apiClient.get<any>(`/bookings/pay/${token}`),
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: paymentMethods = [], isLoading: loadingMethods } = useQuery<PaymentMethod[]>({
|
||||||
|
queryKey: ["paymentMethods"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiClient.get<PaymentMethod[]>("/payments/methods");
|
||||||
|
return Array.isArray(res) ? res : [];
|
||||||
|
},
|
||||||
|
enabled: !!booking,
|
||||||
|
});
|
||||||
|
|
||||||
|
const payMutation = useMutation({
|
||||||
|
mutationFn: (method: string) =>
|
||||||
|
apiClient.post<any>("/payments/initiate", {
|
||||||
|
bookingId: booking.id,
|
||||||
|
method,
|
||||||
|
platform: "web",
|
||||||
|
}),
|
||||||
|
onSuccess: (data: any) => {
|
||||||
|
if (data?.clientAction?.type === "REDIRECT") {
|
||||||
|
window.location.href = data.clientAction.url;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push(`/reserve/pay/${token}/success`);
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
setPaymentError(
|
||||||
|
err?.response?.data?.message ?? err?.message ?? "Payment failed. Please try again."
|
||||||
|
);
|
||||||
|
setIsProcessing(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handlePay = () => {
|
||||||
|
if (!selectedMethod) return;
|
||||||
|
setIsProcessing(true);
|
||||||
|
setPaymentError(null);
|
||||||
|
payMutation.mutate(selectedMethod);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loadingBooking) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<Loader2 className="w-10 h-10 text-primary animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bookingError || !booking) {
|
||||||
|
const msg = (bookingError as any)?.response?.data?.message ?? "This payment link is invalid or has expired.";
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center px-4">
|
||||||
|
<div className="max-w-sm w-full text-center space-y-4">
|
||||||
|
<AlertCircle className="w-14 h-14 text-red-500 mx-auto" />
|
||||||
|
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">Link unavailable</h1>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">{msg}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currency = booking.displayCurrency ?? booking.currency ?? "ETB";
|
||||||
|
const amountMinor = booking.displayTotalMinor ?? booking.totalMinor;
|
||||||
|
const amountDisplay = (amountMinor / 100).toFixed(2);
|
||||||
|
const seat = booking.seats?.[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 flex items-start justify-center px-4 py-10">
|
||||||
|
<div className="w-full max-w-md space-y-4">
|
||||||
|
<div className="text-center space-y-1">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Complete your booking</h1>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Booking <span className="font-semibold text-gray-700 dark:text-gray-300">{booking.bookingRef}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card space-y-3">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400">Route</span>
|
||||||
|
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{booking.schedule?.origin?.name} → {booking.schedule?.destination?.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400">Departure</span>
|
||||||
|
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{booking.schedule?.departureAt ? new Date(booking.schedule.departureAt).toLocaleString() : "N/A"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{seat && (
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400">Seat</span>
|
||||||
|
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{seat.seatNumber} {seat.coach ? `(Coach ${seat.coach})` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="border-t border-gray-100 dark:border-gray-800 pt-3 flex justify-between items-center">
|
||||||
|
<span className="font-bold text-gray-900 dark:text-gray-100">Amount due</span>
|
||||||
|
<span className="text-2xl font-bold text-primary">{currency} {amountDisplay}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card space-y-3">
|
||||||
|
<h2 className="text-base font-bold text-gray-900 dark:text-gray-100">Select payment method</h2>
|
||||||
|
{loadingMethods ? (
|
||||||
|
<div className="flex items-center justify-center py-6 gap-2">
|
||||||
|
<Loader2 className="w-5 h-5 text-primary animate-spin" />
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400">Loading...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{paymentMethods.filter((m) => m.enabled).map((method) => {
|
||||||
|
const Icon = getIconForMethod(method.type);
|
||||||
|
const isSelected = selectedMethod === method.type;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={method.id}
|
||||||
|
onClick={() => setSelectedMethod(method.type)}
|
||||||
|
disabled={isProcessing}
|
||||||
|
className={`w-full p-4 rounded-xl border-2 transition-all text-left ${
|
||||||
|
isSelected
|
||||||
|
? "border-primary bg-primary/8 dark:bg-primary/15 shadow-md"
|
||||||
|
: "border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:border-primary/50"
|
||||||
|
} ${isProcessing ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${isSelected ? "bg-primary" : "bg-gray-100 dark:bg-gray-700"}`}>
|
||||||
|
<Icon className={`w-5 h-5 ${isSelected ? "text-white" : "text-primary"}`} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-semibold text-gray-900 dark:text-gray-100">{method.displayName}</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">{method.region} · {method.currency}</p>
|
||||||
|
</div>
|
||||||
|
{isSelected && <CheckCircle className="w-5 h-5 text-primary flex-shrink-0" />}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{paymentError && (
|
||||||
|
<p className="text-red-600 dark:text-red-400 text-sm text-center">⚠️ {paymentError}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handlePay}
|
||||||
|
disabled={!selectedMethod || isProcessing}
|
||||||
|
className="btn-primary w-full py-3 font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isProcessing ? (
|
||||||
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" /> Processing...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
`Pay ${currency} ${amountDisplay}`
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="text-xs text-gray-400 dark:text-gray-500 text-center">🔒 Secure & encrypted payment</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { CheckCircle } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function ReservationPaySuccessPage() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 flex items-center justify-center px-4">
|
||||||
|
<div className="max-w-sm w-full text-center space-y-4">
|
||||||
|
<CheckCircle className="w-16 h-16 text-green-500 mx-auto" />
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Payment successful</h1>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your booking is confirmed. Your ticket has been sent to you.
|
||||||
|
</p>
|
||||||
|
<Link href="/" className="btn-primary inline-block px-6 py-2.5 font-semibold">
|
||||||
|
Back to home
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -24,9 +24,9 @@ test("BC-11 ✅ travelMinutesToStop drives each stop's estimated arrival indepen
|
|||||||
name: "E2E Check-in Cutoff Route",
|
name: "E2E Check-in Cutoff Route",
|
||||||
effectiveFrom: "2020-01-01T00:00:00Z",
|
effectiveFrom: "2020-01-01T00:00:00Z",
|
||||||
stops: [
|
stops: [
|
||||||
{ stationId: STATIONS.A, sequence: 1 },
|
{ stationId: STATIONS.A, sequence: 1, distanceKm: 0 },
|
||||||
{ stationId: STATIONS.B, sequence: 2, travelMinutesToStop: 60 },
|
{ stationId: STATIONS.B, sequence: 2, travelMinutesToStop: 60, distanceKm: 60 },
|
||||||
{ stationId: STATIONS.C, sequence: 3, travelMinutesToStop: 40 },
|
{ stationId: STATIONS.C, sequence: 3, travelMinutesToStop: 40, distanceKm: 100 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -71,7 +71,15 @@ test("BC-10 ✅ a schedule with a past departure is rejected by the API (past-da
|
|||||||
const arr = new Date(dep.getTime() + 4 * 3600e3);
|
const arr = new Date(dep.getTime() + 4 * 3600e3);
|
||||||
const okRes = await request.post(`${API_URL}/schedules`, {
|
const okRes = await request.post(`${API_URL}/schedules`, {
|
||||||
headers: auth(),
|
headers: auth(),
|
||||||
data: { trainId: UI_IDS.train, routeId: ROUTE_ID, departureAt: dep.toISOString(), arrivalAt: arr.toISOString() },
|
// ROUTE_ID has no route coach template, so createSchedule needs an explicit coach or it 400s
|
||||||
|
// on the "zero coaches assigned" guard (see schedules.service.ts).
|
||||||
|
data: {
|
||||||
|
trainId: UI_IDS.train,
|
||||||
|
routeId: ROUTE_ID,
|
||||||
|
departureAt: dep.toISOString(),
|
||||||
|
arrivalAt: arr.toISOString(),
|
||||||
|
coachIds: [UI_IDS.coach],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
expect(okRes.ok()).toBeTruthy();
|
expect(okRes.ok()).toBeTruthy();
|
||||||
const sched = (await okRes.json())?.data ?? {};
|
const sched = (await okRes.json())?.data ?? {};
|
||||||
|
|||||||
Reference in New Issue
Block a user