diff --git a/.gitignore b/.gitignore index 21edddcd0..cadb36cea 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,13 @@ e2e/**/cypress/downloads/ # e2e launcher state (ports of the running stack) e2e/freight/.e2e-ports.json + +# local run scripts (contain personal DB credentials — never commit) +run-passenger-local.sh +run-passenger-web.sh + +# generated test output +e2e-ui-report/ +test-results/ +playwright-report/ +blob-report/ diff --git a/apps/edr-passenger-api/.env.test.example b/apps/edr-passenger-api/.env.test.example new file mode 100644 index 000000000..b937d7061 --- /dev/null +++ b/apps/edr-passenger-api/.env.test.example @@ -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 diff --git a/apps/edr-passenger-api/.gitignore b/apps/edr-passenger-api/.gitignore new file mode 100644 index 000000000..d25344817 --- /dev/null +++ b/apps/edr-passenger-api/.gitignore @@ -0,0 +1,6 @@ + +# E2E HTML report output +e2e-report/ + +# Track the E2E env TEMPLATE (real .env.test stays ignored) +!.env.test.example diff --git a/apps/edr-passenger-api/package.json b/apps/edr-passenger-api/package.json index a9ee85156..c4587e98a 100644 --- a/apps/edr-passenger-api/package.json +++ b/apps/edr-passenger-api/package.json @@ -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", diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index 8b37f9841..b75f1eeb4 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -865,6 +865,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 +875,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 +897,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 +937,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 +1058,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 +1126,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 +1139,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 +1332,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 +1542,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 +1711,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, diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts index af5321e2d..d40824c36 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts @@ -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'; @@ -42,6 +42,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 +54,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 @@ -250,6 +267,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 +304,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, @@ -485,6 +507,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 +565,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 +585,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, @@ -761,7 +789,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, @@ -977,7 +1005,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, diff --git a/apps/edr-passenger-api/src/modules/currency/currency.service.ts b/apps/edr-passenger-api/src/modules/currency/currency.service.ts index 95ecc2cd7..ae4ca7503 100644 --- a/apps/edr-passenger-api/src/modules/currency/currency.service.ts +++ b/apps/edr-passenger-api/src/modules/currency/currency.service.ts @@ -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(); diff --git a/apps/edr-passenger-api/src/modules/fare-engine/currency.controller.ts b/apps/edr-passenger-api/src/modules/fare-engine/currency.controller.ts index 5a33dcc31..ee096dbc4 100644 --- a/apps/edr-passenger-api/src/modules/fare-engine/currency.controller.ts +++ b/apps/edr-passenger-api/src/modules/fare-engine/currency.controller.ts @@ -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' }) diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts index fd3d75401..69b0266ed 100644 --- a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts +++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts @@ -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(); diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index e5846b48f..82db6eec3 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -1017,6 +1017,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({ diff --git a/apps/edr-passenger-api/src/modules/promos/promos.dto.ts b/apps/edr-passenger-api/src/modules/promos/promos.dto.ts index 759fe371e..2d0ad7a08 100644 --- a/apps/edr-passenger-api/src/modules/promos/promos.dto.ts +++ b/apps/edr-passenger-api/src/modules/promos/promos.dto.ts @@ -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' }) diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts index 7cca548aa..4c2870a8a 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -103,6 +103,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 } }), diff --git a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.dto.ts b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.dto.ts index 1ea743aa6..853076006 100644 --- a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.dto.ts +++ b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.dto.ts @@ -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 }) diff --git a/apps/edr-passenger-api/src/modules/system-config/system-config.controller.ts b/apps/edr-passenger-api/src/modules/system-config/system-config.controller.ts index 0114c03f9..6d2c15eb2 100644 --- a/apps/edr-passenger-api/src/modules/system-config/system-config.controller.ts +++ b/apps/edr-passenger-api/src/modules/system-config/system-config.controller.ts @@ -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) { - 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 = {}; + for (const [key, value] of Object.entries(dto)) { + if (value !== undefined) entries[key] = String(value); + } + return this.service.updateMany(entries); } } diff --git a/apps/edr-passenger-api/src/modules/system-config/system-config.dto.ts b/apps/edr-passenger-api/src/modules/system-config/system-config.dto.ts new file mode 100644 index 000000000..5c1cd9679 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/system-config/system-config.dto.ts @@ -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; +} diff --git a/apps/edr-passenger-api/test/auth-gaps.e2e-spec.ts b/apps/edr-passenger-api/test/auth-gaps.e2e-spec.ts new file mode 100644 index 000000000..b05495296 --- /dev/null +++ b/apps/edr-passenger-api/test/auth-gaps.e2e-spec.ts @@ -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); + }); +}); diff --git a/apps/edr-passenger-api/test/authed-booking-passengerid.e2e-spec.ts b/apps/edr-passenger-api/test/authed-booking-passengerid.e2e-spec.ts new file mode 100644 index 000000000..ff4a59944 --- /dev/null +++ b/apps/edr-passenger-api/test/authed-booking-passengerid.e2e-spec.ts @@ -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); + }); +}); diff --git a/apps/edr-passenger-api/test/config-validation.e2e-spec.ts b/apps/edr-passenger-api/test/config-validation.e2e-spec.ts new file mode 100644 index 000000000..d17f568a1 --- /dev/null +++ b/apps/edr-passenger-api/test/config-validation.e2e-spec.ts @@ -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 { + 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"); + }); +}); diff --git a/apps/edr-passenger-api/test/critical-repro.e2e-spec.ts b/apps/edr-passenger-api/test/critical-repro.e2e-spec.ts new file mode 100644 index 000000000..83a7d8d6b --- /dev/null +++ b/apps/edr-passenger-api/test/critical-repro.e2e-spec.ts @@ -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((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"]); + }); +}); diff --git a/apps/edr-passenger-api/test/fixtures/seed-core.ts b/apps/edr-passenger-api/test/fixtures/seed-core.ts new file mode 100644 index 000000000..098489dab --- /dev/null +++ b/apps/edr-passenger-api/test/fixtures/seed-core.ts @@ -0,0 +1,128 @@ +/** + * 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; + +/** + * 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 { + const rows = await prisma.$queryRawUnsafe>( + `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): Promise { + 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 }, + { stationId: IDS.stationB, sequence: 2, distanceKm: DISTANCE.B }, + { stationId: IDS.stationC, sequence: 3, distanceKm: DISTANCE.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): Promise { + await truncateAllPassenger(prisma); + await seedCore(prisma); +} diff --git a/apps/edr-passenger-api/test/fixtures/seed-passenger-session.ts b/apps/edr-passenger-api/test/fixtures/seed-passenger-session.ts new file mode 100644 index 000000000..ed5e9ca36 --- /dev/null +++ b/apps/edr-passenger-api/test/fixtures/seed-passenger-session.ts @@ -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(); + } + })(); +} diff --git a/apps/edr-passenger-api/test/fixtures/seed-ui.ts b/apps/edr-passenger-api/test/fixtures/seed-ui.ts new file mode 100644 index 000000000..96ea79e65 --- /dev/null +++ b/apps/edr-passenger-api/test/fixtures/seed-ui.ts @@ -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 { + 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(); + } + })(); +} diff --git a/apps/edr-passenger-api/test/jest-e2e.json b/apps/edr-passenger-api/test/jest-e2e.json index 0f4a0d400..8817895fc 100644 --- a/apps/edr-passenger-api/test/jest-e2e.json +++ b/apps/edr-passenger-api/test/jest-e2e.json @@ -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": ["/setup/load-env.ts"], + "moduleNameMapper": { + "^file-type$": "/setup/stubs/file-type.ts", + "^@edr/types$": "/../../../packages/types/src/index.ts", + "^@edr/types/(.*)$": "/../../../packages/types/src/$1", + "^@/(.*)$": "/../src/$1" + }, + "testTimeout": 60000, + "maxWorkers": 1, + "reporters": [ + "default", + [ + "jest-html-reporters", + { + "publicPath": "/../e2e-report", + "filename": "index.html", + "pageTitle": "EDR Passenger — Pricing/Config E2E Results", + "expand": true, + "hideIcon": false + } + ] + ] } diff --git a/apps/edr-passenger-api/test/money-integrity.e2e-spec.ts b/apps/edr-passenger-api/test/money-integrity.e2e-spec.ts new file mode 100644 index 000000000..02ca87863 --- /dev/null +++ b/apps/edr-passenger-api/test/money-integrity.e2e-spec.ts @@ -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, + }, + }); +} diff --git a/apps/edr-passenger-api/test/pricing-currency.e2e-spec.ts b/apps/edr-passenger-api/test/pricing-currency.e2e-spec.ts new file mode 100644 index 000000000..2490366b8 --- /dev/null +++ b/apps/edr-passenger-api/test/pricing-currency.e2e-spec.ts @@ -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); + }); +}); diff --git a/apps/edr-passenger-api/test/pricing-fare-engine.e2e-spec.ts b/apps/edr-passenger-api/test/pricing-fare-engine.e2e-spec.ts new file mode 100644 index 000000000..c519d3856 --- /dev/null +++ b/apps/edr-passenger-api/test/pricing-fare-engine.e2e-spec.ts @@ -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); + }); +}); diff --git a/apps/edr-passenger-api/test/setup/load-env.ts b/apps/edr-passenger-api/test/setup/load-env.ts new file mode 100644 index 000000000..7b67969cd --- /dev/null +++ b/apps/edr-passenger-api/test/setup/load-env.ts @@ -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}`, + ); +} diff --git a/apps/edr-passenger-api/test/setup/prisma.ts b/apps/edr-passenger-api/test/setup/prisma.ts new file mode 100644 index 000000000..484b9e43d --- /dev/null +++ b/apps/edr-passenger-api/test/setup/prisma.ts @@ -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 { + if (client) { + await client.$disconnect(); + client = undefined; + } +} diff --git a/apps/edr-passenger-api/test/setup/slim-app.ts b/apps/edr-passenger-api/test/setup/slim-app.ts new file mode 100644 index 000000000..c497619b8 --- /dev/null +++ b/apps/edr-passenger-api/test/setup/slim-app.ts @@ -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 { + 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; +} + +/** Resolve services for direct method-level assertions. */ +export async function createServiceHarness(): Promise { + 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; +} + +/** Boot a full HTTP app with the production ValidationPipe config from src/main.ts:56. */ +export async function createHttpHarness(): Promise { + 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(); + }, + }; +} diff --git a/apps/edr-passenger-api/test/setup/stubs/file-type.ts b/apps/edr-passenger-api/test/setup/stubs/file-type.ts new file mode 100644 index 000000000..77c308c9a --- /dev/null +++ b/apps/edr-passenger-api/test/setup/stubs/file-type.ts @@ -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 { + return undefined; +} diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx index 4333e95e9..c03397fee 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx @@ -596,6 +596,7 @@ export default function PaymentPage() { return (