From 3760bcdb66ab25f7df4f6cc4acca0b459034b4b1 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Wed, 8 Jul 2026 18:16:57 +0300 Subject: [PATCH] Fare engine updates, seat class amount updates --- .../src/modules/bookings/bookings.dto.ts | 5 ++ .../src/modules/bookings/bookings.service.ts | 80 +++++++++++------ .../src/modules/bookings/guest-booking.dto.ts | 11 ++- .../modules/bookings/guest-booking.service.ts | 90 ++++++++++++++++--- .../src/modules/currency/currency.service.ts | 46 ++++++++-- .../fare-engine/fare-engine.service.ts | 8 +- .../modules/payments/payments.service.spec.ts | 2 +- .../src/modules/payments/payments.service.ts | 1 - .../src/modules/search/search.service.ts | 3 +- .../modules/seat-classes/seat-classes.dto.ts | 20 ++++- .../backoffice/src/app/classes/page.tsx | 5 -- .../backoffice/src/app/tariff-rates/page.tsx | 29 +++--- .../portal/src/app/booking/payment/page.tsx | 2 +- .../portal/src/app/booking/review/page.tsx | 13 ++- .../portal/src/components/SearchWidget.tsx | 27 +----- 15 files changed, 243 insertions(+), 99 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts index 345cfc357..d50f48592 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts @@ -21,6 +21,8 @@ export class PassengerInputDto { @ApiPropertyOptional({ example: 'P1234567', description: 'Passport number for non-Ethiopian passengers (no verification)' }) @IsOptional() @IsString() passportNumber?: string; @ApiPropertyOptional({ example: 'Djibouti', description: 'Passport issuing country for non-Ethiopians' }) @IsOptional() @IsString() passportCountry?: string; @ApiPropertyOptional({ example: 'Ethiopian', description: 'Ethiopian (Verifayda + Telebirr/CBE/eBirr), Djiboutian (Passport + Waafi), Other (Passport + Card)' }) @IsOptional() @IsString() nationality?: string; + @ApiPropertyOptional({ example: 35000, description: 'Actual fare for this passenger in minor units (ETB). When provided, overrides the fare engine calculation — use for berth-specific pricing (Upper/Middle/Lower).' }) @IsOptional() @IsInt() seatFareMinor?: number; + @ApiPropertyOptional({ example: 35000, description: 'Return leg fare for this passenger in minor units (ETB). Used for ROUND_TRIP berth-specific pricing.' }) @IsOptional() @IsInt() returnSeatFareMinor?: number; } export class RoundTripPassengerDto { @@ -143,6 +145,9 @@ export class CreateBookingDto { @ApiPropertyOptional({ description: 'Package price tier ID — required when packageId is provided' }) @IsOptional() @IsString() priceTierId?: string; + @ApiPropertyOptional({ description: 'Total amount in minor units (ETB) as computed and displayed on the review page. When provided, this overrides the fare engine total — use to pass the exact berth-specific amount the user saw.' }) + @IsOptional() @IsInt() reviewedTotalMinor?: number; + @ApiPropertyOptional({ description: 'Promo code for discount (applies to combined fare for round-trip)' }) @IsOptional() @IsString() promoCode?: string; 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 50d953010..b784ea111 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { PrismaService } from '../../common/prisma.service'; @@ -60,6 +60,8 @@ interface BookingFilters { @Injectable() export class BookingsService { + private readonly logger = new Logger(BookingsService.name); + constructor( private readonly prisma: PrismaService, @InjectDataSource() private readonly dataSource: DataSource, @@ -546,30 +548,42 @@ export class BookingsService { : await this.calculateFare(dto.scheduleId, dto.seatClassId, originStop, destStop, passengersData[0]?.nationality, adultCount, childCount, dto.promoCode, dto.loyaltyRedemptionPoints); const displayCurrency = dto.displayCurrency || Currency.ETB; - let displayTotalMinor = fareCalculation.totalMinor; - if (displayCurrency !== Currency.ETB) { - displayTotalMinor = await this.currencyService.convertAmount(fareCalculation.totalMinor, Currency.ETB, displayCurrency); - } - // Track per-seat fare. For package bookings children pay 10% of adult fare; - // for regular bookings the first child is free. + // Track per-seat fare. Use the client-supplied seatFareMinor when present (berth-specific + // pricing for Upper/Middle/Lower beds). Fall back to the fare engine's baseFareMinor. let freeChildUsed = false; + let pkgChildIdx = 0; const passengersWithFares = passengersData.map(p => { let fareMinor: number; if (p.category === PassengerCategory.ADULT) { - fareMinor = fareCalculation.baseFareMinor; + fareMinor = p.seatFareMinor ?? fareCalculation.baseFareMinor; } else if (dto.packageId) { - // Free children (first per adult) get fareMinor=0; paid children pay full adult fare. - // passengersWithFares is built in adult-first order so we track paid children by count. - const childIdx = passengersWithFares.filter(x => x.category !== PassengerCategory.ADULT).length; - fareMinor = childIdx < adultCount ? 0 : fareCalculation.baseFareMinor; + fareMinor = pkgChildIdx < adultCount ? 0 : (p.seatFareMinor ?? fareCalculation.baseFareMinor); + pkgChildIdx++; } else { if (!freeChildUsed) { fareMinor = 0; freeChildUsed = true; } - else fareMinor = fareCalculation.baseFareMinor; + else fareMinor = p.seatFareMinor ?? fareCalculation.baseFareMinor; } return { ...p, fareMinor }; }); + // Use the sum of per-seat fares as the authoritative total when the client supplied + // seatFareMinor for every seat-holding passenger — this captures berth-specific pricing + // (Upper/Middle/Lower) that the fare engine cannot resolve from seatClassId alone. + // Free children have no seatId and no seatFareMinor — exclude them from the check. + const seatedPassengers = passengersData.filter(p => p.seatId); + const allFaresProvided = seatedPassengers.length > 0 && seatedPassengers.every(p => p.seatFareMinor != null); + const resolvedTotalMinor = dto.reviewedTotalMinor ?? + (allFaresProvided + ? passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0) + : fareCalculation.totalMinor); + this.logger.log(`createOneWayBooking: resolvedTotalMinor=${resolvedTotalMinor} (reviewedTotalMinor=${dto.reviewedTotalMinor} allFaresProvided=${allFaresProvided} fareEngine=${fareCalculation.totalMinor})`); + + let displayTotalMinor = resolvedTotalMinor; + if (displayCurrency !== Currency.ETB) { + displayTotalMinor = await this.currencyService.convertAmount(resolvedTotalMinor, Currency.ETB, displayCurrency); + } + const booking = await this.prisma.booking.create({ data: { bookingRef: generateRef(), @@ -577,7 +591,7 @@ export class BookingsService { scheduleId: dto.scheduleId, status: 'PENDING_PAYMENT', bookingType: 'ONE_WAY', - totalMinor: fareCalculation.totalMinor, + totalMinor: resolvedTotalMinor / 100, adultCount, childCount, displayCurrency, @@ -697,8 +711,8 @@ export class BookingsService { displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency); } - // Track per-seat fare. For package bookings children pay 10% of adult fare; - // for regular bookings the first child is free per leg. + // Track per-seat fare. Use client-supplied seatFareMinor/returnSeatFareMinor when + // present (berth-specific pricing). Fall back to fare engine values. let outboundFreeChildUsed = false; let returnFreeChildUsed = false; const passengersWithFares = passengersData.map(p => { @@ -706,24 +720,40 @@ export class BookingsService { let returnFareMinor: number; if (p.category === PassengerCategory.ADULT) { - outboundFareMinor = outboundFare.baseFareMinor; - returnFareMinor = returnFare.baseFareMinor; + outboundFareMinor = p.seatFareMinor ?? outboundFare.baseFareMinor; + returnFareMinor = p.returnSeatFareMinor ?? returnFare.baseFareMinor; } else if (dto.packageId) { - // Free children (first per adult) get fareMinor=0; paid children pay full adult fare. - const childIdx = passengersWithFares.filter(x => x.category !== PassengerCategory.ADULT).length; - const isFreeChild = childIdx < adultCount; - outboundFareMinor = isFreeChild ? 0 : outboundFare.baseFareMinor; - returnFareMinor = isFreeChild ? 0 : returnFare.baseFareMinor; + outboundFareMinor = 0; + returnFareMinor = 0; } else { if (!outboundFreeChildUsed) { outboundFareMinor = 0; outboundFreeChildUsed = true; } - else outboundFareMinor = outboundFare.baseFareMinor; + else outboundFareMinor = p.seatFareMinor ?? outboundFare.baseFareMinor; if (!returnFreeChildUsed) { returnFareMinor = 0; returnFreeChildUsed = true; } - else returnFareMinor = returnFare.baseFareMinor; + else returnFareMinor = p.returnSeatFareMinor ?? returnFare.baseFareMinor; } return { ...p, outboundFareMinor, returnFareMinor }; }); + // Override totalMinor with the sum of actual per-seat fares when all seated passengers + // supplied their fares — free children (no seatId) are excluded from the check. + const rtSeatedPassengers = passengersData.filter(p => p.outboundSeatId); + const allRTFaresProvided = rtSeatedPassengers.length > 0 && + rtSeatedPassengers.every(p => p.seatFareMinor != null && p.returnSeatFareMinor != null); + if (dto.reviewedTotalMinor) { + totalMinor = dto.reviewedTotalMinor; + displayTotalMinor = displayCurrency !== Currency.ETB + ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) + : totalMinor; + } else if (allRTFaresProvided && !dto.packageId) { + totalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0); + if (displayCurrency !== Currency.ETB) { + displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency); + } else { + displayTotalMinor = totalMinor; + } + } + const booking = await this.prisma.booking.create({ data: { bookingRef: generateRef(), diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts index c2acaeece..f5a5559bb 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsArray, ValidateNested, IsOptional, IsEnum, IsDateString, IsBoolean } from 'class-validator'; +import { IsString, IsArray, ValidateNested, IsOptional, IsEnum, IsDateString, IsBoolean, IsInt } from 'class-validator'; import { Type } from 'class-transformer'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Currency, IdDocumentType } from '@prisma/client'; @@ -42,6 +42,12 @@ export class GuestPassengerDto { @ApiPropertyOptional({ example: 'abebe@email.com', description: 'Contact email' }) @IsOptional() @IsString() email?: string; + + @ApiPropertyOptional({ example: 35000, description: 'Actual fare for this passenger in minor units (ETB). Overrides fare engine — use for berth-specific pricing (Upper/Middle/Lower).' }) + @IsOptional() @IsInt() seatFareMinor?: number; + + @ApiPropertyOptional({ example: 35000, description: 'Return leg fare for this passenger in minor units (ETB). Used for ROUND_TRIP berth-specific pricing.' }) + @IsOptional() @IsInt() returnSeatFareMinor?: number; } export class CreateGuestBookingDto { @@ -150,6 +156,9 @@ export class CreateGuestBookingDto { @ApiPropertyOptional({ description: 'Package price tier ID — required when packageId is provided' }) @IsOptional() @IsString() priceTierId?: string; + + @ApiPropertyOptional({ description: 'Total amount in minor units (ETB) as computed and displayed on the review page. When provided, overrides the fare engine total — use to pass the exact berth-specific amount the user saw.' }) + @IsOptional() @IsInt() reviewedTotalMinor?: number; } export class SavedPassengerProfileDto { 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 5d773860c..fb218e052 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 @@ -185,12 +185,38 @@ export class GuestBookingService { } const taxesMinor = 0; - const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor); + + // Per-seat fare: use client-supplied seatFareMinor when present (berth-specific pricing). + // Free children (first child, non-package) get fareMinor=0. + let freeChildUsed = false; + let pkgChildIdx = 0; + const passengersWithFares = passengersData.map(p => { + let fareMinor: number; + if (p.category === PassengerCategory.ADULT) { + fareMinor = p.seatFareMinor ?? baseFareMinor; + } else if (isPackageOneway) { + fareMinor = pkgChildIdx < adultCount ? 0 : (p.seatFareMinor ?? childUnitFare); + pkgChildIdx++; + } else { + if (!freeChildUsed) { fareMinor = 0; freeChildUsed = true; } + else fareMinor = p.seatFareMinor ?? childUnitFare; + } + return { ...p, fareMinor }; + }); + + // Use reviewedTotalMinor from frontend as authoritative total when provided. + // Fall back to per-seat sum when all seated passengers supplied seatFareMinor. + const seatedPassengers = passengersData.filter(p => p.seatId); + const allFaresProvided = seatedPassengers.length > 0 && seatedPassengers.every(p => p.seatFareMinor != null); + const resolvedTotalMinor = dto.reviewedTotalMinor ?? + (allFaresProvided + ? passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0) + : Math.max(0, totalBaseFareMinor - discountMinor)); const displayCurrency = dto.displayCurrency || Currency.ETB; - let displayTotalMinor = totalMinor; + let displayTotalMinor = resolvedTotalMinor; if (displayCurrency !== Currency.ETB) { - displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency); + displayTotalMinor = await this.currencyService.convertAmount(resolvedTotalMinor, Currency.ETB, displayCurrency); } // Resolve or create the guest Passenger record @@ -224,7 +250,7 @@ export class GuestBookingService { passengerId: guestPassengerId, scheduleId: dto.scheduleId, status: 'PENDING_PAYMENT', - totalMinor, + totalMinor: resolvedTotalMinor, adultCount, childCount, displayCurrency, @@ -235,7 +261,7 @@ export class GuestBookingService { contactEmail: firstPassenger.email || null, contactPhone: firstPassenger.phone || null, seats: { - create: passengersData.map((p) => ({ + create: passengersWithFares.map((p) => ({ seat: { connect: { id: p.seatId } }, passengerName: p.passengerName, dateOfBirth: p.dateOfBirth, @@ -245,7 +271,7 @@ export class GuestBookingService { passportCountry: p.passportCountry, verifaydaVerified: p.verifaydaVerified, verifaydaData: p.verifaydaData || undefined, - fareMinor: p.category === PassengerCategory.ADULT ? baseFareMinor : childUnitFare, + fareMinor: p.fareMinor, displayCurrency, })), }, @@ -278,7 +304,7 @@ export class GuestBookingService { totalBaseFareMinor, discountMinor, taxesFeesMinor: taxesMinor, - totalMinor, + totalMinor: resolvedTotalMinor, currency: 'ETB', displayCurrency, displayTotalMinor, @@ -423,13 +449,51 @@ export class GuestBookingService { } const taxesMinor = 0; - const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor); + let totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor); const displayCurrency = dto.displayCurrency || Currency.ETB; - const displayTotalMinor = displayCurrency !== Currency.ETB + let displayTotalMinor = displayCurrency !== Currency.ETB ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) : totalMinor; + // Per-seat fares: use client-supplied seatFareMinor/returnSeatFareMinor when present. + let outboundFreeChildUsed = false; + let returnFreeChildUsed = false; + const passengersWithFares = passengersData.map(p => { + let outboundFareMinor: number; + let returnFareMinor: number; + if (p.category === PassengerCategory.ADULT) { + outboundFareMinor = p.seatFareMinor ?? outboundBaseFare; + returnFareMinor = p.returnSeatFareMinor ?? returnBaseFare; + } else if (isPackageRoundTrip) { + outboundFareMinor = 0; + returnFareMinor = 0; + } else { + if (!outboundFreeChildUsed) { outboundFareMinor = 0; outboundFreeChildUsed = true; } + else outboundFareMinor = p.seatFareMinor ?? outboundChildUnitFare; + if (!returnFreeChildUsed) { returnFareMinor = 0; returnFreeChildUsed = true; } + else returnFareMinor = p.returnSeatFareMinor ?? returnChildUnitFare; + } + return { ...p, outboundFareMinor, returnFareMinor }; + }); + + // Override totalMinor with reviewedTotalMinor when provided, or sum of per-seat fares + // when all seated passengers supplied their fares. + const rtSeatedPassengers = passengersData.filter(p => p.seatId); + const allRTFaresProvided = rtSeatedPassengers.length > 0 && + rtSeatedPassengers.every(p => p.seatFareMinor != null && p.returnSeatFareMinor != null); + if (dto.reviewedTotalMinor) { + totalMinor = dto.reviewedTotalMinor; + displayTotalMinor = displayCurrency !== Currency.ETB + ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) + : totalMinor; + } else if (allRTFaresProvided && !isPackageRoundTrip) { + totalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0); + displayTotalMinor = displayCurrency !== Currency.ETB + ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) + : totalMinor; + } + // Create or resolve guest passenger (same as one-way) const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req); @@ -461,7 +525,7 @@ export class GuestBookingService { contactPhone: passengersData[0]?.phone || null, seats: { create: [ - ...passengersData.map((p) => ({ + ...passengersWithFares.map((p) => ({ seat: { connect: { id: p.seatId } }, leg: 1, scheduleId: dto.scheduleId, @@ -473,10 +537,10 @@ export class GuestBookingService { passportCountry: p.passportCountry, verifaydaVerified: p.verifaydaVerified, verifaydaData: p.verifaydaData || undefined, - fareMinor: p.category === PassengerCategory.ADULT ? outboundBaseFare : outboundChildUnitFare, + fareMinor: p.outboundFareMinor, displayCurrency, })), - ...passengersData.map((p) => ({ + ...passengersWithFares.map((p) => ({ seat: { connect: { id: p.returnSeatId } }, leg: 2, scheduleId: dto.returnScheduleId, @@ -488,7 +552,7 @@ export class GuestBookingService { passportCountry: p.passportCountry, verifaydaVerified: p.verifaydaVerified, verifaydaData: p.verifaydaData || undefined, - fareMinor: p.category === PassengerCategory.ADULT ? returnBaseFare : returnChildUnitFare, + fareMinor: p.returnFareMinor, displayCurrency, })), ], 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 4666d4aaa..304add11c 100644 --- a/apps/edr-passenger-api/src/modules/currency/currency.service.ts +++ b/apps/edr-passenger-api/src/modules/currency/currency.service.ts @@ -30,23 +30,59 @@ export class CurrencyService { private readonly configService: ConfigService, ) {} + /** + * Converts a stored display-currency minor amount to the charge major amount + * sent to the payment provider, without hitting the DB for an exchange rate. + * Use this when the payment method's settlement currency matches the booking's + * displayCurrency — the rate is already baked into displayTotalMinor. + */ + displayMinorToChargeMajor(displayMinor: number, currency: string): number { + const decimals = CHARGE_CURRENCY_DECIMALS[currency.toUpperCase()]; + if (decimals === undefined) { + throw new BadRequestException(`Unsupported charge currency: ${currency}`); + } + return this.roundTo(displayMinor / 100, decimals); + } + + async convertEtbMinorToChargeMinor( + amountMinorEtb: number, + targetCurrency: string, + ): Promise { + const target = targetCurrency.toUpperCase(); + if (CHARGE_CURRENCY_DECIMALS[target] === undefined) { + throw new BadRequestException(`Unsupported charge currency: ${targetCurrency}`); + } + + if (target === Currency.ETB) { + return amountMinorEtb; + } + + // Convert ETB minor → target minor: apply exchange rate, keep as minor units. + const rate = await this.getRateOrThrow(Currency.ETB, target as Currency); + return Math.round(amountMinorEtb * rate); + } + + /** + * Converts an ETB minor-unit amount to the charge major-unit amount sent to the + * payment provider. Applies the exchange rate for foreign currencies then divides + * by 100 to yield major units (e.g. 300000 ETB minor → 3000.00 ETB major). + */ async convertEtbMinorToChargeMajor( amountMinorEtb: number, targetCurrency: string, ): Promise { const target = targetCurrency.toUpperCase(); - const decimals = CHARGE_CURRENCY_DECIMALS[target]; - if (decimals === undefined) { + if (CHARGE_CURRENCY_DECIMALS[target] === undefined) { throw new BadRequestException(`Unsupported charge currency: ${targetCurrency}`); } + const decimals = CHARGE_CURRENCY_DECIMALS[target]; - const sourceMajor = amountMinorEtb / 100; if (target === Currency.ETB) { - return this.roundTo(sourceMajor, decimals); + return this.roundTo(amountMinorEtb / 100, decimals); } const rate = await this.getRateOrThrow(Currency.ETB, target as Currency); - return this.roundTo(sourceMajor * rate, decimals); + return this.roundTo((amountMinorEtb * rate) / 100, decimals); } async getRateOrThrow( 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 515a00655..dd4e15b9c 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 @@ -105,10 +105,16 @@ export class FareEngineService { if (segmentOverride) { baseFarePerPassengerMinor = segmentOverride.baseFareMinor; + if (nationalityType === 'INTERNATIONAL' && !segmentOverride.nationality) { + baseFarePerPassengerMinor *= 2; + } ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0; fareSource = 'SEGMENT_FARE_RULE'; } else if (fareRule?.tripId) { baseFarePerPassengerMinor = fareRule.baseFareMinor; + if (nationalityType === 'INTERNATIONAL' && !fareRule.nationality) { + baseFarePerPassengerMinor *= 2; + } ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0; fareSource = 'SCHEDULE_FARE_RULE'; } else { @@ -165,7 +171,7 @@ export class FareEngineService { const calculation = [ `Distance: ${totalDistanceKm} km (${originStation?.name} → ${destStation?.name})`, - `Nationality: ${dto.nationality ?? 'unspecified'} → ${nationalityType} → ${nationalitySeatClass.name}`, + `Nationality: ${dto.nationality ?? 'unspecified'} → ${nationalityType}${nationalityType === 'INTERNATIONAL' ? ' (2× surcharge applied)' : ''} → ${nationalitySeatClass.name}`, `Rate per km: ${nationalitySeatClass.baseFareMinor} minor → ${nationalitySeatClass.baseFareMinor / 100} ETB/km`, `Insurance: ${nationalitySeatClass.insuranceFeeMinor} minor → factor ${insuranceFactor}${insuranceAlreadyInBase ? ' (baked into base fare)' : ''}`, `USD→ETB rate: ${usdToEtbRate}`, diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts index 112cc5096..bc5faee95 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts @@ -76,7 +76,7 @@ describe("PaymentsService", () => { // Mirrors the real ETB→major conversion: minor units → major price (TELEBIRR settles in ETB). const mockCurrencyService = { convertEtbMinorToChargeMajor: jest.fn((minor: number) => - Promise.resolve(minor / 100), + Promise.resolve(minor), ), getRateOrThrow: jest.fn(), }; 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 9e43139c4..2f4e8deda 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -550,7 +550,6 @@ export class PaymentsService { getSupportedPaymentMethods(region?: PaymentRegionEnum) { return this.prisma.paymentMethod.findMany({ where: { - enabled: true, ...(region ? { region: { diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index a02c7b7e3..4de0f0d21 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -209,7 +209,8 @@ export class SearchService { const cutoffHours = await this.getCutoffHours(); const cutoffThreshold = new Date(now.getTime() + cutoffHours * 60 * 60 * 1000); - const earliest = new Date(Math.max((date < now ? now : date).getTime(), cutoffThreshold.getTime())); + const isToday = now.getFullYear() === y && now.getMonth() === m - 1 && now.getDate() === d; + const earliest = isToday ? cutoffThreshold : date; const schedules = await this.prisma.trainSchedule.findMany({ where: { 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 d12fe0fb6..1f061bf53 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,17 +1,31 @@ -import { IsString, IsInt, IsBoolean, IsOptional } from 'class-validator'; +import { IsString, IsInt, IsBoolean, IsOptional, IsIn } from 'class-validator'; import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; export class CreateSeatClassDto { + @ApiProperty() + @IsString() + coachTypeId: string; + @ApiProperty({ example: 'Economy Seat' }) @IsString() name: string; - @ApiPropertyOptional({ example: 'Standard economy seating' }) + @ApiPropertyOptional() @IsOptional() @IsString() description?: string; - @ApiProperty({ example: 45000, description: 'Base price in minor currency units' }) + @ApiPropertyOptional({ enum: ['LOCAL', 'INTERNATIONAL'] }) + @IsOptional() + @IsIn(['LOCAL', 'INTERNATIONAL']) + nationalityType?: string; + + @ApiPropertyOptional({ enum: ['UPPER', 'MIDDLE', 'LOWER'] }) + @IsOptional() + @IsString() + bedPosition?: string; + + @ApiProperty({ example: 3000, description: 'Per-km rate in minor units (tariff decimal × 100000)' }) @IsInt() basePrice: number; diff --git a/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx index c6d9af92b..7cc9875dc 100644 --- a/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx @@ -326,11 +326,6 @@ export default function ClassesPage() {

Flat fee per passenger (e.g., travel insurance)

-
-

Total Fare Calculation:

-

Total = (Base Fare × Distance) + Insurance

-

• Insurance applies per passenger

-
diff --git a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx index 7b5ff3102..653ffddc3 100644 --- a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx @@ -10,7 +10,6 @@ import Modal from '@/components/ui/Modal'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { apiClient } from '@/lib/api-client'; -const NATIONALITY_TYPES = ['LOCAL', 'INTERNATIONAL'] as const; const BED_POSITIONS = ['UPPER', 'MIDDLE', 'LOWER'] as const; const COACH_TYPE_LABELS: Record = { HSC: 'Regular Seat (Hard Seat)', @@ -18,7 +17,6 @@ const COACH_TYPE_LABELS: Record = { SBC: 'VIP Bed (Soft Berth)', }; -// Tariff reference rates per the official policy document const TARIFF_REFERENCE: Record> = { LOCAL: { 'HSC-null': 0.03, @@ -109,7 +107,7 @@ export default function TariffRatesPage() { name: fd.get('name') as string, nationalityType: selectedNationalityType, bedPosition: selectedBedPosition || null, - baseFareMinor: parseInt(fd.get('baseFareMinor') as string), + basePrice: Math.round(Number(fd.get('baseFareMinor') as string) * 100) || 0, isActive: fd.get('isActive') === 'true', }; if (editingClass) { @@ -127,7 +125,6 @@ export default function TariffRatesPage() { ? classesData : (classesData as any)?.items || (classesData as any)?.data || []; - // Only show classes that have nationalityType set (tariff-managed rows) const tariffClasses = allClasses.filter((c: any) => c.nationalityType); const displayed = tariffClasses.filter((c: any) => { @@ -141,7 +138,6 @@ export default function TariffRatesPage() { ); }); - // Auto-suggest name from selections const suggestName = () => { const ct = coachTypesArray.find((c: any) => c.id === selectedCoachTypeId); if (!ct) return ''; @@ -151,13 +147,12 @@ export default function TariffRatesPage() { return `${label}${pos} (${nat})`; }; - // Auto-suggest baseFareMinor from tariff reference + // Returns the human-readable rate (e.g. 0.03); stored value = this × 100 const suggestRate = () => { const ct = coachTypesArray.find((c: any) => c.id === selectedCoachTypeId); if (!ct) return ''; const ref = getTariffRef(selectedNationalityType, ct.code, selectedBedPosition || null); - // baseFareMinor = tariff_decimal × 100000 - return ref ? Math.round(ref * 100000).toString() : ''; + return ref ? String(ref) : ''; }; const columns = [ @@ -184,18 +179,18 @@ export default function TariffRatesPage() { render: (c: any) => {c.name}, }, { - key: 'baseFareMinor', label: 'Rate per km (minor)', + key: 'baseFareMinor', label: 'Rate per km', render: (c: any) => { const ct = coachTypesArray.find((t: any) => t.id === c.coachTypeId); const ref = ct ? getTariffRef(c.nationalityType, ct.code, c.bedPosition) : undefined; - const tariffMinor = ref ? Math.round(ref * 100000) : undefined; + const tariffMinor = ref ? Math.round(ref * 100) : undefined; const matches = tariffMinor === c.baseFareMinor; return (
- {c.baseFareMinor} + {c.baseFareMinor / 100} {tariffMinor !== undefined && ( - {matches ? '✓ tariff' : `tariff: ${tariffMinor}`} + {matches ? '✓ tariff' : `tariff: ${tariffMinor / 100}`} )}
@@ -237,7 +232,6 @@ export default function TariffRatesPage() {
-
@@ -367,15 +361,16 @@ export default function TariffRatesPage() {
- + {suggestRate() && ( @@ -391,7 +386,7 @@ export default function TariffRatesPage() { > {suggestRate()} - {' '}(= {(parseInt(suggestRate()) / 100000).toFixed(3)} ETB/km) + {' '}(stored as {Math.round(Number(suggestRate()) * 100)})

)}
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 a95179762..ceb54acd9 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 @@ -436,7 +436,7 @@ export default function PaymentPage() {
) : (
- {paymentMethods.map((method) => { + {paymentMethods.filter(m => m.enabled).map((method) => { const Icon = getIconForMethod(method.type); const isSelected = selectedMethod === method.type; return ( diff --git a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx index c5d897a13..cf14cc87c 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx @@ -343,6 +343,9 @@ export default function ReviewPage() { passportNumber: !isEthiopian ? (p.passportNumber || '') : '', passportCountry: !isEthiopian ? (p.passportCountry || '') : '', nationality: p.nationality, + ...(isRoundTrip + ? { seatFareMinor: (p as any).outboundSeatFareMinor ?? undefined, returnSeatFareMinor: (p as any).inboundSeatFareMinor ?? undefined } + : { seatFareMinor: p.seatFareMinor ?? undefined }), }; }), }; @@ -397,8 +400,9 @@ export default function ReviewPage() { passportNumber: !isEthiopian ? (p.passportNumber || '') : '', passportCountry: !isEthiopian ? (p.passportCountry || '') : '', nationality: p.nationality, - phone: p.phone || '', - email: p.email || '', + ...(isRoundTrip + ? { seatFareMinor: (p as any).outboundSeatFareMinor ?? undefined, returnSeatFareMinor: (p as any).inboundSeatFareMinor ?? undefined } + : { seatFareMinor: p.seatFareMinor ?? undefined }), }; }), createAccount: createAccount || false, @@ -444,6 +448,11 @@ export default function ReviewPage() { return { fareMinor, isFree: isFreeChild }; }); setReviewedTotal(computedTotal, passengerFares); + + // Pass the exact total shown on this page to the backend so it stores the + // correct berth-specific amount regardless of what the fare engine calculates. + bookingData.reviewedTotalMinor = computedTotal; + await createBookingMutation.mutateAsync(bookingData); } catch (error) { alert(error instanceof Error ? error.message : 'An unexpected error occurred. Please try again.'); diff --git a/apps/edr-passenger-web/portal/src/components/SearchWidget.tsx b/apps/edr-passenger-web/portal/src/components/SearchWidget.tsx index c51dc6158..a47d4bcc7 100644 --- a/apps/edr-passenger-web/portal/src/components/SearchWidget.tsx +++ b/apps/edr-passenger-web/portal/src/components/SearchWidget.tsx @@ -10,20 +10,6 @@ import { useBookingStore } from '@/lib/booking-store'; import { Station } from '@/types'; import { MapPin, Users, Search, Plus, Minus, ChevronDown, Globe } from 'lucide-react'; import { useState, useRef, useEffect } from 'react'; - -function useDarkMode() { - const [dark, setDark] = useState(() => - typeof window !== 'undefined' && document.documentElement.classList.contains('dark') - ); - useEffect(() => { - const obs = new MutationObserver(() => - setDark(document.documentElement.classList.contains('dark')) - ); - obs.observe(document.documentElement, { attributeFilter: ['class'] }); - return () => obs.disconnect(); - }, []); - return dark; -} import ModernDatePicker from '@/components/ModernDatePicker'; const searchSchema = z.object({ @@ -86,8 +72,6 @@ function CustomSelect({ return () => document.removeEventListener('mousedown', handler); }, []); - const dark = useDarkMode(); - return (