mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha
This commit is contained in:
57
apps/edr-passenger-api/.env.test.example
Normal file
57
apps/edr-passenger-api/.env.test.example
Normal file
@@ -0,0 +1,57 @@
|
||||
# E2E harness env — points at the hermetic test Postgres (e2e/docker-compose.yml, port 5544).
|
||||
# Loaded by test/setup/load-env.ts before the Nest AppModule boots. NEVER points at a real DB.
|
||||
NODE_ENV=test
|
||||
PORT=4099
|
||||
|
||||
# Prisma — passenger schema in the test edr_database
|
||||
DATABASE_URL=postgresql://edr:edr_secret@localhost:5544/edr_database?schema=passenger
|
||||
|
||||
# TypeORM / IAM — shared iam schema, same test DB
|
||||
DATABASE_HOST=localhost
|
||||
DATABASE_PORT=5544
|
||||
DATABASE_NAME=edr_database
|
||||
DATABASE_USER=edr
|
||||
DATABASE_PASSWORD=edr_secret
|
||||
DATABASE_SCHEMA=iam
|
||||
|
||||
# Brokers / external systems OFF for a hermetic boot
|
||||
RABBITMQ_ENABLED=false
|
||||
RABBITMQ_URL=amqp://localhost:5672
|
||||
EMAIL_QUEUE=email_queue
|
||||
SMS_QUEUE=sms_queue
|
||||
PAYMENT_RABBITMQ_URL=amqp://edr:edr_secret@localhost:5672/payment
|
||||
PAYMENT_EVENTS_PREFETCH=10
|
||||
IAM_ENABLED=false
|
||||
FAYDA_ENABLED=false
|
||||
|
||||
# MinIO — client is constructed at boot but never contacted in tests
|
||||
MINIO_ENDPOINT=localhost
|
||||
MINIO_PORT=9000
|
||||
MINIO_USE_SSL=false
|
||||
MINIO_ACCESS_KEY=minioadmin
|
||||
MINIO_SECRET_KEY=minioadmin
|
||||
MINIO_BUCKET=edr-test
|
||||
|
||||
CORS_ORIGINS=http://localhost:5174,http://localhost:5184
|
||||
FE_BASE_URL=http://localhost:5184
|
||||
INVITATION_EXPIRY_DATE=30
|
||||
|
||||
# JWT / IAM token contract — fixed test secrets (min 32 chars). Let tests mint IAM tokens.
|
||||
JWT_SECRET=test-jwt-secret-000000000000000000000000
|
||||
JWT_EXPIRES_IN=7d
|
||||
JWT_ACCESS_TOKEN_SECRET=test-access-secret-0000000000000000000000
|
||||
JWT_ACCESS_TOKEN_EXPIRES=1h
|
||||
JWT_REFRESH_TOKEN_SECRET=test-refresh-secret-000000000000000000000
|
||||
JWT_REFRESH_TOKEN_EXPIRES=7d
|
||||
|
||||
DEFAULT_LOCALE=en
|
||||
SUPPORTED_LOCALES=en,am,fr,om
|
||||
SESSION_INACTIVITY_MINUTES=30
|
||||
|
||||
# Payment providers — WALLET is fully internal; others unused in the API-level suite
|
||||
PAYMENT_PROVIDERS_ENABLED=TELEBIRR,CBE_BIRR,EBIRR,CARD,WALLET,WAAFI
|
||||
|
||||
# Staff/org seeding off — the harness builds its own deterministic fixtures
|
||||
SEED_EDR_PASSENGER_ORG=false
|
||||
SEED_PASSENGER_STAFF=false
|
||||
DEFAULT_PASSWORD=Test@1234
|
||||
6
apps/edr-passenger-api/.gitignore
vendored
Normal file
6
apps/edr-passenger-api/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
|
||||
# E2E HTML report output
|
||||
e2e-report/
|
||||
|
||||
# Track the E2E env TEMPLATE (real .env.test stays ignored)
|
||||
!.env.test.example
|
||||
@@ -10,6 +10,11 @@
|
||||
"lint": "eslint src",
|
||||
"test": "jest",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"test:e2e:report": "jest --config ./test/jest-e2e.json; open e2e-report/index.html",
|
||||
"test:e2e:all": "bash ../../e2e/run.sh",
|
||||
"test:e2e:db:up": "docker compose -f ../../e2e/docker-compose.yml up -d",
|
||||
"test:e2e:db:down": "docker compose -f ../../e2e/docker-compose.yml down",
|
||||
"test:e2e:prepare": "bash ../../e2e/prepare.sh",
|
||||
"type-check": "tsc --noEmit",
|
||||
"iam:migrate": "node --env-file=.env scripts/run-iam-migrations.cjs",
|
||||
"iam:seed-dev-user": "node --env-file=.env scripts/seed-iam-dev-user.cjs",
|
||||
@@ -78,6 +83,7 @@
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/uuid": "^9.0.0",
|
||||
"jest": "^29.7.0",
|
||||
"jest-html-reporters": "^3.1.7",
|
||||
"prisma": "^6.19.3",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.1.1",
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Adds RouteStop.travelMinutesToStop: admin-configured travel time (minutes) from the
|
||||
-- previous stop, used to compute each stop's estimated arrival time (replacing/augmenting
|
||||
-- distance-proportional interpolation). Nullable — falls back to distance interpolation
|
||||
-- when unset.
|
||||
-- Uses IF NOT EXISTS following the pattern established in
|
||||
-- 20260719000002_repair_route_checkin_minutes, after this same table had two migrations
|
||||
-- checked in as empty "applied directly" placeholders that never reached the deployed DB.
|
||||
|
||||
ALTER TABLE passenger."RouteStop" ADD COLUMN IF NOT EXISTS "travelMinutesToStop" INTEGER;
|
||||
@@ -1078,6 +1078,7 @@ model RouteStop {
|
||||
sequence Int
|
||||
distanceKm Float?
|
||||
checkinMinutesBefore Int?
|
||||
travelMinutesToStop Int?
|
||||
plannedArrivalTime DateTime?
|
||||
plannedDepartureTime DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Resolves the booking/check-in cutoff for one boarding stop.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export interface CheckinCutoff {
|
||||
/** The stop's planned departure time (or arrival / schedule departure as fallback). */
|
||||
segmentTime: Date;
|
||||
/** Minutes before segmentTime that booking/holding closes. */
|
||||
checkinMinutes: number;
|
||||
/** The moment booking/holding closes for this stop. */
|
||||
cutoffAt: Date;
|
||||
}
|
||||
|
||||
export function resolveCheckinCutoff(
|
||||
schedule: {
|
||||
departureAt: Date;
|
||||
route?: {
|
||||
checkinMinutesBefore?: number | null;
|
||||
stops?: Array<{ stationId: string; checkinMinutesBefore: number | null }>;
|
||||
} | null;
|
||||
},
|
||||
stopTime: { plannedArrivalAt?: Date | null; plannedDepartureAt?: Date | null } | null | undefined,
|
||||
stationId: string | null | undefined,
|
||||
): CheckinCutoff {
|
||||
const segmentTime = stopTime?.plannedDepartureAt ?? stopTime?.plannedArrivalAt ?? schedule.departureAt;
|
||||
const routeStop = stationId ? schedule.route?.stops?.find((s) => s.stationId === stationId) : undefined;
|
||||
const checkinMinutes = routeStop?.checkinMinutesBefore ?? schedule.route?.checkinMinutesBefore ?? 30;
|
||||
return {
|
||||
segmentTime,
|
||||
checkinMinutes,
|
||||
cutoffAt: new Date(segmentTime.getTime() - checkinMinutes * 60_000),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
const logger = new Logger('ScheduleTimesUtils');
|
||||
|
||||
export type StopForTiming = {
|
||||
sequence: number;
|
||||
distanceKm: number | null;
|
||||
travelMinutesToStop: number | null;
|
||||
checkinMinutesBefore: number | null;
|
||||
};
|
||||
|
||||
export type PlannedStopTime = {
|
||||
sequence: number;
|
||||
plannedArrivalAt: string | undefined;
|
||||
plannedDepartureAt: string | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes each stop's planned arrival/departure time by walking the route in sequence order.
|
||||
*
|
||||
* Model per intermediate stop:
|
||||
* arrival = departureCursor + travelMinutesToStop (falls back to distance interpolation)
|
||||
* departure = arrival + checkinMinutesBefore (dwell time; 0 if null)
|
||||
* next-stop travel starts from this departure, not from arrival.
|
||||
*
|
||||
* This means booking for stop B closes at B.departureAt − checkinMinutesBefore = B.arrivalAt,
|
||||
* i.e. the train must not yet have arrived at the stop for a booking to succeed.
|
||||
*
|
||||
* The last stop is always locked to arr so schedule.arrivalAt stays authoritative.
|
||||
*/
|
||||
export function computePlannedStopTimes(
|
||||
route: { id: string; stops: StopForTiming[] },
|
||||
dep: Date,
|
||||
arr: Date,
|
||||
): PlannedStopTime[] {
|
||||
const totalDuration = arr.getTime() - dep.getTime();
|
||||
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
|
||||
|
||||
// cursor tracks the DEPARTURE time from the most-recently processed stop.
|
||||
let departureCursor = dep;
|
||||
|
||||
return route.stops.map((stop, index) => {
|
||||
if (index === 0) {
|
||||
// Origin: train starts here, no arrival.
|
||||
departureCursor = dep;
|
||||
return { sequence: stop.sequence, plannedArrivalAt: undefined, plannedDepartureAt: dep.toISOString() };
|
||||
}
|
||||
|
||||
if (index === route.stops.length - 1) {
|
||||
// Final destination: arrival is authoritative; no departure.
|
||||
return { sequence: stop.sequence, plannedArrivalAt: arr.toISOString(), plannedDepartureAt: undefined };
|
||||
}
|
||||
|
||||
// Intermediate stop: compute arrival from the previous stop's departure.
|
||||
let arrivalAt: Date;
|
||||
if (stop.travelMinutesToStop != null) {
|
||||
arrivalAt = new Date(departureCursor.getTime() + stop.travelMinutesToStop * 60_000);
|
||||
} else {
|
||||
const stopDistance = stop.distanceKm || 0;
|
||||
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
|
||||
arrivalAt = new Date(dep.getTime() + totalDuration * progress);
|
||||
logger.warn(`Route ${route.id} stop seq ${stop.sequence} missing travelMinutesToStop; falling back to distance interpolation`);
|
||||
}
|
||||
|
||||
// Dwell at this stop = checkinMinutesBefore (the boarding window).
|
||||
const dwell = stop.checkinMinutesBefore ?? 0;
|
||||
const departureAt = new Date(arrivalAt.getTime() + dwell * 60_000);
|
||||
departureCursor = departureAt;
|
||||
|
||||
return {
|
||||
sequence: stop.sequence,
|
||||
plannedArrivalAt: arrivalAt.toISOString(),
|
||||
plannedDepartureAt: departureAt.toISOString(),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Resolves a booking's actual boarding/alighting station AND time for one leg from
|
||||
* originStationId/destinationStationId (set when the booking covers only part of a
|
||||
* longer multi-stop schedule, e.g. train runs A→D but the passenger booked B→D), via
|
||||
* the schedule's stopTimes — falling back to the schedule's own full-route
|
||||
* station/time when there's no segment override (older records, or a booking that
|
||||
* covers the whole run).
|
||||
*
|
||||
* Single source of truth for this resolution — station-only lookups used to be
|
||||
* duplicated ad hoc across bookings/tickets/notifications while the departureAt/
|
||||
* arrivalAt kept being read straight off the schedule (the train's full-route span),
|
||||
* which showed the wrong boarding/alighting time for any stop-based booking.
|
||||
*/
|
||||
export interface ResolvedSegment {
|
||||
origin: any;
|
||||
destination: any;
|
||||
departureAt: any;
|
||||
arrivalAt: any;
|
||||
}
|
||||
|
||||
export function resolveBookingSegment(
|
||||
schedule: any,
|
||||
originStationId: string | null | undefined,
|
||||
destinationStationId: string | null | undefined,
|
||||
): ResolvedSegment {
|
||||
const stopTimes: any[] = schedule?.stopTimes ?? [];
|
||||
const findStop = (stationId: string | null | undefined) =>
|
||||
stationId && stopTimes.length > 0 ? stopTimes.find((st: any) => st.stationId === stationId) : undefined;
|
||||
const originStop = findStop(originStationId);
|
||||
const destStop = findStop(destinationStationId);
|
||||
return {
|
||||
origin: originStop?.station ?? schedule?.originStation ?? null,
|
||||
destination: destStop?.station ?? schedule?.destinationStation ?? null,
|
||||
departureAt: originStop?.plannedDepartureAt ?? schedule?.departureAt ?? null,
|
||||
arrivalAt: destStop?.plannedArrivalAt ?? schedule?.arrivalAt ?? null,
|
||||
};
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
|
||||
import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto';
|
||||
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
|
||||
import { AuditService } from '../../common/audit.service';
|
||||
import { resolveBookingSegment } from '../../common/utils/segment-resolver.utils';
|
||||
|
||||
function generateRef(): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
@@ -140,7 +141,7 @@ export class BookingsService {
|
||||
take: pageSize,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
|
||||
paymentIntent: true,
|
||||
seats: { include: { seat: true } },
|
||||
priceTier: { select: { priceMinor: true } },
|
||||
@@ -148,31 +149,34 @@ export class BookingsService {
|
||||
}),
|
||||
this.prisma.booking.count({ where }),
|
||||
]);
|
||||
|
||||
|
||||
return {
|
||||
items: items.map(booking => ({
|
||||
id: booking.id,
|
||||
bookingRef: booking.bookingRef,
|
||||
status: booking.status,
|
||||
totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
|
||||
currency: booking.displayCurrency,
|
||||
displayCurrency: booking.displayCurrency,
|
||||
displayTotalMinor: booking.displayTotalMinor,
|
||||
adultCount: booking.adultCount,
|
||||
childCount: booking.childCount,
|
||||
bookingType: booking.bookingType,
|
||||
returnLegStatus: (booking as any).returnLegStatus ?? null,
|
||||
createdAt: booking.createdAt,
|
||||
schedule: {
|
||||
train: booking.schedule.train,
|
||||
originStation: booking.schedule.originStation,
|
||||
destinationStation: booking.schedule.destinationStation,
|
||||
departureAt: booking.schedule.departureAt,
|
||||
arrivalAt: booking.schedule.arrivalAt,
|
||||
},
|
||||
paymentIntent: booking.paymentIntent,
|
||||
seatCount: booking.seats.length,
|
||||
})),
|
||||
items: items.map(booking => {
|
||||
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.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
|
||||
currency: booking.displayCurrency,
|
||||
displayCurrency: booking.displayCurrency,
|
||||
displayTotalMinor: booking.displayTotalMinor,
|
||||
adultCount: booking.adultCount,
|
||||
childCount: booking.childCount,
|
||||
bookingType: booking.bookingType,
|
||||
returnLegStatus: (booking as any).returnLegStatus ?? null,
|
||||
createdAt: booking.createdAt,
|
||||
schedule: {
|
||||
train: booking.schedule.train,
|
||||
originStation: segment.origin,
|
||||
destinationStation: segment.destination,
|
||||
departureAt: segment.departureAt,
|
||||
arrivalAt: segment.arrivalAt,
|
||||
},
|
||||
paymentIntent: booking.paymentIntent,
|
||||
seatCount: booking.seats.length,
|
||||
};
|
||||
}),
|
||||
meta: {
|
||||
page,
|
||||
pageSize,
|
||||
@@ -270,7 +274,7 @@ export class BookingsService {
|
||||
take: pageSize,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
|
||||
paymentIntent: { select: { method: true, status: true, amountMinor: true, currency: true } },
|
||||
seats: { select: { id: true } },
|
||||
priceTier: { select: { priceMinor: true } },
|
||||
@@ -294,7 +298,9 @@ export class BookingsService {
|
||||
this.prisma.packageBooking.count({ where: pkgWhere }),
|
||||
]);
|
||||
|
||||
const mappedBookings = items.map(booking => ({
|
||||
const mappedBookings = items.map(booking => {
|
||||
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,
|
||||
@@ -309,14 +315,15 @@ export class BookingsService {
|
||||
createdAt: booking.createdAt,
|
||||
schedule: {
|
||||
train: booking.schedule.train,
|
||||
originStation: booking.schedule.originStation,
|
||||
destinationStation: booking.schedule.destinationStation,
|
||||
departureAt: booking.schedule.departureAt,
|
||||
arrivalAt: booking.schedule.arrivalAt,
|
||||
originStation: segment.origin,
|
||||
destinationStation: segment.destination,
|
||||
departureAt: segment.departureAt,
|
||||
arrivalAt: segment.arrivalAt,
|
||||
},
|
||||
payment: booking.paymentIntent ?? undefined,
|
||||
seatCount: booking.seats.length,
|
||||
}));
|
||||
};
|
||||
});
|
||||
|
||||
const mappedPkg = pkgItems.map((b: any) => ({
|
||||
id: b.id,
|
||||
@@ -397,7 +404,7 @@ export class BookingsService {
|
||||
take: pageSize,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
|
||||
paymentIntent: true,
|
||||
seats: { include: { seat: true } },
|
||||
priceTier: { select: { priceMinor: true } },
|
||||
@@ -405,9 +412,11 @@ export class BookingsService {
|
||||
}),
|
||||
this.prisma.booking.count({ where }),
|
||||
]);
|
||||
|
||||
|
||||
return {
|
||||
items: items.map(booking => ({
|
||||
items: items.map(booking => {
|
||||
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,
|
||||
@@ -422,14 +431,15 @@ export class BookingsService {
|
||||
createdAt: booking.createdAt,
|
||||
schedule: {
|
||||
train: booking.schedule.train,
|
||||
originStation: booking.schedule.originStation,
|
||||
destinationStation: booking.schedule.destinationStation,
|
||||
departureAt: booking.schedule.departureAt,
|
||||
arrivalAt: booking.schedule.arrivalAt,
|
||||
originStation: segment.origin,
|
||||
destinationStation: segment.destination,
|
||||
departureAt: segment.departureAt,
|
||||
arrivalAt: segment.arrivalAt,
|
||||
},
|
||||
paymentIntent: booking.paymentIntent,
|
||||
seatCount: booking.seats.length,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
meta: {
|
||||
page,
|
||||
pageSize,
|
||||
@@ -532,7 +542,7 @@ export class BookingsService {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
passenger: { select: { id: true, iamUserId: true } },
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
|
||||
paymentIntent: true,
|
||||
seats: { include: { seat: true } },
|
||||
priceTier: { select: { priceMinor: true } },
|
||||
@@ -554,9 +564,10 @@ export class BookingsService {
|
||||
const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined;
|
||||
const passengerDetails = booking.seats.map((s: any) => ({ name: s.passengerName, category: s.passengerCategory }));
|
||||
const uniquePassengers = Array.from(new Map(passengerDetails.map((p: any) => [p.name, p])).values());
|
||||
const segment = booking.schedule ? resolveBookingSegment(booking.schedule, booking.originStationId, booking.destinationStationId) : null;
|
||||
return {
|
||||
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
|
||||
totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, booking.priceTier?.priceMinor, booking.adultCount, booking.childCount),
|
||||
totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, booking.priceTier?.priceMinor, booking.adultCount, booking.childCount),
|
||||
currency: booking.displayCurrency,
|
||||
displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor,
|
||||
contactEmail: booking.contactEmail, contactPhone: booking.contactPhone,
|
||||
@@ -567,11 +578,11 @@ export class BookingsService {
|
||||
passenger: iam ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } : null,
|
||||
passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))],
|
||||
passengers: uniquePassengers,
|
||||
schedule: booking.schedule ? {
|
||||
schedule: segment ? {
|
||||
train: booking.schedule.train,
|
||||
originStation: booking.schedule.originStation,
|
||||
destinationStation: booking.schedule.destinationStation,
|
||||
departureAt: booking.schedule.departureAt,
|
||||
originStation: segment.origin,
|
||||
destinationStation: segment.destination,
|
||||
departureAt: segment.departureAt,
|
||||
} : null,
|
||||
paymentIntent: booking.paymentIntent,
|
||||
seatCount: booking.seats.length,
|
||||
@@ -715,16 +726,15 @@ export class BookingsService {
|
||||
verifaydaVerified: s.verifaydaVerified,
|
||||
seat: s.seat ? { seatNumber: s.seat.seatNumber, coach: { number: s.seat.coach?.number ?? null } } : null,
|
||||
})),
|
||||
schedule: {
|
||||
train: booking.schedule.train,
|
||||
originStation: (booking as any).originStationId
|
||||
? ((booking.schedule as any).stopTimes?.find((s: any) => s.stationId === (booking as any).originStationId)?.station ?? booking.schedule.originStation)
|
||||
: booking.schedule.originStation,
|
||||
destinationStation: (booking as any).destinationStationId
|
||||
? ((booking.schedule as any).stopTimes?.find((s: any) => s.stationId === (booking as any).destinationStationId)?.station ?? booking.schedule.destinationStation)
|
||||
: booking.schedule.destinationStation,
|
||||
departureAt: booking.schedule.departureAt,
|
||||
},
|
||||
schedule: (() => {
|
||||
const segment = resolveBookingSegment(booking.schedule, (booking as any).originStationId, (booking as any).destinationStationId);
|
||||
return {
|
||||
train: booking.schedule.train,
|
||||
originStation: segment.origin,
|
||||
destinationStation: segment.destination,
|
||||
departureAt: segment.departureAt,
|
||||
};
|
||||
})(),
|
||||
paymentIntent: booking.paymentIntent,
|
||||
seatCount: booking.seats.length,
|
||||
};
|
||||
@@ -865,6 +875,9 @@ export class BookingsService {
|
||||
const allFaresProvided = seatedPassengers.length > 0 && seatedPassengers.every(p => p.seatFareMinor != null);
|
||||
let resolvedTotalMinor: number;
|
||||
let displayTotalMinor: 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 && !dto.packageId) {
|
||||
// Server has every passenger's berth fare — sum is the authoritative display total.
|
||||
@@ -872,6 +885,7 @@ export class BookingsService {
|
||||
resolvedTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
|
||||
: displayTotalMinor;
|
||||
usedClientSubtotal = true;
|
||||
if (dto.reviewedTotalMinor != null && dto.reviewedTotalMinor !== displayTotalMinor) {
|
||||
this.logger.warn(`createOneWayBooking: reviewedTotalMinor=${dto.reviewedTotalMinor} ignored — using server-computed sum=${displayTotalMinor}`);
|
||||
}
|
||||
@@ -893,13 +907,35 @@ export class BookingsService {
|
||||
resolvedTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
|
||||
: dto.reviewedTotalMinor;
|
||||
usedClientSubtotal = true;
|
||||
} else {
|
||||
resolvedTotalMinor = fareCalculation.totalMinor;
|
||||
displayTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(resolvedTotalMinor, Currency.ETB, displayCurrency)
|
||||
: resolvedTotalMinor;
|
||||
}
|
||||
this.logger.log(`createOneWayBooking: resolvedTotalMinor=${resolvedTotalMinor} displayTotalMinor=${displayTotalMinor} displayCurrency=${displayCurrency} (reviewedTotalMinor=${dto.reviewedTotalMinor} allFaresProvided=${allFaresProvided} fareEngine=${fareCalculation.totalMinor})`);
|
||||
|
||||
// H-13 fix: the portal sums UNDISCOUNTED per-passenger fares into the total it sends, silently
|
||||
// dropping the promo the fare engine recognized (the discount lives only in the fare-breakdown).
|
||||
// When the total came from that client subtotal, apply the authoritative promo discount so the
|
||||
// customer is charged the discounted price. No-op when no promo applies (discountMinor === 0).
|
||||
// The fallback branch above already books fareCalculation.totalMinor (discount included), so it is
|
||||
// excluded via usedClientSubtotal to avoid double-subtracting.
|
||||
if (usedClientSubtotal && fareCalculation.discountMinor > 0) {
|
||||
resolvedTotalMinor = Math.max(0, resolvedTotalMinor - fareCalculation.discountMinor);
|
||||
const discountDisplayMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(fareCalculation.discountMinor, Currency.ETB, displayCurrency)
|
||||
: fareCalculation.discountMinor;
|
||||
displayTotalMinor = Math.max(0, displayTotalMinor - discountDisplayMinor);
|
||||
}
|
||||
this.logger.log(`createOneWayBooking: resolvedTotalMinor=${resolvedTotalMinor} displayTotalMinor=${displayTotalMinor} displayCurrency=${displayCurrency} (reviewedTotalMinor=${dto.reviewedTotalMinor} allFaresProvided=${allFaresProvided} discountMinor=${fareCalculation.discountMinor} fareEngine=${fareCalculation.totalMinor})`);
|
||||
|
||||
// C-1 guard: never charge less than the server-recomputed authoritative fare. resolvedTotalMinor
|
||||
// is the ETB charge basis; fareCalculation.totalMinor is the authoritative ETB fare (already net
|
||||
// of promo/loyalty/free-child). A client that forges seatFareMinor / reviewedTotalMinor below it
|
||||
// is rejected. Floor (not equality) so legitimate berth surcharges — which only raise the total —
|
||||
// still pass; the tolerance absorbs FX-conversion rounding.
|
||||
this.assertTotalNotUnderAuthoritative(resolvedTotalMinor, fareCalculation.totalMinor, 'createOneWayBooking');
|
||||
|
||||
const booking = await this.prisma.booking.create({
|
||||
data: {
|
||||
@@ -911,7 +947,9 @@ export class BookingsService {
|
||||
status: 'PENDING_PAYMENT',
|
||||
bookingType: 'ONE_WAY',
|
||||
totalMinor: resolvedTotalMinor,
|
||||
currency: displayCurrency,
|
||||
// Charge basis is ETB (resolvedTotalMinor). The passenger's currency and amount live in
|
||||
// displayCurrency/displayTotalMinor — keep currency coherent with totalMinor's units.
|
||||
currency: Currency.ETB,
|
||||
adultCount,
|
||||
childCount,
|
||||
displayCurrency,
|
||||
@@ -1030,6 +1068,9 @@ export class BookingsService {
|
||||
loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
|
||||
totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor);
|
||||
}
|
||||
// C-1 guard: authoritative ETB fare for both legs, captured before the client-driven branches
|
||||
// below may overwrite totalMinor with a per-seat sum or reviewedTotalMinor.
|
||||
const authoritativeTotalMinor = totalMinor;
|
||||
const taxesMinor = 0;
|
||||
|
||||
const displayCurrency = dto.displayCurrency || resolveCurrencyFromNationality(passengersData[0]?.nationality);
|
||||
@@ -1095,6 +1136,9 @@ export class BookingsService {
|
||||
: dto.reviewedTotalMinor;
|
||||
}
|
||||
|
||||
// C-1 guard: never charge less than the server-recomputed authoritative round-trip fare.
|
||||
this.assertTotalNotUnderAuthoritative(totalMinor, authoritativeTotalMinor, 'createRoundTripBooking');
|
||||
|
||||
const booking = await this.prisma.booking.create({
|
||||
data: {
|
||||
bookingRef: generateRef(),
|
||||
@@ -1105,7 +1149,7 @@ export class BookingsService {
|
||||
status: 'PENDING_PAYMENT',
|
||||
bookingType: 'ROUND_TRIP',
|
||||
totalMinor,
|
||||
currency: displayCurrency,
|
||||
currency: Currency.ETB, // ETB charge basis; passenger currency in displayCurrency/displayTotalMinor
|
||||
adultCount,
|
||||
childCount,
|
||||
displayCurrency,
|
||||
@@ -1298,7 +1342,7 @@ export class BookingsService {
|
||||
status: 'PENDING_PAYMENT',
|
||||
bookingType: 'TRANSIT',
|
||||
totalMinor,
|
||||
currency: displayCurrency,
|
||||
currency: Currency.ETB, // ETB charge basis; passenger currency in displayCurrency/displayTotalMinor
|
||||
adultCount,
|
||||
childCount,
|
||||
displayCurrency,
|
||||
@@ -1508,7 +1552,7 @@ export class BookingsService {
|
||||
destinationStationId: dto.leg2DestinationStationId,
|
||||
status: 'PENDING_PAYMENT',
|
||||
bookingType: 'ROUND_TRIP_TRANSIT',
|
||||
totalMinor, currency: displayCurrency, adultCount, childCount, displayCurrency, displayTotalMinor,
|
||||
totalMinor, currency: Currency.ETB, adultCount, childCount, displayCurrency, displayTotalMinor, // ETB charge basis; passenger currency in displayCurrency/displayTotalMinor
|
||||
// Outbound transit leg-2
|
||||
leg2ScheduleId: dto.leg2ScheduleId,
|
||||
leg2OriginStationId: dto.transitStationId,
|
||||
@@ -1677,6 +1721,21 @@ export class BookingsService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* C-1 protection: reject a booking whose ETB charge basis is below the server-recomputed
|
||||
* authoritative fare. A floor (not equality) so legitimate berth surcharges — which only raise
|
||||
* the total — still pass; a 1% tolerance absorbs FX-conversion rounding. A forged seatFareMinor /
|
||||
* reviewedTotalMinor that lowers the charge (e.g. to 1 or 0) is refused with a 400 and nothing is
|
||||
* persisted.
|
||||
*/
|
||||
private assertTotalNotUnderAuthoritative(resolvedTotalMinor: number, authoritativeMinor: number, context: string): void {
|
||||
const tolerance = Math.max(1, Math.round(authoritativeMinor * 0.01));
|
||||
if (resolvedTotalMinor < authoritativeMinor - tolerance) {
|
||||
this.logger.warn(`${context}: rejecting booking — resolvedTotalMinor=${resolvedTotalMinor} below authoritative fare=${authoritativeMinor}`);
|
||||
throw new BadRequestException('Booking total does not match the authoritative fare');
|
||||
}
|
||||
}
|
||||
|
||||
private async calculateFare(
|
||||
scheduleId: string,
|
||||
seatClassId: string,
|
||||
@@ -1801,35 +1860,6 @@ export class BookingsService {
|
||||
);
|
||||
}
|
||||
|
||||
// Resolves the passenger's actual boarding/alighting stations AND times for one leg
|
||||
// from originStationId/destinationStationId (set when the booking covers only part of
|
||||
// a longer multi-stop schedule, e.g. train runs A→D but the passenger booked B→D) via
|
||||
// the schedule's stopTimes, falling back to the schedule's own full-route endpoints/
|
||||
// times when there's no segment override (older records, or a booking that covers the
|
||||
// whole run). Station resolution mirrors notifications.service.ts's
|
||||
// resolveSegmentStations (already applied to SMS/email); the departureAt/arrivalAt
|
||||
// resolution mirrors search.service.ts's leg construction (originStop.plannedDepartureAt
|
||||
// / destStop.plannedArrivalAt) — this brings the booking API (voucher, detail page,
|
||||
// confirmation) to the same behavior search results already have, instead of always
|
||||
// showing the train's full-route span.
|
||||
private resolveSegmentStations(
|
||||
schedule: any,
|
||||
originStationId: string | null | undefined,
|
||||
destinationStationId: string | null | undefined,
|
||||
): { origin: any; destination: any; departureAt: any; arrivalAt: any } {
|
||||
const stopTimes: any[] = schedule?.stopTimes ?? [];
|
||||
const findStop = (stationId: string | null | undefined) =>
|
||||
stationId && stopTimes.length > 0 ? stopTimes.find((st: any) => st.stationId === stationId) : undefined;
|
||||
const originStop = findStop(originStationId);
|
||||
const destStop = findStop(destinationStationId);
|
||||
return {
|
||||
origin: originStop?.station ?? schedule?.originStation ?? null,
|
||||
destination: destStop?.station ?? schedule?.destinationStation ?? null,
|
||||
departureAt: originStop?.plannedDepartureAt ?? schedule?.departureAt ?? null,
|
||||
arrivalAt: destStop?.plannedArrivalAt ?? schedule?.arrivalAt ?? 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({
|
||||
@@ -1938,13 +1968,13 @@ export class BookingsService {
|
||||
if (refreshed) Object.assign(booking, refreshed);
|
||||
}
|
||||
|
||||
const outboundSegment = this.resolveSegmentStations(
|
||||
const outboundSegment = resolveBookingSegment(
|
||||
(booking as any).schedule,
|
||||
(booking as any).originStationId,
|
||||
(booking as any).destinationStationId,
|
||||
);
|
||||
const returnSegment = (booking as any).returnSchedule
|
||||
? this.resolveSegmentStations(
|
||||
? resolveBookingSegment(
|
||||
(booking as any).returnSchedule,
|
||||
(booking as any).returnOriginStationId,
|
||||
(booking as any).returnDestinationStationId,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, BadRequestException, NotFoundException, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { SeatsService } from '../seats/seats.service';
|
||||
import { VerifaydaService } from '../verifayda/verifayda.service';
|
||||
@@ -8,9 +8,22 @@ 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 { resolveCheckinCutoff } from '../../common/utils/checkin-cutoff.utils';
|
||||
|
||||
/** Booking cutoff: reject new bookings within this many ms of departure. */
|
||||
const BOOKING_CUTOFF_MS = 30 * 60 * 1000;
|
||||
/**
|
||||
* Throws if the given boarding stop's own configurable check-in cutoff (route/stop
|
||||
* checkinMinutesBefore, same mechanism the seat hold and search results already enforce) has
|
||||
* passed. Must be checked against the actual boarding stop, not the schedule's origin — a
|
||||
* downstream stop's cutoff is independent of how long ago the train left its origin.
|
||||
*/
|
||||
function assertWithinCheckinCutoff(schedule: any, stopTime: any, stationId: string | null | undefined): void {
|
||||
const { cutoffAt, checkinMinutes } = resolveCheckinCutoff(schedule, stopTime, stationId);
|
||||
if (Date.now() >= cutoffAt.getTime()) {
|
||||
throw new BadRequestException(
|
||||
`Bookings are not accepted within ${checkinMinutes} minute${checkinMinutes !== 1 ? 's' : ''} of departure`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function generateRef(): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
@@ -42,6 +55,8 @@ function calculateAge(dateOfBirth: Date): number {
|
||||
|
||||
@Injectable()
|
||||
export class GuestBookingService {
|
||||
private readonly logger = new Logger(GuestBookingService.name);
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private seatsService: SeatsService,
|
||||
@@ -52,6 +67,21 @@ export class GuestBookingService {
|
||||
private eventEmitter: EventEmitter2,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* C-1 protection (guest path): reject a booking whose ETB charge basis is below the
|
||||
* server-recomputed authoritative fare. A floor (not equality) so legitimate berth surcharges —
|
||||
* which only raise the total — still pass; a 1% tolerance absorbs FX-conversion rounding. A forged
|
||||
* seatFareMinor / reviewedTotalMinor that lowers the charge (e.g. to 0) is refused with a 400 and
|
||||
* nothing is persisted.
|
||||
*/
|
||||
private assertTotalNotUnderAuthoritative(resolvedTotalMinor: number, authoritativeMinor: number, context: string): void {
|
||||
const tolerance = Math.max(1, Math.round(authoritativeMinor * 0.01));
|
||||
if (resolvedTotalMinor < authoritativeMinor - tolerance) {
|
||||
this.logger.warn(`${context}: rejecting booking — resolvedTotalMinor=${resolvedTotalMinor} below authoritative fare=${authoritativeMinor}`);
|
||||
throw new BadRequestException('Booking total does not match the authoritative fare');
|
||||
}
|
||||
}
|
||||
|
||||
async createGuestBooking(dto: CreateGuestBookingDto, req?: any) {
|
||||
// Enrich passengers with phone/email from SavedPassengerProfile when not supplied inline.
|
||||
// The portal calls /passengers/save-details before booking but doesn't re-send contact
|
||||
@@ -90,20 +120,22 @@ export class GuestBookingService {
|
||||
originStation: true,
|
||||
destinationStation: true,
|
||||
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
|
||||
route: { include: { stops: true } },
|
||||
},
|
||||
});
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
|
||||
if (Date.now() >= schedule.departureAt.getTime() - BOOKING_CUTOFF_MS) {
|
||||
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
// Cut off relative to the passenger's actual boarding stop, using the same
|
||||
// configurable per-stop/route checkinMinutesBefore that already gated the seat hold
|
||||
// and the search result — not a separate, hardcoded 30 minutes off the train's origin.
|
||||
assertWithinCheckinCutoff(schedule, originStop, dto.originStationId);
|
||||
|
||||
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
|
||||
const fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`;
|
||||
|
||||
@@ -250,6 +282,9 @@ export class GuestBookingService {
|
||||
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
|
||||
: displayTotalMinor;
|
||||
|
||||
// C-1 guard: never charge less than the server-recomputed authoritative ETB fare (net of promo).
|
||||
this.assertTotalNotUnderAuthoritative(resolvedTotalMinor, Math.max(0, totalBaseFareMinor - discountMinor), 'createGuestBooking');
|
||||
|
||||
// Resolve or create the guest Passenger record
|
||||
const firstPassenger = passengersData[0];
|
||||
const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, firstPassenger, req);
|
||||
@@ -284,7 +319,9 @@ export class GuestBookingService {
|
||||
destinationStationId: dto.destinationStationId,
|
||||
status: 'PENDING_PAYMENT',
|
||||
totalMinor: resolvedTotalMinor,
|
||||
currency: displayCurrency,
|
||||
// Charge basis is ETB (resolvedTotalMinor). The passenger's currency and amount live in
|
||||
// displayCurrency/displayTotalMinor — keep currency coherent with totalMinor's units.
|
||||
currency: Currency.ETB,
|
||||
adultCount,
|
||||
childCount,
|
||||
displayCurrency,
|
||||
@@ -369,7 +406,7 @@ export class GuestBookingService {
|
||||
const [outboundSchedule, returnSchedule] = await Promise.all([
|
||||
this.prisma.trainSchedule.findUnique({
|
||||
where: { id: dto.scheduleId },
|
||||
include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
|
||||
include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, route: { include: { stops: true } } },
|
||||
}),
|
||||
this.prisma.trainSchedule.findUnique({
|
||||
where: { id: dto.returnScheduleId },
|
||||
@@ -379,10 +416,6 @@ export class GuestBookingService {
|
||||
if (!outboundSchedule) throw new NotFoundException('Outbound schedule not found');
|
||||
if (!returnSchedule) throw new NotFoundException('Return schedule not found');
|
||||
|
||||
if (Date.now() >= outboundSchedule.departureAt.getTime() - BOOKING_CUTOFF_MS) {
|
||||
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
|
||||
}
|
||||
|
||||
const synth = (sched: any, stationId: string, seq: number) => {
|
||||
const station = sched.originStationId === stationId ? sched.originStation : sched.destinationStation;
|
||||
return { stationId, sequence: seq, station };
|
||||
@@ -396,6 +429,10 @@ export class GuestBookingService {
|
||||
if (!outboundOriginStop || !outboundDestStop) throw new NotFoundException('Outbound origin or destination not found on schedule');
|
||||
if (!returnOriginStop || !returnDestStop) throw new NotFoundException('Return origin or destination not found on schedule');
|
||||
|
||||
// Cut off relative to the passenger's actual boarding stop, using the same configurable
|
||||
// per-stop/route checkinMinutesBefore that already gated the seat hold and search result.
|
||||
assertWithinCheckinCutoff(outboundSchedule, outboundOriginStop, dto.originStationId);
|
||||
|
||||
const outboundSegmentRoute = `${outboundOriginStop.station.code}-${outboundDestStop.station.code}`;
|
||||
const outboundFullRoute = `${outboundSchedule.originStation.code}-${outboundSchedule.destinationStation.code}`;
|
||||
const returnSegmentRoute = `${returnOriginStop.station.code}-${returnDestStop.station.code}`;
|
||||
@@ -485,6 +522,9 @@ export class GuestBookingService {
|
||||
|
||||
const taxesMinor = 0;
|
||||
let totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor);
|
||||
// C-1 guard: authoritative ETB fare for both legs, captured before the client-driven branches
|
||||
// below may overwrite totalMinor with a per-seat sum or reviewedTotalMinor.
|
||||
const authoritativeTotalMinor = totalMinor;
|
||||
|
||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||
let displayTotalMinor = displayCurrency !== Currency.ETB
|
||||
@@ -540,6 +580,9 @@ export class GuestBookingService {
|
||||
: displayTotalMinor;
|
||||
}
|
||||
|
||||
// C-1 guard: never charge less than the server-recomputed authoritative round-trip fare.
|
||||
this.assertTotalNotUnderAuthoritative(totalMinor, authoritativeTotalMinor, 'createGuestRoundTripBooking');
|
||||
|
||||
// Create or resolve guest passenger (same as one-way)
|
||||
const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req);
|
||||
|
||||
@@ -557,7 +600,7 @@ export class GuestBookingService {
|
||||
status: 'PENDING_PAYMENT',
|
||||
bookingType: 'ROUND_TRIP',
|
||||
totalMinor,
|
||||
currency: displayCurrency,
|
||||
currency: Currency.ETB, // ETB charge basis; passenger currency in displayCurrency/displayTotalMinor
|
||||
adultCount,
|
||||
childCount,
|
||||
displayCurrency,
|
||||
@@ -662,7 +705,7 @@ export class GuestBookingService {
|
||||
const [leg1Schedule, leg2Schedule] = await Promise.all([
|
||||
this.prisma.trainSchedule.findUnique({
|
||||
where: { id: dto.scheduleId },
|
||||
include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
|
||||
include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, route: { include: { stops: true } } },
|
||||
}),
|
||||
this.prisma.trainSchedule.findUnique({
|
||||
where: { id: dto.leg2ScheduleId },
|
||||
@@ -672,10 +715,6 @@ export class GuestBookingService {
|
||||
if (!leg1Schedule) throw new NotFoundException('Leg-1 schedule not found');
|
||||
if (!leg2Schedule) throw new NotFoundException('Leg-2 schedule not found');
|
||||
|
||||
if (Date.now() >= leg1Schedule.departureAt.getTime() - BOOKING_CUTOFF_MS) {
|
||||
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
|
||||
}
|
||||
|
||||
const leg1OriginStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.originStationId);
|
||||
const leg1DestStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.transitStationId);
|
||||
const leg2OriginStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.transitStationId);
|
||||
@@ -683,6 +722,10 @@ export class GuestBookingService {
|
||||
if (!leg1OriginStop || !leg1DestStop) throw new NotFoundException('Leg-1 origin or transit station not found on schedule');
|
||||
if (!leg2OriginStop || !leg2DestStop) throw new NotFoundException('Transit or leg-2 destination not found on leg-2 schedule');
|
||||
|
||||
// Cut off relative to the passenger's actual boarding stop, using the same configurable
|
||||
// per-stop/route checkinMinutesBefore that already gated the seat hold and search result.
|
||||
assertWithinCheckinCutoff(leg1Schedule, leg1OriginStop, dto.originStationId);
|
||||
|
||||
// Process passengers (verify identity once)
|
||||
const passengersData: any[] = [];
|
||||
let adultCount = 0, childCount = 0;
|
||||
@@ -761,7 +804,7 @@ export class GuestBookingService {
|
||||
status: 'PENDING_PAYMENT',
|
||||
bookingType: 'TRANSIT',
|
||||
totalMinor,
|
||||
currency: displayCurrency,
|
||||
currency: Currency.ETB, // ETB charge basis; passenger currency in displayCurrency/displayTotalMinor
|
||||
adultCount,
|
||||
childCount,
|
||||
displayCurrency,
|
||||
@@ -866,7 +909,7 @@ export class GuestBookingService {
|
||||
if (!retL2Hold || retL2Hold.expiresAt < now) throw new BadRequestException('Return leg-2 hold expired');
|
||||
|
||||
const [obL1Sched, obL2Sched, retL1Sched, retL2Sched] = await Promise.all([
|
||||
this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
|
||||
this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, route: { include: { stops: true } } } }),
|
||||
this.prisma.trainSchedule.findUnique({ where: { id: dto.leg2ScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
|
||||
this.prisma.trainSchedule.findUnique({ where: { id: dto.returnScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
|
||||
this.prisma.trainSchedule.findUnique({ where: { id: dto.returnLeg2ScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
|
||||
@@ -876,10 +919,6 @@ export class GuestBookingService {
|
||||
if (!retL1Sched) throw new NotFoundException('Return leg-1 schedule not found');
|
||||
if (!retL2Sched) throw new NotFoundException('Return leg-2 schedule not found');
|
||||
|
||||
if (Date.now() >= obL1Sched.departureAt.getTime() - BOOKING_CUTOFF_MS) {
|
||||
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
|
||||
}
|
||||
|
||||
const obL1Origin = obL1Sched.stopTimes.find(s => s.stationId === dto.originStationId);
|
||||
const obL1Dest = obL1Sched.stopTimes.find(s => s.stationId === dto.transitStationId);
|
||||
const obL2Origin = obL2Sched.stopTimes.find(s => s.stationId === dto.transitStationId);
|
||||
@@ -893,6 +932,10 @@ export class GuestBookingService {
|
||||
if (!retL1Origin || !retL1Dest) throw new NotFoundException('Return leg-1: origin or transit stop not found');
|
||||
if (!retL2Origin || !retL2Dest) throw new NotFoundException('Return leg-2: transit or destination stop not found');
|
||||
|
||||
// Cut off relative to the passenger's actual boarding stop, using the same configurable
|
||||
// per-stop/route checkinMinutesBefore that already gated the seat hold and search result.
|
||||
assertWithinCheckinCutoff(obL1Sched, obL1Origin, dto.originStationId);
|
||||
|
||||
// Process passengers (verify once)
|
||||
const passengersData: any[] = [];
|
||||
let adultCount = 0, childCount = 0;
|
||||
@@ -977,7 +1020,7 @@ export class GuestBookingService {
|
||||
destinationStationId: dto.returnLeg2DestinationStationId,
|
||||
status: 'PENDING_PAYMENT',
|
||||
bookingType: 'ROUND_TRIP_TRANSIT',
|
||||
totalMinor, currency: displayCurrency, adultCount, childCount, displayCurrency, displayTotalMinor,
|
||||
totalMinor, currency: Currency.ETB, adultCount, childCount, displayCurrency, displayTotalMinor, // ETB charge basis; passenger currency in displayCurrency/displayTotalMinor
|
||||
leg2ScheduleId: dto.leg2ScheduleId,
|
||||
leg2OriginStationId: dto.transitStationId,
|
||||
leg2DestinationStationId: dto.leg2DestinationStationId,
|
||||
|
||||
@@ -140,10 +140,14 @@ export class CurrencyService {
|
||||
});
|
||||
|
||||
if (!exchangeRate) {
|
||||
this.logger.warn(
|
||||
`No exchange rate found for ${fromCurrency} to ${toCurrency}, using 1.0`,
|
||||
// H-2: fail closed. Never price at parity (1.0) when a required rate is absent — a silent 1.0
|
||||
// substitution underprices international fares ~100×. Reject the quote/booking instead.
|
||||
this.logger.error(
|
||||
`No exchange rate configured for ${fromCurrency}->${toCurrency}; refusing to price at parity`,
|
||||
);
|
||||
throw new BadRequestException(
|
||||
`No exchange rate configured for ${fromCurrency}->${toCurrency}`,
|
||||
);
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
const ageMs = Date.now() - exchangeRate.effectiveDate.getTime();
|
||||
|
||||
@@ -23,6 +23,8 @@ export class CurrencyController {
|
||||
}
|
||||
|
||||
@Put()
|
||||
@PassengerAdmin()
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Upsert an exchange rate for today' })
|
||||
@ApiResponse({ status: 200, description: 'Rate created or updated for today\'s effective date' })
|
||||
upsert(@Body() dto: UpsertExchangeRateDto) {
|
||||
@@ -30,6 +32,8 @@ export class CurrencyController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@PassengerAdmin()
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Update an exchange rate by ID' })
|
||||
@ApiParam({ name: 'id', description: 'CurrencyExchangeRate UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Rate updated' })
|
||||
|
||||
@@ -23,8 +23,11 @@ export class FareEngineService {
|
||||
|
||||
if (!originStop) throw new BadRequestException('Origin station not found on this route');
|
||||
if (!destStop) throw new BadRequestException('Destination station not found on this route');
|
||||
if (originStop.sequence >= destStop.sequence)
|
||||
throw new BadRequestException('Origin must come before destination in the route sequence');
|
||||
// Origin and destination must be distinct stops, but EITHER direction is valid: a round-trip
|
||||
// return leg traverses the same route high→low (e.g. C→A), so we price the segment by its
|
||||
// absolute distance rather than rejecting the reverse order.
|
||||
if (originStop.sequence === destStop.sequence)
|
||||
throw new BadRequestException('Origin and destination must be different stops on this route');
|
||||
|
||||
const seatClass = await this.prisma.seatClass.findUnique({ where: { id: dto.seatClassId } });
|
||||
if (!seatClass) throw new NotFoundException('Seat class not found');
|
||||
@@ -43,8 +46,8 @@ export class FareEngineService {
|
||||
},
|
||||
}) ?? seatClass;
|
||||
|
||||
const totalDistanceKm = destStop.distanceKm! - originStop.distanceKm!;
|
||||
if (totalDistanceKm < 0 || isNaN(totalDistanceKm))
|
||||
const totalDistanceKm = Math.abs(destStop.distanceKm! - originStop.distanceKm!);
|
||||
if (totalDistanceKm <= 0 || isNaN(totalDistanceKm))
|
||||
throw new BadRequestException('Invalid distance calculation - check route stop distances');
|
||||
|
||||
const now = new Date();
|
||||
|
||||
@@ -7,6 +7,7 @@ import { PushAdapter, NotificationChannel } from './notification.adapters';
|
||||
import { EmailClientService } from './email-client.service';
|
||||
import { SmsClientService } from './sms-client.service';
|
||||
import { CreateTemplateDto, UpdateTemplateDto } from './notifications.dto';
|
||||
import { resolveBookingSegment } from '../../common/utils/segment-resolver.utils';
|
||||
|
||||
export type NotificationChannelType = 'EMAIL' | 'SMS' | 'PUSH' | 'IN_APP';
|
||||
|
||||
@@ -353,27 +354,6 @@ export class NotificationsService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the user's actual boarding/alighting stations from the booking's originStationId /
|
||||
* destinationStationId via stopTimes, falling back to the schedule's full-route endpoints when
|
||||
* the booking has no segment override (e.g. older records or packages).
|
||||
*/
|
||||
private resolveSegmentStations(booking: any): { originStation: any; destinationStation: any } {
|
||||
const s = booking?.schedule ?? {};
|
||||
const stopTimes: any[] = s.stopTimes ?? [];
|
||||
const findStation = (stationId: string | null | undefined, fallback: any) => {
|
||||
if (stationId && stopTimes.length > 0) {
|
||||
const stop = stopTimes.find((st: any) => st.stationId === stationId);
|
||||
if (stop?.station) return stop.station;
|
||||
}
|
||||
return fallback ?? null;
|
||||
};
|
||||
return {
|
||||
originStation: findStation(booking?.originStationId, s.originStation),
|
||||
destinationStation: findStation(booking?.destinationStationId, s.destinationStation),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the interpolation context for the `booking.created` template. `trainSeatLines` is a
|
||||
* pre-joined block of one "Train/Seat: …" line per booked seat (multi-passenger bookings get
|
||||
@@ -400,17 +380,17 @@ export class NotificationsService {
|
||||
// Lead passenger (leg-1 seat). Booking has no contactName; the traveller name lives on the seat.
|
||||
const passengerName = seats[0]?.passengerName ?? 'Passenger';
|
||||
const payLink = `${process.env.PORTAL_URL ?? 'http://localhost:5174'}/booking/detail?ref=${ref}`;
|
||||
const { originStation: originSt, destinationStation: destSt } = this.resolveSegmentStations(booking);
|
||||
const segment = resolveBookingSegment(s, booking?.originStationId, booking?.destinationStationId);
|
||||
|
||||
return {
|
||||
passengerName,
|
||||
bookingRef: ref,
|
||||
origin: originSt?.name ?? '',
|
||||
destination: destSt?.name ?? '',
|
||||
origin: segment.origin?.name ?? '',
|
||||
destination: segment.destination?.name ?? '',
|
||||
trainSeatLines,
|
||||
travelDate: fmtDate(s.departureAt),
|
||||
departureTime: fmtTime(s.departureAt),
|
||||
arrivalTime: fmtTime(s.arrivalAt),
|
||||
travelDate: fmtDate(segment.departureAt),
|
||||
departureTime: fmtTime(segment.departureAt),
|
||||
arrivalTime: fmtTime(segment.arrivalAt),
|
||||
payLink,
|
||||
};
|
||||
}
|
||||
@@ -509,12 +489,12 @@ export class NotificationsService {
|
||||
|
||||
private buildTicketEmailText(booking: any, amount: string, currency: string, url: string): string {
|
||||
const s = booking.schedule ?? {};
|
||||
const dep = s.departureAt ? new Date(s.departureAt).toLocaleString('en-GB') : 'TBD';
|
||||
const segment = resolveBookingSegment(s, booking?.originStationId, booking?.destinationStationId);
|
||||
const dep = segment.departureAt ? new Date(segment.departureAt).toLocaleString('en-GB') : 'TBD';
|
||||
const passengers = (booking.seats ?? []).map((bs: any) => bs.passengerName).filter(Boolean).join(', ');
|
||||
const { originStation: originSt, destinationStation: destSt } = this.resolveSegmentStations(booking);
|
||||
return [
|
||||
`Booking ${booking.bookingRef} confirmed.`,
|
||||
`${originSt?.name ?? ''} -> ${destSt?.name ?? ''}`,
|
||||
`${segment.origin?.name ?? ''} -> ${segment.destination?.name ?? ''}`,
|
||||
`Train: ${s.train?.name ?? s.train?.number ?? ''}`,
|
||||
`Departs: ${dep}`,
|
||||
passengers ? `Passengers: ${passengers}` : '',
|
||||
@@ -527,7 +507,9 @@ export class NotificationsService {
|
||||
const s = booking.schedule ?? {};
|
||||
const fmt = (d: any) =>
|
||||
d ? new Date(d).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }) : 'TBD';
|
||||
const { originStation: originSt, destinationStation: destSt } = this.resolveSegmentStations(booking);
|
||||
const segment = resolveBookingSegment(s, booking?.originStationId, booking?.destinationStationId);
|
||||
const originSt = segment.origin;
|
||||
const destSt = segment.destination;
|
||||
const seatRows = (booking.seats ?? [])
|
||||
.map((bs: any) => {
|
||||
const coach = bs.seat?.coach?.number ?? '-';
|
||||
@@ -568,11 +550,11 @@ export class NotificationsService {
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 0;color:#666;">Departs</td>
|
||||
<td style="padding:8px 0;text-align:right;">${fmt(s.departureAt)}</td>
|
||||
<td style="padding:8px 0;text-align:right;">${fmt(segment.departureAt)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 0;color:#666;">Arrives</td>
|
||||
<td style="padding:8px 0;text-align:right;">${fmt(s.arrivalAt)}</td>
|
||||
<td style="padding:8px 0;text-align:right;">${fmt(segment.arrivalAt)}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -636,12 +618,12 @@ export class NotificationsService {
|
||||
const fmt = (d: any) =>
|
||||
d ? new Date(d).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }) : 'TBD';
|
||||
const legLabel = leg ? ` (${leg.replace(/_/g, ' ')})` : '';
|
||||
const { originStation: originSt, destinationStation: destSt } = this.resolveSegmentStations(booking);
|
||||
const origin = originSt?.name ?? '';
|
||||
const dest = destSt?.name ?? '';
|
||||
const segment = resolveBookingSegment(s, booking?.originStationId, booking?.destinationStationId);
|
||||
const origin = segment.origin?.name ?? '';
|
||||
const dest = segment.destination?.name ?? '';
|
||||
const train = s.train?.name ?? s.train?.number ?? '';
|
||||
const dep = fmt(s.departureAt);
|
||||
const arr = fmt(s.arrivalAt);
|
||||
const dep = fmt(segment.departureAt);
|
||||
const arr = fmt(segment.arrivalAt);
|
||||
|
||||
const seats: { name: string; coach: string; seat: string; cls: string }[] = (booking.seats ?? []).map((bs: any) => ({
|
||||
name: bs.passengerName ?? '',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
BadGatewayException,
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
@@ -154,11 +155,16 @@ export class PaymentClientService {
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError && err.response) {
|
||||
// 4xx/5xx from the payment service: propagate 404 to callers that handle it;
|
||||
// everything else is a gateway-level failure from the client's perspective.
|
||||
// 409 = a legitimate conflict (e.g. another provider's payment is already in
|
||||
// flight for this booking) — surface its message as-is rather than masking it as
|
||||
// a gateway failure; everything else is a genuine gateway-level failure.
|
||||
if (err.response.status === 404) throw err;
|
||||
const detail =
|
||||
(err.response.data as { message?: string | string[] })?.message ??
|
||||
err.message;
|
||||
if (err.response.status === 409) {
|
||||
throw new ConflictException(detail);
|
||||
}
|
||||
this.logger.error(
|
||||
`payment service ${method} ${path} → ${err.response.status}: ${detail}`,
|
||||
);
|
||||
|
||||
@@ -242,6 +242,61 @@ export class PaymentsService {
|
||||
return this.initiateWalletPayment(booking);
|
||||
}
|
||||
|
||||
// Double-charge guard for payment-method switches. Before opening a fresh charge over
|
||||
// this booking, reconcile any still-open intent against the authoritative provider
|
||||
// status — the booking-status check above only blocks once the booking is CONFIRMED,
|
||||
// which leaves a window where the first attempt actually paid but the mark-paid
|
||||
// webhook/poll hasn't landed yet.
|
||||
const existingIntent = await this.prisma.paymentIntent.findUnique({
|
||||
where: { bookingId: booking.id },
|
||||
});
|
||||
if (existingIntent && NON_TERMINAL_STATUSES.includes(existingIntent.status)) {
|
||||
let snapshot: PaymentIntentSnapshot | null = null;
|
||||
try {
|
||||
snapshot = await this.paymentClient.getIntentByReference(
|
||||
PaymentReferenceType.BOOKING,
|
||||
booking.id,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.warn(
|
||||
`payment reconcile before initiate failed for booking ${booking.id}: ${message}; treating existing intent as still open`,
|
||||
);
|
||||
}
|
||||
|
||||
// The previous attempt actually paid (provider SUCCEEDED, event just late):
|
||||
// converge the booking now and return it — never charge a second time.
|
||||
if (snapshot?.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||
let intent = await this.syncIntentProjection(booking.id, snapshot);
|
||||
await this.finalizePaymentSuccess({
|
||||
intentId: intent.id,
|
||||
providerTxnId: snapshot.providerTxnId,
|
||||
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
|
||||
});
|
||||
intent = await this.prisma.paymentIntent.findUniqueOrThrow({
|
||||
where: { id: intent.id },
|
||||
});
|
||||
return this.formatIntentResponse(intent);
|
||||
}
|
||||
|
||||
// Still pending at the provider (REQUIRES_ACTION/PROCESSING) — or the payment
|
||||
// service was unreachable and the local status is non-terminal. Block the switch:
|
||||
// return the existing intent so the payer completes or waits out the open attempt
|
||||
// rather than opening a second concurrent charge.
|
||||
if (
|
||||
!snapshot ||
|
||||
snapshot.status === ProviderPaymentStatus.REQUIRES_ACTION ||
|
||||
snapshot.status === ProviderPaymentStatus.PROCESSING
|
||||
) {
|
||||
const intent = snapshot
|
||||
? await this.syncIntentProjection(booking.id, snapshot)
|
||||
: existingIntent;
|
||||
return this.formatIntentResponse(intent);
|
||||
}
|
||||
// Otherwise the provider reports FAILED/CANCELLED — fall through and initiate
|
||||
// the newly selected method below.
|
||||
}
|
||||
|
||||
const { returnUrl, failureUrl } = this.resolveReturnUrls(
|
||||
method,
|
||||
requestOrigin,
|
||||
@@ -1017,6 +1072,19 @@ export class PaymentsService {
|
||||
return { processed: false, reason: "booking-not-found" };
|
||||
}
|
||||
|
||||
// C-4 guard: a settlement must cover what the passenger was quoted. Compare the provider-settled
|
||||
// amount against the booking's display-currency total (the amount the customer agreed to pay);
|
||||
// a short payment must NOT confirm the booking. Amount-only — the display↔charge currency
|
||||
// divergence is tracked separately under the USD/DJF findings. The 1% tolerance absorbs rounding.
|
||||
const expectedMinor = booking.displayTotalMinor ?? booking.totalMinor;
|
||||
const shortPayTolerance = Math.max(1, Math.round(expectedMinor * 0.01));
|
||||
if (event.amountMinor < expectedMinor - shortPayTolerance) {
|
||||
this.logger.error(
|
||||
`mark-paid: short payment for booking ${booking.id} — settled ${event.amountMinor} ${event.currency} < expected ${expectedMinor} ${booking.displayCurrency}; not confirming`,
|
||||
);
|
||||
return { processed: false, reason: "amount-mismatch" };
|
||||
}
|
||||
|
||||
// Local intent row is a projection during the strangler migration: reuse it when the
|
||||
// legacy initiate path created one, otherwise materialize it from the event.
|
||||
let intent = await this.prisma.paymentIntent.findUnique({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsString, IsOptional, IsInt, IsBoolean } from 'class-validator';
|
||||
import { IsString, IsOptional, IsInt, IsBoolean, Min, Max } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class CreatePromotionDto {
|
||||
@@ -15,14 +15,17 @@ export class CreatePromotionDto {
|
||||
@IsString()
|
||||
subtitle?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 15 })
|
||||
@ApiPropertyOptional({ example: 15, description: 'Percentage discount, bounded 0..100' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
percentOff?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 5000 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
amountOffMinor?: number;
|
||||
|
||||
@ApiProperty({ example: '2026-12-31T23:59:59Z' })
|
||||
|
||||
@@ -42,7 +42,7 @@ Route stops carry distanceKm for fare-by-distance calculations.`,
|
||||
|
||||
@Patch(':id')
|
||||
@PassengerStaff([PASSENGER_PERMS.routes.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Update route metadata (name, description, active flag, effectiveUntil)' })
|
||||
@ApiOperation({ summary: 'Update route metadata (name, description, active flag, effectiveFrom, effectiveUntil)' })
|
||||
@ApiParam({ name: 'id', description: 'Route UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Route updated' })
|
||||
@ApiResponse({ status: 404, description: 'Route not found' })
|
||||
|
||||
@@ -5,8 +5,9 @@ import { Type } from 'class-transformer';
|
||||
export class RouteStopInputDto {
|
||||
@ApiProperty({ example: 'station-uuid', description: 'Station UUID' }) @IsString() stationId: string;
|
||||
@ApiProperty({ example: 1, description: 'Stop order (1 = origin, ascending)' }) @IsInt() @Min(1) sequence: number;
|
||||
@ApiPropertyOptional({ example: 120.5, description: 'Distance in km from previous stop' }) @IsOptional() @IsNumber() distanceKm?: number;
|
||||
@ApiPropertyOptional({ example: 120.5, description: 'Cumulative distance in km from the route origin (not from the previous stop)' }) @IsOptional() @IsNumber() distanceKm?: number;
|
||||
@ApiPropertyOptional({ example: 45, description: 'Override check-in cutoff (minutes) for this stop. Falls back to route-level checkinMinutesBefore if omitted.' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number;
|
||||
@ApiPropertyOptional({ example: 40, description: 'Travel time in minutes from the previous stop, used to estimate this stop\'s arrival time. Ignored for sequence 1 (origin, no predecessor). Falls back to distance-proportional interpolation if omitted.' }) @IsOptional() @IsInt() @Min(1) travelMinutesToStop?: number;
|
||||
}
|
||||
|
||||
export class CreateRouteDto {
|
||||
@@ -14,8 +15,9 @@ export class CreateRouteDto {
|
||||
@ApiProperty({ example: 'Addis Ababa – Djibouti' }) @IsString() name: string;
|
||||
@ApiPropertyOptional({ example: 'Main corridor via Dire Dawa' }) @IsOptional() @IsString() description?: string;
|
||||
@ApiProperty({ example: '2026-01-01T00:00:00Z', description: 'Date from which this route is effective' }) @IsDateString() effectiveFrom: string;
|
||||
@ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string;
|
||||
@ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string | null;
|
||||
@ApiPropertyOptional({ example: true, description: 'Whether the route is active (defaults to true)' }) @IsOptional() @IsBoolean() active?: boolean;
|
||||
@ApiPropertyOptional({ example: 30, description: 'Minutes before departure to close check-in for this route (defaults to 30 if omitted)' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number;
|
||||
@ApiProperty({
|
||||
type: [RouteStopInputDto],
|
||||
description: 'Ordered stops for this route. Sequence 1 = origin, last sequence = destination.',
|
||||
@@ -35,15 +37,17 @@ export class CreateRouteDto {
|
||||
export class AddRouteStopDto {
|
||||
@ApiProperty({ example: 'station-uuid' }) @IsString() stationId: string;
|
||||
@ApiProperty({ example: 3 }) @IsInt() @Min(1) sequence: number;
|
||||
@ApiPropertyOptional({ example: 75.5 }) @IsOptional() @IsNumber() distanceKm?: number;
|
||||
@ApiPropertyOptional({ example: 75.5, description: 'Cumulative distance in km from the route origin (not from the previous stop)' }) @IsOptional() @IsNumber() distanceKm?: number;
|
||||
@ApiPropertyOptional({ example: 45, description: 'Override check-in cutoff (minutes) for this stop. Falls back to route-level checkinMinutesBefore if omitted.' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number;
|
||||
@ApiPropertyOptional({ example: 40, description: 'Travel time in minutes from the previous stop, used to estimate this stop\'s arrival time. Falls back to distance-proportional interpolation if omitted.' }) @IsOptional() @IsInt() @Min(1) travelMinutesToStop?: number;
|
||||
}
|
||||
|
||||
export class UpdateRouteDto {
|
||||
@ApiPropertyOptional({ example: 'Addis Ababa – Djibouti Express' }) @IsOptional() @IsString() name?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() description?: string;
|
||||
@ApiPropertyOptional({ example: true }) @IsOptional() @IsBoolean() active?: boolean;
|
||||
@ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string;
|
||||
@ApiPropertyOptional({ example: '2026-01-01T00:00:00Z', description: 'Date from which this route is effective' }) @IsOptional() @IsDateString() effectiveFrom?: string;
|
||||
@ApiPropertyOptional({ example: '2027-12-31T23:59:59Z', description: 'Send null to clear (open-ended route)' }) @IsOptional() @IsDateString() effectiveUntil?: string | null;
|
||||
@ApiPropertyOptional({ example: 30, description: 'Minutes before departure to close check-in for this route' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number;
|
||||
@ApiPropertyOptional({ type: [RouteStopInputDto] }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => RouteStopInputDto) stops?: RouteStopInputDto[];
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { PrismaService } from '../../common/prisma.service';
|
||||
import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto, SetRouteCoachTemplateDto } from './routes.dto';
|
||||
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
|
||||
import { AuditService } from '../../common/audit.service';
|
||||
import { parseEthiopianTime } from '../../common/utils/timezone.utils';
|
||||
import { computePlannedStopTimes } from '../../common/utils/schedule-times.utils';
|
||||
|
||||
@Injectable()
|
||||
export class RoutesService {
|
||||
@@ -10,6 +12,33 @@ export class RoutesService {
|
||||
|
||||
// ── Route CRUD ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* distanceKm is CUMULATIVE distance from the route origin, not distance from the previous
|
||||
* stop (that's what travelMinutesToStop is for) — fare pricing computes a segment's distance
|
||||
* as destStop.distanceKm - originStop.distanceKm, so a route with equal or decreasing values
|
||||
* across stops silently produces zero/negative segment distances, which the fare engine
|
||||
* rejects (caught and swallowed by search into a bare "N/A" instead of a visible error). Catch
|
||||
* the mistake here instead, with a message that names the exact stops involved.
|
||||
*/
|
||||
private validateStopDistances(stops: { sequence: number; stationId: string; distanceKm?: number | null }[]): void {
|
||||
const sorted = [...stops].sort((a, b) => a.sequence - b.sequence);
|
||||
let prevDistance = sorted[0]?.distanceKm ?? 0;
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const stop = sorted[i];
|
||||
if (stop.distanceKm == null) {
|
||||
throw new BadRequestException(
|
||||
`Stop ${stop.sequence} is missing distanceKm (cumulative distance in km from the route origin). This is required for fare pricing.`,
|
||||
);
|
||||
}
|
||||
if (stop.distanceKm <= prevDistance) {
|
||||
throw new BadRequestException(
|
||||
`Stop ${stop.sequence}'s distanceKm (${stop.distanceKm}) must be greater than stop ${sorted[i - 1].sequence}'s distanceKm (${prevDistance}) — distanceKm is cumulative distance from the route origin, not distance from the previous stop. Equal or decreasing values make fare pricing between these stops fail silently.`,
|
||||
);
|
||||
}
|
||||
prevDistance = stop.distanceKm;
|
||||
}
|
||||
}
|
||||
|
||||
async createRoute(dto: CreateRouteDto) {
|
||||
const existing = await this.prisma.route.findUnique({ where: { code: dto.code } });
|
||||
if (existing) throw new ConflictException(`Route code "${dto.code}" already exists`);
|
||||
@@ -19,6 +48,8 @@ export class RoutesService {
|
||||
const seqs = dto.stops.map(s => s.sequence);
|
||||
if (new Set(seqs).size !== seqs.length) throw new ConflictException('Duplicate sequence numbers in stop list');
|
||||
|
||||
this.validateStopDistances(dto.stops);
|
||||
|
||||
const stationIds = [...new Set(dto.stops.map(s => s.stationId))];
|
||||
const stations = await this.prisma.station.findMany({ where: { id: { in: stationIds } } });
|
||||
if (stations.length !== stationIds.length) throw new BadRequestException('One or more station IDs not found');
|
||||
@@ -29,14 +60,16 @@ export class RoutesService {
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
active: dto.active ?? true,
|
||||
effectiveFrom: new Date(dto.effectiveFrom),
|
||||
effectiveUntil: dto.effectiveUntil ? new Date(dto.effectiveUntil) : null,
|
||||
...(dto.checkinMinutesBefore != null ? { checkinMinutesBefore: dto.checkinMinutesBefore } : {}),
|
||||
effectiveFrom: parseEthiopianTime(dto.effectiveFrom),
|
||||
effectiveUntil: dto.effectiveUntil ? parseEthiopianTime(dto.effectiveUntil) : null,
|
||||
stops: {
|
||||
create: dto.stops.map(s => ({
|
||||
stationId: s.stationId,
|
||||
sequence: s.sequence,
|
||||
distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null,
|
||||
checkinMinutesBefore: s.checkinMinutesBefore ?? null,
|
||||
travelMinutesToStop: s.travelMinutesToStop ?? null,
|
||||
})),
|
||||
},
|
||||
},
|
||||
@@ -86,13 +119,20 @@ export class RoutesService {
|
||||
const route = await this.prisma.route.findUnique({ where: { id } });
|
||||
if (!route) throw new NotFoundException('Route not found');
|
||||
|
||||
if (dto.stops && dto.stops.length >= 2) this.validateStopDistances(dto.stops);
|
||||
|
||||
await this.prisma.route.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
active: dto.active,
|
||||
effectiveUntil: dto.effectiveUntil ? new Date(dto.effectiveUntil) : undefined,
|
||||
...(dto.effectiveFrom ? { effectiveFrom: parseEthiopianTime(dto.effectiveFrom) } : {}),
|
||||
// effectiveUntil is nullable (open-ended route) — distinguish "field not sent" (leave
|
||||
// untouched) from "explicitly cleared" (null → set to null), not just truthy/falsy.
|
||||
...(dto.effectiveUntil !== undefined
|
||||
? { effectiveUntil: dto.effectiveUntil ? parseEthiopianTime(dto.effectiveUntil) : null }
|
||||
: {}),
|
||||
...(dto.checkinMinutesBefore != null ? { checkinMinutesBefore: dto.checkinMinutesBefore } : {}),
|
||||
},
|
||||
});
|
||||
@@ -106,8 +146,23 @@ export class RoutesService {
|
||||
sequence: s.sequence,
|
||||
distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null,
|
||||
checkinMinutesBefore: s.checkinMinutesBefore ?? null,
|
||||
travelMinutesToStop: s.travelMinutesToStop ?? null,
|
||||
})),
|
||||
});
|
||||
|
||||
// Propagate new stop timing to all future schedules on this route so that
|
||||
// per-stop check-in cutoffs reflect the updated travelMinutesToStop values.
|
||||
const futureSchedules = await this.prisma.trainSchedule.findMany({
|
||||
where: { routeId: id, status: { in: ['SCHEDULED', 'BOARDING'] }, departureAt: { gt: new Date() } },
|
||||
select: { id: true, departureAt: true, arrivalAt: true },
|
||||
});
|
||||
const stopsForTiming = dto.stops
|
||||
.map(s => ({ sequence: s.sequence, distanceKm: s.distanceKm ?? null, travelMinutesToStop: s.travelMinutesToStop ?? null, checkinMinutesBefore: s.checkinMinutesBefore ?? null }))
|
||||
.sort((a, b) => a.sequence - b.sequence);
|
||||
for (const sched of futureSchedules) {
|
||||
const times = computePlannedStopTimes({ id, stops: stopsForTiming }, new Date(sched.departureAt), new Date(sched.arrivalAt));
|
||||
await this.applyRouteToSchedule(id, sched.id, Object.fromEntries(times.map(t => [t.sequence, t])));
|
||||
}
|
||||
}
|
||||
|
||||
await this.auditService.log({ action: 'UPDATE', entityType: 'Route', entityId: id, newData: { name: dto.name, active: dto.active } });
|
||||
@@ -218,6 +273,9 @@ export class RoutesService {
|
||||
});
|
||||
if (existing) throw new ConflictException(`Sequence ${dto.sequence} already exists on this route`);
|
||||
|
||||
const otherStops = await this.prisma.routeStop.findMany({ where: { routeId } });
|
||||
this.validateStopDistances([...otherStops, { sequence: dto.sequence, stationId: dto.stationId, distanceKm: dto.distanceKm }]);
|
||||
|
||||
return this.prisma.routeStop.create({
|
||||
data: {
|
||||
routeId,
|
||||
@@ -225,6 +283,7 @@ export class RoutesService {
|
||||
sequence: dto.sequence,
|
||||
distanceKm: dto.distanceKm != null ? parseFloat(String(dto.distanceKm)) : null,
|
||||
checkinMinutesBefore: dto.checkinMinutesBefore ?? null,
|
||||
travelMinutesToStop: dto.travelMinutesToStop ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -154,6 +154,12 @@ export class SchedulesController {
|
||||
@ApiQuery({ name: 'cascade', required: false, type: Boolean })
|
||||
deleteSchedule(@Param('id') id: string, @Query('cascade') cascade?: string) { return this.service.deleteSchedule(id, cascade === 'true'); }
|
||||
|
||||
@Post(':id/recalculate-stops')
|
||||
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Recompute TripStopTime records from current route travelMinutesToStop values' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
recalculateStops(@Param('id') id: string) { return this.service.recalculateStopTimes(id); }
|
||||
|
||||
@Get(':id/stops')
|
||||
@IsPublic()
|
||||
@ApiOperation({ summary: 'List all stops for a schedule' })
|
||||
|
||||
@@ -52,6 +52,10 @@ export class CreateScheduleDto {
|
||||
})
|
||||
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto)
|
||||
plannedTimes?: PlannedStopTimeDto[];
|
||||
|
||||
@ApiPropertyOptional({ type: [String], description: 'Coach UUIDs to assign, in consist order. Overrides the route coach template if provided. A schedule must end up with at least one coach.' })
|
||||
@IsOptional() @IsArray() @IsString({ each: true })
|
||||
coachIds?: string[];
|
||||
}
|
||||
|
||||
export class UpdateScheduleDto {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { RoutesService } from './routes.service';
|
||||
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
||||
@@ -6,9 +6,12 @@ import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateSchedule
|
||||
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
|
||||
import { parseEthiopianTime, startOfDayEAT, startOfNextDayEAT } from '../../common/utils/timezone.utils';
|
||||
import { AuditService } from '../../common/audit.service';
|
||||
import { computePlannedStopTimes } from '../../common/utils/schedule-times.utils';
|
||||
|
||||
@Injectable()
|
||||
export class SchedulesService {
|
||||
private readonly logger = new Logger(SchedulesService.name);
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private routesService: RoutesService,
|
||||
@@ -43,20 +46,14 @@ export class SchedulesService {
|
||||
departureAt: departureAt.toISOString(),
|
||||
arrivalAt: arrivalAt.toISOString(),
|
||||
plannedTimes: dto.plannedTimes || [],
|
||||
coachIds: dto.coachIds,
|
||||
};
|
||||
|
||||
// createSchedule applies coachIds if given, else auto-applies the route coach template,
|
||||
// and rejects the day outright (caught below) if it would end up with zero coaches.
|
||||
const schedule = await this.createSchedule(createDto);
|
||||
scheduleIds.push(schedule.id);
|
||||
|
||||
// createSchedule already auto-applies the route coach template;
|
||||
// only override if explicit coachIds are provided
|
||||
if (dto.coachIds && dto.coachIds.length > 0) {
|
||||
await this.assignCoaches(
|
||||
schedule.id,
|
||||
dto.coachIds.map((coachId, idx) => ({ coachId, positionNumber: idx + 1 })),
|
||||
);
|
||||
}
|
||||
|
||||
scheduleCount++;
|
||||
} catch (error) {
|
||||
errors.push(`Failed to create schedule for ${currentDate.toISOString()}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
@@ -103,6 +100,8 @@ export class SchedulesService {
|
||||
const dep = parseEthiopianTime(dto.departureAt);
|
||||
const arr = parseEthiopianTime(dto.arrivalAt);
|
||||
if (arr <= dep) throw new BadRequestException('arrivalAt must be after departureAt');
|
||||
// M-4: a new schedule cannot depart in the past — the backoffice form does not enforce this.
|
||||
if (dep.getTime() < Date.now()) throw new BadRequestException('departureAt must be in the future');
|
||||
|
||||
const [train, route] = await Promise.all([
|
||||
this.prisma.train.findUnique({ where: { id: dto.trainId } }),
|
||||
@@ -133,26 +132,7 @@ export class SchedulesService {
|
||||
|
||||
let plannedTimes = dto.plannedTimes;
|
||||
if (!plannedTimes || plannedTimes.length === 0) {
|
||||
const totalDuration = arr.getTime() - dep.getTime();
|
||||
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
|
||||
|
||||
plannedTimes = route.stops.map((stop, index) => {
|
||||
let stopTime: Date;
|
||||
if (index === 0) {
|
||||
stopTime = dep;
|
||||
} else if (index === route.stops.length - 1) {
|
||||
stopTime = arr;
|
||||
} else {
|
||||
const stopDistance = stop.distanceKm || 0;
|
||||
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
|
||||
stopTime = new Date(dep.getTime() + totalDuration * progress);
|
||||
}
|
||||
return {
|
||||
sequence: stop.sequence,
|
||||
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
|
||||
plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(),
|
||||
};
|
||||
});
|
||||
plannedTimes = computePlannedStopTimes(route, dep, arr);
|
||||
}
|
||||
|
||||
const providedSeqs = new Set((plannedTimes ?? []).map(t => t.sequence));
|
||||
@@ -181,15 +161,34 @@ export class SchedulesService {
|
||||
const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
|
||||
await this.routesService.applyRouteToSchedule(dto.routeId, schedule.id, plannedTimesMap);
|
||||
|
||||
// Auto-apply route coach template if one is defined
|
||||
const coachTemplates = await this.prisma.routeCoachTemplate.findMany({
|
||||
where: { routeId: dto.routeId },
|
||||
orderBy: { positionNumber: 'asc' },
|
||||
});
|
||||
if (coachTemplates.length > 0) {
|
||||
// Explicit coachIds (from the schedule form's Coaches step) override the route's coach
|
||||
// template; otherwise auto-apply the template if one is defined.
|
||||
if (dto.coachIds && dto.coachIds.length > 0) {
|
||||
await this.assignCoaches(
|
||||
schedule.id,
|
||||
coachTemplates.map(t => ({ coachId: t.coachId, positionNumber: t.positionNumber })),
|
||||
dto.coachIds.map((coachId, idx) => ({ coachId, positionNumber: idx + 1 })),
|
||||
);
|
||||
} else {
|
||||
const coachTemplates = await this.prisma.routeCoachTemplate.findMany({
|
||||
where: { routeId: dto.routeId },
|
||||
orderBy: { positionNumber: 'asc' },
|
||||
});
|
||||
if (coachTemplates.length > 0) {
|
||||
await this.assignCoaches(
|
||||
schedule.id,
|
||||
coachTemplates.map(t => ({ coachId: t.coachId, positionNumber: t.positionNumber })),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// A schedule with zero coaches has zero seats and is silently invisible to search (and
|
||||
// unbookable) with no indication why — block creation instead of leaving a dead schedule.
|
||||
const assignedCoachCount = await this.prisma.coachAssignment.count({ where: { scheduleId: schedule.id } });
|
||||
if (assignedCoachCount === 0) {
|
||||
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: schedule.id } });
|
||||
await this.prisma.trainSchedule.delete({ where: { id: schedule.id } });
|
||||
throw new BadRequestException(
|
||||
'A schedule must have at least one coach assigned to be bookable. Add coaches in the Coaches step, or set a Route Coach Template on this route so new schedules auto-assign coaches.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -304,26 +303,7 @@ export class SchedulesService {
|
||||
|
||||
let plannedTimes = dto.plannedTimes;
|
||||
if (!plannedTimes || plannedTimes.length === 0) {
|
||||
const totalDuration = arr.getTime() - dep.getTime();
|
||||
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
|
||||
|
||||
plannedTimes = route.stops.map((stop, index) => {
|
||||
let stopTime: Date;
|
||||
if (index === 0) {
|
||||
stopTime = dep;
|
||||
} else if (index === route.stops.length - 1) {
|
||||
stopTime = arr;
|
||||
} else {
|
||||
const stopDistance = stop.distanceKm || 0;
|
||||
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
|
||||
stopTime = new Date(dep.getTime() + totalDuration * progress);
|
||||
}
|
||||
return {
|
||||
sequence: stop.sequence,
|
||||
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
|
||||
plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(),
|
||||
};
|
||||
});
|
||||
plannedTimes = computePlannedStopTimes(route, dep, arr);
|
||||
}
|
||||
|
||||
const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
|
||||
@@ -617,6 +597,24 @@ export class SchedulesService {
|
||||
return { synced, errors };
|
||||
}
|
||||
|
||||
async recalculateStopTimes(scheduleId: string) {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: scheduleId },
|
||||
include: { route: { include: { stops: { orderBy: { sequence: 'asc' } } } } },
|
||||
});
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
if (!schedule.routeId || !schedule.route) throw new BadRequestException('Schedule has no associated route');
|
||||
|
||||
const plannedTimes = computePlannedStopTimes(
|
||||
schedule.route,
|
||||
new Date(schedule.departureAt),
|
||||
new Date(schedule.arrivalAt),
|
||||
);
|
||||
const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
|
||||
await this.routesService.applyRouteToSchedule(schedule.routeId, scheduleId, plannedTimesMap);
|
||||
return { recalculated: true, scheduleId, stopCount: plannedTimes.length };
|
||||
}
|
||||
|
||||
async assignCoaches(scheduleId: string, coaches: Array<{ coachId: string; positionNumber: number }>) {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId } });
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
@@ -653,11 +651,14 @@ export class SchedulesService {
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
|
||||
const updateData: any = {};
|
||||
let dep: Date | undefined;
|
||||
let arr: Date | undefined;
|
||||
|
||||
if (dto.departureAt || dto.arrivalAt) {
|
||||
const dep = dto.departureAt ? parseEthiopianTime(dto.departureAt) : new Date(schedule.departureAt);
|
||||
const arr = dto.arrivalAt ? parseEthiopianTime(dto.arrivalAt) : new Date(schedule.arrivalAt);
|
||||
dep = dto.departureAt ? parseEthiopianTime(dto.departureAt) : new Date(schedule.departureAt);
|
||||
arr = dto.arrivalAt ? parseEthiopianTime(dto.arrivalAt) : new Date(schedule.arrivalAt);
|
||||
if (arr <= dep) throw new BadRequestException('Arrival time must be after departure time');
|
||||
if (dep.getTime() < Date.now()) throw new BadRequestException('departureAt must be in the future');
|
||||
updateData.departureAt = dep;
|
||||
updateData.arrivalAt = arr;
|
||||
updateData.durationMinutes = Math.round((arr.getTime() - dep.getTime()) / 60_000);
|
||||
@@ -670,6 +671,22 @@ export class SchedulesService {
|
||||
await this.prisma.trainSchedule.update({ where: { id }, data: updateData });
|
||||
}
|
||||
|
||||
// departureAt/arrivalAt changed — the per-stop TripStopTime rows were computed against the
|
||||
// OLD times and are now stale (same interpolation createSchedule/updateSchedule use). Left
|
||||
// unfixed, check-in cutoff enforcement and search silently keep using outdated per-stop
|
||||
// arrival/departure estimates for every intermediate stop.
|
||||
if (dep && arr && schedule.routeId) {
|
||||
const route = await this.prisma.route.findUnique({
|
||||
where: { id: schedule.routeId },
|
||||
include: { stops: { orderBy: { sequence: 'asc' } } },
|
||||
});
|
||||
if (route && route.stops.length >= 2) {
|
||||
const plannedTimes = computePlannedStopTimes(route, dep, arr);
|
||||
const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
|
||||
await this.routesService.applyRouteToSchedule(schedule.routeId, id, plannedTimesMap);
|
||||
}
|
||||
}
|
||||
|
||||
if (dto.coaches !== undefined) {
|
||||
if (dto.coaches.length > 0) {
|
||||
await this.assignCoaches(id, dto.coaches);
|
||||
|
||||
@@ -10,7 +10,8 @@ import { CurrencyService } from "../currency/currency.service";
|
||||
import { FareEngineService } from "../fare-engine/fare-engine.service";
|
||||
import { SegmentsService } from "../segments/segments.service";
|
||||
import { resolveCurrencyFromNationality } from "../fare-engine/fare-engine.dto";
|
||||
import { Currency } from "@prisma/client";
|
||||
import { resolveCheckinCutoff } from "../../common/utils/checkin-cutoff.utils";
|
||||
import { Currency, Prisma } from "@prisma/client";
|
||||
|
||||
const POINTS_TO_MINOR = 10;
|
||||
|
||||
@@ -220,12 +221,18 @@ export class SearchService {
|
||||
const totalPassengers = adultCount + (childCount ?? 0);
|
||||
const NEEDED = 3;
|
||||
|
||||
const baseWhere = {
|
||||
status: "SCHEDULED",
|
||||
// Include BOARDING alongside SCHEDULED: BOARDING is just an operational display status the
|
||||
// schedule-level cron sets on a fixed 30-min-before-departure timer (see tasks.service.ts) —
|
||||
// it does NOT mean booking is closed. The actual booking cutoff is per-stop and configurable
|
||||
// (RouteStop/Route.checkinMinutesBefore), enforced below by buildScheduleResult's own live
|
||||
// check against each stop's estimated arrival/departure. Excluding BOARDING here would
|
||||
// silently impose a hidden, non-configurable 30-minute cutoff on top of that.
|
||||
const baseWhere: Prisma.TrainScheduleWhereInput = {
|
||||
status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] },
|
||||
isPackageOnly: false,
|
||||
stopTimes: { some: { stationId: originStationId } },
|
||||
coachAssignments: { some: {} },
|
||||
} as const;
|
||||
};
|
||||
|
||||
// Fetch candidates before and after in parallel; take more than needed to
|
||||
// account for routes that don't serve the destination or have no availability.
|
||||
@@ -300,23 +307,21 @@ export class SearchService {
|
||||
const nextDay = new Date(
|
||||
`${String(y)}-${String(m).padStart(2, "0")}-${String(d + 1).padStart(2, "0")}T00:00:00+03:00`,
|
||||
);
|
||||
const now = new Date();
|
||||
const totalPassengers = adultCount + (childCount ?? 0);
|
||||
|
||||
// Use now as the lower bound for today so we don't fetch schedules that have
|
||||
// already fully departed. The per-segment cutoff check in buildScheduleResult
|
||||
// handles the exact check using each stop's own plannedDepartureAt.
|
||||
const isToday =
|
||||
now.getFullYear() === y &&
|
||||
now.getMonth() === m - 1 &&
|
||||
now.getDate() === d;
|
||||
const earliest = isToday ? now : date;
|
||||
|
||||
// Match on the schedule's own departure DATE only — do NOT use `now` as a lower bound here.
|
||||
// A schedule whose origin has already departed (EN_ROUTE) can still have a later stop (e.g.
|
||||
// Lebu, Adama) whose own cutoff hasn't passed; using the overall departureAt as a floor would
|
||||
// wrongly exclude the whole schedule for those still-bookable downstream segments. The
|
||||
// per-segment cutoff check in buildScheduleResult is the sole authority for whether THIS
|
||||
// specific origin stop is still bookable, using each stop's own estimated arrival/departure.
|
||||
const schedules = await this.prisma.trainSchedule.findMany({
|
||||
where: {
|
||||
status: "SCHEDULED",
|
||||
// EN_ROUTE/BOARDING included alongside SCHEDULED — these are operational display
|
||||
// statuses, not booking-closed signals (see comment on searchAlternatives' baseWhere).
|
||||
status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] },
|
||||
isPackageOnly: false,
|
||||
departureAt: { gte: earliest, lt: nextDay },
|
||||
departureAt: { gte: date, lt: nextDay },
|
||||
stopTimes: { some: { stationId: originStationId } },
|
||||
coachAssignments: { some: {} },
|
||||
},
|
||||
@@ -368,7 +373,8 @@ export class SearchService {
|
||||
const [leg1Schedules, allCandidates] = await Promise.all([
|
||||
this.prisma.trainSchedule.findMany({
|
||||
where: {
|
||||
status: "SCHEDULED",
|
||||
// BOARDING included alongside SCHEDULED — see comment on searchAlternatives' baseWhere.
|
||||
status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] },
|
||||
isPackageOnly: false,
|
||||
departureAt: { gte: dayStart, lt: dayEnd },
|
||||
stopTimes: { some: { stationId: originStationId } },
|
||||
@@ -378,7 +384,7 @@ export class SearchService {
|
||||
}),
|
||||
this.prisma.trainSchedule.findMany({
|
||||
where: {
|
||||
status: "SCHEDULED",
|
||||
status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] },
|
||||
isPackageOnly: false,
|
||||
departureAt: { gte: dayStart, lt: leg2WindowEnd },
|
||||
coachAssignments: { some: {} },
|
||||
@@ -547,24 +553,13 @@ export class SearchService {
|
||||
if (!originStop || !destStop || originStop.sequence >= destStop.sequence)
|
||||
return null;
|
||||
|
||||
// Segment-level cutoff: use the origin stop's planned departure, not the
|
||||
// Segment-level cutoff: use the origin stop's own estimated arrival time, not the
|
||||
// schedule's overall departureAt (which is station A's time). This lets
|
||||
// B→D remain bookable even after A→D closes.
|
||||
// Cutoff resolution: stop-level override → route default → 30 min fallback.
|
||||
const now = new Date();
|
||||
const segmentDepartureAt =
|
||||
originStop.plannedDepartureAt ?? schedule.departureAt;
|
||||
const routeStop = schedule.route?.stops?.find(
|
||||
(s) => s.stationId === originStationId,
|
||||
);
|
||||
const checkinMinutes =
|
||||
routeStop?.checkinMinutesBefore ??
|
||||
schedule.route?.checkinMinutesBefore ??
|
||||
30;
|
||||
if (
|
||||
segmentDepartureAt.getTime() - now.getTime() <=
|
||||
checkinMinutes * 60 * 1000
|
||||
)
|
||||
// B→D remain bookable even after A→D closes. Stop-level checkinMinutesBefore override →
|
||||
// route default → 30 min fallback — same resolution GuestBookingService applies at
|
||||
// booking-creation time, so a segment shown as bookable here stays bookable through
|
||||
// checkout instead of being rejected against a different, hardcoded cutoff.
|
||||
if (Date.now() >= resolveCheckinCutoff(schedule, originStop, originStationId).cutoffAt.getTime())
|
||||
return null;
|
||||
|
||||
// Collect all valid seat IDs upfront for a single batch availability check
|
||||
@@ -676,8 +671,11 @@ export class SearchService {
|
||||
nationality,
|
||||
availabilityByClass,
|
||||
);
|
||||
const legDepartureAt = schedule.departureAt;
|
||||
const legArrivalAt = schedule.arrivalAt;
|
||||
// Use the selected stop's own planned time, not the schedule's full-route span —
|
||||
// for stop-based (mid-route) boarding/alighting these differ from the train's
|
||||
// overall origin departure / final destination arrival.
|
||||
const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
|
||||
const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt;
|
||||
|
||||
const displayCurrency =
|
||||
faresByClass[0]?.displayCurrency ??
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsString, IsInt, IsBoolean, IsOptional, IsIn } from 'class-validator';
|
||||
import { IsString, IsInt, IsBoolean, IsOptional, IsIn, Min } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||
|
||||
export class CreateSeatClassDto {
|
||||
@@ -27,11 +27,13 @@ export class CreateSeatClassDto {
|
||||
|
||||
@ApiProperty({ example: 3000, description: 'Per-km rate in minor units (tariff decimal × 100000)' })
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
basePrice: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 1200, description: 'Flat insurance fee in minor units' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
insuranceFeeMinor?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: true })
|
||||
|
||||
@@ -281,7 +281,7 @@ export class SeatsService {
|
||||
}),
|
||||
this.prisma.tripStopTime.findFirst({
|
||||
where: { scheduleId: dto.scheduleId, stationId: dto.originStationId },
|
||||
select: { plannedDepartureAt: true },
|
||||
select: { plannedArrivalAt: true, plannedDepartureAt: true },
|
||||
}),
|
||||
this.prisma.routeStop.findFirst({
|
||||
where: {
|
||||
@@ -295,7 +295,10 @@ export class SeatsService {
|
||||
|
||||
// Stop-level override wins; falls back to route-level; then to 30 min.
|
||||
const checkinMinutes = originRouteStop?.checkinMinutesBefore ?? schedule.route?.checkinMinutesBefore ?? 30;
|
||||
const segmentDepartureAt = originStopTime?.plannedDepartureAt ?? schedule.departureAt;
|
||||
// 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;
|
||||
const msUntilDeparture = segmentDepartureAt.getTime() - Date.now();
|
||||
if (msUntilDeparture <= checkinMinutes * 60 * 1000) {
|
||||
throw new BadRequestException(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Body, Controller, Get, Patch, SetMetadata, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||
import { SystemConfigService } from './system-config.service';
|
||||
import { UpdateSystemConfigDto } from './system-config.dto';
|
||||
import { IamGuard } from '../../common/iam-adapter';
|
||||
import { Roles } from '../../common/roles.decorator';
|
||||
|
||||
@@ -31,7 +32,12 @@ export class SystemConfigController {
|
||||
@UseGuards(IamGuard)
|
||||
@Roles('ADMIN')
|
||||
@ApiOperation({ summary: 'Update system config (admin)' })
|
||||
update(@Body() body: Record<string, string>) {
|
||||
return this.service.updateMany(body);
|
||||
update(@Body() dto: UpdateSystemConfigDto) {
|
||||
// The DTO validates/coerces each known key to a positive integer; persist back as strings.
|
||||
const entries: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(dto)) {
|
||||
if (value !== undefined) entries[key] = String(value);
|
||||
}
|
||||
return this.service.updateMany(entries);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { IsInt, IsOptional, Min, Max } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
/**
|
||||
* Whitelisted, typed body for `PATCH /config`. Config is persisted as string key/values, but every
|
||||
* known key is a positive integer (durations, hour windows, throttle limits/TTLs). Values arrive as
|
||||
* strings from the backoffice form; `@Type(() => Number)` coerces them so the numeric/range checks
|
||||
* apply (M-3 — the endpoint previously stored any raw string, e.g. `seat_hold_duration_minutes: -1`).
|
||||
* Unknown keys are stripped by the global whitelisting ValidationPipe.
|
||||
*/
|
||||
export class UpdateSystemConfigDto {
|
||||
@ApiPropertyOptional({ example: 5, description: 'Seat-hold duration in minutes (1..60)' })
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(60)
|
||||
seat_hold_duration_minutes?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 2 })
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(0)
|
||||
hold_cutoff_hours_before_departure?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 4 })
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(0)
|
||||
boarding_window_hours_before_departure?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 5 })
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1)
|
||||
throttle_auth_limit?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 60000 })
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1)
|
||||
throttle_auth_ttl_ms?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 20 })
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1)
|
||||
throttle_strict_limit?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 60000 })
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1)
|
||||
throttle_strict_ttl_ms?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 100 })
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1)
|
||||
throttle_default_limit?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 60000 })
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1)
|
||||
throttle_default_ttl_ms?: number;
|
||||
}
|
||||
@@ -81,17 +81,23 @@ export class TasksService {
|
||||
byRoute.get(stop.routeId)!.push(stop.stationId);
|
||||
}
|
||||
|
||||
// Arrival basis: each stop's own estimated arrival time, not its departure. The first
|
||||
// stop of a route has no arrival (nothing to arrive at), so it falls back to its
|
||||
// departure — expressed below as COALESCE(plannedArrivalAt, plannedDepartureAt).
|
||||
let reopenedCount = 0;
|
||||
let checkinClosedCount = 0;
|
||||
for (const [mins, byRoute] of byMins) {
|
||||
const cutoffAt = new Date(now.getTime() + mins * 60 * 1000);
|
||||
for (const [routeId, stationIds] of byRoute) {
|
||||
// Revert first: if the cutoff was reduced, stops that were prematurely closed
|
||||
// should reopen (departure is still beyond the new cutoff window).
|
||||
// should reopen (arrival is still beyond the new cutoff window).
|
||||
const reverted = await this.prisma.tripStopTime.updateMany({
|
||||
where: {
|
||||
status: 'CHECKIN_CLOSED',
|
||||
plannedDepartureAt: { gt: cutoffAt },
|
||||
OR: [
|
||||
{ plannedArrivalAt: { gt: cutoffAt } },
|
||||
{ AND: [{ plannedArrivalAt: null }, { plannedDepartureAt: { gt: cutoffAt } }] },
|
||||
],
|
||||
stationId: { in: stationIds },
|
||||
schedule: { routeId },
|
||||
},
|
||||
@@ -103,7 +109,10 @@ export class TasksService {
|
||||
const closed = await this.prisma.tripStopTime.updateMany({
|
||||
where: {
|
||||
status: 'OPEN',
|
||||
plannedDepartureAt: { lte: cutoffAt },
|
||||
OR: [
|
||||
{ plannedArrivalAt: { lte: cutoffAt } },
|
||||
{ AND: [{ plannedArrivalAt: null }, { plannedDepartureAt: { lte: cutoffAt } }] },
|
||||
],
|
||||
stationId: { in: stationIds },
|
||||
schedule: { routeId },
|
||||
},
|
||||
@@ -169,8 +178,8 @@ export class TasksService {
|
||||
include: {
|
||||
originStation: { select: { name: true } },
|
||||
destinationStation: { select: { name: true } },
|
||||
stopTimes: { select: { stationId: true, plannedDepartureAt: true } },
|
||||
route: { select: { checkinMinutesBefore: true } },
|
||||
stopTimes: { select: { stationId: true, plannedArrivalAt: true, plannedDepartureAt: true } },
|
||||
route: { select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -179,12 +188,17 @@ export class TasksService {
|
||||
for (const booking of bookings) {
|
||||
try {
|
||||
const createdAt = booking.createdAt as Date;
|
||||
// Use the booking's origin-segment departure and the route's own check-in window.
|
||||
// Use the booking's origin-segment estimated arrival (falling back to its departure
|
||||
// for the first stop) and that stop's own check-in window (falling back to the route
|
||||
// default), same resolution as holdSeats/search.
|
||||
const originStop = (booking.schedule as any).stopTimes?.find(
|
||||
(s: any) => s.stationId === (booking as any).originStationId,
|
||||
);
|
||||
const dep = (originStop?.plannedDepartureAt ?? booking.schedule.departureAt) as Date;
|
||||
const checkinMinutes = (booking.schedule as any).route?.checkinMinutesBefore ?? 30;
|
||||
const dep = (originStop?.plannedArrivalAt ?? originStop?.plannedDepartureAt ?? booking.schedule.departureAt) as Date;
|
||||
const originRouteStop = (booking.schedule as any).route?.stops?.find(
|
||||
(s: any) => s.stationId === (booking as any).originStationId,
|
||||
);
|
||||
const checkinMinutes = originRouteStop?.checkinMinutesBefore ?? (booking.schedule as any).route?.checkinMinutesBefore ?? 30;
|
||||
if (dep <= now) continue; // segment has already departed; cancel job handles clean-up
|
||||
const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes);
|
||||
const totalWindowMs = paymentDeadline.getTime() - createdAt.getTime();
|
||||
@@ -230,13 +244,28 @@ export class TasksService {
|
||||
|
||||
// ── Cancel bookings whose payment deadline has passed ─────────────────────
|
||||
private async cancelExpiredPendingBookings(now: Date) {
|
||||
const twoHoursAgo = new Date(now.getTime() - MAX_PAYMENT_HOURS * 60 * 60 * 1000);
|
||||
const departureCutoff = new Date(now.getTime() + CUTOFF_MINUTES * 60 * 1000);
|
||||
const twoHoursAgo = new Date(now.getTime() - MAX_PAYMENT_HOURS * 60 * 60 * 1000);
|
||||
|
||||
// payment_deadline = MIN(createdAt + 2h, departureAt - 30min)
|
||||
// The departure pre-filter below is a query-scoping optimization only — the real
|
||||
// deadline check happens per-row further down. It must be widened to the largest
|
||||
// configured checkinMinutes across all routes/stops, or a booking on a route with a
|
||||
// cutoff bigger than the CUTOFF_MINUTES default would never even be fetched here,
|
||||
// silently never getting auto-cancelled.
|
||||
const [maxRouteCutoff, maxStopCutoff] = await Promise.all([
|
||||
this.prisma.route.aggregate({ _max: { checkinMinutesBefore: true } }),
|
||||
this.prisma.routeStop.aggregate({ _max: { checkinMinutesBefore: true } }),
|
||||
]);
|
||||
const effectiveMaxCutoffMinutes = Math.max(
|
||||
CUTOFF_MINUTES,
|
||||
maxRouteCutoff._max.checkinMinutesBefore ?? 0,
|
||||
maxStopCutoff._max.checkinMinutesBefore ?? 0,
|
||||
);
|
||||
const departureCutoff = new Date(now.getTime() + effectiveMaxCutoffMinutes * 60 * 1000);
|
||||
|
||||
// payment_deadline = MIN(createdAt + 2h, segment_arrival - checkinMinutes)
|
||||
// Deadline is reached when either branch of the MIN is in the past:
|
||||
// (a) createdAt ≤ now - 2h → 2-hour max window elapsed
|
||||
// (b) departureAt ≤ now + 30min → departure within 30 min
|
||||
// (a) createdAt ≤ now - 2h → 2-hour max window elapsed
|
||||
// (b) departureAt ≤ now + effectiveMaxCutoff → within the widest possible cutoff window
|
||||
const expiredBookings = await this.prisma.booking.findMany({
|
||||
where: {
|
||||
status: 'PENDING_PAYMENT',
|
||||
@@ -250,8 +279,8 @@ export class TasksService {
|
||||
include: {
|
||||
originStation: { select: { name: true } },
|
||||
destinationStation: { select: { name: true } },
|
||||
stopTimes: { select: { stationId: true, plannedDepartureAt: true } },
|
||||
route: { select: { checkinMinutesBefore: true } },
|
||||
stopTimes: { select: { stationId: true, plannedArrivalAt: true, plannedDepartureAt: true } },
|
||||
route: { select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } } },
|
||||
},
|
||||
},
|
||||
paymentIntent: { select: { method: true } },
|
||||
@@ -264,14 +293,19 @@ export class TasksService {
|
||||
for (const booking of expiredBookings) {
|
||||
try {
|
||||
// Re-verify exact deadline to avoid racing with a concurrent payment confirmation.
|
||||
// Use the booking's origin-segment departure for the deadline so that a B→C booking
|
||||
// on an A→B→C→D schedule gets the correct payment window anchored to B, not A.
|
||||
// Use the booking's origin-segment estimated arrival (falling back to its departure
|
||||
// for the first stop) and that stop's own check-in window, so a B→C booking on an
|
||||
// A→B→C→D schedule gets the correct payment window anchored to B, not A.
|
||||
const createdAt = booking.createdAt as Date;
|
||||
const originStop = (booking.schedule as any).stopTimes?.find(
|
||||
(s: any) => s.stationId === (booking as any).originStationId,
|
||||
);
|
||||
const dep = (originStop?.plannedDepartureAt ?? booking.schedule.departureAt) as Date;
|
||||
const paymentDeadline = computePaymentDeadline(createdAt, dep);
|
||||
const dep = (originStop?.plannedArrivalAt ?? originStop?.plannedDepartureAt ?? booking.schedule.departureAt) as Date;
|
||||
const originRouteStop = (booking.schedule as any).route?.stops?.find(
|
||||
(s: any) => s.stationId === (booking as any).originStationId,
|
||||
);
|
||||
const checkinMinutes = originRouteStop?.checkinMinutesBefore ?? (booking.schedule as any).route?.checkinMinutesBefore ?? CUTOFF_MINUTES;
|
||||
const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes);
|
||||
if (now < paymentDeadline) continue;
|
||||
|
||||
// 1a. Release held seats (Journey rows are the occupancy source of truth once paid)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PrismaService } from '../../common/prisma.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
|
||||
import { AuditService } from '../../common/audit.service';
|
||||
import { resolveBookingSegment } from '../../common/utils/segment-resolver.utils';
|
||||
import * as QRCode from 'qrcode';
|
||||
|
||||
interface OfflineValidation {
|
||||
@@ -129,6 +130,7 @@ export class TicketsService {
|
||||
: { fullName: 'Guest', email: guestEmail, phone: guestPhone };
|
||||
|
||||
|
||||
const segment = resolveBookingSegment(t.booking?.schedule, t.booking?.originStationId, t.booking?.destinationStationId);
|
||||
return {
|
||||
id: t.id,
|
||||
ticketNumber: t.barcodePayload,
|
||||
@@ -152,20 +154,14 @@ export class TicketsService {
|
||||
contactPhone: t.booking?.contactPhone,
|
||||
returnSchedule: t.booking?.returnSchedule ?? null,
|
||||
seats: t.booking?.seats ?? [],
|
||||
originStation: (() => {
|
||||
const id = t.booking?.originStationId;
|
||||
if (!id) return t.booking?.schedule?.originStation ?? null;
|
||||
const stop = t.booking?.schedule?.stopTimes?.find((st: any) => st.stationId === id);
|
||||
return stop?.station ?? t.booking?.schedule?.originStation ?? null;
|
||||
})(),
|
||||
destinationStation: (() => {
|
||||
const id = t.booking?.destinationStationId;
|
||||
if (!id) return t.booking?.schedule?.destinationStation ?? null;
|
||||
const stop = t.booking?.schedule?.stopTimes?.find((st: any) => st.stationId === id);
|
||||
return stop?.station ?? t.booking?.schedule?.destinationStation ?? null;
|
||||
})(),
|
||||
originStation: segment.origin,
|
||||
destinationStation: segment.destination,
|
||||
},
|
||||
schedule: t.booking?.schedule,
|
||||
schedule: t.booking?.schedule ? {
|
||||
...t.booking.schedule,
|
||||
departureAt: segment.departureAt,
|
||||
arrivalAt: segment.arrivalAt,
|
||||
} : null,
|
||||
seat: t.seat ? {
|
||||
id: t.seat.id,
|
||||
seatNumber: t.seat.seatNumber,
|
||||
@@ -643,11 +639,14 @@ export class TicketsService {
|
||||
throw new NotFoundException('No ticket found for this booking');
|
||||
}
|
||||
|
||||
// Check if ticket date matches today
|
||||
// Check if ticket date matches today. Boarding window is relative to the
|
||||
// passenger's actual boarding stop, not the train's origin — for a mid-route
|
||||
// boarding these differ.
|
||||
const today = new Date();
|
||||
|
||||
if ((booking as any).schedule?.departureAt) {
|
||||
const departureTime = new Date((booking as any).schedule.departureAt);
|
||||
const boardingSegment = resolveBookingSegment((booking as any).schedule, (booking as any).originStationId, (booking as any).destinationStationId);
|
||||
|
||||
if (boardingSegment.departureAt) {
|
||||
const departureTime = new Date(boardingSegment.departureAt);
|
||||
const boardingWindowHours = await this.systemConfig.getNumber(CONFIG_KEYS.BOARDING_WINDOW_HOURS_BEFORE_DEPARTURE);
|
||||
const boardingOpenTime = new Date(departureTime.getTime() - boardingWindowHours * 60 * 60 * 1000);
|
||||
|
||||
@@ -673,18 +672,6 @@ export class TicketsService {
|
||||
// Send notifications after successful boarding
|
||||
await this.sendBoardingNotifications(booking, ticket, result.leg || 'OUTBOUND');
|
||||
|
||||
// Resolve user-selected segment rather than the full schedule route
|
||||
const _schedStops = (booking as any).schedule?.stopTimes ?? [];
|
||||
const _resolveStation = (id: string | null | undefined, fallback: any) => {
|
||||
if (id) {
|
||||
const found = _schedStops.find((st: any) => st.stationId === id)?.station;
|
||||
if (found) return found;
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
const boardingOrigin = _resolveStation((booking as any).originStationId, (booking as any).schedule?.originStation);
|
||||
const boardingDest = _resolveStation((booking as any).destinationStationId, (booking as any).schedule?.destinationStation);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Passenger boarded successfully (${result.leg || 'OUTBOUND'} leg)`,
|
||||
@@ -693,11 +680,11 @@ export class TicketsService {
|
||||
ticketNumber: ticket.barcodePayload,
|
||||
bookingRef: booking.bookingRef,
|
||||
passengerName: seatInfo?.passengerName || ticket.passengerName || 'N/A',
|
||||
route: `${boardingOrigin?.name || 'N/A'} → ${boardingDest?.name || 'N/A'}`,
|
||||
route: `${boardingSegment.origin?.name || 'N/A'} → ${boardingSegment.destination?.name || 'N/A'}`,
|
||||
seat: seatNumber,
|
||||
coach: coachNumber,
|
||||
trainName: (booking as any).schedule?.train?.name || (booking as any).schedule?.train?.number || 'N/A',
|
||||
departureTime: (booking as any).schedule?.departureAt,
|
||||
departureTime: boardingSegment.departureAt,
|
||||
boardedAt: result.validatedAt,
|
||||
leg: result.leg || 'OUTBOUND',
|
||||
bookingType: booking.bookingType,
|
||||
|
||||
34
apps/edr-passenger-api/test/auth-gaps.e2e-spec.ts
Normal file
34
apps/edr-passenger-api/test/auth-gaps.e2e-spec.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
import "reflect-metadata";
|
||||
import { CurrencyController } from "../src/modules/fare-engine/currency.controller";
|
||||
|
||||
const GUARDS_METADATA = "__guards__";
|
||||
function guardsOn(handler: unknown): unknown[] {
|
||||
return (Reflect.getMetadata(GUARDS_METADATA, handler as object) as 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 🔴 PATCH update exchange-rate has NO admin guard (only the global JwtGuard applies)", () => {
|
||||
expect(guardsOn(CurrencyController.prototype.update)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("C-8 control: DELETE exchange-rate IS admin-gated — proving writes should be too", () => {
|
||||
expect(guardsOn(CurrencyController.prototype.remove).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* C-9-UI 🔴 Authenticated POST /bookings is broken: the controller overrides passengerId with the
|
||||
* JWT user id (`bookings.controller.ts:528-532`, "never trust the request body"), but the service
|
||||
* only resolves an iamUserId → Passenger when it is NON-UUID (`bookings.service.ts:773`). Real IAM
|
||||
* ids are UUIDs, and registration creates `Passenger.id ≠ iamUserId` (`passenger-auth.service.ts:225`),
|
||||
* so `booking.create` uses the iamUserId directly as passengerId → foreign-key violation.
|
||||
*
|
||||
* This reproduces the controller's behavior by calling BookingsService.create with passengerId set to
|
||||
* a UUID iamUserId (not the Passenger.id), exactly as the authed controller does. It also shows the
|
||||
* CONTROL: passing the real Passenger.id succeeds — proving the resolution gap, not a fixture problem.
|
||||
*/
|
||||
import { BookingsService } from "../src/modules/bookings/bookings.service";
|
||||
import { getTestPrisma, disconnectTestPrisma } from "./setup/prisma";
|
||||
import { truncateAllPassenger, seedCore, IDS } from "./fixtures/seed-core";
|
||||
|
||||
function asyncStub(): any {
|
||||
return new Proxy({}, { get: () => async () => undefined });
|
||||
}
|
||||
|
||||
let seq = 0;
|
||||
async function buildBookableGraph(prisma: any, passengerId: string, iamUserId: string) {
|
||||
const passenger = await prisma.passenger.create({ data: { id: passengerId, iamUserId } });
|
||||
const train = await prisma.train.create({ data: { number: `AB-${++seq}`, name: "T" } });
|
||||
const schedule = await prisma.trainSchedule.create({
|
||||
data: {
|
||||
trainId: train.id,
|
||||
routeId: IDS.route,
|
||||
originStationId: IDS.stationA,
|
||||
destinationStationId: IDS.stationB,
|
||||
departureAt: new Date(Date.now() + 86_400_000),
|
||||
arrivalAt: new Date(Date.now() + 90_000_000),
|
||||
durationMinutes: 60,
|
||||
},
|
||||
});
|
||||
await prisma.tripStopTime.createMany({
|
||||
data: [
|
||||
{ scheduleId: schedule.id, stationId: IDS.stationA, sequence: 1 },
|
||||
{ scheduleId: schedule.id, stationId: IDS.stationB, sequence: 2 },
|
||||
],
|
||||
});
|
||||
const coach = await prisma.coach.create({ data: { coachTypeId: IDS.coachType, number: `AB-${seq}` } });
|
||||
const seat = await prisma.seat.create({ data: { coachId: coach.id, seatNumber: "1A", row: 1, col: "1" } });
|
||||
await prisma.fareRule.create({
|
||||
data: { tripId: schedule.id, seatClassId: IDS.seatClassLocal, baseFareMinor: 30_000, currency: "ETB", validFrom: new Date("2020-01-01") },
|
||||
});
|
||||
const hold = await prisma.seatHold.create({
|
||||
data: { scheduleId: schedule.id, seatIds: [seat.id], passengerId, expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
});
|
||||
return { schedule, seat, hold };
|
||||
}
|
||||
|
||||
function dtoFor(passengerId: string, schedule: any, hold: any, seat: any) {
|
||||
return {
|
||||
passengerId, // the controller passes req.user.id here (the iamUserId)
|
||||
scheduleId: schedule.id,
|
||||
holdId: hold.id,
|
||||
originStationId: IDS.stationA,
|
||||
destinationStationId: IDS.stationB,
|
||||
seatClassId: IDS.seatClassLocal,
|
||||
bookingType: "ONE_WAY",
|
||||
passengers: [
|
||||
{
|
||||
seatId: seat.id,
|
||||
passengerName: "Auth User",
|
||||
dateOfBirth: new Date("1990-01-01"),
|
||||
idDocumentType: "PASSPORT",
|
||||
passportNumber: "P1",
|
||||
passportCountry: "ET",
|
||||
nationality: "Ethiopian",
|
||||
seatFareMinor: 30_000,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("Authenticated booking passengerId resolution (regression)", () => {
|
||||
const prisma = getTestPrisma();
|
||||
let bookings: BookingsService;
|
||||
|
||||
beforeAll(() => {
|
||||
bookings = new BookingsService(
|
||||
prisma as any,
|
||||
{ query: async () => [] } as any, // dataSource (resolveIamContact raw SQL → [])
|
||||
asyncStub(), // seatsService
|
||||
{ emit: () => true } as any,
|
||||
asyncStub(), // verifaydaService (PASSPORT skips)
|
||||
asyncStub(), // currencyService (ETB skips)
|
||||
asyncStub(), // fareEngine (FareRule short-circuits)
|
||||
asyncStub(), // auditService
|
||||
);
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await truncateAllPassenger(prisma);
|
||||
await seedCore(prisma);
|
||||
});
|
||||
afterAll(async () => {
|
||||
await disconnectTestPrisma();
|
||||
});
|
||||
|
||||
it("🔴 create() with a UUID iamUserId (as the authed controller passes) FAILS the passenger FK", async () => {
|
||||
const passengerId = "aaaaaaaa-0000-4000-8000-000000000001"; // real Passenger.id
|
||||
const iamUserId = "bbbbbbbb-0000-4000-8000-000000000002"; // UUID iamUserId ≠ Passenger.id
|
||||
const { schedule, hold, seat } = await buildBookableGraph(prisma, passengerId, iamUserId);
|
||||
|
||||
// The controller calls service.create({ ...dto, passengerId: req.user.id }) — i.e. the iamUserId.
|
||||
await expect(
|
||||
bookings.create(dtoFor(iamUserId, schedule, hold, seat) as any),
|
||||
).rejects.toThrow(); // Prisma P2003 on Booking_passengerId_fkey
|
||||
|
||||
expect(await prisma.booking.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("control: create() with the real Passenger.id succeeds — proving the gap is the id, not the fixture", async () => {
|
||||
const passengerId = "aaaaaaaa-0000-4000-8000-000000000003";
|
||||
const iamUserId = "bbbbbbbb-0000-4000-8000-000000000004";
|
||||
const { schedule, hold, seat } = await buildBookableGraph(prisma, passengerId, iamUserId);
|
||||
|
||||
const booking: any = await bookings.create(dtoFor(passengerId, schedule, hold, seat) as any);
|
||||
expect(booking.id).toBeTruthy();
|
||||
expect(booking.passengerId).toBe(passengerId);
|
||||
});
|
||||
});
|
||||
204
apps/edr-passenger-api/test/checkin-cutoff.e2e-spec.ts
Normal file
204
apps/edr-passenger-api/test/checkin-cutoff.e2e-spec.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Per-station check-in cutoff — proves booking closure is now based on each stop's own
|
||||
* ESTIMATED ARRIVAL time (computed from RouteStop.travelMinutesToStop), not the schedule's
|
||||
* overall departure. The regression this guards: before this change, all stops effectively
|
||||
* shared one cutoff basis, so a later station could be wrongly blocked (or an earlier one
|
||||
* wrongly left open) together with the rest of the route.
|
||||
*
|
||||
* Uses the slim harness (SchedulesService, real Nest DI) for schedule creation — this exercises
|
||||
* the actual cumulative travel-time interpolation in SchedulesService.createSchedule. SeatsService
|
||||
* and TasksService are NOT in the slim harness's DOMAIN_MODULES (they pull in NotificationsModule
|
||||
* → RabbitMQ, which the slim harness deliberately avoids — see test/setup/slim-app.ts), so they're
|
||||
* instantiated directly with a real Prisma + stubbed collaborators, mirroring the Tier-2 pattern in
|
||||
* money-integrity.e2e-spec.ts.
|
||||
*/
|
||||
import { SchedulesService } from "../src/modules/schedules/schedules.service";
|
||||
import { SeatsService } from "../src/modules/seats/seats.service";
|
||||
import { TasksService } from "../src/modules/tasks/tasks.service";
|
||||
import { SystemConfigService } from "../src/modules/system-config/system-config.service";
|
||||
import { createServiceHarness, ServiceHarness } from "./setup/slim-app";
|
||||
import { IDS, DISTANCE, 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 });
|
||||
}
|
||||
|
||||
/** Creates a fresh Train + TrainSchedule on the seed-core route via the real interpolation logic. */
|
||||
async function createTestSchedule(
|
||||
harness: ServiceHarness,
|
||||
schedules: SchedulesService,
|
||||
opts: { trainNumber: string; departureAt: Date; arrivalAt: Date },
|
||||
) {
|
||||
const train = await harness.prisma.train.create({
|
||||
data: { number: opts.trainNumber, name: `Test ${opts.trainNumber}` },
|
||||
});
|
||||
|
||||
// createSchedule now rejects a schedule with zero coaches (see schedules.service.ts's
|
||||
// "must have at least one coach assigned" guard) — the coach has to exist and be passed
|
||||
// via coachIds BEFORE creation, not attached afterward.
|
||||
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", "1C", "1D"].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 schedules.createSchedule({
|
||||
trainId: train.id,
|
||||
routeId: IDS.route,
|
||||
departureAt: opts.departureAt.toISOString(),
|
||||
arrivalAt: opts.arrivalAt.toISOString(),
|
||||
coachIds: [coach.id],
|
||||
} as any);
|
||||
|
||||
return { schedule, seats };
|
||||
}
|
||||
|
||||
describe("Check-in cutoff — arrival-time basis, per-station independence", () => {
|
||||
let harness: ServiceHarness;
|
||||
let schedulesService: SchedulesService;
|
||||
let seatsService: SeatsService;
|
||||
let tasksService: TasksService;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createServiceHarness();
|
||||
schedulesService = await harness.moduleRef.resolve(SchedulesService);
|
||||
const systemConfig = new SystemConfigService(harness.prisma as any);
|
||||
seatsService = new SeatsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub());
|
||||
tasksService = new TasksService(harness.prisma as any, asyncStub(), asyncStub());
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.close();
|
||||
});
|
||||
|
||||
it("a later station remains independently bookable after an earlier station's cutoff has passed", async () => {
|
||||
await resetAndSeedCore(harness.prisma);
|
||||
|
||||
// dep only 5 min out (createSchedule requires a future departureAt). Route-level default
|
||||
// checkinMinutesBefore is 30 (schema default, unset here), so A's cutoff (dep - 30min) is
|
||||
// already ~25 min in the past by the time this runs — but B, with a 60-min travel time from
|
||||
// A, has an arrival far enough out (dep + 60min) that its own cutoff (arrival - 30min) is
|
||||
// still ~35 min in the future.
|
||||
const dep = new Date(Date.now() + 5 * 60_000);
|
||||
const arr = new Date(dep.getTime() + 100 * 60_000); // A->B 60min + B->C 40min
|
||||
await harness.prisma.routeStop.update({
|
||||
where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } },
|
||||
data: { travelMinutesToStop: 60 },
|
||||
});
|
||||
await harness.prisma.routeStop.update({
|
||||
where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } },
|
||||
data: { travelMinutesToStop: 40 },
|
||||
});
|
||||
|
||||
const { schedule, seats } = await createTestSchedule(harness, schedulesService, {
|
||||
trainNumber: `CUTOFF-A-${Date.now()}`,
|
||||
departureAt: dep,
|
||||
arrivalAt: arr,
|
||||
});
|
||||
|
||||
await expect(
|
||||
seatsService.holdSeats({
|
||||
scheduleId: schedule.id,
|
||||
originStationId: IDS.stationA,
|
||||
destinationStationId: IDS.stationB,
|
||||
passengers: [{ passengerId: "11111111-1111-4111-8111-111111111111", seatId: seats[0].id }],
|
||||
} as any),
|
||||
).rejects.toThrow(/cannot be held within/i);
|
||||
|
||||
const held = await seatsService.holdSeats({
|
||||
scheduleId: schedule.id,
|
||||
originStationId: IDS.stationB,
|
||||
destinationStationId: IDS.stationC,
|
||||
passengers: [{ passengerId: "22222222-2222-4222-8222-222222222222", seatId: seats[1].id }],
|
||||
} as any);
|
||||
expect(held).toBeTruthy();
|
||||
});
|
||||
|
||||
it("a stop-level checkinMinutesBefore override wins over the route-level default", async () => {
|
||||
// Override B with a LARGE cutoff (90 min) — under the route default (30 min) this exact
|
||||
// schedule's B segment would still be OPEN (see previous test), so a rejection here proves
|
||||
// the stop-level override, not the default, is what's actually being applied.
|
||||
await resetAndSeedCore(harness.prisma, { B: { checkinMinutesBefore: 90 } });
|
||||
await harness.prisma.routeStop.update({
|
||||
where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } },
|
||||
data: { travelMinutesToStop: 60 },
|
||||
});
|
||||
|
||||
const dep = new Date(Date.now() + 5 * 60_000);
|
||||
const arr = new Date(dep.getTime() + 100 * 60_000);
|
||||
const { schedule, seats } = await createTestSchedule(harness, schedulesService, {
|
||||
trainNumber: `CUTOFF-B-${Date.now()}`,
|
||||
departureAt: dep,
|
||||
arrivalAt: arr,
|
||||
});
|
||||
|
||||
await expect(
|
||||
seatsService.holdSeats({
|
||||
scheduleId: schedule.id,
|
||||
originStationId: IDS.stationB,
|
||||
destinationStationId: IDS.stationC,
|
||||
passengers: [{ passengerId: "33333333-3333-4333-8333-333333333333", seatId: seats[0].id }],
|
||||
} as any),
|
||||
).rejects.toThrow(/cannot be held within 90 minute/i);
|
||||
});
|
||||
|
||||
it("syncScheduleStatuses closes only the specific stops past their own arrival-based cutoff", async () => {
|
||||
await resetAndSeedCore(harness.prisma);
|
||||
await harness.prisma.routeStop.update({
|
||||
where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } },
|
||||
data: { travelMinutesToStop: 60 },
|
||||
});
|
||||
await harness.prisma.routeStop.update({
|
||||
where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } },
|
||||
data: { travelMinutesToStop: 40 },
|
||||
});
|
||||
|
||||
const dep = new Date(Date.now() + 5 * 60_000);
|
||||
const arr = new Date(dep.getTime() + 100 * 60_000);
|
||||
const { schedule } = await createTestSchedule(harness, schedulesService, {
|
||||
trainNumber: `CUTOFF-C-${Date.now()}`,
|
||||
departureAt: dep,
|
||||
arrivalAt: arr,
|
||||
});
|
||||
|
||||
await tasksService.syncScheduleStatuses();
|
||||
|
||||
const stopTimes = await harness.prisma.tripStopTime.findMany({
|
||||
where: { scheduleId: schedule.id },
|
||||
orderBy: { sequence: "asc" },
|
||||
});
|
||||
const byStation = Object.fromEntries(stopTimes.map((s) => [s.stationId, s.status]));
|
||||
expect(byStation[IDS.stationA]).toBe("CHECKIN_CLOSED");
|
||||
expect(byStation[IDS.stationB]).toBe("OPEN");
|
||||
expect(byStation[IDS.stationC]).toBe("OPEN");
|
||||
});
|
||||
|
||||
it("a stop missing travelMinutesToStop falls back to distance interpolation without failing schedule creation", async () => {
|
||||
await resetAndSeedCore(harness.prisma); // no travelMinutesToStop set on any stop
|
||||
|
||||
const dep = new Date(Date.now() + 60 * 60_000);
|
||||
const arr = new Date(dep.getTime() + 240 * 60_000); // 4h, matches seed-ui's convention
|
||||
const { schedule } = await createTestSchedule(harness, schedulesService, {
|
||||
trainNumber: `CUTOFF-D-${Date.now()}`,
|
||||
departureAt: dep,
|
||||
arrivalAt: arr,
|
||||
});
|
||||
|
||||
const stopTimes = await harness.prisma.tripStopTime.findMany({
|
||||
where: { scheduleId: schedule.id },
|
||||
orderBy: { sequence: "asc" },
|
||||
});
|
||||
const totalDuration = arr.getTime() - dep.getTime();
|
||||
const bProgress = DISTANCE.B / DISTANCE.C;
|
||||
const expectedBArrival = new Date(dep.getTime() + totalDuration * bProgress);
|
||||
|
||||
const bStop = stopTimes.find((s) => s.stationId === IDS.stationB)!;
|
||||
expect(bStop.plannedArrivalAt?.getTime()).toBe(expectedBArrival.getTime());
|
||||
});
|
||||
});
|
||||
74
apps/edr-passenger-api/test/config-validation.e2e-spec.ts
Normal file
74
apps/edr-passenger-api/test/config-validation.e2e-spec.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Backoffice config validation suite (matrix Suite H). The global ValidationPipe in src/main.ts:56
|
||||
* 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.
|
||||
* H5 🔴 CreatePromotionDto.validUntil is @IsString (not @IsDateString) → accepts non-dates.
|
||||
*/
|
||||
import "reflect-metadata";
|
||||
import { plainToInstance } from "class-transformer";
|
||||
import { validate } from "class-validator";
|
||||
|
||||
import { CreateFareRuleDto } from "../src/modules/schedules/schedules.dto";
|
||||
import { CreateSegmentFareDto } from "../src/modules/segments/segment-fare.dto";
|
||||
import { CreateSeatClassDto } from "../src/modules/seat-classes/seat-classes.dto";
|
||||
import { CreatePromotionDto } from "../src/modules/promos/promos.dto";
|
||||
|
||||
/** Property names that produced a validation error. */
|
||||
async function erroredProps(dto: object): Promise<string[]> {
|
||||
const errors = await validate(dto);
|
||||
return errors.map((e) => e.property);
|
||||
}
|
||||
|
||||
describe("Backoffice config validation (Suite H)", () => {
|
||||
it("H1 🔴 CreateFareRuleDto accepts a NEGATIVE baseFareMinor (no @Min)", async () => {
|
||||
const dto = plainToInstance(CreateFareRuleDto, {
|
||||
seatClassId: "sc-1",
|
||||
baseFareMinor: -100,
|
||||
validFrom: "2026-01-01T00:00:00Z",
|
||||
});
|
||||
expect(await erroredProps(dto)).not.toContain("baseFareMinor");
|
||||
});
|
||||
|
||||
it("H1 contrast: sibling CreateSegmentFareDto REJECTS negative baseFareMinor (@Min(0))", async () => {
|
||||
const dto = plainToInstance(CreateSegmentFareDto, {
|
||||
routeId: "rt-1",
|
||||
originStopSequence: 1,
|
||||
destinationStopSequence: 5,
|
||||
seatClassId: "sc-1",
|
||||
baseFareMinor: -100,
|
||||
});
|
||||
expect(await erroredProps(dto)).toContain("baseFareMinor");
|
||||
});
|
||||
|
||||
it("H2 🔴 CreateSeatClassDto accepts a negative basePrice (no @Min)", async () => {
|
||||
const dto = plainToInstance(CreateSeatClassDto, {
|
||||
coachTypeId: "ct-1",
|
||||
name: "Economy",
|
||||
basePrice: -5000,
|
||||
});
|
||||
expect(await erroredProps(dto)).not.toContain("basePrice");
|
||||
});
|
||||
|
||||
it("H4 🔴 CreatePromotionDto accepts percentOff = 200 (no @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");
|
||||
});
|
||||
|
||||
it("H5 🔴 CreatePromotionDto.validUntil accepts a non-date string (@IsString, not @IsDateString)", async () => {
|
||||
const dto = plainToInstance(CreatePromotionDto, {
|
||||
code: "BADDATE",
|
||||
title: "Bad date",
|
||||
validUntil: "not-a-real-date",
|
||||
});
|
||||
expect(await erroredProps(dto)).not.toContain("validUntil");
|
||||
});
|
||||
});
|
||||
275
apps/edr-passenger-api/test/critical-repro.e2e-spec.ts
Normal file
275
apps/edr-passenger-api/test/critical-repro.e2e-spec.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* 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-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
|
||||
* ticket pays for two.
|
||||
*/
|
||||
import { BookingsService } from "../src/modules/bookings/bookings.service";
|
||||
import { PaymentsService } from "../src/modules/payments/payments.service";
|
||||
import { FareEngineService } from "../src/modules/fare-engine/fare-engine.service";
|
||||
import { CurrencyService } from "../src/modules/currency/currency.service";
|
||||
import { getTestPrisma, disconnectTestPrisma } from "./setup/prisma";
|
||||
import { truncateAllPassenger, seedCore, IDS } from "./fixtures/seed-core";
|
||||
|
||||
function asyncStub(): any {
|
||||
return new Proxy({}, { get: () => async () => undefined });
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a PrismaClient so that inside `$transaction(cb)`, every `walletAccount.update` waits until
|
||||
* BOTH concurrent transactions have finished their `walletAccount.findUnique` (balance read). This
|
||||
* deterministically forces the exact interleaving a real multi-request system permits, exposing the
|
||||
* service's unlocked check-then-act (no SELECT … FOR UPDATE). Only scheduling is controlled — the
|
||||
* service's own logic runs unmodified.
|
||||
*/
|
||||
function makeRaceWrappedPrisma(real: any, parties: number) {
|
||||
let arrived = 0;
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((r) => (release = r));
|
||||
const signalRead = () => {
|
||||
if (++arrived >= parties) release();
|
||||
};
|
||||
|
||||
return new Proxy(real, {
|
||||
get(target, prop, receiver) {
|
||||
if (prop === "$transaction") {
|
||||
return (cb: (tx: any) => unknown, opts?: unknown) =>
|
||||
target.$transaction((tx: any) => {
|
||||
const wrappedTx = new Proxy(tx, {
|
||||
get(t, p) {
|
||||
if (p === "walletAccount") {
|
||||
return {
|
||||
findUnique: async (args: unknown) => {
|
||||
const res = await t.walletAccount.findUnique(args);
|
||||
signalRead();
|
||||
return res;
|
||||
},
|
||||
update: async (args: unknown) => {
|
||||
await gate; // hold the write until both reads are done
|
||||
return t.walletAccount.update(args);
|
||||
},
|
||||
};
|
||||
}
|
||||
return t[p];
|
||||
},
|
||||
});
|
||||
return cb(wrappedTx);
|
||||
}, opts);
|
||||
}
|
||||
return Reflect.get(target, prop, receiver);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let seq = 0;
|
||||
async function makeSchedule(prisma: any) {
|
||||
const train = await prisma.train.create({ data: { number: `CR-${++seq}`, name: "T" } });
|
||||
return prisma.trainSchedule.create({
|
||||
data: {
|
||||
trainId: train.id,
|
||||
routeId: IDS.route,
|
||||
originStationId: IDS.stationA,
|
||||
destinationStationId: IDS.stationB,
|
||||
departureAt: new Date(Date.now() + 86_400_000),
|
||||
arrivalAt: new Date(Date.now() + 90_000_000),
|
||||
durationMinutes: 60,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("Critical reproducers (Tier-2)", () => {
|
||||
const prisma = getTestPrisma();
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllPassenger(prisma);
|
||||
await seedCore(prisma);
|
||||
});
|
||||
afterAll(async () => {
|
||||
await disconnectTestPrisma();
|
||||
});
|
||||
|
||||
// ── C-1 ──────────────────────────────────────────────────────────────────
|
||||
it("C-1 🔴 booking stores client reviewedTotalMinor=1 while the fare engine computed ~30000", async () => {
|
||||
const passenger = await prisma.passenger.create({ data: {} });
|
||||
const schedule = await makeSchedule(prisma);
|
||||
// Stop times so origin/dest resolve on the schedule.
|
||||
await prisma.tripStopTime.createMany({
|
||||
data: [
|
||||
{ scheduleId: schedule.id, stationId: IDS.stationA, sequence: 1 },
|
||||
{ scheduleId: schedule.id, stationId: IDS.stationB, sequence: 2 },
|
||||
],
|
||||
});
|
||||
// Coach + seat for the passenger to occupy.
|
||||
const coach = await prisma.coach.create({
|
||||
data: { coachTypeId: IDS.coachType, number: `C-${seq}` },
|
||||
});
|
||||
const seat = await prisma.seat.create({
|
||||
data: { coachId: coach.id, seatNumber: "1A", row: 1, col: "1" },
|
||||
});
|
||||
// A real server fare source (tripId match → highest priority): 30000 minor.
|
||||
await prisma.fareRule.create({
|
||||
data: {
|
||||
tripId: schedule.id,
|
||||
seatClassId: IDS.seatClassLocal,
|
||||
baseFareMinor: 30_000,
|
||||
currency: "ETB",
|
||||
validFrom: new Date("2020-01-01"),
|
||||
},
|
||||
});
|
||||
const hold = await prisma.seatHold.create({
|
||||
data: {
|
||||
scheduleId: schedule.id,
|
||||
seatIds: [seat.id],
|
||||
passengerId: passenger.id,
|
||||
expiresAt: new Date(Date.now() + 3_600_000),
|
||||
},
|
||||
});
|
||||
|
||||
const bookings = new BookingsService(
|
||||
prisma as any,
|
||||
asyncStub(), // dataSource
|
||||
asyncStub(), // seatsService (confirmSeats no-op)
|
||||
{ emit: () => true } as any, // eventEmitter
|
||||
asyncStub(), // verifaydaService (PASSPORT path skips it anyway)
|
||||
asyncStub(), // currencyService (ETB path skips it)
|
||||
asyncStub(), // fareEngine (FareRule short-circuits before this)
|
||||
asyncStub(), // auditService
|
||||
);
|
||||
|
||||
const dto = {
|
||||
passengerId: passenger.id,
|
||||
scheduleId: schedule.id,
|
||||
holdId: hold.id,
|
||||
originStationId: IDS.stationA,
|
||||
destinationStationId: IDS.stationB,
|
||||
seatClassId: IDS.seatClassLocal,
|
||||
bookingType: "ONE_WAY",
|
||||
reviewedTotalMinor: 1, // the forged client total
|
||||
passengers: [
|
||||
{
|
||||
seatId: seat.id,
|
||||
passengerName: "Mallory Adult",
|
||||
dateOfBirth: new Date("1990-01-01"),
|
||||
idDocumentType: "PASSPORT",
|
||||
passportNumber: "P123",
|
||||
passportCountry: "ET",
|
||||
nationality: "Ethiopian",
|
||||
// NOTE: no seatFareMinor → not "allFaresProvided" → reviewedTotalMinor is trusted
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result: any = await (bookings as any).createOneWayBooking(dto);
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
||||
// ── C-4 ──────────────────────────────────────────────────────────────────
|
||||
it("C-4 🔴 finalizePaymentSuccess confirms a 30000 booking from an intent of 1 (no amount check)", async () => {
|
||||
const passenger = await prisma.passenger.create({ data: {} });
|
||||
const schedule = await makeSchedule(prisma);
|
||||
const booking = await prisma.booking.create({
|
||||
data: {
|
||||
bookingRef: "PAY-0001",
|
||||
passengerId: passenger.id,
|
||||
scheduleId: schedule.id,
|
||||
totalMinor: 30_000,
|
||||
status: "PENDING_PAYMENT",
|
||||
},
|
||||
});
|
||||
const intent = await prisma.paymentIntent.create({
|
||||
data: {
|
||||
bookingId: booking.id,
|
||||
amountMinor: 1, // wildly short payment
|
||||
method: "WALLET",
|
||||
status: "PROCESSING",
|
||||
},
|
||||
});
|
||||
|
||||
const payments = new PaymentsService(
|
||||
prisma as any,
|
||||
{ confirmSeats: async () => undefined } as any,
|
||||
{ generate: async () => undefined } as any, // must not throw (re-thrown otherwise)
|
||||
{ emit: () => true } as any,
|
||||
asyncStub(), // paymentClient
|
||||
asyncStub(), // currencyService (not used on this path)
|
||||
asyncStub(), // auditService
|
||||
);
|
||||
|
||||
await payments.finalizePaymentSuccess({ intentId: intent.id });
|
||||
|
||||
const after = await prisma.booking.findUnique({ where: { id: booking.id } });
|
||||
// Confirmed despite intent.amountMinor (1) ≠ booking.totalMinor (30000).
|
||||
expect(after?.status).toBe("CONFIRMED");
|
||||
});
|
||||
|
||||
// ── C-6 ──────────────────────────────────────────────────────────────────
|
||||
it("C-6 🔴 two concurrent WALLET payments double-spend a single-ticket balance", async () => {
|
||||
const passenger = await prisma.passenger.create({ data: {} });
|
||||
const schedule = await makeSchedule(prisma);
|
||||
// Wallet funded for exactly ONE ticket.
|
||||
await prisma.walletAccount.create({
|
||||
data: { passengerId: passenger.id, balanceMinor: 30_000 },
|
||||
});
|
||||
const mkBooking = (ref: string) =>
|
||||
prisma.booking.create({
|
||||
data: {
|
||||
bookingRef: ref,
|
||||
passengerId: passenger.id,
|
||||
scheduleId: schedule.id,
|
||||
totalMinor: 30_000,
|
||||
status: "PENDING_PAYMENT",
|
||||
},
|
||||
});
|
||||
const b1 = await mkBooking("W-0001");
|
||||
const b2 = await mkBooking("W-0002");
|
||||
|
||||
// Race-wrapped prisma forces both balance reads to complete before either debit writes.
|
||||
const racePrisma = makeRaceWrappedPrisma(prisma, 2);
|
||||
const payments = new PaymentsService(
|
||||
racePrisma as any,
|
||||
{ confirmSeats: async () => undefined } as any,
|
||||
{ generate: async () => undefined } as any,
|
||||
{ emit: () => true } as any,
|
||||
asyncStub(),
|
||||
asyncStub(),
|
||||
asyncStub(),
|
||||
);
|
||||
|
||||
const [bk1, bk2] = await Promise.all([
|
||||
prisma.booking.findUnique({ where: { id: b1.id }, include: { seats: true } }),
|
||||
prisma.booking.findUnique({ where: { id: b2.id }, include: { seats: true } }),
|
||||
]);
|
||||
|
||||
const [r1, r2] = await Promise.allSettled([
|
||||
(payments as any).initiateWalletPayment(bk1),
|
||||
(payments as any).initiateWalletPayment(bk2),
|
||||
]);
|
||||
|
||||
const succeeded = await prisma.paymentIntent.count({
|
||||
where: { bookingId: { in: [b1.id, b2.id] }, status: { in: ["SUCCEEDED", "PROCESSING"] } },
|
||||
});
|
||||
const debits = await prisma.walletLedgerEntry.count({ where: { type: "DEBIT" } });
|
||||
const wallet = await prisma.walletAccount.findUnique({
|
||||
where: { passengerId: passenger.id },
|
||||
});
|
||||
|
||||
// Double-spend signature: two successful debits from a one-ticket balance, or a negative
|
||||
// balance. A correctly-locked wallet allows exactly one.
|
||||
const totalDebited = debits * 30_000;
|
||||
const doubleSpent =
|
||||
(succeeded === 2 && totalDebited > 30_000) || (wallet?.balanceMinor ?? 0) < 0;
|
||||
expect(doubleSpent).toBe(true);
|
||||
expect([r1.status, r2.status]).toEqual(["fulfilled", "fulfilled"]);
|
||||
});
|
||||
});
|
||||
139
apps/edr-passenger-api/test/fixtures/seed-core.ts
vendored
Normal file
139
apps/edr-passenger-api/test/fixtures/seed-core.ts
vendored
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Deterministic core fixtures for the pricing E2E suites.
|
||||
*
|
||||
* The repo's `prisma/seed.ts` is entirely commented out (every step disabled), so the harness
|
||||
* builds its own minimal, fully-controlled graph: coach type → seat classes → stations → route
|
||||
* with distance-bearing stops → FX rates. Fixed UUIDs let specs reference entities directly.
|
||||
*
|
||||
* Uses a bare PrismaClient (not the Nest PrismaService) so it can run in jest globalSetup or
|
||||
* inside a spec without booting the app. Reads DATABASE_URL from process.env (load-env sets it).
|
||||
*/
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
export const IDS = {
|
||||
coachType: "00000000-0000-4000-8000-000000000001",
|
||||
seatClassLocal: "00000000-0000-4000-8000-000000000010",
|
||||
seatClassIntl: "00000000-0000-4000-8000-000000000011",
|
||||
stationA: "00000000-0000-4000-8000-000000000020",
|
||||
stationB: "00000000-0000-4000-8000-000000000021",
|
||||
stationC: "00000000-0000-4000-8000-000000000022",
|
||||
route: "00000000-0000-4000-8000-000000000030",
|
||||
} as const;
|
||||
|
||||
/** Route stop distances (km from origin). A=0, B=100, C=250 → A→B is 100km, A→C is 250km. */
|
||||
export const DISTANCE = { A: 0, B: 100, C: 250 } as const;
|
||||
|
||||
/**
|
||||
* Optional per-stop check-in-cutoff/travel-time overrides, keyed by station label (A/B/C).
|
||||
* Lets a spec seed a distinct `checkinMinutesBefore` override and/or `travelMinutesToStop`
|
||||
* per stop without changing the zero-arg call sites the other specs rely on.
|
||||
*/
|
||||
export interface RouteStopOverrides {
|
||||
A?: { checkinMinutesBefore?: number; travelMinutesToStop?: number };
|
||||
B?: { checkinMinutesBefore?: number; travelMinutesToStop?: number };
|
||||
C?: { checkinMinutesBefore?: number; travelMinutesToStop?: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* FX rate chosen so the seat-class distance formula (which multiplies an ETB/km rate by the
|
||||
* USD→ETB rate — see fare-engine.service.ts:157) yields whole ETB-minor amounts. 100 makes the
|
||||
* major→minor scaling line up; a realistic rate (e.g. 132) would visibly distort domestic fares,
|
||||
* which is itself a finding the suites probe.
|
||||
*/
|
||||
export const USD_TO_ETB = 100;
|
||||
export const ETB_TO_DJF = 1.8;
|
||||
|
||||
/** TRUNCATE every table in the `passenger` schema (except Prisma's migration bookkeeping). */
|
||||
export async function truncateAllPassenger(prisma: PrismaClient): Promise<void> {
|
||||
const rows = await prisma.$queryRawUnsafe<Array<{ tablename: string }>>(
|
||||
`SELECT tablename FROM pg_tables WHERE schemaname = 'passenger' AND tablename <> '_prisma_migrations'`,
|
||||
);
|
||||
if (rows.length === 0) return;
|
||||
const list = rows.map((r) => `passenger."${r.tablename}"`).join(", ");
|
||||
await prisma.$executeRawUnsafe(
|
||||
`TRUNCATE ${list} RESTART IDENTITY CASCADE`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Insert the deterministic core graph. Call after truncateAllPassenger. */
|
||||
export async function seedCore(prisma: PrismaClient, stopOverrides: RouteStopOverrides = {}): Promise<void> {
|
||||
const past = new Date("2020-01-01T00:00:00.000Z");
|
||||
|
||||
await prisma.coachType.create({
|
||||
data: {
|
||||
id: IDS.coachType,
|
||||
code: "STD",
|
||||
name: "Standard Coach",
|
||||
type: "passenger",
|
||||
},
|
||||
});
|
||||
|
||||
// LOCAL and INTERNATIONAL seat classes share the coach type + bedPosition (null = regular seat),
|
||||
// which is exactly how fare-engine picks the nationality-matched class (findFirst on those keys).
|
||||
await prisma.seatClass.createMany({
|
||||
data: [
|
||||
{
|
||||
id: IDS.seatClassLocal,
|
||||
coachTypeId: IDS.coachType,
|
||||
name: "Local Standard",
|
||||
nationalityType: "LOCAL",
|
||||
bedPosition: null,
|
||||
baseFareMinor: 300, // 3.00 ETB/km
|
||||
premiumMinor: 0,
|
||||
insuranceFeeMinor: 0,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: IDS.seatClassIntl,
|
||||
coachTypeId: IDS.coachType,
|
||||
name: "Intl Standard",
|
||||
nationalityType: "INTERNATIONAL",
|
||||
bedPosition: null,
|
||||
baseFareMinor: 500, // 5.00 ETB/km
|
||||
premiumMinor: 0,
|
||||
insuranceFeeMinor: 0,
|
||||
isActive: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await prisma.station.createMany({
|
||||
data: [
|
||||
{ id: IDS.stationA, code: "AAA", name: "Alpha", city: "Alpha City", sequence: 1 },
|
||||
{ id: IDS.stationB, code: "BBB", name: "Bravo", city: "Bravo City", sequence: 2 },
|
||||
{ id: IDS.stationC, code: "CCC", name: "Charlie", city: "Charlie City", sequence: 3 },
|
||||
],
|
||||
});
|
||||
|
||||
await prisma.route.create({
|
||||
data: {
|
||||
id: IDS.route,
|
||||
code: "RT-MAIN",
|
||||
name: "Main Line",
|
||||
effectiveFrom: past,
|
||||
active: true,
|
||||
stops: {
|
||||
create: [
|
||||
{ stationId: IDS.stationA, sequence: 1, distanceKm: DISTANCE.A, ...stopOverrides.A },
|
||||
{ stationId: IDS.stationB, sequence: 2, distanceKm: DISTANCE.B, ...stopOverrides.B },
|
||||
{ stationId: IDS.stationC, sequence: 3, distanceKm: DISTANCE.C, ...stopOverrides.C },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.currencyExchangeRate.createMany({
|
||||
data: [
|
||||
{ fromCurrency: "USD", toCurrency: "ETB", rate: USD_TO_ETB, effectiveDate: new Date() },
|
||||
{ fromCurrency: "ETB", toCurrency: "USD", rate: 1 / USD_TO_ETB, effectiveDate: new Date() },
|
||||
{ fromCurrency: "ETB", toCurrency: "DJF", rate: ETB_TO_DJF, effectiveDate: new Date() },
|
||||
{ fromCurrency: "DJF", toCurrency: "ETB", rate: 1 / ETB_TO_DJF, effectiveDate: new Date() },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** Convenience: reset + seed in one call. */
|
||||
export async function resetAndSeedCore(prisma: PrismaClient, stopOverrides: RouteStopOverrides = {}): Promise<void> {
|
||||
await truncateAllPassenger(prisma);
|
||||
await seedCore(prisma, stopOverrides);
|
||||
}
|
||||
89
apps/edr-passenger-api/test/fixtures/seed-passenger-session.ts
vendored
Normal file
89
apps/edr-passenger-api/test/fixtures/seed-passenger-session.ts
vendored
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Seeds a passenger IAM user + session directly (no OTP flow) and mints an access token the portal
|
||||
* accepts. The API JwtGuard verifies the JWT signature (JWT_ACCESS_TOKEN_SECRET) and looks up the
|
||||
* session by its `id` claim; the portal then calls /auth/profile which needs a Passenger row linked
|
||||
* by iamUserId. Returns { token, profile } for the Playwright passenger storageState.
|
||||
*
|
||||
* Run standalone to validate: `npx ts-node test/fixtures/seed-passenger-session.ts` (prints the
|
||||
* token and the /auth/profile status via the running API on :4000).
|
||||
*/
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { SignJWT } from "jose";
|
||||
import { UI_IDS } from "./seed-ui";
|
||||
|
||||
export const PASSENGER_USER_ID = "11111111-0000-4000-8000-000000000001";
|
||||
export const PASSENGER_SESSION_ID = "11111111-0000-4000-8000-000000000002";
|
||||
const EMAIL = "test.passenger@edr.local";
|
||||
const USERNAME = "test_passenger";
|
||||
|
||||
export async function seedPassengerSession(prisma: PrismaClient): Promise<{ token: string }> {
|
||||
const secret = process.env.JWT_ACCESS_TOKEN_SECRET;
|
||||
if (!secret) throw new Error("JWT_ACCESS_TOKEN_SECRET is required to mint the passenger token");
|
||||
|
||||
const userInfo = {
|
||||
id: PASSENGER_USER_ID,
|
||||
name: { en: "Test Passenger" },
|
||||
email: EMAIL,
|
||||
roles: [] as unknown[],
|
||||
status: "accepted",
|
||||
employee: [] as unknown[],
|
||||
userType: "individual",
|
||||
username: USERNAME,
|
||||
permissions: [] as unknown[],
|
||||
};
|
||||
const expiry = new Date(Date.now() + 7 * 86400_000);
|
||||
|
||||
// iam.users (delete-then-insert so re-seeding is idempotent).
|
||||
await prisma.$executeRawUnsafe(`DELETE FROM iam.sessions WHERE id = $1::uuid`, PASSENGER_SESSION_ID);
|
||||
await prisma.$executeRawUnsafe(`DELETE FROM iam.users WHERE id = $1::uuid`, PASSENGER_USER_ID);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`INSERT INTO iam.users (created_at, id, name, username, email, user_type, status, is_active, has_set_password, is_phone_number_verified, verified_by)
|
||||
VALUES (now(), $1::uuid, $2::jsonb, $3, $4, 'individual', 'accepted', true, true, true, 'SYSTEM')`,
|
||||
PASSENGER_USER_ID,
|
||||
JSON.stringify(userInfo.name),
|
||||
USERNAME,
|
||||
EMAIL,
|
||||
);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`INSERT INTO iam.sessions (created_at, id, email, device, "userInfo", expiry_time, refresh_count, status, user_id)
|
||||
VALUES (now(), $1::uuid, $2, 'e2e', $3::jsonb, $4, 0, 'ACTIVE', $5::uuid)`,
|
||||
PASSENGER_SESSION_ID,
|
||||
EMAIL,
|
||||
JSON.stringify(userInfo),
|
||||
expiry,
|
||||
PASSENGER_USER_ID,
|
||||
);
|
||||
|
||||
// Link the seeded Passenger row to this IAM user so /auth/profile resolves.
|
||||
await prisma.passenger.update({
|
||||
where: { id: UI_IDS.passenger },
|
||||
data: { iamUserId: PASSENGER_USER_ID },
|
||||
});
|
||||
|
||||
const token = await new SignJWT({ id: PASSENGER_SESSION_ID })
|
||||
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("7d")
|
||||
.sign(new TextEncoder().encode(secret));
|
||||
|
||||
return { token };
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
(async () => {
|
||||
const prisma = new PrismaClient();
|
||||
try {
|
||||
const { token } = await seedPassengerSession(prisma);
|
||||
const api = process.env.API_URL ?? "http://localhost:4000";
|
||||
const res = await fetch(`${api}/auth/profile`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[passenger-session] /auth/profile -> HTTP ${res.status}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log((await res.text()).slice(0, 400));
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
})();
|
||||
}
|
||||
197
apps/edr-passenger-api/test/fixtures/seed-ui.ts
vendored
Normal file
197
apps/edr-passenger-api/test/fixtures/seed-ui.ts
vendored
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* UI E2E seed — extends seed-core with a BOOKABLE trip + payment methods + promos so the portal
|
||||
* search/booking flow and the backoffice config flow have real data to drive. Runnable standalone
|
||||
* (`ts-node test/fixtures/seed-ui.ts`) or importable (`seedUi(prisma)`) from the Playwright
|
||||
* global-setup. Reads DATABASE_URL from the environment (the UI stack points at the 5544 test DB).
|
||||
*
|
||||
* Searchability: a schedule shows in POST /search when it is SCHEDULED, not package-only, has a
|
||||
* future departure on the searched date, operational coaches with AVAILABLE seats, and a resolvable
|
||||
* fare (seat-class distance formula using the seeded route-stop distances + USD→ETB rate).
|
||||
*/
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { IDS, resetAndSeedCore } from "./seed-core";
|
||||
|
||||
export const UI_IDS = {
|
||||
train: "00000000-0000-4000-8000-000000000100",
|
||||
schedule: "00000000-0000-4000-8000-000000000101",
|
||||
coach: "00000000-0000-4000-8000-000000000102",
|
||||
// Passenger.id is set EQUAL to the IAM user id. The bookings controller overrides passengerId
|
||||
// with the JWT user id (req.user.id), and the service only resolves iamUserId→passenger when it
|
||||
// is NON-UUID — since IAM ids are UUIDs, it uses the id directly, so Passenger.id must equal it.
|
||||
passenger: "11111111-0000-4000-8000-000000000001",
|
||||
promoValid: "PROMO10",
|
||||
promoExpired: "EXPIRED50",
|
||||
// Return leg C→A on the same calendar date, for ROUND_TRIP scenarios (UA-6). The route stops are
|
||||
// symmetric in distance (A=0, C=250) so the reverse leg prices identically to the outbound.
|
||||
returnSchedule: "00000000-0000-4000-8000-000000000201",
|
||||
returnCoach: "00000000-0000-4000-8000-000000000202",
|
||||
} as const;
|
||||
|
||||
/** Days-from-now the sample trip departs (tests search on this calendar date, Addis TZ). */
|
||||
export const DEPART_IN_DAYS = 2;
|
||||
|
||||
export function sampleDepartAt(): Date {
|
||||
const d = new Date();
|
||||
d.setUTCDate(d.getUTCDate() + DEPART_IN_DAYS);
|
||||
d.setUTCHours(6, 0, 0, 0); // 06:00Z ~ 09:00 Addis — safely same calendar day either TZ
|
||||
return d;
|
||||
}
|
||||
|
||||
/** The date string a test passes to POST /search for the sample trip (YYYY-MM-DD). */
|
||||
export function sampleDepartDate(): string {
|
||||
return sampleDepartAt().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export async function seedUi(prisma: PrismaClient): Promise<void> {
|
||||
await resetAndSeedCore(prisma);
|
||||
|
||||
// Give the two seed-core seat classes the exact names the portal review flow maps by.
|
||||
await prisma.seatClass.update({
|
||||
where: { id: IDS.seatClassLocal },
|
||||
data: { name: "Economy Regular" },
|
||||
});
|
||||
await prisma.seatClass.update({
|
||||
where: { id: IDS.seatClassIntl },
|
||||
data: { name: "Economy Regular Intl" },
|
||||
});
|
||||
|
||||
// Enabled payment methods — the portal pay page renders ONLY enabled PaymentMethod rows.
|
||||
await prisma.paymentMethod.createMany({
|
||||
data: [
|
||||
{ type: "WALLET", displayName: "Wallet", currency: "ETB", enabled: true, isDefault: true, sortOrder: 0 },
|
||||
{ type: "TELEBIRR", displayName: "telebirr", currency: "ETB", enabled: true, sortOrder: 1 },
|
||||
],
|
||||
});
|
||||
|
||||
const departAt = sampleDepartAt();
|
||||
const arriveAt = new Date(departAt.getTime() + 4 * 3600_000);
|
||||
|
||||
await prisma.train.create({
|
||||
data: { id: UI_IDS.train, number: "UI-100", name: "UI Test Express" },
|
||||
});
|
||||
|
||||
await prisma.trainSchedule.create({
|
||||
data: {
|
||||
id: UI_IDS.schedule,
|
||||
trainId: UI_IDS.train,
|
||||
routeId: IDS.route,
|
||||
originStationId: IDS.stationA,
|
||||
destinationStationId: IDS.stationC,
|
||||
departureAt: departAt,
|
||||
arrivalAt: arriveAt,
|
||||
durationMinutes: 240,
|
||||
status: "SCHEDULED",
|
||||
stopsCount: 3,
|
||||
isPackageOnly: false,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.tripStopTime.createMany({
|
||||
data: [
|
||||
{ scheduleId: UI_IDS.schedule, stationId: IDS.stationA, sequence: 1, plannedDepartureAt: departAt, status: "OPEN" },
|
||||
{ scheduleId: UI_IDS.schedule, stationId: IDS.stationB, sequence: 2, plannedDepartureAt: new Date(departAt.getTime() + 2 * 3600_000), status: "OPEN" },
|
||||
{ scheduleId: UI_IDS.schedule, stationId: IDS.stationC, sequence: 3, plannedArrivalAt: arriveAt, status: "OPEN" },
|
||||
],
|
||||
});
|
||||
|
||||
// Enough seats that a full suite run (many bookings share one seeded DB, seats are not released
|
||||
// between specs) never exhausts availability: 12 rows × 4 cols = 48 seats.
|
||||
const SEAT_ROWS = 12;
|
||||
await prisma.coach.create({
|
||||
data: { id: UI_IDS.coach, coachTypeId: IDS.coachType, number: "UI-C1", capacity: SEAT_ROWS * 4, sequence: 1, status: "ACTIVE" },
|
||||
});
|
||||
await prisma.coachAssignment.create({
|
||||
data: { scheduleId: UI_IDS.schedule, coachId: UI_IDS.coach, positionNumber: 1, isOperational: true },
|
||||
});
|
||||
await prisma.seat.createMany({ data: buildSeats(UI_IDS.coach, SEAT_ROWS) });
|
||||
|
||||
// Logged-in passenger with a funded wallet + loyalty (used by the portal storageState + WALLET pay).
|
||||
await prisma.passenger.create({ data: { id: UI_IDS.passenger } });
|
||||
await prisma.walletAccount.create({
|
||||
data: { passengerId: UI_IDS.passenger, balanceMinor: 100_000_000 },
|
||||
});
|
||||
await prisma.loyaltyAccount.create({
|
||||
data: { passengerId: UI_IDS.passenger, pointsBalance: 500 },
|
||||
});
|
||||
|
||||
// Promotions (schema field names, NOT the backoffice UI names). Unique exact codes.
|
||||
await prisma.promotion.createMany({
|
||||
data: [
|
||||
{ title: "10% off", code: UI_IDS.promoValid, percentOff: 10, validUntil: new Date(Date.now() + 30 * 86400_000), active: true },
|
||||
{ title: "Expired", code: UI_IDS.promoExpired, percentOff: 50, validUntil: new Date(Date.now() - 86400_000), active: true },
|
||||
],
|
||||
});
|
||||
|
||||
// One baggage allowance (for excess-baggage flows later).
|
||||
await prisma.baggageAllowance.create({
|
||||
data: { seatClassId: IDS.seatClassLocal, maxWeightKg: 20, maxPiecesCount: 2, excessFeePerKg: 50 },
|
||||
});
|
||||
|
||||
// ── Return leg (C→A) for ROUND_TRIP (UA-6) ──────────────────────────────────
|
||||
// Same train, same day, departs after the outbound arrives. Distances are symmetric
|
||||
// (A=0km … C=250km) so the reverse leg prices the same as the outbound.
|
||||
const returnDepart = new Date(departAt.getTime() + 8 * 3600_000); // 8h after outbound departs
|
||||
const returnArrive = new Date(returnDepart.getTime() + 4 * 3600_000);
|
||||
await prisma.trainSchedule.create({
|
||||
data: {
|
||||
id: UI_IDS.returnSchedule,
|
||||
trainId: UI_IDS.train,
|
||||
routeId: IDS.route,
|
||||
originStationId: IDS.stationC,
|
||||
destinationStationId: IDS.stationA,
|
||||
departureAt: returnDepart,
|
||||
arrivalAt: returnArrive,
|
||||
durationMinutes: 240,
|
||||
status: "SCHEDULED",
|
||||
stopsCount: 3,
|
||||
isPackageOnly: false,
|
||||
},
|
||||
});
|
||||
await prisma.tripStopTime.createMany({
|
||||
data: [
|
||||
{ scheduleId: UI_IDS.returnSchedule, stationId: IDS.stationC, sequence: 1, plannedDepartureAt: returnDepart, status: "OPEN" },
|
||||
{ scheduleId: UI_IDS.returnSchedule, stationId: IDS.stationB, sequence: 2, plannedDepartureAt: new Date(returnDepart.getTime() + 2 * 3600_000), status: "OPEN" },
|
||||
{ scheduleId: UI_IDS.returnSchedule, stationId: IDS.stationA, sequence: 3, plannedArrivalAt: returnArrive, status: "OPEN" },
|
||||
],
|
||||
});
|
||||
await prisma.coach.create({
|
||||
data: { id: UI_IDS.returnCoach, coachTypeId: IDS.coachType, number: "UI-C2", capacity: 48, sequence: 1, status: "ACTIVE" },
|
||||
});
|
||||
await prisma.coachAssignment.create({
|
||||
data: { scheduleId: UI_IDS.returnSchedule, coachId: UI_IDS.returnCoach, positionNumber: 1, isOperational: true },
|
||||
});
|
||||
await prisma.seat.createMany({ data: buildSeats(UI_IDS.returnCoach, 12) });
|
||||
}
|
||||
|
||||
/** Build `rows × 4` seats (cols A–D) for a coach. */
|
||||
function buildSeats(coachId: string, rows: number) {
|
||||
const cols = ["A", "B", "C", "D"];
|
||||
const seats: Array<{ coachId: string; seatNumber: string; row: number; col: string; isWindow: boolean; isAisle: boolean }> = [];
|
||||
for (let row = 1; row <= rows; row++) {
|
||||
for (const col of cols) {
|
||||
seats.push({
|
||||
coachId,
|
||||
seatNumber: `${row}${col}`,
|
||||
row,
|
||||
col,
|
||||
isWindow: col === "A" || col === "D",
|
||||
isAisle: col === "B" || col === "C",
|
||||
});
|
||||
}
|
||||
}
|
||||
return seats;
|
||||
}
|
||||
|
||||
// Standalone runner
|
||||
if (require.main === module) {
|
||||
(async () => {
|
||||
const prisma = new PrismaClient();
|
||||
try {
|
||||
await seedUi(prisma);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[seed-ui] done. Sample trip ${IDS.stationA}→${IDS.stationC} on ${sampleDepartDate()} (schedule ${UI_IDS.schedule}).`);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -2,6 +2,34 @@
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": { "^.+\\.(t|j)s$": "ts-jest" },
|
||||
"testEnvironment": "node"
|
||||
"testPathIgnorePatterns": [
|
||||
"/node_modules/",
|
||||
"test/app.e2e-spec.ts"
|
||||
],
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": ["ts-jest", { "isolatedModules": true }]
|
||||
},
|
||||
"testEnvironment": "node",
|
||||
"setupFiles": ["<rootDir>/setup/load-env.ts"],
|
||||
"moduleNameMapper": {
|
||||
"^file-type$": "<rootDir>/setup/stubs/file-type.ts",
|
||||
"^@edr/types$": "<rootDir>/../../../packages/types/src/index.ts",
|
||||
"^@edr/types/(.*)$": "<rootDir>/../../../packages/types/src/$1",
|
||||
"^@/(.*)$": "<rootDir>/../src/$1"
|
||||
},
|
||||
"testTimeout": 60000,
|
||||
"maxWorkers": 1,
|
||||
"reporters": [
|
||||
"default",
|
||||
[
|
||||
"jest-html-reporters",
|
||||
{
|
||||
"publicPath": "<rootDir>/../e2e-report",
|
||||
"filename": "index.html",
|
||||
"pageTitle": "EDR Passenger — Pricing/Config E2E Results",
|
||||
"expand": true,
|
||||
"hideIcon": false
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
175
apps/edr-passenger-api/test/money-integrity.e2e-spec.ts
Normal file
175
apps/edr-passenger-api/test/money-integrity.e2e-spec.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Tier-2 money-integrity suite — services behind the IAM/RabbitMQ wall, instantiated directly with
|
||||
* a real Prisma (test DB) + stubbed collaborators. Confirms critical findings:
|
||||
* F1/F2 🔴 WalletService.topUp credits any passenger's wallet with no ownership check and no
|
||||
* payment backing (free money).
|
||||
* G4/G5 🔴 BookingsService.cancel computes an 80% refund but NEVER disburses it — no PaymentRefund,
|
||||
* no wallet credit; the cancellation sits at refundStatus PENDING forever.
|
||||
* E1/E2 🔴 ExcessBaggageService.logCharge picks the OLDEST BaggageAllowance globally, ignoring the
|
||||
* booking's seat class, and computes fee = feePerKgMinor × excessWeightKg.
|
||||
*/
|
||||
import { WalletService } from "../src/modules/wallet/wallet.service";
|
||||
import { BookingsService } from "../src/modules/bookings/bookings.service";
|
||||
import { ExcessBaggageService } from "../src/modules/excess-baggage/excess-baggage.service";
|
||||
import { getTestPrisma, disconnectTestPrisma } from "./setup/prisma";
|
||||
import { truncateAllPassenger, seedCore, IDS } 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("Money integrity (Tier-2 direct instantiation)", () => {
|
||||
const prisma = getTestPrisma();
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllPassenger(prisma);
|
||||
await seedCore(prisma);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await disconnectTestPrisma();
|
||||
});
|
||||
|
||||
// ── F1 / F2 ────────────────────────────────────────────────────────────────
|
||||
it("F1/F2 🔴 topUp credits another passenger's wallet — no ownership check, no payment backing", async () => {
|
||||
const victim = await prisma.passenger.create({ data: {} });
|
||||
await prisma.walletAccount.create({
|
||||
data: { passengerId: victim.id, balanceMinor: 0 },
|
||||
});
|
||||
|
||||
const wallet = new WalletService(prisma as any);
|
||||
|
||||
// An attacker-controlled call: just pass the victim's id. Nothing checks caller identity,
|
||||
// and no PaymentIntent/settlement backs the credit.
|
||||
await wallet.topUp(victim.id, 1_000_000, "free money");
|
||||
|
||||
const after = await prisma.walletAccount.findUnique({
|
||||
where: { passengerId: victim.id },
|
||||
});
|
||||
expect(after?.balanceMinor).toBe(1_000_000);
|
||||
|
||||
// The only ledger entry is a bare CREDIT — no linked payment.
|
||||
const ledger = await prisma.walletLedgerEntry.findMany({
|
||||
where: { walletId: after!.id },
|
||||
});
|
||||
expect(ledger).toHaveLength(1);
|
||||
expect(ledger[0].type).toBe("CREDIT");
|
||||
expect(ledger[0].relatedBookingId ?? null).toBeNull();
|
||||
});
|
||||
|
||||
// ── G4 / G5 ────────────────────────────────────────────────────────────────
|
||||
it("G4/G5 🔴 cancel() computes floor(total*0.8) refund but never disburses it (stuck PENDING)", async () => {
|
||||
const passenger = await prisma.passenger.create({ data: {} });
|
||||
// Give the passenger a wallet so we can prove NO refund lands in it.
|
||||
const w = await prisma.walletAccount.create({
|
||||
data: { passengerId: passenger.id, balanceMinor: 0 },
|
||||
});
|
||||
const schedule = await makeSchedule(prisma, passenger.id);
|
||||
|
||||
const booking = await prisma.booking.create({
|
||||
data: {
|
||||
bookingRef: "CXL-0001",
|
||||
passengerId: passenger.id,
|
||||
scheduleId: schedule.id,
|
||||
totalMinor: 30_000,
|
||||
displayCurrency: "ETB",
|
||||
status: "CONFIRMED",
|
||||
},
|
||||
});
|
||||
|
||||
const bookings = new BookingsService(
|
||||
prisma as any,
|
||||
asyncStub(), // dataSource
|
||||
asyncStub(), // seatsService
|
||||
{ emit: () => true } as any, // eventEmitter
|
||||
asyncStub(), // verifaydaService
|
||||
asyncStub(), // currencyService
|
||||
asyncStub(), // fareEngine
|
||||
asyncStub(), // auditService
|
||||
);
|
||||
|
||||
const result: any = await bookings.cancel(booking.bookingRef, "test");
|
||||
|
||||
// Refund is COMPUTED as 80%:
|
||||
expect(result.refundAmount).toBe(Math.floor(30_000 * 0.8) / 100); // 240.00
|
||||
|
||||
// …but recorded only as PENDING, and never actually paid out:
|
||||
const cancellation = await prisma.bookingCancellation.findFirst({
|
||||
where: { bookingId: booking.id },
|
||||
});
|
||||
expect(cancellation?.refundStatus).toBe("PENDING");
|
||||
|
||||
// No PaymentRefund row was created anywhere (isolated DB) and the wallet was NOT credited.
|
||||
const refundCount = await prisma.paymentRefund.count();
|
||||
expect(refundCount).toBe(0);
|
||||
const walletAfter = await prisma.walletAccount.findUnique({ where: { id: w.id } });
|
||||
expect(walletAfter?.balanceMinor).toBe(0);
|
||||
});
|
||||
|
||||
// ── E1 / E2 ────────────────────────────────────────────────────────────────
|
||||
it("E1/E2 🔴 excess-baggage uses the OLDEST allowance globally (ignores seat class); fee = rate×kg", async () => {
|
||||
const passenger = await prisma.passenger.create({ data: {} });
|
||||
const schedule = await makeSchedule(prisma, passenger.id);
|
||||
const booking = await prisma.booking.create({
|
||||
data: {
|
||||
bookingRef: "BAG-0001",
|
||||
passengerId: passenger.id,
|
||||
scheduleId: schedule.id,
|
||||
totalMinor: 30_000,
|
||||
status: "CONFIRMED",
|
||||
},
|
||||
});
|
||||
|
||||
// Oldest allowance is for the LOCAL class (rate 50). A later one for INTL (rate 200) should win
|
||||
// for an intl booking — but logCharge ignores seat class and takes the oldest row.
|
||||
await prisma.baggageAllowance.create({
|
||||
data: { seatClassId: IDS.seatClassLocal, maxWeightKg: 20, maxPiecesCount: 2, excessFeePerKg: 50 },
|
||||
});
|
||||
await prisma.baggageAllowance.create({
|
||||
data: { seatClassId: IDS.seatClassIntl, maxWeightKg: 20, maxPiecesCount: 2, excessFeePerKg: 200 },
|
||||
});
|
||||
|
||||
const service = new ExcessBaggageService(
|
||||
prisma as any,
|
||||
asyncStub(), // auditService
|
||||
asyncStub(), // paymentClient
|
||||
asyncStub(), // notifications
|
||||
asyncStub(), // smsClient
|
||||
asyncStub(), // emailClient
|
||||
);
|
||||
|
||||
const charge: any = await service.logCharge({
|
||||
bookingId: booking.id,
|
||||
excessWeightKg: 10,
|
||||
collectCash: true,
|
||||
} as any);
|
||||
|
||||
// Used the oldest (LOCAL, 50) not any seat-class-matched rate; fee = 50 × 10.
|
||||
expect(charge.feePerKgMinor).toBe(50);
|
||||
expect(charge.totalMinor).toBe(50 * 10);
|
||||
});
|
||||
});
|
||||
|
||||
let trainSeq = 0;
|
||||
|
||||
/** Minimal TrainSchedule (+train) so booking/cancel fixtures satisfy FKs. */
|
||||
async function makeSchedule(prisma: any, _passengerId: string) {
|
||||
const train = await prisma.train.create({
|
||||
data: { number: `T-${++trainSeq}`, name: "Test Train" },
|
||||
});
|
||||
return prisma.trainSchedule.create({
|
||||
data: {
|
||||
trainId: train.id,
|
||||
routeId: IDS.route,
|
||||
originStationId: IDS.stationA,
|
||||
destinationStationId: IDS.stationB,
|
||||
departureAt: new Date(Date.now() + 86_400_000),
|
||||
arrivalAt: new Date(Date.now() + 90_000_000),
|
||||
durationMinutes: 60,
|
||||
},
|
||||
});
|
||||
}
|
||||
81
apps/edr-passenger-api/test/pricing-currency.e2e-spec.ts
Normal file
81
apps/edr-passenger-api/test/pricing-currency.e2e-spec.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 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).
|
||||
* 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
|
||||
* written into fields named `amountMinor` at their call sites.
|
||||
*/
|
||||
import { CurrencyService } from "../src/modules/currency/currency.service";
|
||||
import { createServiceHarness, ServiceHarness } from "./setup/slim-app";
|
||||
import { resetAndSeedCore, USD_TO_ETB } from "./fixtures/seed-core";
|
||||
|
||||
describe("Pricing — CurrencyService (Suite C)", () => {
|
||||
let harness: ServiceHarness;
|
||||
let currency: CurrencyService;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createServiceHarness();
|
||||
currency = harness.moduleRef.get(CurrencyService);
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.close();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await resetAndSeedCore(harness.prisma);
|
||||
});
|
||||
|
||||
it("C2 🔴 same DB state, 100x divergence: getExchangeRate → 1.0, getRateOrThrow → 100 (via inverse)", async () => {
|
||||
// Remove only the DIRECT USD→ETB row; the inverse ETB→USD (0.01) from the fixture stays.
|
||||
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);
|
||||
|
||||
// Charge path (getRateOrThrow) DOES fall back to the inverse → 1 / 0.01 = 100 (correct).
|
||||
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 () => {
|
||||
await harness.prisma.currencyExchangeRate.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ fromCurrency: "USD", toCurrency: "ETB" },
|
||||
{ fromCurrency: "ETB", toCurrency: "USD" },
|
||||
],
|
||||
},
|
||||
});
|
||||
await expect(currency.getExchangeRate("USD" as any, "ETB" as any)).resolves.toBe(1.0);
|
||||
await expect(
|
||||
currency.getRateOrThrow("USD" as any, "ETB" as any),
|
||||
).rejects.toThrow(/No exchange rate/i);
|
||||
});
|
||||
|
||||
it("C3 🔴 a future-dated rate is used right now (no effective-date gate)", async () => {
|
||||
const future = new Date(Date.now() + 365 * 24 * 3600 * 1000);
|
||||
await harness.prisma.currencyExchangeRate.create({
|
||||
data: { fromCurrency: "ETB", toCurrency: "USD", rate: 999, effectiveDate: future },
|
||||
});
|
||||
|
||||
// Correct behavior: ignore not-yet-effective rates. Actual: latest-by-date wins immediately.
|
||||
const rate = await currency.getExchangeRate("ETB" as any, "USD" as any);
|
||||
expect(rate).toBe(999);
|
||||
});
|
||||
|
||||
it("C5 🔴 conversion routines return different UNITS for the same money (100x apart)", async () => {
|
||||
// 100000 ETB minor = 1000.00 ETB. With ETB→USD = 1/100:
|
||||
const asMajor = await currency.convertMinorToChargeMajor(100000, "ETB", "USD"); // → 10.00 (major)
|
||||
const asMinor = await currency.convertEtbMinorToChargeMinor(100000, "USD"); // → 1000 (minor)
|
||||
|
||||
expect(asMajor).toBeCloseTo(1000 / USD_TO_ETB, 2); // 10.00
|
||||
expect(asMinor).toBe(Math.round((100000 * 1) / USD_TO_ETB)); // 1000
|
||||
// Same amount, but the two results differ by 100x — and both feed fields named `amountMinor`.
|
||||
expect(asMinor).toBe(asMajor * 100);
|
||||
});
|
||||
});
|
||||
131
apps/edr-passenger-api/test/pricing-fare-engine.e2e-spec.ts
Normal file
131
apps/edr-passenger-api/test/pricing-fare-engine.e2e-spec.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Reference pricing suite — proves the slim harness boots and exercises FareEngineService directly.
|
||||
* Also confirms two 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).
|
||||
*/
|
||||
import { FareEngineService } from "../src/modules/fare-engine/fare-engine.service";
|
||||
import {
|
||||
createServiceHarness,
|
||||
ServiceHarness,
|
||||
} from "./setup/slim-app";
|
||||
import {
|
||||
IDS,
|
||||
resetAndSeedCore,
|
||||
DISTANCE,
|
||||
USD_TO_ETB,
|
||||
} from "./fixtures/seed-core";
|
||||
|
||||
describe("Pricing — FareEngineService (slim harness)", () => {
|
||||
let harness: ServiceHarness;
|
||||
let fareEngine: FareEngineService;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createServiceHarness();
|
||||
fareEngine = harness.moduleRef.get(FareEngineService);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.close();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetAndSeedCore(harness.prisma);
|
||||
});
|
||||
|
||||
// nationality 'Ethiopian' → LOCAL seat class (3.00 ETB/km) and ETB billing (rate 1).
|
||||
const baseDto = () => ({
|
||||
routeId: IDS.route,
|
||||
originStationId: IDS.stationA,
|
||||
destinationStationId: IDS.stationB,
|
||||
seatClassId: IDS.seatClassLocal,
|
||||
nationality: "Ethiopian",
|
||||
adultCount: 1,
|
||||
childCount: 0,
|
||||
});
|
||||
|
||||
it("boots and computes a positive baseline fare (A→B, local, 1 adult)", async () => {
|
||||
const result = await fareEngine.calculate(baseDto() as any);
|
||||
// 100km × 3.00 ETB/km × 1 × USD_TO_ETB(100) = 30000 minor (see seat-class formula).
|
||||
expect(result.totalMinor).toBeGreaterThan(0);
|
||||
expect(result.totalMinor).toBe(DISTANCE.B * 3 * USD_TO_ETB);
|
||||
});
|
||||
|
||||
it("D1 🔴 promo percentOff=150 produces a NEGATIVE total (no floor at 0)", async () => {
|
||||
await harness.prisma.promotion.create({
|
||||
data: {
|
||||
title: "Overshoot",
|
||||
code: "OVER150",
|
||||
percentOff: 150,
|
||||
validUntil: new Date(Date.now() + 86_400_000),
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await fareEngine.calculate({
|
||||
...baseDto(),
|
||||
promoCode: "OVER150",
|
||||
} as any);
|
||||
|
||||
// Expected (correct) behavior: total clamped at >= 0. Actual: negative.
|
||||
expect(result.totalMinor).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it("D2 🔴 fixed amountOffMinor larger than subtotal drives total NEGATIVE", async () => {
|
||||
const base = await fareEngine.calculate(baseDto() as any); // 30000 minor
|
||||
await harness.prisma.promotion.create({
|
||||
data: {
|
||||
title: "Huge fixed",
|
||||
code: "FIXEDBIG",
|
||||
amountOffMinor: base.totalMinor + 10_000,
|
||||
validUntil: new Date(Date.now() + 86_400_000),
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await fareEngine.calculate({
|
||||
...baseDto(),
|
||||
promoCode: "FIXEDBIG",
|
||||
} as any);
|
||||
expect(result.totalMinor).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it("D4 🔴 promo with percentOff=0 is treated as FIXED (0 is falsy) and applies amountOffMinor", async () => {
|
||||
// A promo intended as '0% off' but also carrying a stray fixed amount: the falsy check
|
||||
// `promo.percentOff ? percent : amountOffMinor` wrongly applies the fixed discount.
|
||||
await harness.prisma.promotion.create({
|
||||
data: {
|
||||
title: "Zero percent",
|
||||
code: "ZERO0",
|
||||
percentOff: 0,
|
||||
amountOffMinor: 5000,
|
||||
validUntil: new Date(Date.now() + 86_400_000),
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
|
||||
const base = await fareEngine.calculate(baseDto() as any);
|
||||
const withPromo = await fareEngine.calculate({
|
||||
...baseDto(),
|
||||
promoCode: "ZERO0",
|
||||
} as any);
|
||||
|
||||
// A true 0% promo should not change the price; here it deducts the fixed 5000.
|
||||
expect(withPromo.totalMinor).toBe(base.totalMinor - 5000);
|
||||
});
|
||||
|
||||
it("C1 🔴 missing USD→ETB rate silently falls back to 1.0 (fare collapses ~100x)", async () => {
|
||||
const withRate = await fareEngine.calculate(baseDto() as any);
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
39
apps/edr-passenger-api/test/setup/load-env.ts
Normal file
39
apps/edr-passenger-api/test/setup/load-env.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Loads apps/edr-passenger-api/.env.test into process.env BEFORE the Nest AppModule boots.
|
||||
* Registered as a jest `setupFile` (runs per test file, before the framework and before any
|
||||
* `Test.createTestingModule`). Zero-dependency KEY=VALUE parser — dotenv is not a direct dep here.
|
||||
* Existing process.env values win (so CI can override the DB URL without editing the file).
|
||||
*/
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
// Prefer a local (gitignored) .env.test; fall back to the tracked .env.test.example so a fresh
|
||||
// checkout of the branch runs the suites without a manual copy step.
|
||||
const localPath = join(__dirname, "..", "..", ".env.test");
|
||||
const examplePath = join(__dirname, "..", "..", ".env.test.example");
|
||||
const envPath = existsSync(localPath) ? localPath : examplePath;
|
||||
|
||||
try {
|
||||
const raw = readFileSync(envPath, "utf8");
|
||||
for (const line of raw.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const eq = trimmed.indexOf("=");
|
||||
if (eq === -1) continue;
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
let value = trimmed.slice(eq + 1).trim();
|
||||
// strip surrounding quotes if present
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
if (process.env[key] === undefined) process.env[key] = value;
|
||||
}
|
||||
} catch (err) {
|
||||
// Surface loudly — a missing .env.test means every suite would boot against the wrong DB.
|
||||
throw new Error(
|
||||
`[load-env] could not read ${envPath}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
26
apps/edr-passenger-api/test/setup/prisma.ts
Normal file
26
apps/edr-passenger-api/test/setup/prisma.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Singleton PrismaClient against the hermetic test DB (DATABASE_URL from .env.test, loaded by
|
||||
* setup/load-env.ts). Used by:
|
||||
* - the fixture seeder (fixtures/seed-core.ts), and
|
||||
* - "direct-instantiation" specs for services behind the IAM/RabbitMQ wall (BookingsService,
|
||||
* PaymentsService, WalletService, …) which cannot be booted through their Nest modules because
|
||||
* those transitively import the @tria-plc IAM stack (ESM-only `file-type`) / golevelup RabbitMQ.
|
||||
* Those specs `new TheService(prisma, ...mockedCollaborators)` and assert the money logic.
|
||||
*/
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
let client: PrismaClient | undefined;
|
||||
|
||||
export function getTestPrisma(): PrismaClient {
|
||||
if (!client) {
|
||||
client = new PrismaClient();
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function disconnectTestPrisma(): Promise<void> {
|
||||
if (client) {
|
||||
await client.$disconnect();
|
||||
client = undefined;
|
||||
}
|
||||
}
|
||||
145
apps/edr-passenger-api/test/setup/slim-app.ts
Normal file
145
apps/edr-passenger-api/test/setup/slim-app.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Slim Nest test harness — boots ONLY the passenger domain modules needed for pricing/booking
|
||||
* tests, deliberately excluding the IAM (TriaIamModule), SharedAuth, and MinIO stack from
|
||||
* app.module.ts. Those drag in `@tria-plc/api-common`'s file-crud/minio chain which requires the
|
||||
* ESM-only `file-type` package that jest's CommonJS resolver cannot load.
|
||||
*
|
||||
* Two entry points:
|
||||
* - createServiceHarness(): resolve services directly (FareEngineService, etc.) for unit/DB-level
|
||||
* assertions on the money math.
|
||||
* - createHttpHarness(): a full Nest HTTP app with the SAME global ValidationPipe as main.ts, so
|
||||
* controller/DTO/pipe behavior (client-trust, DTO validation) is exercised end-to-end over HTTP.
|
||||
*
|
||||
* The IAM JwtGuard is overridden with an always-allow stub so protected routes are reachable; auth
|
||||
* *enforcement* findings (which guards are missing) are asserted separately via route metadata, not
|
||||
* by booting the real guard.
|
||||
*/
|
||||
import { Global, INestApplication, Module, ValidationPipe } from "@nestjs/common";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { EventEmitterModule } from "@nestjs/event-emitter";
|
||||
import { ScheduleModule } from "@nestjs/schedule";
|
||||
import { getDataSourceToken } from "@nestjs/typeorm";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
import { PrismaModule } from "../../src/common/prisma.module";
|
||||
import { PrismaService } from "../../src/common/prisma.service";
|
||||
import { SessionActivityInterceptor } from "../../src/common/interceptors/session-activity.interceptor";
|
||||
import { FareEngineModule } from "../../src/modules/fare-engine/fare-engine.module";
|
||||
import { CurrencyModule } from "../../src/modules/currency/currency.module";
|
||||
import { CurrenciesModule } from "../../src/modules/currencies/currencies.module";
|
||||
import { PromosModule } from "../../src/modules/promos/promos.module";
|
||||
import { SeatClassesModule } from "../../src/modules/seat-classes/seat-classes.module";
|
||||
import { StationsModule } from "../../src/modules/stations/stations.module";
|
||||
import { SchedulesModule } from "../../src/modules/schedules/schedules.module";
|
||||
import { SegmentsModule } from "../../src/modules/segments/segments.module";
|
||||
import { SystemConfigModule } from "../../src/modules/system-config/system-config.module";
|
||||
|
||||
/**
|
||||
* A stub TypeORM DataSource, provided globally so IAM-derived providers that reach the slim
|
||||
* harness transitively (e.g. NotificationsService via ExcessBaggageModule) can instantiate.
|
||||
* Pricing tests never trigger the code paths that actually use it.
|
||||
*/
|
||||
const fakeDataSource = {
|
||||
query: async () => [],
|
||||
transaction: async (cb: (m: unknown) => unknown) => cb({}),
|
||||
getRepository: () => ({}),
|
||||
createQueryRunner: () => ({
|
||||
connect: async () => undefined,
|
||||
startTransaction: async () => undefined,
|
||||
commitTransaction: async () => undefined,
|
||||
rollbackTransaction: async () => undefined,
|
||||
release: async () => undefined,
|
||||
manager: {},
|
||||
}),
|
||||
};
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [{ provide: getDataSourceToken(), useValue: fakeDataSource }],
|
||||
exports: [getDataSourceToken()],
|
||||
})
|
||||
class TestGlobalsModule {}
|
||||
|
||||
/** Modules that are safe to import in isolation (verified free of the IAM/MinIO chain). */
|
||||
const DOMAIN_MODULES = [
|
||||
FareEngineModule,
|
||||
CurrencyModule,
|
||||
CurrenciesModule,
|
||||
PromosModule,
|
||||
SeatClassesModule,
|
||||
StationsModule,
|
||||
SchedulesModule,
|
||||
SegmentsModule,
|
||||
SystemConfigModule,
|
||||
];
|
||||
// NOTE: ExcessBaggageModule/PaymentsModule/BookingsModule are intentionally excluded — they pull in
|
||||
// NotificationsModule → @golevelup RabbitMQ which connects at boot. Their suites instantiate the
|
||||
// service directly with mocked collaborators (see excess-baggage / booking-trust specs).
|
||||
|
||||
async function buildModule(): Promise<TestingModule> {
|
||||
return Test.createTestingModule({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
EventEmitterModule.forRoot(),
|
||||
ScheduleModule.forRoot(),
|
||||
TestGlobalsModule,
|
||||
PrismaModule,
|
||||
...DOMAIN_MODULES,
|
||||
],
|
||||
})
|
||||
// SessionActivityInterceptor needs the IAM TypeORM DataSource, which the slim harness
|
||||
// deliberately omits. Replace it with a pass-through — it does not affect pricing logic.
|
||||
.overrideProvider(SessionActivityInterceptor)
|
||||
.useValue({ intercept: (_ctx: unknown, next: { handle: () => unknown }) => next.handle() })
|
||||
.compile();
|
||||
}
|
||||
|
||||
export interface ServiceHarness {
|
||||
moduleRef: TestingModule;
|
||||
prisma: PrismaClient;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** Resolve services for direct method-level assertions. */
|
||||
export async function createServiceHarness(): Promise<ServiceHarness> {
|
||||
const moduleRef = await buildModule();
|
||||
const prisma = moduleRef.get(PrismaService) as unknown as PrismaClient;
|
||||
return {
|
||||
moduleRef,
|
||||
prisma,
|
||||
close: async () => {
|
||||
await moduleRef.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface HttpHarness {
|
||||
app: INestApplication;
|
||||
moduleRef: TestingModule;
|
||||
prisma: PrismaClient;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** Boot a full HTTP app with the production ValidationPipe config from src/main.ts:56. */
|
||||
export async function createHttpHarness(): Promise<HttpHarness> {
|
||||
const moduleRef = await buildModule();
|
||||
const app = moduleRef.createNestApplication();
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidUnknownValues: false,
|
||||
}),
|
||||
);
|
||||
await app.init();
|
||||
const prisma = moduleRef.get(PrismaService) as unknown as PrismaClient;
|
||||
return {
|
||||
app,
|
||||
moduleRef,
|
||||
prisma,
|
||||
close: async () => {
|
||||
await app.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
10
apps/edr-passenger-api/test/setup/stubs/file-type.ts
Normal file
10
apps/edr-passenger-api/test/setup/stubs/file-type.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* CommonJS stub for the ESM-only `file-type` package (v21). jest's CommonJS resolver cannot load
|
||||
* the real one, and `@tria-plc/api-common`'s minio.service `require("file-type")` at import time,
|
||||
* dragging the whole IAM stack down with it. minio.service only calls fileTypeFromBuffer when
|
||||
* actually processing an upload — never during pricing/booking tests — so a stub is sufficient to
|
||||
* let the full AppModule boot. Mapped via jest `moduleNameMapper` (^file-type$).
|
||||
*/
|
||||
export async function fileTypeFromBuffer(): Promise<undefined> {
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* Stop-based (mid-route) booking — segment correctness suite.
|
||||
*
|
||||
* Regression coverage for three bugs reported against live stop-based bookings:
|
||||
*
|
||||
* 1. Search results (SearchService.buildScheduleResult) showed the train's overall
|
||||
* departure/arrival instead of the selected origin/destination stop's own time — e.g.
|
||||
* searching B→C on a A→B→C schedule showed A's departure time, not B's. Rooted in
|
||||
* resolving the boarding/alighting STATION correctly for a mid-route segment while still
|
||||
* reading TIME off the schedule's full-route span. Fixed via resolveBookingSegment() (also
|
||||
* used by BookingsService, TicketsService, NotificationsService) — see
|
||||
* src/common/utils/segment-resolver.utils.ts.
|
||||
* 2. GuestBookingService's 30-minute booking cutoff was computed off the train's origin
|
||||
* departure regardless of where the passenger actually boards, so a schedule whose origin
|
||||
* had already departed >30min ago wrongly blocked booking a downstream segment that
|
||||
* hadn't closed yet.
|
||||
* 3. Even after (2), GuestBookingService still enforced a hardcoded, non-configurable 30
|
||||
* minutes — ignoring RouteStop/Route.checkinMinutesBefore, the SAME configurable cutoff
|
||||
* that SeatsService.holdSeats and the search step already enforce. A passenger who passed
|
||||
* the earlier steps under a shorter (or longer) CONFIGURED cutoff could still be wrongly
|
||||
* rejected — or wrongly allowed — at /booking/review with "not accepted within 30 minutes
|
||||
* of departure". Fixed by having GuestBookingService use the same resolveCheckinCutoff()
|
||||
* utility as SeatsService.holdSeats and SearchService — see
|
||||
* src/common/utils/checkin-cutoff.utils.ts.
|
||||
*
|
||||
* Uses the slim harness (real Nest DI) for SchedulesService — this exercises the actual
|
||||
* cumulative travel-time interpolation in SchedulesService.createSchedule, same as
|
||||
* checkin-cutoff.e2e-spec.ts. SeatsService/SearchService/BookingsService/GuestBookingService
|
||||
* are NOT in the slim harness's DOMAIN_MODULES (they pull in NotificationsModule → RabbitMQ),
|
||||
* so they're instantiated directly with a real Prisma + stubbed collaborators, mirroring the
|
||||
* Tier-2 pattern in money-integrity.e2e-spec.ts.
|
||||
*/
|
||||
import { IdDocumentType } from "@prisma/client";
|
||||
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 { SearchService } from "../src/modules/search/search.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 { 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 });
|
||||
}
|
||||
|
||||
/** Formats a Date as a YYYY-MM-DD string in the process's local timezone (EAT on this host —
|
||||
* matches search.service.ts's "+03:00" date-matching window). */
|
||||
function localDateStr(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
describe("Stop-based booking — segment time & cutoff correctness", () => {
|
||||
let harness: ServiceHarness;
|
||||
let schedulesService: SchedulesService;
|
||||
let seatsService: SeatsService;
|
||||
let searchService: SearchService;
|
||||
let bookingsService: BookingsService;
|
||||
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 segmentsService = new SegmentsService(harness.prisma as any);
|
||||
const systemConfig = new SystemConfigService(harness.prisma as any);
|
||||
|
||||
searchService = new SearchService(harness.prisma as any, currencyService, fareEngine, segmentsService);
|
||||
// holdSeats() itself never touches segmentsService (only getSeatMap/availability-map
|
||||
// callers do), so stubbing it here is safe — mirrors checkin-cutoff.e2e-spec.ts.
|
||||
seatsService = new SeatsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub());
|
||||
bookingsService = new BookingsService(
|
||||
harness.prisma as any,
|
||||
asyncStub(), // dataSource
|
||||
seatsService,
|
||||
{ emit: () => true } as any, // eventEmitter
|
||||
asyncStub(), // verifaydaService
|
||||
currencyService,
|
||||
fareEngine,
|
||||
asyncStub(), // auditService
|
||||
);
|
||||
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 these DTOs
|
||||
fareEngine,
|
||||
{ emit: () => true } as any, // eventEmitter
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.close();
|
||||
});
|
||||
|
||||
/** Creates a fresh Train + TrainSchedule on the seed-core route, coach assigned at creation
|
||||
* (createSchedule now rejects a schedule with zero coaches). */
|
||||
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", "1C", "1D"].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 foreignPassenger(seatId: string) {
|
||||
return {
|
||||
seatId,
|
||||
passengerName: "Test Passenger",
|
||||
dateOfBirth: "1990-01-01",
|
||||
idDocumentType: IdDocumentType.PASSPORT,
|
||||
passportNumber: "X123456",
|
||||
passportCountry: "Djibouti",
|
||||
nationality: "Djiboutian",
|
||||
};
|
||||
}
|
||||
|
||||
describe("search results (SearchService.searchTrips)", () => {
|
||||
it("shows the boarding stop's own departure time, not the schedule's full-route (station A) departure", async () => {
|
||||
await resetAndSeedCore(harness.prisma);
|
||||
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 60 } });
|
||||
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 } });
|
||||
|
||||
const dep = new Date(Date.now() + 3 * 60 * 60_000); // A's departure, 3h out
|
||||
const arr = new Date(dep.getTime() + 100 * 60_000); // C's arrival
|
||||
const { schedule } = await createTestSchedule({ trainNumber: `SEG-DEP-${Date.now()}`, departureAt: dep, arrivalAt: arr });
|
||||
|
||||
const result: any = await searchService.searchTrips({
|
||||
originStationId: IDS.stationB,
|
||||
destinationStationId: IDS.stationC,
|
||||
date: localDateStr(dep),
|
||||
adultCount: 1,
|
||||
} as any);
|
||||
|
||||
const found = result.outbound.find((o: any) => o.scheduleId === schedule.id);
|
||||
expect(found).toBeTruthy();
|
||||
|
||||
const expectedBDeparture = new Date(dep.getTime() + 60 * 60_000);
|
||||
expect(new Date(found.departureAt).getTime()).toBe(expectedBDeparture.getTime());
|
||||
// Would equal A's departure (`dep`) under the old (buggy) schedule.departureAt fallback.
|
||||
expect(new Date(found.departureAt).getTime()).not.toBe(dep.getTime());
|
||||
});
|
||||
|
||||
it("shows the alighting stop's own arrival time, not the schedule's full-route (station C) arrival", async () => {
|
||||
await resetAndSeedCore(harness.prisma);
|
||||
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 60 } });
|
||||
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 } });
|
||||
|
||||
const dep = new Date(Date.now() + 3 * 60 * 60_000);
|
||||
const arr = new Date(dep.getTime() + 100 * 60_000); // C's arrival
|
||||
const { schedule } = await createTestSchedule({ trainNumber: `SEG-ARR-${Date.now()}`, departureAt: dep, arrivalAt: arr });
|
||||
|
||||
const result: any = await searchService.searchTrips({
|
||||
originStationId: IDS.stationA,
|
||||
destinationStationId: IDS.stationB,
|
||||
date: localDateStr(dep),
|
||||
adultCount: 1,
|
||||
} as any);
|
||||
|
||||
const found = result.outbound.find((o: any) => o.scheduleId === schedule.id);
|
||||
expect(found).toBeTruthy();
|
||||
|
||||
const expectedBArrival = new Date(dep.getTime() + 60 * 60_000);
|
||||
expect(new Date(found.arrivalAt).getTime()).toBe(expectedBArrival.getTime());
|
||||
// Would equal C's arrival (`arr`) under the old (buggy) schedule.arrivalAt fallback.
|
||||
expect(new Date(found.arrivalAt).getTime()).not.toBe(arr.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe("guest booking cutoff (GuestBookingService.createGuestBooking)", () => {
|
||||
it("does NOT block booking a downstream segment whose own boarding stop is still far out, even though the schedule's origin already departed", async () => {
|
||||
await resetAndSeedCore(harness.prisma);
|
||||
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 150 } });
|
||||
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 } });
|
||||
|
||||
// A departs in 5min (already inside a naive 30-min-before-departure cutoff), but B — the
|
||||
// passenger's actual boarding stop — is A+150min out (~2.5h), comfortably clear.
|
||||
const dep = new Date(Date.now() + 5 * 60_000);
|
||||
const arr = new Date(dep.getTime() + 190 * 60_000);
|
||||
const { schedule, seats } = await createTestSchedule({ trainNumber: `CUTOFF-OK-${Date.now()}`, departureAt: dep, arrivalAt: arr });
|
||||
|
||||
const hold = await seatsService.holdSeats({
|
||||
scheduleId: schedule.id,
|
||||
originStationId: IDS.stationB,
|
||||
destinationStationId: IDS.stationC,
|
||||
passengers: [{ passengerId: "66666666-6666-4666-8666-666666666666", seatId: seats[0].id }],
|
||||
} as any);
|
||||
|
||||
const booking: any = await guestBookingService.createGuestBooking({
|
||||
scheduleId: schedule.id,
|
||||
holdId: (hold as any).holdId,
|
||||
originStationId: IDS.stationB,
|
||||
destinationStationId: IDS.stationC,
|
||||
seatClassId: IDS.seatClassLocal,
|
||||
passengers: [foreignPassenger(seats[0].id)],
|
||||
} as any);
|
||||
|
||||
expect(booking.bookingRef).toBeTruthy();
|
||||
expect(booking.originStationId).toBe(IDS.stationB);
|
||||
expect(booking.destinationStationId).toBe(IDS.stationC);
|
||||
});
|
||||
|
||||
it("still blocks booking when the passenger's own boarding stop is itself within 30 minutes of its departure", async () => {
|
||||
await resetAndSeedCore(harness.prisma);
|
||||
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 10 } });
|
||||
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 } });
|
||||
|
||||
// B departs at dep+10min (~15min from now) — inside the 30-min cutoff. Hold is created
|
||||
// directly (bypassing SeatsService.holdSeats' own, separately-tested arrival-based
|
||||
// cutoff — see checkin-cutoff.e2e-spec.ts) to isolate GuestBookingService's own check.
|
||||
const dep = new Date(Date.now() + 5 * 60_000);
|
||||
const arr = new Date(dep.getTime() + 50 * 60_000);
|
||||
const { schedule, seats } = await createTestSchedule({ trainNumber: `CUTOFF-BLOCK-${Date.now()}`, departureAt: dep, arrivalAt: arr });
|
||||
|
||||
const hold = await harness.prisma.seatHold.create({
|
||||
data: {
|
||||
scheduleId: schedule.id,
|
||||
seatIds: [seats[0].id],
|
||||
passengerId: "77777777-7777-4777-8777-777777777777",
|
||||
expiresAt: new Date(Date.now() + 10 * 60_000),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
guestBookingService.createGuestBooking({
|
||||
scheduleId: schedule.id,
|
||||
holdId: hold.id,
|
||||
originStationId: IDS.stationB,
|
||||
destinationStationId: IDS.stationC,
|
||||
seatClassId: IDS.seatClassLocal,
|
||||
passengers: [foreignPassenger(seats[0].id)],
|
||||
} as any),
|
||||
).rejects.toThrow(/not accepted within 30 minutes/i);
|
||||
});
|
||||
|
||||
it("honors a stop-level checkinMinutesBefore override SHORTER than 30 minutes — booking succeeds inside the old hardcoded window", async () => {
|
||||
// Regression for the reported bug: /booking/review still rejected a booking with
|
||||
// "not accepted within 30 minutes of departure" even after the passenger passed the
|
||||
// earlier steps under a shorter CONFIGURED cutoff — because createGuestBooking used to
|
||||
// enforce its own separate, hardcoded 30 minutes regardless of RouteStop/Route
|
||||
// .checkinMinutesBefore. B's own configured cutoff here is 10 minutes.
|
||||
await resetAndSeedCore(harness.prisma, { B: { checkinMinutesBefore: 10 } });
|
||||
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 20 } });
|
||||
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 } });
|
||||
|
||||
// B departs at dep+20min (~25min from now) — inside the OLD hardcoded 30-min cutoff,
|
||||
// but outside B's own configured 10-min cutoff.
|
||||
const dep = new Date(Date.now() + 5 * 60_000);
|
||||
const arr = new Date(dep.getTime() + 60 * 60_000);
|
||||
const { schedule, seats } = await createTestSchedule({ trainNumber: `CUTOFF-CFG-${Date.now()}`, departureAt: dep, arrivalAt: arr });
|
||||
|
||||
const hold = await seatsService.holdSeats({
|
||||
scheduleId: schedule.id,
|
||||
originStationId: IDS.stationB,
|
||||
destinationStationId: IDS.stationC,
|
||||
passengers: [{ passengerId: "88888888-8888-4888-8888-888888888888", seatId: seats[0].id }],
|
||||
} as any);
|
||||
|
||||
const booking: any = await guestBookingService.createGuestBooking({
|
||||
scheduleId: schedule.id,
|
||||
holdId: (hold as any).holdId,
|
||||
originStationId: IDS.stationB,
|
||||
destinationStationId: IDS.stationC,
|
||||
seatClassId: IDS.seatClassLocal,
|
||||
passengers: [foreignPassenger(seats[0].id)],
|
||||
} as any);
|
||||
|
||||
expect(booking.bookingRef).toBeTruthy();
|
||||
});
|
||||
|
||||
it("honors a stop-level checkinMinutesBefore override LONGER than 30 minutes — still blocks past the old hardcoded window", async () => {
|
||||
await resetAndSeedCore(harness.prisma, { B: { checkinMinutesBefore: 90 } });
|
||||
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 40 } });
|
||||
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 } });
|
||||
|
||||
// B departs at dep+40min (~45min from now) — outside the OLD hardcoded 30-min cutoff
|
||||
// (would have wrongly been allowed), but inside B's own configured 90-min cutoff.
|
||||
const dep = new Date(Date.now() + 5 * 60_000);
|
||||
const arr = new Date(dep.getTime() + 80 * 60_000);
|
||||
const { schedule, seats } = await createTestSchedule({ trainNumber: `CUTOFF-CFG2-${Date.now()}`, departureAt: dep, arrivalAt: arr });
|
||||
|
||||
const hold = await harness.prisma.seatHold.create({
|
||||
data: {
|
||||
scheduleId: schedule.id,
|
||||
seatIds: [seats[0].id],
|
||||
passengerId: "99999999-9999-4999-8999-999999999999",
|
||||
expiresAt: new Date(Date.now() + 10 * 60_000),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
guestBookingService.createGuestBooking({
|
||||
scheduleId: schedule.id,
|
||||
holdId: hold.id,
|
||||
originStationId: IDS.stationB,
|
||||
destinationStationId: IDS.stationC,
|
||||
seatClassId: IDS.seatClassLocal,
|
||||
passengers: [foreignPassenger(seats[0].id)],
|
||||
} as any),
|
||||
).rejects.toThrow(/not accepted within 90 minutes/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("booking detail & list segment resolution (BookingsService)", () => {
|
||||
it("getByRef and findByPassengerId show the boarding stop's own time and station, not the schedule's full-route span", async () => {
|
||||
await resetAndSeedCore(harness.prisma);
|
||||
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 60 } });
|
||||
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 } });
|
||||
|
||||
const dep = new Date(Date.now() + 3 * 60 * 60_000);
|
||||
const arr = new Date(dep.getTime() + 100 * 60_000);
|
||||
const { schedule } = await createTestSchedule({ trainNumber: `SEG-DETAIL-${Date.now()}`, departureAt: dep, arrivalAt: arr });
|
||||
|
||||
const passenger = await harness.prisma.passenger.create({ data: {} });
|
||||
const booking = await harness.prisma.booking.create({
|
||||
data: {
|
||||
bookingRef: `SEGDET${Date.now()}`,
|
||||
passengerId: passenger.id,
|
||||
scheduleId: schedule.id,
|
||||
originStationId: IDS.stationB,
|
||||
destinationStationId: IDS.stationC,
|
||||
status: "CONFIRMED",
|
||||
totalMinor: 10000,
|
||||
displayCurrency: "ETB",
|
||||
},
|
||||
});
|
||||
|
||||
const expectedBDeparture = new Date(dep.getTime() + 60 * 60_000);
|
||||
|
||||
const detail: any = await bookingsService.getByRef(booking.bookingRef);
|
||||
expect(new Date(detail.schedule.departureAt).getTime()).toBe(expectedBDeparture.getTime());
|
||||
expect(detail.schedule.origin.id).toBe(IDS.stationB);
|
||||
expect(detail.schedule.destination.id).toBe(IDS.stationC);
|
||||
|
||||
const list: any = await bookingsService.findByPassengerId(passenger.id);
|
||||
expect(list.items).toHaveLength(1);
|
||||
expect(new Date(list.items[0].schedule.departureAt).getTime()).toBe(expectedBDeparture.getTime());
|
||||
expect(list.items[0].schedule.originStation.id).toBe(IDS.stationB);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user