diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index a5f25ad21..578e73278 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -1424,6 +1424,18 @@ export class BookingsService { schedule?.status ?? null; } + // A generated-but-unsigned handover means the customer must approve delivery. + // Surfaced so the portal shows "Approve delivery" as soon as the handover + // exists, independent of the truck-arrival flag. + const [pendingHandover] = await this.dataSource.query( + `SELECT 1 FROM freight.booking_handovers + WHERE booking_id = $1 AND signed_at IS NULL AND deleted_at IS NULL + LIMIT 1`, + [id], + ); + (booking as Booking & { handoverAwaitingSignature?: boolean }).handoverAwaitingSignature = + Boolean(pendingHandover); + return booking; } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index 40994d589..659f767c7 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -108,6 +108,7 @@ export function ReadonlyBookingView({ : status === "SELECTED_FOR_BATCH"); const canApproveDelivery = status === "COMPLETED" || + Boolean(booking.handoverAwaitingSignature) || (status === "TRUCK_ASSIGNED" && Boolean(booking.customerTruckArrivedAt)); const usesCustomerTruck = booking.tradeDirection === "IMPORT" 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/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx index 220ecb1b3..515eb4449 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx @@ -8,7 +8,7 @@ import { usePaymentStore } from '@/lib/payment-store'; import { useQuery } from '@tanstack/react-query'; import { apiClient } from '@/lib/api-client'; import { useEffect, useState, useRef } from 'react'; -import { CheckCircle, Copy, Train, FileText } from 'lucide-react'; +import { CheckCircle, Clock, Copy, Train, FileText } from 'lucide-react'; import { format } from 'date-fns'; import { isChild, isFirstChild } from '@/utils/fare-utils'; @@ -45,7 +45,7 @@ export default function ConfirmationPage() { return { id: bookingId || '', pnr: pnr || undefined, - status: 'CONFIRMED', + status: 'PENDING_PAYMENT', totalMinor: passengers.reduce((sum) => sum + (selectedSchedule?.baseFareAdult || 0), 0), }; } @@ -53,6 +53,10 @@ export default function ConfirmationPage() { enabled: !!bookingId, }); + // Only trust an actually-confirmed booking to show ticket numbers / a "CONFIRMED" badge — + // a gateway redirect back here does not mean payment succeeded (see payment return pages). + const isConfirmed = _booking?.status === 'CONFIRMED'; + useEffect(() => { if (bookingId && !confirmAttempted.current) { confirmAttempted.current = true; @@ -190,14 +194,33 @@ export default function ConfirmationPage() { {/* Success Header */}
-
- -
+ {isConfirmed ? ( +
+ +
+ ) : ( +
+ +
+ )}
-

- {packageName ? `${packageName} booking confirmed!` : 'Booking confirmed!'} -

-

Your train tickets are ready

+ {isConfirmed ? ( + <> +

+ {packageName ? `${packageName} booking confirmed!` : 'Booking confirmed!'} +

+

Your train tickets are ready

+ + ) : ( + <> +

+ Booking received — payment pending +

+

+ We haven't confirmed your payment yet. Your tickets will be issued once payment is completed. +

+ + )}
{/* PNR Card */} @@ -340,7 +363,9 @@ export default function ConfirmationPage() {

Status

-

{_booking?.status || 'CONFIRMED'}

+

+ {_booking?.status || 'PENDING_PAYMENT'} +

Passengers

@@ -366,7 +391,9 @@ export default function ConfirmationPage() {
{passengers.map((passenger, index) => { const backendTicket = _booking?.ticket || null; - const ticketNumber = backendTicket?.barcodePayload || `TKT-${bookingId?.slice(0, 8).toUpperCase()}-${(index + 1).toString().padStart(2, '0')}`; + const ticketNumber = isConfirmed + ? backendTicket?.barcodePayload || `TKT-${bookingId?.slice(0, 8).toUpperCase()}-${(index + 1).toString().padStart(2, '0')}` + : null; return (
@@ -377,13 +404,17 @@ export default function ConfirmationPage() {

{passenger.name}

Passenger {index + 1}

- CONFIRMED + {isConfirmed ? ( + CONFIRMED + ) : ( + AWAITING PAYMENT + )}
- +

Ticket Number

-

{ticketNumber}

+

{ticketNumber || 'Pending payment'}

Date of Birth

diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/dmoney/failure/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/dmoney/failure/page.tsx new file mode 100644 index 000000000..bb66105f3 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/dmoney/failure/page.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { useSearchParams, useRouter } from 'next/navigation'; +import { usePaymentStore } from '@/lib/payment-store'; +import { consumeManageBookingPaymentReturn } from '@/utils/manage-booking-return'; +import { useEffect, useState, Suspense } from 'react'; +import { XCircle, Loader2, ChevronLeft } from 'lucide-react'; + +function DmoneyFailureContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { updateStatus } = usePaymentStore(); + // A Manage Booking payment (paying for an already-existing booking) has no in-progress + // booking-store session to go "back to review" from — send it back to that booking's + // detail view instead, where the user can pick a different payment method. + const [backTarget, setBackTarget] = useState('/booking/review'); + + // D-Money callback query params (mirrors Telebirr) + const merchantOrderId = searchParams.get('merchantOrderId') || ''; + const trxRef = searchParams.get('trxRef') || searchParams.get('outTradeNo') || ''; + const resultCode = searchParams.get('resultCode') || searchParams.get('code') || ''; + const resultMsg = searchParams.get('resultMsg') || searchParams.get('message') || 'Payment was not completed.'; + + useEffect(() => { + const manageBookingRef = consumeManageBookingPaymentReturn(); + if (manageBookingRef) { + setBackTarget(`/booking/detail?ref=${encodeURIComponent(manageBookingRef)}`); + } + updateStatus('FAILED'); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
+
+ +

Payment Failed

+

{resultMsg}

+ {resultCode &&

Code: {resultCode}

} + {merchantOrderId &&

Order ID: {merchantOrderId}

} + {trxRef &&

Ref: {trxRef}

} +
+ +
+
+
+ ); +} + +export default function DmoneyFailurePage() { + return ( +
}> + + + ); +} diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/dmoney/success/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/dmoney/success/page.tsx index 33d6a20f6..cf132bc7f 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/dmoney/success/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/dmoney/success/page.tsx @@ -3,15 +3,42 @@ import { useEffect, useState } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { usePaymentStore } from '@/lib/payment-store'; +import { useBookingStore } from '@/lib/booking-store'; import { consumeManageBookingPaymentReturn } from '@/utils/manage-booking-return'; -import { CheckCircle, Loader2 } from 'lucide-react'; +import { apiClient } from '@/lib/api-client'; +import { CheckCircle, XCircle, Loader2, ChevronLeft } from 'lucide-react'; import { Suspense } from 'react'; +type IntentStatus = 'PENDING' | 'PROCESSING' | 'REQUIRES_ACTION' | 'SUCCEEDED' | 'FAILED'; +type ViewState = 'checking' | 'succeeded' | 'failed' | 'unknown'; + +// D-Money redirects the browser to this ONE url regardless of outcome — a hit here is not +// proof of payment. Poll the backend (which reconciles with the provider) before showing +// "Payment Successful". Real confirmation still happens via the webhook; this only decides +// what the browser shows. +async function verifyBookingPaid(bookingId: string): Promise<'SUCCEEDED' | 'FAILED' | 'UNKNOWN'> { + const maxAttempts = 5; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + const intent = await apiClient.get<{ status: IntentStatus }>(`/payments/intents/${bookingId}`); + if (intent?.status === 'SUCCEEDED') return 'SUCCEEDED'; + if (intent?.status === 'FAILED') return 'FAILED'; + } catch { + // transient lookup failure — keep retrying until attempts are exhausted + } + if (attempt < maxAttempts - 1) { + await new Promise((resolve) => setTimeout(resolve, 1500)); + } + } + return 'UNKNOWN'; +} + function DmoneySuccessContent() { const router = useRouter(); const searchParams = useSearchParams(); const { updateStatus } = usePaymentStore(); - const [status, setStatus] = useState<'processing' | 'done' | 'error'>('processing'); + const { bookingId } = useBookingStore(); + const [view, setView] = useState('checking'); const [returnTarget, setReturnTarget] = useState('/booking/confirmation'); // D-Money callback query params (mirrors Telebirr) @@ -19,30 +46,56 @@ function DmoneySuccessContent() { const trxRef = searchParams.get('trxRef') || searchParams.get('outTradeNo') || ''; useEffect(() => { - // Actual booking confirmation happens server-side via the provider webhook — this page - // only reflects that back to the user. A Manage Booking payment (paying for an - // already-existing booking) has no in-progress booking-store session to show a - // confirmation from, so it goes back to that booking's detail view instead. + let cancelled = false; + const manageBookingRef = consumeManageBookingPaymentReturn(); const target = manageBookingRef ? `/booking/detail?ref=${encodeURIComponent(manageBookingRef)}` : '/booking/confirmation'; setReturnTarget(target); - updateStatus('SUCCEEDED'); - setStatus('done'); - setTimeout(() => router.push(target), 1500); + + // Manage Booking sessions don't carry a bookingId in the client store — the detail page + // it lands on re-fetches the booking's real status itself, so there's nothing to verify + // client-side here; just hand off without claiming an outcome we can't confirm. + if (!bookingId) { + if (!cancelled) { + router.push(target); + } + return; + } + + verifyBookingPaid(bookingId).then((result) => { + if (cancelled) return; + if (result === 'SUCCEEDED') { + updateStatus('SUCCEEDED'); + setView('succeeded'); + setTimeout(() => router.push(target), 1500); + } else if (result === 'FAILED') { + updateStatus('FAILED'); + setView('failed'); + } else { + // Still not confirmed after polling — don't claim success or failure. Hand off to + // /booking/confirmation, which now reflects the booking's real (pending) status. + setView('unknown'); + setTimeout(() => router.push(target), 1500); + } + }); + + return () => { + cancelled = true; + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return (
- {status === 'processing' && ( + {view === 'checking' && ( <>

Confirming payment…

Please wait while we confirm your D-Money payment.

)} - {status === 'done' && ( + {view === 'succeeded' && ( <>

Payment Successful!

@@ -52,15 +105,25 @@ function DmoneySuccessContent() {

Redirecting…

)} - {status === 'error' && ( + {view === 'failed' && ( <> -
- ⚠️ -
-

Something went wrong

-

Unable to confirm payment

+ +

Payment Failed

+

Your D-Money payment was not completed.

+ className="btn-secondary w-full flex items-center justify-center gap-2"> + + Back + + + )} + {view === 'unknown' && ( + <> + +

Still confirming…

+

+ We haven't received final confirmation from D-Money yet. Taking you to your booking status. +

)}
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/payment/telebirr/success/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/success/page.tsx index 1a830cee3..a47fdd07c 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/success/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/success/page.tsx @@ -3,15 +3,42 @@ import { useEffect, useState } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { usePaymentStore } from '@/lib/payment-store'; +import { useBookingStore } from '@/lib/booking-store'; import { consumeManageBookingPaymentReturn } from '@/utils/manage-booking-return'; -import { CheckCircle, Loader2 } from 'lucide-react'; +import { apiClient } from '@/lib/api-client'; +import { CheckCircle, XCircle, Loader2, ChevronLeft } from 'lucide-react'; import { Suspense } from 'react'; +type IntentStatus = 'PENDING' | 'PROCESSING' | 'REQUIRES_ACTION' | 'SUCCEEDED' | 'FAILED'; +type ViewState = 'checking' | 'succeeded' | 'failed' | 'unknown'; + +// Telebirr redirects the browser to this ONE url regardless of outcome — a hit here is not +// proof of payment. Poll the backend (which reconciles with the provider) before showing +// "Payment Successful". Real confirmation still happens via the webhook; this only decides +// what the browser shows. +async function verifyBookingPaid(bookingId: string): Promise<'SUCCEEDED' | 'FAILED' | 'UNKNOWN'> { + const maxAttempts = 5; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + const intent = await apiClient.get<{ status: IntentStatus }>(`/payments/intents/${bookingId}`); + if (intent?.status === 'SUCCEEDED') return 'SUCCEEDED'; + if (intent?.status === 'FAILED') return 'FAILED'; + } catch { + // transient lookup failure — keep retrying until attempts are exhausted + } + if (attempt < maxAttempts - 1) { + await new Promise((resolve) => setTimeout(resolve, 1500)); + } + } + return 'UNKNOWN'; +} + function TelebirrSuccessContent() { const router = useRouter(); const searchParams = useSearchParams(); const { updateStatus } = usePaymentStore(); - const [status, setStatus] = useState<'processing' | 'done' | 'error'>('processing'); + const { bookingId } = useBookingStore(); + const [view, setView] = useState('checking'); const [returnTarget, setReturnTarget] = useState('/booking/confirmation'); // Telebirr callback query params @@ -19,30 +46,56 @@ function TelebirrSuccessContent() { const trxRef = searchParams.get('trxRef') || searchParams.get('outTradeNo') || ''; useEffect(() => { - // Actual booking confirmation happens server-side via the provider webhook — this page - // only reflects that back to the user. A Manage Booking payment (paying for an - // already-existing booking) has no in-progress booking-store session to show a - // confirmation from, so it goes back to that booking's detail view instead. + let cancelled = false; + const manageBookingRef = consumeManageBookingPaymentReturn(); const target = manageBookingRef ? `/booking/detail?ref=${encodeURIComponent(manageBookingRef)}` : '/booking/confirmation'; setReturnTarget(target); - updateStatus('SUCCEEDED'); - setStatus('done'); - setTimeout(() => router.push(target), 1500); + + // Manage Booking sessions don't carry a bookingId in the client store — the detail page + // it lands on re-fetches the booking's real status itself, so there's nothing to verify + // client-side here; just hand off without claiming an outcome we can't confirm. + if (!bookingId) { + if (!cancelled) { + router.push(target); + } + return; + } + + verifyBookingPaid(bookingId).then((result) => { + if (cancelled) return; + if (result === 'SUCCEEDED') { + updateStatus('SUCCEEDED'); + setView('succeeded'); + setTimeout(() => router.push(target), 1500); + } else if (result === 'FAILED') { + updateStatus('FAILED'); + setView('failed'); + } else { + // Still not confirmed after polling — don't claim success or failure. Hand off to + // /booking/confirmation, which now reflects the booking's real (pending) status. + setView('unknown'); + setTimeout(() => router.push(target), 1500); + } + }); + + return () => { + cancelled = true; + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return (
- {status === 'processing' && ( + {view === 'checking' && ( <>

Confirming payment…

Please wait while we confirm your Telebirr payment.

)} - {status === 'done' && ( + {view === 'succeeded' && ( <>

Payment Successful!

@@ -52,15 +105,25 @@ function TelebirrSuccessContent() {

Redirecting…

)} - {status === 'error' && ( + {view === 'failed' && ( <> -
- ⚠️ -
-

Something went wrong

-

Unable to confirm payment

+ +

Payment Failed

+

Your Telebirr payment was not completed.

+ className="btn-secondary w-full flex items-center justify-center gap-2"> + + Back + + + )} + {view === 'unknown' && ( + <> + +

Still confirming…

+

+ We haven't received final confirmation from Telebirr yet. Taking you to your booking status. +

)}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/success/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/success/page.tsx index c75a0c390..89a8b3098 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/success/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/success/page.tsx @@ -3,14 +3,41 @@ import { useEffect, useState, Suspense } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { usePaymentStore } from '@/lib/payment-store'; +import { useBookingStore } from '@/lib/booking-store'; import { consumeManageBookingPaymentReturn } from '@/utils/manage-booking-return'; -import { CheckCircle, Loader2 } from 'lucide-react'; +import { apiClient } from '@/lib/api-client'; +import { CheckCircle, XCircle, Loader2, ChevronLeft } from 'lucide-react'; + +type IntentStatus = 'PENDING' | 'PROCESSING' | 'REQUIRES_ACTION' | 'SUCCEEDED' | 'FAILED'; +type ViewState = 'checking' | 'succeeded' | 'failed' | 'unknown'; + +// Waafi registers a dedicated success URL, but a hit here still isn't proof of payment on +// its own (gateway redirect vs. real settlement can disagree). Poll the backend (which +// reconciles with the provider) before showing "Payment Successful". +async function verifyBookingPaid(bookingId: string): Promise<'SUCCEEDED' | 'FAILED' | 'UNKNOWN'> { + const maxAttempts = 5; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + const intent = await apiClient.get<{ status: IntentStatus }>(`/payments/intents/${bookingId}`); + if (intent?.status === 'SUCCEEDED') return 'SUCCEEDED'; + if (intent?.status === 'FAILED') return 'FAILED'; + } catch { + // transient lookup failure — keep retrying until attempts are exhausted + } + if (attempt < maxAttempts - 1) { + await new Promise((resolve) => setTimeout(resolve, 1500)); + } + } + return 'UNKNOWN'; +} function WaafiSuccessContent() { const router = useRouter(); const searchParams = useSearchParams(); const { updateStatus } = usePaymentStore(); - const [status, setStatus] = useState<'processing' | 'done' | 'error'>('processing'); + const { bookingId } = useBookingStore(); + const [view, setView] = useState('checking'); + const [returnTarget, setReturnTarget] = useState('/booking/confirmation'); // Waafi callback query params const referenceId = searchParams.get('referenceId') || ''; @@ -19,29 +46,56 @@ function WaafiSuccessContent() { const currency = searchParams.get('currency') || ''; useEffect(() => { - // Actual booking confirmation happens server-side via the provider webhook — this page - // only reflects that back to the user. A Manage Booking payment (paying for an - // already-existing booking) has no in-progress booking-store session to show a - // confirmation from, so it goes back to that booking's detail view instead. + let cancelled = false; + const manageBookingRef = consumeManageBookingPaymentReturn(); const target = manageBookingRef ? `/booking/detail?ref=${encodeURIComponent(manageBookingRef)}` : '/booking/confirmation'; - updateStatus('SUCCEEDED'); - setStatus('done'); - setTimeout(() => router.push(target), 1500); + setReturnTarget(target); + + // Manage Booking sessions don't carry a bookingId in the client store — the detail page + // it lands on re-fetches the booking's real status itself, so there's nothing to verify + // client-side here; just hand off without claiming an outcome we can't confirm. + if (!bookingId) { + if (!cancelled) { + router.push(target); + } + return; + } + + verifyBookingPaid(bookingId).then((result) => { + if (cancelled) return; + if (result === 'SUCCEEDED') { + updateStatus('SUCCEEDED'); + setView('succeeded'); + setTimeout(() => router.push(target), 1500); + } else if (result === 'FAILED') { + updateStatus('FAILED'); + setView('failed'); + } else { + // Still not confirmed after polling — don't claim success or failure. Hand off to + // /booking/confirmation, which now reflects the booking's real (pending) status. + setView('unknown'); + setTimeout(() => router.push(target), 1500); + } + }); + + return () => { + cancelled = true; + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return (
- {status === 'processing' && ( + {view === 'checking' && ( <>

Confirming payment…

Please wait while we confirm your Waafi payment.

)} - {status === 'done' && ( + {view === 'succeeded' && ( <>

Payment Successful!

@@ -54,6 +108,27 @@ function WaafiSuccessContent() {

Redirecting…

)} + {view === 'failed' && ( + <> + +

Payment Failed

+

Your Waafi payment was not completed.

+ + + )} + {view === 'unknown' && ( + <> + +

Still confirming…

+

+ We haven't received final confirmation from Waafi yet. Taking you to your booking status. +

+ + )}
); diff --git a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx index 66f91dd60..0f8ac2004 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx @@ -379,36 +379,50 @@ export default function ResultsPage() { const coachCurrency = "ETB"; const CoachIcon = getCoachIcon(coachType.coachTypeName); + const selectThisCoach = () => + handleSelectCoachType( + scheduleId, + coachType.coachTypeId, + coachType.coachTypeCode, + coachType.coachTypeName, + coachType.classes?.[0]?.name || + coachType.coachTypeName, + ); + return ( - + )}
- +
); })}
@@ -512,33 +555,6 @@ export default function ResultsPage() {
)} - -
-
- - {!selectedCoachType && ( -

- - Select a coach type to continue -

- )} -
-