Adding all the tests and fixes to the passengers app

This commit is contained in:
Muluhabt
2026-07-21 13:45:49 +03:00
parent 8ce2d2d874
commit f2ae9c883f
3316 changed files with 15548 additions and 37 deletions

View File

@@ -863,6 +863,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.
@@ -870,6 +873,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}`);
}
@@ -891,13 +895,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: {
@@ -1028,6 +1054,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);
@@ -1093,6 +1122,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(),
@@ -1675,6 +1707,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,

View File

@@ -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);
@@ -485,6 +505,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 +563,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);

View File

@@ -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();

View File

@@ -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' })

View File

@@ -969,6 +969,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({

View File

@@ -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' })

View File

@@ -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 } }),

View File

@@ -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 })

View File

@@ -1,6 +1,7 @@
import { Body, Controller, Get, Patch, SetMetadata, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { SystemConfigService } from './system-config.service';
import { UpdateSystemConfigDto } from './system-config.dto';
import { IamGuard } from '../../common/iam-adapter';
import { Roles } from '../../common/roles.decorator';
@@ -31,7 +32,12 @@ export class SystemConfigController {
@UseGuards(IamGuard)
@Roles('ADMIN')
@ApiOperation({ summary: 'Update system config (admin)' })
update(@Body() body: Record<string, string>) {
return this.service.updateMany(body);
update(@Body() dto: UpdateSystemConfigDto) {
// The DTO validates/coerces each known key to a positive integer; persist back as strings.
const entries: Record<string, string> = {};
for (const [key, value] of Object.entries(dto)) {
if (value !== undefined) entries[key] = String(value);
}
return this.service.updateMany(entries);
}
}

View File

@@ -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;
}