mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +00:00
@@ -11,4 +11,18 @@ export const PassengerStaff = (permission: string | string[]) =>
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Like {@link PassengerStaff} but the permission must be explicitly granted —
|
||||
* super admins / org admins get no automatic bypass.
|
||||
*/
|
||||
export const PassengerStaffStrict = (permission: string | string[]) =>
|
||||
applyDecorators(
|
||||
UseGuards(
|
||||
JwtGuard,
|
||||
PassengerPermissionGuard(Array.isArray(permission) ? permission : [permission], {
|
||||
strict: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
export const PassengerAdmin = () => PassengerStaff(PASSENGER_PERMS.admin);
|
||||
|
||||
@@ -6,9 +6,25 @@ import {
|
||||
Type,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { hasPassengerPermission } from './passenger-permission.util';
|
||||
import {
|
||||
hasPassengerPermission,
|
||||
hasPassengerPermissionStrict,
|
||||
} from './passenger-permission.util';
|
||||
|
||||
export type PassengerPermissionGuardOptions = {
|
||||
/**
|
||||
* When true, super admins and org admins do NOT bypass the check — the
|
||||
* permission key must be explicitly granted to them like anyone else.
|
||||
*/
|
||||
strict?: boolean;
|
||||
};
|
||||
|
||||
export function PassengerPermissionGuard(
|
||||
permissions: string[],
|
||||
options: PassengerPermissionGuardOptions = {},
|
||||
): Type<CanActivate> {
|
||||
const check = options.strict ? hasPassengerPermissionStrict : hasPassengerPermission;
|
||||
|
||||
export function PassengerPermissionGuard(permissions: string[]): Type<CanActivate> {
|
||||
@Injectable()
|
||||
class PassengerPermissionsGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
@@ -18,10 +34,11 @@ export function PassengerPermissionGuard(permissions: string[]): Type<CanActivat
|
||||
if (!permissions?.length) return true;
|
||||
if (!user) throw new UnauthorizedException('Authentication required');
|
||||
|
||||
if (permissions.some((p) => hasPassengerPermission(user, p))) return true;
|
||||
if (permissions.some((p) => check(user, p))) return true;
|
||||
|
||||
throw new ForbiddenException(
|
||||
`Missing permission. Required one of: ${permissions.join(', ')}`,
|
||||
`Missing permission. Required one of: ${permissions.join(', ')}` +
|
||||
(options.strict ? ' (granted explicitly — admin role does not bypass)' : ''),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,20 @@ export function hasPassengerPermission(
|
||||
return collectPermissionKeys(user).includes(permissionKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Same check as {@link hasPassengerPermission} but WITHOUT the super-admin /
|
||||
* org-admin bypass — the permission key must be explicitly granted, whether via
|
||||
* a role or an employee position. Use for actions that must stay auditable to a
|
||||
* deliberate grant (e.g. ticket generation, which can waive a fare).
|
||||
*/
|
||||
export function hasPassengerPermissionStrict(
|
||||
user: MeLikeUser | null | undefined,
|
||||
permissionKey: string,
|
||||
): boolean {
|
||||
if (!user) return false;
|
||||
return collectPermissionKeys(user).includes(permissionKey);
|
||||
}
|
||||
|
||||
export function assertPassengerPermission(
|
||||
user: MeLikeUser | null | undefined,
|
||||
permissionKey: string,
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
/**
|
||||
* Resolves the booking/check-in cutoff for one boarding stop.
|
||||
* Resolves the booking/check-in cutoff for one boarding stop: stop-level
|
||||
* `RouteStop.checkinMinutesBefore` override wins, else the route-level default
|
||||
* (`Route.checkinMinutesBefore`), else a bare 30-minute fallback for routes/stops with
|
||||
* neither configured. The basis is the stop's own estimated ARRIVAL time (the train reaching
|
||||
* that stop), not its departure or the schedule's overall origin departure — a downstream
|
||||
* stop's cutoff must be independent of how long ago the train left its origin. The first stop
|
||||
* of a route has no arrival (nothing to arrive at), so it falls back to its own departure.
|
||||
*
|
||||
* Priority for checkinMinutes: RouteStop.checkinMinutesBefore → Route.checkinMinutesBefore → 30.
|
||||
*
|
||||
* Anchor (segmentTime): plannedDepartureAt ?? plannedArrivalAt ?? schedule.departureAt.
|
||||
* - For the origin stop: plannedDepartureAt = schedule.departureAt (no arrival).
|
||||
* - For intermediate stops: plannedDepartureAt = plannedArrivalAt + dwell (checkinMinutesBefore).
|
||||
* cutoffAt = departureAt − checkinMinutesBefore = arrivalAt, so booking closes the
|
||||
* moment the train reaches the stop — independent of how long ago it left the origin.
|
||||
*
|
||||
* Single source of truth — SeatsService.holdSeats and SearchService.buildScheduleResult both
|
||||
* apply it; GuestBookingService.createGuestBooking also applies it per boarding stop.
|
||||
* Single source of truth for this computation — SeatsService.holdSeats and
|
||||
* SearchService.buildScheduleResult already applied it (search results only ever showed a
|
||||
* segment as bookable if this same cutoff hadn't passed); GuestBookingService.createGuestBooking
|
||||
* used to independently hardcode a flat, non-configurable 30 minutes off the schedule's origin
|
||||
* departure, which could reject a booking the search/hold steps had just accepted under the
|
||||
* route's actual configured cutoff.
|
||||
*/
|
||||
export interface CheckinCutoff {
|
||||
/** The stop's planned departure time (or arrival / schedule departure as fallback). */
|
||||
/** The stop's own estimated arrival time (or departure, for the first stop / missing data). */
|
||||
segmentTime: Date;
|
||||
/** Minutes before segmentTime that booking/holding closes. */
|
||||
checkinMinutes: number;
|
||||
@@ -32,7 +34,7 @@ export function resolveCheckinCutoff(
|
||||
stopTime: { plannedArrivalAt?: Date | null; plannedDepartureAt?: Date | null } | null | undefined,
|
||||
stationId: string | null | undefined,
|
||||
): CheckinCutoff {
|
||||
const segmentTime = stopTime?.plannedDepartureAt ?? stopTime?.plannedArrivalAt ?? schedule.departureAt;
|
||||
const segmentTime = stopTime?.plannedArrivalAt ?? stopTime?.plannedDepartureAt ?? schedule.departureAt;
|
||||
const routeStop = stationId ? schedule.route?.stops?.find((s) => s.stationId === stationId) : undefined;
|
||||
const checkinMinutes = routeStop?.checkinMinutesBefore ?? schedule.route?.checkinMinutesBefore ?? 30;
|
||||
return {
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
IssueReservationBookingDto,
|
||||
} from "./guest-booking.dto";
|
||||
import { JwtGuard } from "../../common/jwt.guard";
|
||||
import { PassengerAdmin, PassengerStaff } from "../../common/passenger-guards";
|
||||
import { PassengerAdmin, PassengerStaff, PassengerStaffStrict } from "../../common/passenger-guards";
|
||||
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
||||
|
||||
@ApiTags("Booking")
|
||||
@@ -352,12 +352,12 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Post("reservations/:seatId/issue")
|
||||
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.bookings.manage, PASSENGER_PERMS.admin])
|
||||
@PassengerStaffStrict(PASSENGER_PERMS.tickets.generate)
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({
|
||||
summary: "Issue a booking from a reserved (blocked) seat",
|
||||
summary: "Issue a booking from a reserved (blocked) seat — requires tickets:generate",
|
||||
description:
|
||||
"Converts an admin-reserved seat into a real booking for one traveler. bookingKind STAFF waives the fee and issues the ticket immediately; bookingKind PASSENGER creates the booking as PENDING_PAYMENT and texts a payment link to the traveler's phone.",
|
||||
"Converts an admin-reserved seat into a real booking for one traveler. bookingKind STAFF waives the fee and issues the ticket immediately; bookingKind PASSENGER creates the booking as PENDING_PAYMENT and texts a payment link to the traveler's phone. Because STAFF issuance waives the fare, this requires edr_passenger_app:tickets:generate to be explicitly granted — super admins and org admins do NOT bypass it.",
|
||||
})
|
||||
@ApiBody({ type: IssueReservationBookingDto })
|
||||
issueBookingFromReservation(
|
||||
@@ -369,6 +369,24 @@ export class BookingsController {
|
||||
return this.guestService.issueBookingFromReservation(seatId, dto, actingUserId);
|
||||
}
|
||||
|
||||
@Delete("reservations/:seatId")
|
||||
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.bookings.manage, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({
|
||||
summary: "Cancel a seat's pending-payment reservation and release the seat",
|
||||
description:
|
||||
"For a seat with an active PASSENGER-kind reservation (payment link sent, not yet paid): cancels that booking and releases the seat's hold, so it's genuinely free for someone else. The old payment link stops working immediately (the booking is no longer PENDING_PAYMENT).",
|
||||
})
|
||||
@ApiQuery({ name: "scheduleId", required: true, description: "TrainSchedule UUID the reservation was issued on" })
|
||||
cancelReservationForSeat(
|
||||
@Param("seatId") seatId: string,
|
||||
@Query("scheduleId") scheduleId: string,
|
||||
@Req() req: any,
|
||||
) {
|
||||
const actingUserId = req.user?.id ?? req.user?.sub ?? null;
|
||||
return this.service.cancelReservationForSeat(seatId, scheduleId, actingUserId);
|
||||
}
|
||||
|
||||
@Get("pay/:token")
|
||||
@SetMetadata("isPublic", true)
|
||||
@ApiOperation({
|
||||
|
||||
@@ -2138,6 +2138,39 @@ export class BookingsService {
|
||||
return { cancelled: true, refundAmount: refundAmount / 100, currency: booking.displayCurrency};
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff releasing a seat that already has an in-flight backoffice reservation must not
|
||||
* leave that booking dangling as PENDING_PAYMENT with a still-payable link — the traveler
|
||||
* could pay for a seat that's since been given away. Finds the active reservation covering
|
||||
* this exact seat+schedule and cancels it via the normal cancel() path (refund=0, since it's
|
||||
* still unpaid), then separately releases the SeatHold issueBookingFromReservation created —
|
||||
* cancel()'s releaseSeats() only deletes Journey/JourneySegment rows, which don't exist yet
|
||||
* for an unpaid reservation, so without this the seat would stay held until the hold's own
|
||||
* expiry. Once status flips to CANCELLED, getByPayToken's existing status check already
|
||||
* rejects the old payToken with "This booking is no longer awaiting payment" — no separate
|
||||
* payToken invalidation needed.
|
||||
*/
|
||||
async cancelReservationForSeat(seatId: string, scheduleId: string, actingUserId: string | null) {
|
||||
const bookingSeat = await this.prisma.bookingSeat.findFirst({
|
||||
where: {
|
||||
seatId,
|
||||
scheduleId,
|
||||
booking: { source: 'BACKOFFICE_RESERVATION', status: 'PENDING_PAYMENT' },
|
||||
},
|
||||
include: { booking: true },
|
||||
});
|
||||
if (!bookingSeat) throw new NotFoundException('No pending reservation found for this seat');
|
||||
|
||||
const { bookingRef } = bookingSeat.booking;
|
||||
const result = await this.cancel(bookingRef, 'Seat released by staff before payment', actingUserId ?? undefined);
|
||||
|
||||
await this.prisma.seatHold.deleteMany({
|
||||
where: { scheduleId, seatIds: { hasSome: [seatId] } },
|
||||
});
|
||||
|
||||
return { ...result, bookingRef };
|
||||
}
|
||||
|
||||
async update(id: string, dto: any) {
|
||||
const booking = await this.prisma.booking.findUnique({ where: { id } });
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
|
||||
@@ -294,8 +294,12 @@ export class GuestBookingService {
|
||||
|
||||
let displayTotalMinor: number;
|
||||
let resolvedTotalMinor: number;
|
||||
// True when the total came from a client-summed subtotal (per-seat sum or reviewedTotalMinor),
|
||||
// which the portal computes UNDISCOUNTED — the promo must still be applied to it (H-13).
|
||||
let usedClientSubtotal = false;
|
||||
if (allFaresProvided && !isPackageOneway) {
|
||||
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0);
|
||||
usedClientSubtotal = true;
|
||||
} else if (isPackageOneway && dto.reviewedTotalMinor != null) {
|
||||
displayTotalMinor = dto.reviewedTotalMinor;
|
||||
if (seatedPassengers.length > 0) {
|
||||
@@ -306,6 +310,7 @@ export class GuestBookingService {
|
||||
}
|
||||
} else if (dto.reviewedTotalMinor != null && dto.reviewedTotalMinor > 0) {
|
||||
displayTotalMinor = dto.reviewedTotalMinor;
|
||||
usedClientSubtotal = true;
|
||||
} else {
|
||||
// fare engine returns ETB — convert forward to display currency
|
||||
const etbTotal = Math.max(0, totalBaseFareMinor - discountMinor);
|
||||
@@ -313,6 +318,18 @@ export class GuestBookingService {
|
||||
? await this.currencyService.convertAmount(etbTotal, Currency.ETB, displayCurrency)
|
||||
: etbTotal;
|
||||
}
|
||||
|
||||
// H-13: the portal sums UNDISCOUNTED per-passenger fares into a client subtotal, silently
|
||||
// dropping the promo the fare engine recognized. Apply the authoritative discount now so the
|
||||
// customer is charged the discounted price. The non-client-subtotal branch above already
|
||||
// nets the discount out of etbTotal, so it's excluded here to avoid double-subtracting.
|
||||
if (usedClientSubtotal && discountMinor > 0) {
|
||||
const discountDisplayMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(discountMinor, Currency.ETB, displayCurrency)
|
||||
: discountMinor;
|
||||
displayTotalMinor = Math.max(0, displayTotalMinor - discountDisplayMinor);
|
||||
}
|
||||
|
||||
resolvedTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
|
||||
: displayTotalMinor;
|
||||
@@ -834,17 +851,16 @@ export class GuestBookingService {
|
||||
const allRTFaresProvided = rtSeatedPassengers.length > 0 &&
|
||||
rtSeatedPassengers.every(p => p.seatFareMinor != null && p.returnSeatFareMinor != null);
|
||||
|
||||
// True when the total came from a client-summed subtotal (per-seat sum or reviewedTotalMinor),
|
||||
// which the portal computes UNDISCOUNTED — the promo must still be applied to it (H-13).
|
||||
let usedClientSubtotal = false;
|
||||
|
||||
if (allRTFaresProvided && !isPackageRoundTrip) {
|
||||
// Server-computed sum is authoritative — prevents race-condition under-count.
|
||||
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
|
||||
totalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
|
||||
: displayTotalMinor;
|
||||
usedClientSubtotal = true;
|
||||
} else if (isPackageRoundTrip && dto.reviewedTotalMinor != null) {
|
||||
displayTotalMinor = dto.reviewedTotalMinor;
|
||||
totalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
|
||||
: displayTotalMinor;
|
||||
const seatedCount = passengersData.filter(p => p.seatId).length;
|
||||
if (seatedCount > 0) {
|
||||
const perSeatPerLeg = Math.round(dto.reviewedTotalMinor / (seatedCount * 2));
|
||||
@@ -855,11 +871,24 @@ export class GuestBookingService {
|
||||
}
|
||||
} else if (dto.reviewedTotalMinor != null && dto.reviewedTotalMinor > 0) {
|
||||
displayTotalMinor = dto.reviewedTotalMinor;
|
||||
totalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
|
||||
: displayTotalMinor;
|
||||
usedClientSubtotal = true;
|
||||
}
|
||||
|
||||
// H-13: the portal sums UNDISCOUNTED per-passenger fares into a client subtotal, silently
|
||||
// dropping the promo the fare engine recognized. Apply the authoritative discount now so the
|
||||
// customer is charged the discounted price. The no-override case above already starts from a
|
||||
// discounted displayTotalMinor, so it's excluded here to avoid double-subtracting.
|
||||
if (usedClientSubtotal && discountMinor > 0) {
|
||||
const discountDisplayMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(discountMinor, Currency.ETB, displayCurrency)
|
||||
: discountMinor;
|
||||
displayTotalMinor = Math.max(0, displayTotalMinor - discountDisplayMinor);
|
||||
}
|
||||
|
||||
totalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
|
||||
: displayTotalMinor;
|
||||
|
||||
// C-1 guard: never charge less than the server-recomputed authoritative round-trip fare.
|
||||
this.assertTotalNotUnderAuthoritative(totalMinor, authoritativeTotalMinor, 'createGuestRoundTripBooking');
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@ import { Module } from '@nestjs/common';
|
||||
import { LiveController } from './live.controller';
|
||||
import { LiveService } from './live.service';
|
||||
|
||||
@Module({ controllers: [LiveController], providers: [LiveService] })
|
||||
@Module({ controllers: [LiveController], providers: [LiveService], exports: [LiveService] })
|
||||
export class LiveModule {}
|
||||
|
||||
@@ -312,6 +312,18 @@ export class NotificationsService {
|
||||
const ref = booking?.bookingRef ?? payload.booking.bookingRef;
|
||||
const passengerId = booking?.passengerId ?? payload.booking.passengerId;
|
||||
|
||||
// A backoffice-issued reservation already sends its own purpose-built message —
|
||||
// GuestBookingService.issueBookingFromReservation texts /reserve/pay/<payToken> for a
|
||||
// PASSENGER-kind booking (the traveler has no portal session, so this generic template's
|
||||
// /booking/detail?ref= link doesn't work), and for STAFF kind the booking is finalized
|
||||
// immediately after this event fires, so onPaymentSucceeded's "ticket ready" message is
|
||||
// the correct one to send, not a redundant/contradictory "awaiting payment" notice.
|
||||
const source = (booking as any)?.source ?? payload.booking?.source;
|
||||
if (source === 'BACKOFFICE_RESERVATION') {
|
||||
this.logger.log(`Skipping generic booking.created notification for ${ref} — reservation flow sends its own`);
|
||||
return;
|
||||
}
|
||||
|
||||
const template = await this.prisma.notificationTemplate.findUnique({
|
||||
where: { code: 'booking.created' },
|
||||
});
|
||||
|
||||
@@ -415,8 +415,18 @@ export class PaymentsController {
|
||||
paySupplementaryCharge(
|
||||
@Param('token') token: string,
|
||||
@Body() dto: PaySupplementaryChargeDto,
|
||||
@Headers('origin') origin?: string,
|
||||
@Headers('referer') referer?: string,
|
||||
@Headers('x-frontend-base-url') frontendBaseUrl?: string,
|
||||
) {
|
||||
return this.supplementaryService.pay(token, dto.method, dto.platform);
|
||||
// Same domain-follows-the-user rule as /initiate — the self-pay page can be
|
||||
// opened on either portal domain.
|
||||
return this.supplementaryService.pay(
|
||||
token,
|
||||
dto.method,
|
||||
dto.platform,
|
||||
resolveAllowedOrigin(origin, referer, frontendBaseUrl),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('supplementary/:id/mark-paid')
|
||||
|
||||
@@ -48,12 +48,14 @@ const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [
|
||||
];
|
||||
|
||||
// Methods whose return/failure URLs are browser-facing pages on the passenger
|
||||
// portal, so they should follow whichever domain the user came in on. DMONEY is
|
||||
// deliberately excluded — its return URL is a server-to-server webhook host, not
|
||||
// a page the browser lands on.
|
||||
// portal, so they should follow whichever domain the user came in on. For DMONEY
|
||||
// this is the preOrder `redirect_url` (the page the browser lands on after
|
||||
// checkout) — NOT `notify_url`, which is the server-to-server webhook and is
|
||||
// configured provider-side, never rebased.
|
||||
const DOMAIN_AWARE_METHODS = new Set<PaymentMethodType>([
|
||||
PaymentMethodType.TELEBIRR,
|
||||
PaymentMethodType.WAAFI,
|
||||
PaymentMethodType.DMONEY,
|
||||
]);
|
||||
|
||||
@Injectable()
|
||||
@@ -279,22 +281,35 @@ export class PaymentsService {
|
||||
return this.formatIntentResponse(intent);
|
||||
}
|
||||
|
||||
// A web↔mobile switch needs a different clientAction (Telebirr: REDIRECT for web
|
||||
// vs LAUNCH_APP for the native app). Detect the open session's platform from its
|
||||
// clientAction shape so a platform change is NOT blocked below: it must flow
|
||||
// through to re-initiate, where the payment service retires the stale session and
|
||||
// opens a fresh one with the correct launch method for the requested platform.
|
||||
const requestedMobile = (dto.platform ?? "web") === "mobile";
|
||||
const storedAction = (snapshot?.clientAction ??
|
||||
existingIntent.clientAction) as unknown as ClientAction | null;
|
||||
const platformChanged =
|
||||
(storedAction?.type === "LAUNCH_APP") !== requestedMobile;
|
||||
|
||||
// Still pending at the provider (REQUIRES_ACTION/PROCESSING) — or the payment
|
||||
// service was unreachable and the local status is non-terminal. Block the switch:
|
||||
// return the existing intent so the payer completes or waits out the open attempt
|
||||
// rather than opening a second concurrent charge.
|
||||
// rather than opening a second concurrent charge. A platform switch is exempt — it
|
||||
// falls through so a session with the correct clientAction is opened for it.
|
||||
if (
|
||||
!snapshot ||
|
||||
snapshot.status === ProviderPaymentStatus.REQUIRES_ACTION ||
|
||||
snapshot.status === ProviderPaymentStatus.PROCESSING
|
||||
!platformChanged &&
|
||||
(!snapshot ||
|
||||
snapshot.status === ProviderPaymentStatus.REQUIRES_ACTION ||
|
||||
snapshot.status === ProviderPaymentStatus.PROCESSING)
|
||||
) {
|
||||
const intent = snapshot
|
||||
? await this.syncIntentProjection(booking.id, snapshot)
|
||||
: existingIntent;
|
||||
return this.formatIntentResponse(intent);
|
||||
}
|
||||
// Otherwise the provider reports FAILED/CANCELLED — fall through and initiate
|
||||
// the newly selected method below.
|
||||
// Otherwise the provider reports FAILED/CANCELLED, or the payer switched platform —
|
||||
// fall through and initiate the newly selected method below.
|
||||
}
|
||||
|
||||
const { returnUrl, failureUrl } = this.resolveReturnUrls(
|
||||
|
||||
@@ -116,11 +116,21 @@ export class SupplementaryChargesService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
async pay(token: string, method: string, platform?: 'web' | 'mobile') {
|
||||
async pay(
|
||||
token: string,
|
||||
method: string,
|
||||
platform?: 'web' | 'mobile',
|
||||
requestOrigin?: string | null,
|
||||
) {
|
||||
const charge = await this.getByToken(token); // validates status/expiry
|
||||
|
||||
const paymentMethod = method as ProviderMethod;
|
||||
const portalUrl = process.env.PORTAL_URL ?? 'http://localhost:5174';
|
||||
// Self-pay links are opened on whichever portal domain the recipient used
|
||||
// (bookingedr.et vs passenger.edrsc.com), so the return pages must live on
|
||||
// that same domain. `requestOrigin` is already allowlist-validated by the
|
||||
// controller; PORTAL_URL is the fallback for non-browser callers.
|
||||
const portalUrl =
|
||||
requestOrigin ?? process.env.PORTAL_URL ?? 'http://localhost:5174';
|
||||
const returnUrl = `${portalUrl}/pay-balance/${token}/success`;
|
||||
const failureUrl = `${portalUrl}/pay-balance/${token}/failed`;
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, ParseInt
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||
import { SchedulesService } from './schedules.service';
|
||||
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto, TripStatus } from './schedules.dto';
|
||||
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto, TripStatus, ApplyDelayDto } from './schedules.dto';
|
||||
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
|
||||
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||
|
||||
@@ -177,6 +177,22 @@ export class SchedulesController {
|
||||
@Body() dto: UpdateStopTimeDto,
|
||||
) { return this.service.updateStop(id, sequence, dto); }
|
||||
|
||||
@Post(':id/delay')
|
||||
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Report a delay — pushes every downstream stop\'s planned times (and check-in cutoffs) back by the same amount',
|
||||
description: `Shifts plannedArrivalAt/plannedDepartureAt on every stop not yet BOARDED/COMPLETED (or from fromSequence
|
||||
onward, if given) by delayMinutes. Since check-in cutoffs are derived directly from these planned
|
||||
times, this is the only action needed for booking closure to reflect the delay — no separate cutoff
|
||||
update. Also shifts the schedule's own departureAt/arrivalAt when the origin stop is included, and
|
||||
records the accumulated delay on the schedule's live status. Does not change schedule/stop status.`,
|
||||
})
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Schedule with shifted stop times' })
|
||||
applyDelay(@Param('id') id: string, @Body() dto: ApplyDelayDto) {
|
||||
return this.service.applyDelay(id, dto);
|
||||
}
|
||||
|
||||
@Put(':scheduleId/fares/:seatClassId')
|
||||
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({
|
||||
|
||||
@@ -113,6 +113,14 @@ export class UpdateScheduleStatusDto {
|
||||
@ApiProperty({ enum: TripStatus, example: TripStatus.EN_ROUTE }) @IsEnum(TripStatus) status: TripStatus;
|
||||
}
|
||||
|
||||
export class ApplyDelayDto {
|
||||
@ApiProperty({ example: 60, description: 'Minutes to shift downstream stop times by. Negative to correct an over-reported delay.' })
|
||||
@IsInt() delayMinutes: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 3, description: 'Only shift stops from this sequence onward. Omit to default to every stop not yet BOARDED/COMPLETED.' })
|
||||
@IsOptional() @IsInt() @Min(1) fromSequence?: number;
|
||||
}
|
||||
|
||||
export class BulkCreateSchedulesDto {
|
||||
@ApiProperty({ example: 'train-uuid', description: 'Train UUID' }) @IsString() trainId: string;
|
||||
@ApiProperty({ example: 'route-uuid', description: 'Route UUID' }) @IsString() routeId: string;
|
||||
|
||||
@@ -5,9 +5,10 @@ import { RoutesController } from './routes.controller';
|
||||
import { RoutesService } from './routes.service';
|
||||
import { FareEngineModule } from '../fare-engine/fare-engine.module';
|
||||
import { AuditModule } from '../../common/audit.module';
|
||||
import { LiveModule } from '../live/live.module';
|
||||
|
||||
@Module({
|
||||
imports: [FareEngineModule, AuditModule],
|
||||
imports: [FareEngineModule, AuditModule, LiveModule],
|
||||
controllers: [RoutesController, SchedulesController],
|
||||
providers: [RoutesService, SchedulesService],
|
||||
exports: [RoutesService, SchedulesService],
|
||||
|
||||
@@ -2,10 +2,11 @@ import { Injectable, Logger, NotFoundException, BadRequestException } from '@nes
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { RoutesService } from './routes.service';
|
||||
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
||||
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto } from './schedules.dto';
|
||||
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, ApplyDelayDto } from './schedules.dto';
|
||||
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
|
||||
import { parseEthiopianTime, startOfDayEAT, startOfNextDayEAT } from '../../common/utils/timezone.utils';
|
||||
import { AuditService } from '../../common/audit.service';
|
||||
import { LiveService } from '../live/live.service';
|
||||
import { computePlannedStopTimes } from '../../common/utils/schedule-times.utils';
|
||||
|
||||
@Injectable()
|
||||
@@ -17,8 +18,47 @@ export class SchedulesService {
|
||||
private routesService: RoutesService,
|
||||
private fareEngine: FareEngineService,
|
||||
private auditService: AuditService,
|
||||
private liveService: LiveService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Computes each stop's planned arrival/departure time by walking the route in sequence
|
||||
* order and accumulating `RouteStop.travelMinutesToStop` (minutes of travel from the
|
||||
* previous stop). Falls back to distance-proportional interpolation over `distanceKm` for
|
||||
* any stop missing `travelMinutesToStop`. The last stop is always locked to the confirmed
|
||||
* overall `arr` regardless of the accumulated cursor, so schedule.arrivalAt stays
|
||||
* authoritative even if per-stop estimates drift.
|
||||
*/
|
||||
private computePlannedTimes(
|
||||
route: { id: string; stops: { sequence: number; distanceKm: number | null; travelMinutesToStop: number | null }[] },
|
||||
dep: Date,
|
||||
arr: Date,
|
||||
) {
|
||||
const totalDuration = arr.getTime() - dep.getTime();
|
||||
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
|
||||
|
||||
let cursor = dep;
|
||||
return route.stops.map((stop, index) => {
|
||||
if (index === 0) {
|
||||
cursor = dep;
|
||||
} else if (index === route.stops.length - 1) {
|
||||
cursor = arr;
|
||||
} else if (stop.travelMinutesToStop != null) {
|
||||
cursor = new Date(cursor.getTime() + stop.travelMinutesToStop * 60_000);
|
||||
} else {
|
||||
const stopDistance = stop.distanceKm || 0;
|
||||
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
|
||||
cursor = new Date(dep.getTime() + totalDuration * progress);
|
||||
this.logger.warn(`Route ${route.id} stop seq ${stop.sequence} missing travelMinutesToStop; falling back to distance interpolation`);
|
||||
}
|
||||
return {
|
||||
sequence: stop.sequence,
|
||||
plannedArrivalAt: index === 0 ? undefined : cursor.toISOString(),
|
||||
plannedDepartureAt: index === route.stops.length - 1 ? undefined : cursor.toISOString(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async bulkGenerateSchedules(dto: BulkCreateSchedulesDto) {
|
||||
const startDate = parseEthiopianTime(dto.startDateTime);
|
||||
const endDate = new Date(startDate.getTime() + dto.forNextDays * 24 * 60 * 60 * 1000);
|
||||
@@ -89,6 +129,7 @@ export class SchedulesService {
|
||||
include: { coach: true },
|
||||
orderBy: { positionNumber: 'asc' },
|
||||
},
|
||||
liveStatus: { select: { delayMinutes: true } },
|
||||
_count: { select: { coachAssignments: true, bookings: true } },
|
||||
},
|
||||
orderBy: { departureAt: 'asc' },
|
||||
@@ -132,7 +173,7 @@ export class SchedulesService {
|
||||
|
||||
let plannedTimes = dto.plannedTimes;
|
||||
if (!plannedTimes || plannedTimes.length === 0) {
|
||||
plannedTimes = computePlannedStopTimes(route, dep, arr);
|
||||
plannedTimes = this.computePlannedTimes(route, dep, arr);
|
||||
}
|
||||
|
||||
const providedSeqs = new Set((plannedTimes ?? []).map(t => t.sequence));
|
||||
@@ -209,6 +250,7 @@ export class SchedulesService {
|
||||
orderBy: { positionNumber: 'asc' },
|
||||
},
|
||||
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
|
||||
liveStatus: { select: { delayMinutes: true } },
|
||||
},
|
||||
});
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
@@ -303,7 +345,7 @@ export class SchedulesService {
|
||||
|
||||
let plannedTimes = dto.plannedTimes;
|
||||
if (!plannedTimes || plannedTimes.length === 0) {
|
||||
plannedTimes = computePlannedStopTimes(route, dep, arr);
|
||||
plannedTimes = this.computePlannedTimes(route, dep, arr);
|
||||
}
|
||||
|
||||
const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
|
||||
@@ -425,6 +467,70 @@ export class SchedulesService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shifts stored planned times additively rather than reusing updateSchedulePartial's
|
||||
* recompute-from-route-interpolation path — that path also guards `departureAt must be in the
|
||||
* future`, which a delay report for an already-departed/EN_ROUTE train would legitimately
|
||||
* fail. Check-in cutoffs (resolveCheckinCutoff, SeatsService.holdSeats) are both derived
|
||||
* directly from TripStopTime.plannedArrivalAt/plannedDepartureAt at read time, so shifting the
|
||||
* stored values here is the entire fix — neither of those needs to change.
|
||||
*/
|
||||
async applyDelay(scheduleId: string, dto: ApplyDelayDto) {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId } });
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
|
||||
const stopWhere: any = { scheduleId };
|
||||
if (dto.fromSequence != null) {
|
||||
stopWhere.sequence = { gte: dto.fromSequence };
|
||||
} else {
|
||||
// Default: only stops the train hasn't reached yet — a delay report must not retroactively
|
||||
// move a stop that's already BOARDED/COMPLETED.
|
||||
stopWhere.status = { notIn: ['BOARDED', 'COMPLETED'] };
|
||||
}
|
||||
|
||||
const stopsToShift = await this.prisma.tripStopTime.findMany({ where: stopWhere });
|
||||
const shiftMs = dto.delayMinutes * 60_000;
|
||||
const includesOrigin = stopsToShift.some((s) => s.sequence === 1);
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
for (const stop of stopsToShift) {
|
||||
await tx.tripStopTime.update({
|
||||
where: { id: stop.id },
|
||||
data: {
|
||||
plannedArrivalAt: stop.plannedArrivalAt ? new Date(stop.plannedArrivalAt.getTime() + shiftMs) : undefined,
|
||||
plannedDepartureAt: stop.plannedDepartureAt ? new Date(stop.plannedDepartureAt.getTime() + shiftMs) : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Origin stop shifted → the schedule's own departureAt/arrivalAt drive search's day-window
|
||||
// queries and the displayed departure time, so they must move too (both together, so
|
||||
// durationMinutes stays correct).
|
||||
if (includesOrigin) {
|
||||
await tx.trainSchedule.update({
|
||||
where: { id: scheduleId },
|
||||
data: {
|
||||
departureAt: new Date(schedule.departureAt.getTime() + shiftMs),
|
||||
arrivalAt: new Date(schedule.arrivalAt.getTime() + shiftMs),
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const currentLive = await this.prisma.tripLiveStatus.findUnique({ where: { scheduleId } });
|
||||
const accumulatedDelayMinutes = Math.max(0, (currentLive?.delayMinutes ?? 0) + dto.delayMinutes);
|
||||
await this.liveService.updateLiveStatus(scheduleId, { delayMinutes: accumulatedDelayMinutes });
|
||||
|
||||
await this.auditService.log({
|
||||
action: 'UPDATE',
|
||||
entityType: 'Schedule',
|
||||
entityId: scheduleId,
|
||||
newData: { delayMinutes: dto.delayMinutes, fromSequence: dto.fromSequence, accumulatedDelayMinutes },
|
||||
});
|
||||
|
||||
return this.getSchedule(scheduleId);
|
||||
}
|
||||
|
||||
async upsertScheduleFare(
|
||||
scheduleId: string,
|
||||
seatClassId: string,
|
||||
@@ -681,7 +787,7 @@ export class SchedulesService {
|
||||
include: { stops: { orderBy: { sequence: 'asc' } } },
|
||||
});
|
||||
if (route && route.stops.length >= 2) {
|
||||
const plannedTimes = computePlannedStopTimes(route, dep, arr);
|
||||
const plannedTimes = this.computePlannedTimes(route, dep, arr);
|
||||
const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
|
||||
await this.routesService.applyRouteToSchedule(schedule.routeId, id, plannedTimesMap);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Body, Controller, Post, Get, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger';
|
||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||
import { SearchService } from './search.service';
|
||||
import { SearchTripsDto, FareQuoteDto, FareBreakdownRequestDto } from './search.dto';
|
||||
import { SearchTripsDto, FareQuoteDto, FareBreakdownRequestDto, AvailableDatesQueryDto } from './search.dto';
|
||||
|
||||
@ApiTags('Search')
|
||||
@Controller('search')
|
||||
@@ -67,6 +67,21 @@ Nationality-Based:
|
||||
return this.service.getFareQuote(dto);
|
||||
}
|
||||
|
||||
@Get('available-dates')
|
||||
@ApiOperation({
|
||||
summary: 'Which dates in a range have a bookable schedule for an origin/destination pair',
|
||||
description: `Used to disable schedule-less dates on the search date picker before the user submits a search.
|
||||
|
||||
For each date in the (server-clamped, max 90-day) range, a date is "available" if at least one
|
||||
schedule exists for the origin→destination pair whose status/package/coach state is bookable and
|
||||
whose check-in cutoff has not yet passed. This does not check seat-level availability — a date
|
||||
can be marked available and still turn out fully booked when actually searched.`,
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'routeExists flag plus a per-date availability list' })
|
||||
getAvailableDates(@Query() dto: AvailableDatesQueryDto) {
|
||||
return this.service.getAvailableDates(dto);
|
||||
}
|
||||
|
||||
@Get('fare-breakdown')
|
||||
@ApiOperation({
|
||||
summary: 'Per-passenger fare breakdown for booking review page',
|
||||
|
||||
@@ -29,6 +29,20 @@ export class SearchTripsDto {
|
||||
@IsOptional() @IsDateString() returnDate?: string;
|
||||
}
|
||||
|
||||
export class AvailableDatesQueryDto {
|
||||
@ApiProperty({ example: 'station-uuid', description: 'Origin station UUID' })
|
||||
@IsString() originStationId: string;
|
||||
|
||||
@ApiProperty({ example: 'station-uuid', description: 'Destination station UUID' })
|
||||
@IsString() destinationStationId: string;
|
||||
|
||||
@ApiProperty({ example: '2026-06-15', description: 'Start of the date range (YYYY-MM-DD)' })
|
||||
@IsDateString() from: string;
|
||||
|
||||
@ApiProperty({ example: '2026-09-13', description: 'End of the date range (YYYY-MM-DD), inclusive — server clamps to a max 90-day span' })
|
||||
@IsDateString() to: string;
|
||||
}
|
||||
|
||||
export class FareQuoteDto {
|
||||
@ApiProperty({ example: 'schedule-uuid', description: 'TrainSchedule UUID from search results' })
|
||||
@IsString() scheduleId: string;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
FareQuoteDto,
|
||||
FareBreakdownRequestDto,
|
||||
FareBreakdownPassengerDto,
|
||||
AvailableDatesQueryDto,
|
||||
} from "./search.dto";
|
||||
import { CurrencyService } from "../currency/currency.service";
|
||||
import { FareEngineService } from "../fare-engine/fare-engine.service";
|
||||
@@ -223,13 +224,10 @@ export class SearchService {
|
||||
childCount?: number,
|
||||
nationality?: string,
|
||||
) {
|
||||
const [y, m, d] = dateStr.split("-").map(Number);
|
||||
const requestedDate = new Date(
|
||||
`${String(y)}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}T00:00:00+03:00`,
|
||||
);
|
||||
const requestedNextDay = new Date(
|
||||
`${String(y)}-${String(m).padStart(2, "0")}-${String(d + 1).padStart(2, "0")}T00:00:00+03:00`,
|
||||
);
|
||||
// Real millisecond arithmetic, not string-padded day-of-month increment — the latter
|
||||
// produces an invalid date (e.g. "2026-07-32") for any search on the last day of a month.
|
||||
const requestedDate = new Date(`${dateStr}T00:00:00+03:00`);
|
||||
const requestedNextDay = new Date(requestedDate.getTime() + 24 * 60 * 60 * 1000);
|
||||
const now = new Date();
|
||||
const totalPassengers = adultCount + (childCount ?? 0);
|
||||
const NEEDED = 3;
|
||||
@@ -313,13 +311,10 @@ export class SearchService {
|
||||
childCount?: number,
|
||||
nationality?: string,
|
||||
) {
|
||||
const [y, m, d] = dateStr.split("-").map(Number);
|
||||
const date = new Date(
|
||||
`${String(y)}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}T00:00:00+03:00`,
|
||||
);
|
||||
const nextDay = new Date(
|
||||
`${String(y)}-${String(m).padStart(2, "0")}-${String(d + 1).padStart(2, "0")}T00:00:00+03:00`,
|
||||
);
|
||||
// Real millisecond arithmetic, not string-padded day-of-month increment — the latter
|
||||
// produces an invalid date (e.g. "2026-07-32") for any search on the last day of a month.
|
||||
const date = new Date(`${dateStr}T00:00:00+03:00`);
|
||||
const nextDay = new Date(date.getTime() + 24 * 60 * 60 * 1000);
|
||||
const totalPassengers = adultCount + (childCount ?? 0);
|
||||
|
||||
// Match on the schedule's own departure DATE only — do NOT use `now` as a lower bound here.
|
||||
@@ -383,26 +378,16 @@ export class SearchService {
|
||||
|
||||
// 1. Does any active route connect these two stations, in this direction, at all —
|
||||
// ignoring date entirely?
|
||||
const candidateRoutes = await this.prisma.route.findMany({
|
||||
where: { active: true, stops: { some: { stationId: originStationId } } },
|
||||
select: { stops: { select: { stationId: true, sequence: true } } },
|
||||
});
|
||||
const routeExists = candidateRoutes.some((r) => {
|
||||
const o = r.stops.find((s) => s.stationId === originStationId);
|
||||
const d = r.stops.find((s) => s.stationId === destinationStationId);
|
||||
return !!o && !!d && o.sequence < d.sequence;
|
||||
});
|
||||
if (!routeExists) return withCode(Passenger.SearchEmptyReasonCode.NoRoute);
|
||||
if (!(await this.routeExistsForPair(originStationId, destinationStationId))) {
|
||||
return withCode(Passenger.SearchEmptyReasonCode.NoRoute);
|
||||
}
|
||||
|
||||
// 2. A route exists — is there any schedule at all on the requested date for this pair
|
||||
// (regardless of status/package/coach/cutoff — those are checked next)?
|
||||
const [y, m, d] = dateStr.split("-").map(Number);
|
||||
const date = new Date(
|
||||
`${String(y)}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}T00:00:00+03:00`,
|
||||
);
|
||||
const nextDay = new Date(
|
||||
`${String(y)}-${String(m).padStart(2, "0")}-${String(d + 1).padStart(2, "0")}T00:00:00+03:00`,
|
||||
);
|
||||
// Real millisecond arithmetic, not string-padded day-of-month increment — the latter
|
||||
// produces an invalid date (e.g. "2026-07-32") for any search on the last day of a month.
|
||||
const date = new Date(`${dateStr}T00:00:00+03:00`);
|
||||
const nextDay = new Date(date.getTime() + 24 * 60 * 60 * 1000);
|
||||
const dayCandidates = await this.prisma.trainSchedule.findMany({
|
||||
where: { departureAt: { gte: date, lt: nextDay }, stopTimes: { some: { stationId: originStationId } } },
|
||||
select: {
|
||||
@@ -425,12 +410,7 @@ export class SearchService {
|
||||
|
||||
// 3. Schedules exist that date — narrow to ones that would otherwise be bookable
|
||||
// (right status, not package-only, has at least one coach assigned).
|
||||
const bookable = sameDayForPair.filter(
|
||||
(s) =>
|
||||
(["SCHEDULED", "BOARDING", "EN_ROUTE"] as string[]).includes(s.status) &&
|
||||
!s.isPackageOnly &&
|
||||
s.coachAssignments.length > 0,
|
||||
);
|
||||
const bookable = sameDayForPair.filter((s) => this.isBookableSchedule(s));
|
||||
if (bookable.length === 0) {
|
||||
if (sameDayForPair.every((s) => s.status === "CANCELLED"))
|
||||
return withCode(Passenger.SearchEmptyReasonCode.Cancelled);
|
||||
@@ -452,6 +432,110 @@ export class SearchService {
|
||||
return withCode(Passenger.SearchEmptyReasonCode.FullyBooked);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any active route connects originStationId → destinationStationId in this
|
||||
* direction, ignoring date/schedule state entirely. Shared by classifyEmptySearch and
|
||||
* getAvailableDates.
|
||||
*/
|
||||
private async routeExistsForPair(originStationId: string, destinationStationId: string): Promise<boolean> {
|
||||
const candidateRoutes = await this.prisma.route.findMany({
|
||||
where: { active: true, stops: { some: { stationId: originStationId } } },
|
||||
select: { stops: { select: { stationId: true, sequence: true } } },
|
||||
});
|
||||
return candidateRoutes.some((r) => {
|
||||
const o = r.stops.find((s) => s.stationId === originStationId);
|
||||
const d = r.stops.find((s) => s.stationId === destinationStationId);
|
||||
return !!o && !!d && o.sequence < d.sequence;
|
||||
});
|
||||
}
|
||||
|
||||
/** Status/package/coach bookability only — ignores date, cutoff, and seat-level availability. */
|
||||
private isBookableSchedule(s: { status: string; isPackageOnly: boolean; coachAssignments: { id: string }[] }): boolean {
|
||||
return (
|
||||
(["SCHEDULED", "BOARDING", "EN_ROUTE"] as string[]).includes(s.status) &&
|
||||
!s.isPackageOnly &&
|
||||
s.coachAssignments.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
private readonly MAX_AVAILABLE_DATES_SPAN_DAYS = 90;
|
||||
private readonly ADDIS_OFFSET_MS = 3 * 60 * 60 * 1000;
|
||||
private readonly ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Converts an absolute instant to its calendar date string in Africa/Addis_Ababa (fixed UTC+3, no DST). */
|
||||
private toAddisDateStr(d: Date): string {
|
||||
return new Date(d.getTime() + this.ADDIS_OFFSET_MS).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* For each date in the (server-clamped) range, whether at least one bookable schedule exists
|
||||
* for originStationId → destinationStationId — used to disable schedule-less dates on the
|
||||
* search date picker before the user submits a search. Reuses the same route-existence and
|
||||
* bookability checks as classifyEmptySearch, plus the same check-in cutoff resolution used
|
||||
* throughout this service, but does not compute seat-level availability (see buildScheduleResult)
|
||||
* — a date can be marked available and still turn out fully booked when actually searched.
|
||||
*/
|
||||
async getAvailableDates(dto: AvailableDatesQueryDto) {
|
||||
const { originStationId, destinationStationId } = dto;
|
||||
|
||||
const todayStr = this.toAddisDateStr(new Date());
|
||||
const from = dto.from > todayStr ? dto.from : todayStr;
|
||||
const fromDate = new Date(`${from}T00:00:00+03:00`);
|
||||
|
||||
const maxToDate = new Date(fromDate.getTime() + this.MAX_AVAILABLE_DATES_SPAN_DAYS * this.ONE_DAY_MS);
|
||||
const requestedToDate = new Date(`${dto.to}T00:00:00+03:00`);
|
||||
const toDate = requestedToDate < maxToDate ? requestedToDate : maxToDate;
|
||||
const to = this.toAddisDateStr(toDate);
|
||||
|
||||
if (!(await this.routeExistsForPair(originStationId, destinationStationId))) {
|
||||
return {
|
||||
originStationId,
|
||||
destinationStationId,
|
||||
from,
|
||||
to,
|
||||
routeExists: false,
|
||||
dates: [] as { date: string; available: boolean }[],
|
||||
};
|
||||
}
|
||||
|
||||
const rangeEnd = new Date(toDate.getTime() + this.ONE_DAY_MS);
|
||||
const schedules = await this.prisma.trainSchedule.findMany({
|
||||
where: {
|
||||
departureAt: { gte: fromDate, lt: rangeEnd },
|
||||
stopTimes: { some: { stationId: originStationId } },
|
||||
},
|
||||
select: {
|
||||
departureAt: true,
|
||||
status: true,
|
||||
isPackageOnly: true,
|
||||
route: {
|
||||
select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } },
|
||||
},
|
||||
stopTimes: { select: { stationId: true, sequence: true, plannedArrivalAt: true, plannedDepartureAt: true } },
|
||||
coachAssignments: { select: { id: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const now = Date.now();
|
||||
const availableDays = new Set<string>();
|
||||
for (const s of schedules) {
|
||||
const originStop = s.stopTimes.find((st) => st.stationId === originStationId);
|
||||
const destinationStop = s.stopTimes.find((st) => st.stationId === destinationStationId);
|
||||
if (!originStop || !destinationStop || originStop.sequence >= destinationStop.sequence) continue;
|
||||
if (!this.isBookableSchedule(s)) continue;
|
||||
if (now >= resolveCheckinCutoff(s, originStop, originStationId).cutoffAt.getTime()) continue;
|
||||
availableDays.add(this.toAddisDateStr(s.departureAt));
|
||||
}
|
||||
|
||||
const dates: { date: string; available: boolean }[] = [];
|
||||
for (let cursor = fromDate; cursor <= toDate; cursor = new Date(cursor.getTime() + this.ONE_DAY_MS)) {
|
||||
const dateStr = this.toAddisDateStr(cursor);
|
||||
dates.push({ date: dateStr, available: availableDays.has(dateStr) });
|
||||
}
|
||||
|
||||
return { originStationId, destinationStationId, from, to, routeExists: true, dates };
|
||||
}
|
||||
|
||||
// ── Transit search ─────────────────────────────────────────────────────────
|
||||
private readonly MIN_CONNECTION_MINUTES = 30;
|
||||
private readonly MAX_CONNECTION_MINUTES = 360;
|
||||
@@ -464,13 +548,10 @@ export class SearchService {
|
||||
childCount?: number,
|
||||
nationality?: string,
|
||||
) {
|
||||
const [y, m, d] = dateStr.split("-").map(Number);
|
||||
const dayStart = new Date(
|
||||
`${String(y)}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}T00:00:00+03:00`,
|
||||
);
|
||||
const dayEnd = new Date(
|
||||
`${String(y)}-${String(m).padStart(2, "0")}-${String(d + 1).padStart(2, "0")}T00:00:00+03:00`,
|
||||
);
|
||||
// Real millisecond arithmetic, not string-padded day-of-month increment — the latter
|
||||
// produces an invalid date (e.g. "2026-07-32") for any search on the last day of a month.
|
||||
const dayStart = new Date(`${dateStr}T00:00:00+03:00`);
|
||||
const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000);
|
||||
const leg2WindowEnd = new Date(
|
||||
dayEnd.getTime() + this.MAX_CONNECTION_MINUTES * 60_000,
|
||||
);
|
||||
|
||||
@@ -45,13 +45,16 @@ export class SeatsService {
|
||||
});
|
||||
|
||||
const allSeatIds = assignments.flatMap((a: any) => a.coach.seats.map((s: any) => s.id));
|
||||
const effectiveStatuses = await this.resolveEffectiveStatuses(
|
||||
scheduleId,
|
||||
allSeatIds,
|
||||
originStationId ?? schedule.originStationId,
|
||||
destinationStationId ?? schedule.destinationStationId,
|
||||
journeyDirection
|
||||
);
|
||||
const [effectiveStatuses, reservations] = await Promise.all([
|
||||
this.resolveEffectiveStatuses(
|
||||
scheduleId,
|
||||
allSeatIds,
|
||||
originStationId ?? schedule.originStationId,
|
||||
destinationStationId ?? schedule.destinationStationId,
|
||||
journeyDirection
|
||||
),
|
||||
this.resolveActiveReservations(allSeatIds, scheduleId),
|
||||
]);
|
||||
|
||||
return {
|
||||
coaches: assignments.map((a) => {
|
||||
@@ -70,6 +73,7 @@ export class SeatsService {
|
||||
? this.resolveBedPosition(s.col, s.bedPosition)
|
||||
: s.bedPosition;
|
||||
const effectiveStatus = effectiveStatuses.get(s.id) ?? (s.status === 'BLOCKED' || s.status === 'BOOKED' ? s.status : 'AVAILABLE');
|
||||
const reservation = reservations.get(s.id);
|
||||
return {
|
||||
id: s.id,
|
||||
seatNumber: s.seatNumber,
|
||||
@@ -88,6 +92,15 @@ export class SeatsService {
|
||||
position: this.colToPosition(s.col, a.coach.arrangement),
|
||||
bed_type: this.bedPositionToType(resolvedBedPosition),
|
||||
} : {}),
|
||||
// Backoffice-issued reservation covering this seat, if any — lets staff see who's
|
||||
// paying/ticketed for a HELD (awaiting payment) or BLOCKED (ticketed) seat without
|
||||
// leaving the seat map. See resolveActiveReservations.
|
||||
...(reservation ? {
|
||||
bookingRef: reservation.bookingRef,
|
||||
reservationStatus: reservation.status,
|
||||
reservationPassengerName: reservation.passengerName,
|
||||
reservationContactPhone: reservation.contactPhone,
|
||||
} : {}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -259,6 +272,46 @@ export class SeatsService {
|
||||
return statusMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-resolves the backoffice-issued reservation (if any) covering each of these seats on
|
||||
* this schedule — a booking created via GuestBookingService.issueBookingFromReservation
|
||||
* (`source: 'BACKOFFICE_RESERVATION'`), still PENDING_PAYMENT (payment link sent, not yet
|
||||
* paid) or already CONFIRMED (ticketed). Used to surface the booking reference on the
|
||||
* backoffice seat map so staff can see who's paying/ticketed for a given seat without
|
||||
* looking it up separately.
|
||||
*/
|
||||
private async resolveActiveReservations(
|
||||
seatIds: string[],
|
||||
scheduleId: string,
|
||||
): Promise<Map<string, { bookingRef: string; status: string; passengerName: string | null; contactPhone: string | null }>> {
|
||||
const map = new Map<string, { bookingRef: string; status: string; passengerName: string | null; contactPhone: string | null }>();
|
||||
if (seatIds.length === 0) return map;
|
||||
|
||||
const bookingSeats = await this.prisma.bookingSeat.findMany({
|
||||
where: {
|
||||
seatId: { in: seatIds },
|
||||
scheduleId,
|
||||
booking: { source: 'BACKOFFICE_RESERVATION', status: { in: ['PENDING_PAYMENT', 'CONFIRMED'] } },
|
||||
},
|
||||
select: {
|
||||
seatId: true,
|
||||
passengerName: true,
|
||||
booking: { select: { bookingRef: true, status: true, contactPhone: true } },
|
||||
},
|
||||
});
|
||||
|
||||
for (const bs of bookingSeats) {
|
||||
if (!bs.seatId) continue;
|
||||
map.set(bs.seatId, {
|
||||
bookingRef: bs.booking.bookingRef,
|
||||
status: bs.booking.status,
|
||||
passengerName: bs.passengerName,
|
||||
contactPhone: bs.booking.contactPhone,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async holdSeats(dto: HoldSeatsDto) {
|
||||
const passengerIds = dto.passengers.map(p => p.passengerId);
|
||||
const seatIds = dto.passengers.map(p => p.seatId);
|
||||
@@ -295,10 +348,9 @@ export class SeatsService {
|
||||
|
||||
// Stop-level override wins; falls back to route-level; then to 30 min.
|
||||
const checkinMinutes = originRouteStop?.checkinMinutesBefore ?? schedule.route?.checkinMinutesBefore ?? 30;
|
||||
// Departure basis: plannedDepartureAt = arrival + dwell. For the origin there is no
|
||||
// arrival so plannedDepartureAt = schedule.departureAt. cutoffAt = departure - dwell = arrival,
|
||||
// so holding closes the moment the train reaches the boarding stop.
|
||||
const segmentDepartureAt = originStopTime?.plannedDepartureAt ?? originStopTime?.plannedArrivalAt ?? schedule.departureAt;
|
||||
// Arrival basis: the origin stop's own estimated arrival, not its departure. The first
|
||||
// stop of a route has no arrival (nothing to arrive at), so it falls back to its departure.
|
||||
const segmentDepartureAt = originStopTime?.plannedArrivalAt ?? originStopTime?.plannedDepartureAt ?? schedule.departureAt;
|
||||
const msUntilDeparture = segmentDepartureAt.getTime() - Date.now();
|
||||
if (msUntilDeparture <= checkinMinutes * 60 * 1000) {
|
||||
throw new BadRequestException(
|
||||
|
||||
@@ -26,6 +26,7 @@ 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 { SegmentsService } from "../src/modules/segments/segments.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";
|
||||
@@ -34,6 +35,7 @@ import { SystemConfigService } from "../src/modules/system-config/system-config.
|
||||
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 { NotificationsService } from "../src/modules/notifications/notifications.service";
|
||||
import { createServiceHarness, ServiceHarness } from "./setup/slim-app";
|
||||
import { IDS, resetAndSeedCore } from "./fixtures/seed-core";
|
||||
|
||||
@@ -48,7 +50,9 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => {
|
||||
let seatsService: SeatsService;
|
||||
let guestBookingService: GuestBookingService;
|
||||
let bookingsService: BookingsService;
|
||||
let notificationsService: NotificationsService;
|
||||
let smsClient: { sendSms: jest.Mock };
|
||||
let emailClient: { sendEmail: jest.Mock };
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createServiceHarness();
|
||||
@@ -57,7 +61,11 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => {
|
||||
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());
|
||||
// Real SegmentsService (not asyncStub) — the getSeatMap test below exercises
|
||||
// resolveEffectiveStatuses, which calls segmentsService.getSeatAvailabilityMap and needs
|
||||
// an actual Map back, not asyncStub's `async () => undefined`.
|
||||
const segmentsService = new SegmentsService(harness.prisma as any);
|
||||
seatsService = new SeatsService(harness.prisma as any, segmentsService, systemConfig, asyncStub(), asyncStub());
|
||||
const ticketsService = new TicketsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub());
|
||||
const paymentsService = new PaymentsService(
|
||||
harness.prisma as any,
|
||||
@@ -85,12 +93,26 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => {
|
||||
harness.prisma as any,
|
||||
asyncStub(), // dataSource
|
||||
seatsService,
|
||||
{ emit: () => true } as any,
|
||||
ticketsService,
|
||||
{ emit: () => true } as any, // eventEmitter
|
||||
asyncStub(), // verifaydaService
|
||||
currencyService,
|
||||
fareEngine,
|
||||
asyncStub(), // auditService
|
||||
);
|
||||
|
||||
emailClient = { sendEmail: jest.fn().mockResolvedValue({ queued: true }) };
|
||||
// Same smsClient instance guestBookingService uses — lets the notification-suppression
|
||||
// test assert on ONE shared call count across both services, proving the reservation
|
||||
// flow's own SMS is the only message sent for a BACKOFFICE_RESERVATION booking.
|
||||
notificationsService = new NotificationsService(
|
||||
harness.prisma as any,
|
||||
asyncStub(), // dataSource (TypeORM) — only reached for non-UUID recipients / IAM lookups,
|
||||
// never hit by these guest-passenger-id-keyed test bookings
|
||||
emailClient as any,
|
||||
smsClient as any,
|
||||
asyncStub(), // pushAdapter
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -99,6 +121,7 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
smsClient.sendSms.mockClear();
|
||||
emailClient.sendEmail.mockClear();
|
||||
});
|
||||
|
||||
/** Creates a fresh Train + TrainSchedule on the seed-core route, coach assigned at creation. */
|
||||
@@ -294,6 +317,53 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => {
|
||||
expect(byToken.schedule.origin.id).toBe(IDS.stationA);
|
||||
});
|
||||
|
||||
it("NotificationsService.onBookingCreated skips its own message for a BACKOFFICE_RESERVATION booking (issueBookingFromReservation already sent one), but still fires for a normal booking", async () => {
|
||||
// Regression for: the customer got TWO conflicting messages for one reservation —
|
||||
// the reservation-specific /reserve/pay/<payToken> SMS from issueBookingFromReservation,
|
||||
// AND a second, generic booking.created notification pointing at /booking/detail?ref=,
|
||||
// a page that doesn't work for a traveler with no portal session.
|
||||
await resetAndSeedCore(harness.prisma);
|
||||
await harness.prisma.notificationTemplate.upsert({
|
||||
where: { code: "booking.created" },
|
||||
update: { active: true },
|
||||
create: { code: "booking.created", channel: "SMS,EMAIL", bodyTemplate: "Booking {{bookingRef}} created. Pay: {{payLink}}", active: true },
|
||||
});
|
||||
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-NOTIFY-${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-5",
|
||||
);
|
||||
expect(smsClient.sendSms).toHaveBeenCalledTimes(1); // the reservation flow's own SMS
|
||||
|
||||
// Directly invoke the event handler (the test harness's eventEmitter is a stub, so the
|
||||
// real 'booking.created' emit from issueBookingFromReservation never reaches it) — this
|
||||
// is what NotificationsService would have done had it received that event.
|
||||
await notificationsService.onBookingCreated({ booking: { id: result.booking.id, bookingRef: result.booking.bookingRef } });
|
||||
expect(smsClient.sendSms).toHaveBeenCalledTimes(1); // still 1 — onBookingCreated no-oped
|
||||
expect(emailClient.sendEmail).not.toHaveBeenCalled();
|
||||
|
||||
// Control: a normal (non-reservation) booking must still get the generic notification.
|
||||
const passenger = await harness.prisma.passenger.create({ data: {} });
|
||||
const normalBooking = await harness.prisma.booking.create({
|
||||
data: {
|
||||
bookingRef: `WEB-CTRL-${Date.now()}`,
|
||||
passengerId: passenger.id,
|
||||
scheduleId: schedule.id,
|
||||
status: "PENDING_PAYMENT",
|
||||
totalMinor: 10_000,
|
||||
contactPhone: "+251911234567",
|
||||
source: "WEB",
|
||||
},
|
||||
});
|
||||
await notificationsService.onBookingCreated({ booking: { id: normalBooking.id, bookingRef: normalBooking.bookingRef } });
|
||||
expect(smsClient.sendSms).toHaveBeenCalledTimes(2); // suppression didn't leak to non-reservation bookings
|
||||
});
|
||||
|
||||
it("PASSENGER path: the seat stays reserved (not publicly available) after the payment link is sent", async () => {
|
||||
// Regression for: unblockSeat() released the reservation's SeatBlock and confirmSeats()
|
||||
// was a no-op with no SeatHold to extend, so the seat had no SeatBlock, no SeatHold, and
|
||||
@@ -325,6 +395,87 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => {
|
||||
).rejects.toThrow(/already (held|booked)/i);
|
||||
});
|
||||
|
||||
it("cancelReservationForSeat: cancels the pending booking, frees the seat, and kills the old 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-CANCEL-${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-7",
|
||||
);
|
||||
const payToken = result.booking.payToken;
|
||||
|
||||
const cancelResult: any = await bookingsService.cancelReservationForSeat(seats[0].id, schedule.id, "staff-user-7");
|
||||
expect(cancelResult.cancelled).toBe(true);
|
||||
expect(cancelResult.bookingRef).toBe(result.booking.bookingRef);
|
||||
|
||||
const cancelledBooking = await harness.prisma.booking.findUnique({ where: { id: result.booking.id } });
|
||||
expect(cancelledBooking?.status).toBe("CANCELLED");
|
||||
|
||||
// The seat is genuinely free — a member of the public can now hold it.
|
||||
await expect(
|
||||
seatsService.holdSeats({
|
||||
scheduleId: schedule.id,
|
||||
originStationId: IDS.stationA,
|
||||
destinationStationId: IDS.stationB,
|
||||
passengers: [{ passengerId: "someone-else", seatId: seats[0].id }],
|
||||
} as any),
|
||||
).resolves.toBeTruthy();
|
||||
|
||||
// The old payment link no longer works.
|
||||
await expect(bookingsService.getByPayToken(payToken)).rejects.toThrow(/no longer awaiting payment/i);
|
||||
});
|
||||
|
||||
it("cancelReservationForSeat 404s when there's no pending reservation for this seat", 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-CANCEL-404-${Date.now()}`, departureAt: dep, arrivalAt: arr });
|
||||
|
||||
await expect(
|
||||
bookingsService.cancelReservationForSeat(seats[0].id, schedule.id, "staff-user-8"),
|
||||
).rejects.toThrow(/no pending reservation/i);
|
||||
});
|
||||
|
||||
it("getSeatMap surfaces the bookingRef (PNR) for a seat with an active reservation — pending payment AND ticketed", 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-SEATMAP-${Date.now()}`, departureAt: dep, arrivalAt: arr });
|
||||
|
||||
// Seat 0: PASSENGER reservation — still PENDING_PAYMENT.
|
||||
await seatsService.blockSeat(seats[0].id, "Reserved pending payment", schedule.id);
|
||||
const pending: any = await guestBookingService.issueBookingFromReservation(
|
||||
seats[0].id,
|
||||
baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.PASSENGER, phone: "+253771234567" }) as any,
|
||||
"staff-user-6",
|
||||
);
|
||||
|
||||
// Seat 1: STAFF reservation — fee-waived, ticketed, CONFIRMED immediately.
|
||||
await seatsService.blockSeat(seats[1].id, "Reserved for staff issue", schedule.id);
|
||||
const staffResult: any = await guestBookingService.issueBookingFromReservation(
|
||||
seats[1].id,
|
||||
baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.STAFF }) as any,
|
||||
"staff-user-6",
|
||||
);
|
||||
|
||||
const seatMap: any = await seatsService.getSeatMap(schedule.id);
|
||||
const flatSeats = seatMap.coaches.flatMap((c: any) => c.seats ?? []);
|
||||
const pendingSeat = flatSeats.find((s: any) => s.id === seats[0].id);
|
||||
const ticketedSeat = flatSeats.find((s: any) => s.id === seats[1].id);
|
||||
|
||||
expect(pendingSeat.bookingRef).toBe(pending.booking.bookingRef);
|
||||
expect(pendingSeat.reservationStatus).toBe("PENDING_PAYMENT");
|
||||
expect(pendingSeat.status).toBe("HELD"); // covered by the SeatHold, not a SeatBlock
|
||||
|
||||
expect(ticketedSeat.bookingRef).toBe(staffResult.booking.bookingRef);
|
||||
expect(ticketedSeat.reservationStatus).toBe("CONFIRMED");
|
||||
});
|
||||
|
||||
it("requires a phone number for a PASSENGER booking", async () => {
|
||||
await resetAndSeedCore(harness.prisma);
|
||||
const dep = new Date(Date.now() + 3 * 60 * 60_000);
|
||||
|
||||
Reference in New Issue
Block a user