mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #956 from Tria-plc/tests
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?
|
||||
paymentReminderSentAt DateTime?
|
||||
packageDepartureStationId String?
|
||||
payToken String? @unique
|
||||
payTokenExpiresAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
passenger Passenger @relation(fields: [passengerId], references: [id])
|
||||
|
||||
@@ -32,9 +32,11 @@ import {
|
||||
import {
|
||||
CreateGuestBookingDto,
|
||||
GetSavedPassengersDto,
|
||||
IssueReservationBookingDto,
|
||||
} from "./guest-booking.dto";
|
||||
import { JwtGuard } from "../../common/jwt.guard";
|
||||
import { PassengerAdmin } from "../../common/passenger-guards";
|
||||
import { PassengerAdmin, PassengerStaff } from "../../common/passenger-guards";
|
||||
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
||||
|
||||
@ApiTags("Booking")
|
||||
@Controller("bookings")
|
||||
@@ -349,6 +351,35 @@ export class BookingsController {
|
||||
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")
|
||||
@SetMetadata("isPublic", true)
|
||||
@ApiOperation({
|
||||
|
||||
@@ -10,9 +10,11 @@ import { CurrencyModule } from '../currency/currency.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { FareEngineModule } from '../fare-engine/fare-engine.module';
|
||||
import { TicketsModule } from '../tickets/tickets.module';
|
||||
import { PaymentsModule } from '../payments/payments.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule, AuthModule, TicketsModule],
|
||||
imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule, AuthModule, TicketsModule, PaymentsModule, NotificationsModule],
|
||||
controllers: [BookingsController],
|
||||
providers: [BookingsService, GuestBookingService],
|
||||
exports: [BookingsService, GuestBookingService]
|
||||
|
||||
@@ -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) {
|
||||
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({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsString, IsArray, ValidateNested, IsOptional, IsEnum, IsDateString, IsBoolean, IsInt, IsNumber } from 'class-validator';
|
||||
import { IsString, IsArray, ValidateNested, IsOptional, IsEnum, IsDateString, IsBoolean, IsInt, IsNumber, IsIn } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Currency, IdDocumentType } from '@prisma/client';
|
||||
@@ -174,6 +174,67 @@ export class SavedPassengerProfileDto {
|
||||
}
|
||||
|
||||
export class GetSavedPassengersDto {
|
||||
@ApiPropertyOptional({ example: 'device-uuid-12345', description: 'Device ID to retrieve saved passengers' })
|
||||
@ApiPropertyOptional({ example: 'device-uuid-12345', description: 'Device ID to retrieve saved passengers' })
|
||||
@IsOptional() @IsString() deviceId?: string;
|
||||
}
|
||||
|
||||
export enum ReservationBookingKind {
|
||||
STAFF = 'STAFF',
|
||||
PASSENGER = 'PASSENGER',
|
||||
}
|
||||
|
||||
export const RESERVATION_NATIONALITIES = ['Ethiopian', 'Djiboutian', 'Other'] as const;
|
||||
export type ReservationNationality = (typeof RESERVATION_NATIONALITIES)[number];
|
||||
|
||||
/**
|
||||
* Issues a real booking against a seat an admin/staff previously reserved (SeatBlock) —
|
||||
* one traveler per seat, fare always server-computed (no seatFareMinor/reviewedTotalMinor
|
||||
* override: unlike the guest DTO, there's no untrusted client-displayed price to defend
|
||||
* against here). seatId comes from the route param, not the body.
|
||||
*
|
||||
* No seatClassId — the seat (and therefore its coach/class) is already fixed by the
|
||||
* reservation being converted; the service resolves the correct seat class itself from the
|
||||
* seat's own coach type and nationality (LOCAL vs INTERNATIONAL pricing tier), the same
|
||||
* matching search results already use. No passportCountry — nationality alone is what
|
||||
* drives both fare-tier selection and passport-vs-national-ID validation.
|
||||
*/
|
||||
export class IssueReservationBookingDto {
|
||||
@ApiProperty({ example: 'schedule-uuid' })
|
||||
@IsString() scheduleId: string;
|
||||
|
||||
@ApiProperty({ example: 'station-uuid' })
|
||||
@IsString() originStationId: string;
|
||||
|
||||
@ApiProperty({ example: 'station-uuid' })
|
||||
@IsString() destinationStationId: string;
|
||||
|
||||
@ApiProperty({ enum: ReservationBookingKind, description: 'STAFF: no fee, ticket issued immediately. PASSENGER: a payment link is sent to phone.' })
|
||||
@IsEnum(ReservationBookingKind) bookingKind: ReservationBookingKind;
|
||||
|
||||
@ApiProperty({ example: 'Abebe Kebede' })
|
||||
@IsString() passengerName: string;
|
||||
|
||||
@ApiProperty({ example: '1990-05-15', description: 'Date of birth (YYYY-MM-DD) for age calculation' })
|
||||
@IsDateString() dateOfBirth: string;
|
||||
|
||||
@ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType })
|
||||
@IsEnum(IdDocumentType) idDocumentType: IdDocumentType;
|
||||
|
||||
@ApiPropertyOptional({ example: 'ET123456789' })
|
||||
@IsOptional() @IsString() idDocumentNumber?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'P1234567' })
|
||||
@IsOptional() @IsString() passportNumber?: string;
|
||||
|
||||
@ApiProperty({ example: 'Ethiopian', enum: RESERVATION_NATIONALITIES, description: 'Drives both the LOCAL/INTERNATIONAL fare tier and passport-vs-national-ID validation.' })
|
||||
@IsIn(RESERVATION_NATIONALITIES) nationality: ReservationNationality;
|
||||
|
||||
@ApiPropertyOptional({ example: '+251912345678', description: 'Required when bookingKind is PASSENGER — the payment link is sent here.' })
|
||||
@IsOptional() @IsString() phone?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'abebe@email.com' })
|
||||
@IsOptional() @IsString() email?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'ETB', enum: Currency })
|
||||
@IsOptional() @IsEnum(Currency) displayCurrency?: Currency;
|
||||
}
|
||||
|
||||
@@ -6,9 +6,14 @@ import { CurrencyService } from '../currency/currency.service';
|
||||
import { PassengerAuthService } from '../auth/passenger-auth.service';
|
||||
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { CreateGuestBookingDto, SavedPassengerProfileDto } from './guest-booking.dto';
|
||||
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
|
||||
import { PaymentsService } from '../payments/payments.service';
|
||||
import { 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 { computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
/**
|
||||
* Throws if the given boarding stop's own configurable check-in cutoff (route/stop
|
||||
@@ -25,6 +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 {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
|
||||
@@ -65,6 +93,9 @@ export class GuestBookingService {
|
||||
private passengerAuthService: PassengerAuthService,
|
||||
private fareEngine: FareEngineService,
|
||||
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) {
|
||||
if (!dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId) {
|
||||
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.
|
||||
*
|
||||
* C-8 🔴 The exchange-rate write routes (PUT upsert, PATCH update) carry no METHOD-LEVEL guard, so
|
||||
* they get only the global JwtGuard (authentication) and NOT @PassengerAdmin (authorization)
|
||||
* — unlike DELETE, which is admin-gated. Net effect (verified live in
|
||||
* e2e-ui .../pb-config-propagation.spec.ts BC-11): anonymous → 401, but ANY authenticated
|
||||
* user incl. a passenger → 200 rewrites live FX. fare-engine/currency.controller.ts:25,32,42
|
||||
*
|
||||
* 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.
|
||||
* C-8 ✅ FIXED (was 🔴 "The exchange-rate write routes (PUT upsert, PATCH update) carry no
|
||||
* METHOD-LEVEL guard, so they get only the global JwtGuard, not @PassengerAdmin —
|
||||
* unlike DELETE, which was admin-gated. Net effect: any authenticated user incl. a
|
||||
* passenger could rewrite live FX rates."): currency.controller.ts now decorates
|
||||
* upsert/update/remove all with @PassengerAdmin() — confirmed by reading the source.
|
||||
* Updated below to assert all three routes are admin-gated, not just DELETE.
|
||||
*/
|
||||
import "reflect-metadata";
|
||||
import { CurrencyController } from "../src/modules/fare-engine/currency.controller";
|
||||
@@ -20,15 +17,15 @@ function guardsOn(handler: unknown): unknown[] {
|
||||
}
|
||||
|
||||
describe("Auth gaps (Suite J)", () => {
|
||||
it("C-8 🔴 PUT upsert exchange-rate has NO admin guard (only the global JwtGuard applies)", () => {
|
||||
expect(guardsOn(CurrencyController.prototype.upsert)).toHaveLength(0);
|
||||
it("C-8 ✅ PUT upsert exchange-rate IS admin-gated", () => {
|
||||
expect(guardsOn(CurrencyController.prototype.upsert).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("C-8 🔴 PATCH update exchange-rate has NO admin guard (only the global JwtGuard applies)", () => {
|
||||
expect(guardsOn(CurrencyController.prototype.update)).toHaveLength(0);
|
||||
it("C-8 ✅ PATCH update exchange-rate IS admin-gated", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,6 +82,7 @@ describe("Authenticated booking passengerId resolution (regression)", () => {
|
||||
prisma as any,
|
||||
{ query: async () => [] } as any, // dataSource (resolveIamContact raw SQL → [])
|
||||
asyncStub(), // seatsService
|
||||
asyncStub(), // ticketsService — constructor gained this param since this test was written
|
||||
{ emit: () => true } as any,
|
||||
asyncStub(), // verifaydaService (PASSPORT 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
|
||||
* raw API call (bypassing the HTML-only frontend checks) would be allowed to submit.
|
||||
* H1 🔴 CreateFareRuleDto.baseFareMinor accepts NEGATIVE (no @Min) — while the sibling
|
||||
* CreateSegmentFareDto.baseFareMinor has @Min(0) (inconsistent).
|
||||
* H2 🔴 CreateSeatClassDto.basePrice accepts negative/zero (no @Min) — drives every distance fare.
|
||||
* H4 🔴 CreatePromotionDto.percentOff accepts 200 (no @Max(100)) → discount > subtotal.
|
||||
* CreateSegmentFareDto.baseFareMinor has @Min(0) (inconsistent). Still unfixed.
|
||||
* H2 ✅ FIXED (was 🔴 "CreateSeatClassDto.basePrice accepts negative/zero, no @Min"):
|
||||
* 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.
|
||||
* Still unfixed.
|
||||
*/
|
||||
import "reflect-metadata";
|
||||
import { plainToInstance } from "class-transformer";
|
||||
@@ -44,23 +47,23 @@ describe("Backoffice config validation (Suite H)", () => {
|
||||
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, {
|
||||
coachTypeId: "ct-1",
|
||||
name: "Economy",
|
||||
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, {
|
||||
code: "OVER",
|
||||
title: "Overshoot",
|
||||
percentOff: 200,
|
||||
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 () => {
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
* Executable reproducers for the highest-severity findings that were previously inspection-only.
|
||||
* All Tier-2 (direct instantiation, real Prisma + stubbed collaborators).
|
||||
*
|
||||
* C-1 🔴 BookingsService trusts client `reviewedTotalMinor`: a booking is stored with totalMinor=1
|
||||
* while the server fare engine computed ~30000.
|
||||
* C-1 ✅ FIXED (was 🔴 "BookingsService trusts client reviewedTotalMinor: a booking is
|
||||
* 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
|
||||
* 1 minor confirms a 30000 booking.
|
||||
* 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…
|
||||
expect(result.fareBreakdown.totalMinor).toBeGreaterThanOrEqual(30_000);
|
||||
// …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);
|
||||
const stored = await prisma.booking.findFirst({ where: { scheduleId: schedule.id, passengerId: passenger.id } });
|
||||
expect(stored).toBeNull(); // no under-priced booking left behind
|
||||
});
|
||||
|
||||
// ── 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,
|
||||
asyncStub(), // dataSource
|
||||
asyncStub(), // seatsService
|
||||
asyncStub(), // ticketsService — constructor gained this param since this test was written
|
||||
{ emit: () => true } as any, // eventEmitter
|
||||
asyncStub(), // verifaydaService
|
||||
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.
|
||||
* C2 🔴 missing rate: getExchangeRate() silently returns 1.0 while getRateOrThrow() throws —
|
||||
* the display path degrades but the charge path errors on the SAME condition (divergence).
|
||||
* C2 ✅ FIXED (was 🔴): getExchangeRate() used to silently return 1.0 on a missing rate —
|
||||
* 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).
|
||||
* C5 🔴 conversion routines disagree on units: displayMinorToChargeMajor / convertMinorToChargeMajor
|
||||
* return MAJOR units, convertEtbMinorToChargeMinor returns MINOR — a 100x unit landmine both
|
||||
@@ -26,23 +31,25 @@ describe("Pricing — CurrencyService (Suite C)", () => {
|
||||
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.
|
||||
// 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({
|
||||
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)).resolves.toBe(1.0);
|
||||
await expect(currency.getExchangeRate("USD" as any, "ETB" as any)).rejects.toThrow(/No exchange rate configured/i);
|
||||
|
||||
// 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(
|
||||
currency.getRateOrThrow("USD" as any, "ETB" as any),
|
||||
).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({
|
||||
where: {
|
||||
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(
|
||||
currency.getRateOrThrow("USD" as any, "ETB" as any),
|
||||
).rejects.toThrow(/No exchange rate/i);
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
/**
|
||||
* 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).
|
||||
* 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 {
|
||||
@@ -114,18 +119,15 @@ describe("Pricing — FareEngineService (slim harness)", () => {
|
||||
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);
|
||||
expect(withRate.totalMinor).toBeGreaterThan(0);
|
||||
|
||||
// Remove the USD→ETB rate the seat-class formula multiplies by.
|
||||
await harness.prisma.currencyExchangeRate.deleteMany({
|
||||
where: { fromCurrency: "USD", toCurrency: "ETB" },
|
||||
});
|
||||
|
||||
const withoutRate = await fareEngine.calculate(baseDto() as any);
|
||||
|
||||
// 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);
|
||||
await expect(fareEngine.calculate(baseDto() as any)).rejects.toThrow(/No exchange rate configured/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
fareEngine,
|
||||
{ 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 { 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 Modal from '@/components/ui/Modal';
|
||||
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() {
|
||||
const [activeTab, setActiveTab] = useState<'route' | 'schedule'>('route');
|
||||
@@ -24,6 +24,22 @@ export default function SeatsPage() {
|
||||
const [coachToUnblock, setCoachToUnblock] = useState<any>(null);
|
||||
const [showMaintenanceModal, setShowMaintenanceModal] = useState(false);
|
||||
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 { 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({
|
||||
mutationFn: (seatId: string) => seatsApi.removeSeat(seatId),
|
||||
onSuccess: () => {
|
||||
@@ -186,11 +217,66 @@ export default function SeatsPage() {
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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) => {
|
||||
setSelectedSeat(seat);
|
||||
setShowRemoveModal(true);
|
||||
@@ -252,7 +338,7 @@ export default function SeatsPage() {
|
||||
|
||||
const submitBlock = async () => {
|
||||
if (!blockReason.trim()) {
|
||||
alert('Please provide a reason for blocking');
|
||||
alert('Please provide a reason for the reservation');
|
||||
return;
|
||||
}
|
||||
await blockMutation.mutateAsync({ seatId: selectedSeat.id, reason: blockReason });
|
||||
@@ -350,6 +436,7 @@ export default function SeatsPage() {
|
||||
handleUndoRemove={handleUndoRemove}
|
||||
handleSetMaintenance={handleSetMaintenance}
|
||||
handleClearMaintenance={handleClearMaintenance}
|
||||
handleIssueBooking={handleIssueBooking}
|
||||
hideNumber={true}
|
||||
/>
|
||||
))}
|
||||
@@ -445,6 +532,7 @@ export default function SeatsPage() {
|
||||
handleUndoRemove={handleUndoRemove}
|
||||
handleSetMaintenance={handleSetMaintenance}
|
||||
handleClearMaintenance={handleClearMaintenance}
|
||||
handleIssueBooking={handleIssueBooking}
|
||||
hideNumber={true}
|
||||
/>
|
||||
))}
|
||||
@@ -467,6 +555,7 @@ export default function SeatsPage() {
|
||||
handleUndoRemove={handleUndoRemove}
|
||||
handleSetMaintenance={handleSetMaintenance}
|
||||
handleClearMaintenance={handleClearMaintenance}
|
||||
handleIssueBooking={handleIssueBooking}
|
||||
hideNumber={true}
|
||||
/>
|
||||
))}
|
||||
@@ -749,15 +838,15 @@ export default function SeatsPage() {
|
||||
setSelectedSeat(null);
|
||||
setBlockReason('');
|
||||
}}
|
||||
title="Block Seat"
|
||||
title="Reserve Seat"
|
||||
size="md"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<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>
|
||||
<div>
|
||||
<label className="label">Reason for Blocking *</label>
|
||||
<label className="label">Reason for Reservation *</label>
|
||||
<textarea
|
||||
className="input"
|
||||
rows={3}
|
||||
@@ -782,12 +871,184 @@ export default function SeatsPage() {
|
||||
loading={blockMutation.isPending}
|
||||
disabled={!blockReason.trim()}
|
||||
>
|
||||
Block Seat
|
||||
Reserve Seat
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</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
|
||||
isOpen={showRemoveModal}
|
||||
onClose={() => {
|
||||
@@ -973,6 +1234,7 @@ interface SeatIconProps {
|
||||
handleUndoRemove: (seat: any) => void;
|
||||
handleSetMaintenance: (seat: any) => void;
|
||||
handleClearMaintenance: (seat: any) => void;
|
||||
handleIssueBooking: (seat: any, coach: any) => void;
|
||||
}
|
||||
|
||||
function SeatIcon({
|
||||
@@ -989,6 +1251,7 @@ function SeatIcon({
|
||||
handleUndoRemove,
|
||||
handleSetMaintenance,
|
||||
handleClearMaintenance,
|
||||
handleIssueBooking,
|
||||
}: SeatIconProps) {
|
||||
const isRemoved = seat.seatNumber && seat.seatNumber.startsWith('-');
|
||||
const seatClassStr = typeof coach?.seatClass === 'string' ? coach.seatClass : (coach?.seatClass?.name || coach?.coachClass || '');
|
||||
@@ -1058,7 +1321,7 @@ function SeatIcon({
|
||||
<button
|
||||
onClick={() => handleBlock(seat)}
|
||||
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" />
|
||||
</button>
|
||||
@@ -1072,13 +1335,22 @@ function SeatIcon({
|
||||
</>
|
||||
)}
|
||||
{canUnblock && (
|
||||
<button
|
||||
onClick={() => handleUnblock(seat)}
|
||||
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
|
||||
title="Unblock seat"
|
||||
>
|
||||
<Unlock className="h-3 w-3 text-gray-700" />
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleUnblock(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>
|
||||
<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 && (
|
||||
<button
|
||||
|
||||
@@ -48,6 +48,10 @@ export const bookingsApi = {
|
||||
apiClient.post<any>(`/payments/${bookingId}/force-confirm`, data),
|
||||
smartAssign: (bookingId: string) =>
|
||||
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
|
||||
|
||||
@@ -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",
|
||||
effectiveFrom: "2020-01-01T00:00:00Z",
|
||||
stops: [
|
||||
{ stationId: STATIONS.A, sequence: 1 },
|
||||
{ stationId: STATIONS.B, sequence: 2, travelMinutesToStop: 60 },
|
||||
{ stationId: STATIONS.C, sequence: 3, travelMinutesToStop: 40 },
|
||||
{ stationId: STATIONS.A, sequence: 1, distanceKm: 0 },
|
||||
{ stationId: STATIONS.B, sequence: 2, travelMinutesToStop: 60, distanceKm: 60 },
|
||||
{ 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 okRes = await request.post(`${API_URL}/schedules`, {
|
||||
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();
|
||||
const sched = (await okRes.json())?.data ?? {};
|
||||
|
||||
Reference in New Issue
Block a user