From 4c153b0e3b3f7d8d0bb7d6736f62ac654d46aeb0 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Mon, 29 Jun 2026 16:37:01 +0300 Subject: [PATCH] Update payment methods and cron job for payment cancellation --- .../migration.sql | 59 +- .../src/modules/bookings/bookings.service.ts | 26 +- .../modules/bookings/guest-booking.service.ts | 92 ++- .../fare-engine/fare-engine.service.ts | 7 +- .../modules/payments/payments.controller.ts | 26 +- .../src/modules/payments/payments.dto.ts | 6 + .../src/modules/payments/payments.service.ts | 33 +- .../src/modules/search/search.service.ts | 4 +- .../src/modules/tasks/tasks.service.ts | 137 +++-- .../src/app/booking/confirmation/page.tsx | 111 ++-- .../src/app/booking/passengers/page.tsx | 155 ++++- .../portal/src/app/booking/payment/page.tsx | 63 +- .../portal/src/app/booking/results/page.tsx | 30 +- .../portal/src/app/booking/review/page.tsx | 35 +- .../portal/src/lib/booking-store.ts | 4 + .../portal/src/lib/generate-voucher.ts | 569 ++++++++---------- 16 files changed, 793 insertions(+), 564 deletions(-) diff --git a/apps/edr-passenger-api/prisma/migrations/20260101000000_add_configurable_fare_system/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260101000000_add_configurable_fare_system/migration.sql index 15b2502f5..ff5a9545e 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260101000000_add_configurable_fare_system/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260101000000_add_configurable_fare_system/migration.sql @@ -1,7 +1,7 @@ -- Migration: Add Configurable Fare Management System -- Main fare configuration table -CREATE TABLE "fare_configurations" ( +CREATE TABLE IF NOT EXISTS "fare_configurations" ( "id" TEXT NOT NULL, "name" TEXT NOT NULL, "description" TEXT, @@ -19,7 +19,7 @@ CREATE TABLE "fare_configurations" ( ); -- Rate structure by nationality and coach/position -CREATE TABLE "fare_rate_rules" ( +CREATE TABLE IF NOT EXISTS "fare_rate_rules" ( "id" TEXT NOT NULL, "fare_config_id" TEXT NOT NULL, "nationality_type" TEXT NOT NULL, -- 'LOCAL' or 'INTERNATIONAL' @@ -34,7 +34,7 @@ CREATE TABLE "fare_rate_rules" ( ); -- Configurable fare components (insurance, premiums, service charges, taxes) -CREATE TABLE "fare_components" ( +CREATE TABLE IF NOT EXISTS "fare_components" ( "id" TEXT NOT NULL, "fare_config_id" TEXT NOT NULL, "component_type" TEXT NOT NULL, -- 'INSURANCE', 'PREMIUM', 'SERVICE_CHARGE', 'TAX', 'DEMAND' @@ -52,7 +52,7 @@ CREATE TABLE "fare_components" ( ); -- Age-based pricing rules -CREATE TABLE "age_pricing_rules" ( +CREATE TABLE IF NOT EXISTS "age_pricing_rules" ( "id" TEXT NOT NULL, "fare_config_id" TEXT NOT NULL, "rule_name" TEXT NOT NULL, @@ -70,7 +70,7 @@ CREATE TABLE "age_pricing_rules" ( ); -- Audit trail for configuration changes -CREATE TABLE "fare_configuration_audit" ( +CREATE TABLE IF NOT EXISTS "fare_configuration_audit" ( "id" TEXT NOT NULL, "fare_config_id" TEXT NOT NULL, "action" TEXT NOT NULL, -- 'CREATED', 'UPDATED', 'ACTIVATED', 'DEACTIVATED' @@ -81,27 +81,37 @@ CREATE TABLE "fare_configuration_audit" ( CONSTRAINT "fare_configuration_audit_pkey" PRIMARY KEY ("id") ); --- Foreign key constraints -ALTER TABLE "fare_rate_rules" ADD CONSTRAINT "fare_rate_rules_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE; -ALTER TABLE "fare_components" ADD CONSTRAINT "fare_components_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE; -ALTER TABLE "age_pricing_rules" ADD CONSTRAINT "age_pricing_rules_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE; -ALTER TABLE "fare_configuration_audit" ADD CONSTRAINT "fare_configuration_audit_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE; +-- Foreign key constraints (idempotent) +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fare_rate_rules_fare_config_id_fkey') THEN + ALTER TABLE "fare_rate_rules" ADD CONSTRAINT "fare_rate_rules_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fare_components_fare_config_id_fkey') THEN + ALTER TABLE "fare_components" ADD CONSTRAINT "fare_components_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'age_pricing_rules_fare_config_id_fkey') THEN + ALTER TABLE "age_pricing_rules" ADD CONSTRAINT "age_pricing_rules_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fare_configuration_audit_fare_config_id_fkey') THEN + ALTER TABLE "fare_configuration_audit" ADD CONSTRAINT "fare_configuration_audit_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE; + END IF; +END $$; --- Indexes for performance -CREATE INDEX "fare_configurations_effective_date_idx" ON "fare_configurations"("effective_date"); -CREATE INDEX "fare_configurations_is_active_idx" ON "fare_configurations"("is_active"); -CREATE UNIQUE INDEX "fare_configurations_default_unique_idx" ON "fare_configurations"("is_default") WHERE "is_default" = true; +-- Indexes for performance (idempotent) +CREATE INDEX IF NOT EXISTS "fare_configurations_effective_date_idx" ON "fare_configurations"("effective_date"); +CREATE INDEX IF NOT EXISTS "fare_configurations_is_active_idx" ON "fare_configurations"("is_active"); +CREATE UNIQUE INDEX IF NOT EXISTS "fare_configurations_default_unique_idx" ON "fare_configurations"("is_default") WHERE "is_default" = true; -CREATE INDEX "fare_rate_rules_config_lookup_idx" ON "fare_rate_rules"("fare_config_id", "nationality_type", "coach_type", "bed_position"); -CREATE INDEX "fare_components_config_order_idx" ON "fare_components"("fare_config_id", "apply_order"); -CREATE INDEX "age_pricing_rules_age_lookup_idx" ON "age_pricing_rules"("fare_config_id", "min_age", "max_age"); +CREATE INDEX IF NOT EXISTS "fare_rate_rules_config_lookup_idx" ON "fare_rate_rules"("fare_config_id", "nationality_type", "coach_type", "bed_position"); +CREATE INDEX IF NOT EXISTS "fare_components_config_order_idx" ON "fare_components"("fare_config_id", "apply_order"); +CREATE INDEX IF NOT EXISTS "age_pricing_rules_age_lookup_idx" ON "age_pricing_rules"("fare_config_id", "min_age", "max_age"); --- Add legacy mode flag to existing fare tables for gradual migration -ALTER TABLE "FareRule" ADD COLUMN "migrated_to_config_id" TEXT; -ALTER TABLE "SegmentFareRule" ADD COLUMN "migrated_to_config_id" TEXT; +-- Add legacy mode flag to existing fare tables for gradual migration (idempotent) +ALTER TABLE "passenger"."FareRule" ADD COLUMN IF NOT EXISTS "migrated_to_config_id" TEXT; +ALTER TABLE "passenger"."SegmentFareRule" ADD COLUMN IF NOT EXISTS "migrated_to_config_id" TEXT; -- Add feature flag support -CREATE TABLE "system_features" ( +CREATE TABLE IF NOT EXISTS "system_features" ( "id" TEXT NOT NULL, "feature_name" TEXT NOT NULL UNIQUE, "is_enabled" BOOLEAN NOT NULL DEFAULT false, @@ -112,6 +122,7 @@ CREATE TABLE "system_features" ( CONSTRAINT "system_features_pkey" PRIMARY KEY ("id") ); --- Insert the configurable fares feature flag -INSERT INTO "system_features" ("id", "feature_name", "is_enabled", "config") -VALUES ('cf-001', 'USE_CONFIGURABLE_FARES', false, '{"rollout_percentage": 0}'); \ No newline at end of file +-- Insert the configurable fares feature flag (idempotent) +INSERT INTO "system_features" ("id", "feature_name", "is_enabled", "config", "updated_at") +VALUES ('cf-001', 'USE_CONFIGURABLE_FARES', false, '{"rollout_percentage": 0}', CURRENT_TIMESTAMP) +ON CONFLICT ("feature_name") DO NOTHING; \ No newline at end of file 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 7e8947034..2ca7d5ac4 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -483,8 +483,8 @@ export class BookingsService { } const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10; - const taxesMinor = Math.round(combinedBaseFareMinor * 0.05); - const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor); + const taxesMinor = 0; + const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor); const displayCurrency = dto.displayCurrency || Currency.ETB; let displayTotalMinor = totalMinor; @@ -660,8 +660,8 @@ export class BookingsService { } } const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10; - const taxesMinor = Math.round(combinedBase * 0.05); - const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor + taxesMinor); + const taxesMinor = 0; + const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor); const displayCurrency = dto.displayCurrency || Currency.ETB; const displayTotalMinor = displayCurrency !== Currency.ETB ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) @@ -854,8 +854,8 @@ export class BookingsService { } } const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10; - const taxesMinor = Math.round(combinedBase * 0.05); - const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor + taxesMinor); + const taxesMinor = 0; + const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor); const displayCurrency = dto.displayCurrency || Currency.ETB; const displayTotalMinor = displayCurrency !== Currency.ETB ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) @@ -1060,7 +1060,7 @@ export class BookingsService { loyaltyRedemptionPoints?: number ) { const segmentRoute = `${originStop.station.code}-${destStop.station.code}`; - const baseFareMinor = await this.getBaseFare(scheduleId, seatClassId, segmentRoute, undefined, nationality, originStop.sequence, destStop.sequence); + const baseFareMinor = await this.getBaseFare(scheduleId, seatClassId, segmentRoute, undefined, nationality, originStop.sequence, destStop.sequence, originStop.stationId, destStop.stationId); const adultFareMinor = baseFareMinor * adultCount; const paidChildrenCount = Math.max(0, childCount - 1); @@ -1076,8 +1076,8 @@ export class BookingsService { } const loyaltyMinor = (loyaltyRedemptionPoints ?? 0) * 10; - const taxesMinor = Math.round(totalBaseFareMinor * 0.05); - const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor); + const taxesMinor = 0; + const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor); return { baseFareMinor, @@ -1103,6 +1103,8 @@ export class BookingsService { nationality?: string, originStopSeq?: number, destStopSeq?: number, + originStationId?: string, + destinationStationId?: string, ): Promise { const now = new Date(); @@ -1149,13 +1151,13 @@ export class BookingsService { const bestMatch = this.selectBestFareRule(candidates, scheduleId, segmentRoute, fullRoute, nationality); if (bestMatch) return bestMatch.baseFareMinor; - // 3. FareEngine — distance × rate-per-km from the schedule's route + // 3. FareEngine — distance × rate-per-km from the booking's actual segment stations if (schedule?.routeId) { try { const fare = await this.fareEngine.calculate({ routeId: schedule.routeId, - originStationId: schedule.originStationId, - destinationStationId: schedule.destinationStationId, + originStationId: originStationId ?? schedule.originStationId, + destinationStationId: destinationStationId ?? schedule.destinationStationId, seatClassId, nationality, }); 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 cfddd77f8..6907d14a3 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 @@ -9,6 +9,9 @@ import { EventEmitter2 } from '@nestjs/event-emitter'; import { CreateGuestBookingDto, SavedPassengerProfileDto } from './guest-booking.dto'; import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client'; +/** Booking cutoff: reject new bookings within this many ms of departure. */ +const BOOKING_CUTOFF_MS = 30 * 60 * 1000; + function generateRef(): string { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; return 'EDR-' + Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join(''); @@ -74,6 +77,10 @@ export class GuestBookingService { }); if (!schedule) throw new NotFoundException('Schedule not found'); + if (Date.now() >= schedule.departureAt.getTime() - BOOKING_CUTOFF_MS) { + throw new BadRequestException('Bookings are not accepted within 30 minutes of departure'); + } + const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId); const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId); if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found'); @@ -142,7 +149,9 @@ export class GuestBookingService { dto.seatClassId, segmentRoute, fullRoute, - primaryNationality + primaryNationality, + dto.originStationId, + dto.destinationStationId, ); const adultFareMinor = baseFareMinor * adultCount; @@ -160,8 +169,8 @@ export class GuestBookingService { } } - const taxesMinor = Math.round(totalBaseFareMinor * 0.05); - const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor + taxesMinor); + const taxesMinor = 0; + const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor); const displayCurrency = dto.displayCurrency || Currency.ETB; let displayTotalMinor = totalMinor; @@ -293,6 +302,10 @@ export class GuestBookingService { if (!outboundSchedule) throw new NotFoundException('Outbound schedule not found'); if (!returnSchedule) throw new NotFoundException('Return schedule not found'); + if (Date.now() >= outboundSchedule.departureAt.getTime() - BOOKING_CUTOFF_MS) { + throw new BadRequestException('Bookings are not accepted within 30 minutes of departure'); + } + const outboundOriginStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.originStationId); const outboundDestStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.destinationStationId); const returnOriginStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnOriginStationId); @@ -350,8 +363,8 @@ export class GuestBookingService { const primaryNationality = passengersData[0]?.nationality; const [outboundBaseFare, returnBaseFare] = await Promise.all([ - this.getBaseFare(dto.scheduleId, dto.seatClassId, outboundSegmentRoute, outboundFullRoute, primaryNationality), - this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality), + this.getBaseFare(dto.scheduleId, dto.seatClassId, outboundSegmentRoute, outboundFullRoute, primaryNationality, dto.originStationId, dto.destinationStationId), + this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality, dto.returnOriginStationId, dto.returnDestinationStationId), ]); const paidChildrenCount = Math.max(0, childCount - 1); @@ -369,8 +382,8 @@ export class GuestBookingService { } } - const taxesMinor = Math.round(combinedBaseFareMinor * 0.05); - const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor + taxesMinor); + const taxesMinor = 0; + const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor); const displayCurrency = dto.displayCurrency || Currency.ETB; const displayTotalMinor = displayCurrency !== Currency.ETB @@ -505,6 +518,10 @@ export class GuestBookingService { if (!leg1Schedule) throw new NotFoundException('Leg-1 schedule not found'); if (!leg2Schedule) throw new NotFoundException('Leg-2 schedule not found'); + if (Date.now() >= leg1Schedule.departureAt.getTime() - BOOKING_CUTOFF_MS) { + throw new BadRequestException('Bookings are not accepted within 30 minutes of departure'); + } + const leg1OriginStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.originStationId); const leg1DestStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.transitStationId); const leg2OriginStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.transitStationId); @@ -551,11 +568,11 @@ export class GuestBookingService { this.getBaseFare(dto.scheduleId, dto.seatClassId, `${leg1OriginStop.station.code}-${leg1DestStop.station.code}`, `${leg1Schedule.originStation.code}-${leg1Schedule.destinationStation.code}`, - primaryNationality), + primaryNationality, dto.originStationId, dto.transitStationId), this.getBaseFare(dto.leg2ScheduleId, leg2SeatClassId, `${leg2OriginStop.station.code}-${leg2DestStop.station.code}`, `${leg2Schedule.originStation.code}-${leg2Schedule.destinationStation.code}`, - primaryNationality), + primaryNationality, dto.transitStationId, dto.leg2DestinationStationId), ]); const leg1Total = leg1BaseFare * adultCount + leg1BaseFare * paidChildrenCount; @@ -569,8 +586,8 @@ export class GuestBookingService { discountMinor = promo.percentOff ? Math.round(combinedBase * promo.percentOff / 100) : (promo.amountOffMinor ?? 0); } } - const taxesMinor = Math.round(combinedBase * 0.05); - const totalMinor = Math.max(0, combinedBase - discountMinor + taxesMinor); + const taxesMinor = 0; + const totalMinor = Math.max(0, combinedBase - discountMinor); const displayCurrency = dto.displayCurrency || Currency.ETB; const displayTotalMinor = displayCurrency !== Currency.ETB @@ -702,6 +719,10 @@ export class GuestBookingService { if (!retL1Sched) throw new NotFoundException('Return leg-1 schedule not found'); if (!retL2Sched) throw new NotFoundException('Return leg-2 schedule not found'); + if (Date.now() >= obL1Sched.departureAt.getTime() - BOOKING_CUTOFF_MS) { + throw new BadRequestException('Bookings are not accepted within 30 minutes of departure'); + } + const obL1Origin = obL1Sched.stopTimes.find(s => s.stationId === dto.originStationId); const obL1Dest = obL1Sched.stopTimes.find(s => s.stationId === dto.transitStationId); const obL2Origin = obL2Sched.stopTimes.find(s => s.stationId === dto.transitStationId); @@ -750,10 +771,10 @@ export class GuestBookingService { const retL2ClassId = dto.returnLeg2SeatClassId ?? dto.seatClassId; const [obL1Fare, obL2Fare, retL1Fare, retL2Fare] = await Promise.all([ - this.getBaseFare(dto.scheduleId, dto.seatClassId, `${obL1Origin.station.code}-${obL1Dest.station.code}`, `${obL1Sched.originStation.code}-${obL1Sched.destinationStation.code}`, nat), - this.getBaseFare(dto.leg2ScheduleId!, obL2ClassId, `${obL2Origin.station.code}-${obL2Dest.station.code}`, `${obL2Sched.originStation.code}-${obL2Sched.destinationStation.code}`, nat), - this.getBaseFare(dto.returnScheduleId!, retL1ClassId, `${retL1Origin.station.code}-${retL1Dest.station.code}`, `${retL1Sched.originStation.code}-${retL1Sched.destinationStation.code}`, nat), - this.getBaseFare(dto.returnLeg2ScheduleId!,retL2ClassId, `${retL2Origin.station.code}-${retL2Dest.station.code}`, `${retL2Sched.originStation.code}-${retL2Sched.destinationStation.code}`, nat), + this.getBaseFare(dto.scheduleId, dto.seatClassId, `${obL1Origin.station.code}-${obL1Dest.station.code}`, `${obL1Sched.originStation.code}-${obL1Sched.destinationStation.code}`, nat, dto.originStationId, dto.transitStationId), + this.getBaseFare(dto.leg2ScheduleId!, obL2ClassId, `${obL2Origin.station.code}-${obL2Dest.station.code}`, `${obL2Sched.originStation.code}-${obL2Sched.destinationStation.code}`, nat, dto.transitStationId, dto.leg2DestinationStationId), + this.getBaseFare(dto.returnScheduleId!, retL1ClassId, `${retL1Origin.station.code}-${retL1Dest.station.code}`, `${retL1Sched.originStation.code}-${retL1Sched.destinationStation.code}`, nat, dto.returnOriginStationId, dto.returnTransitStationId), + this.getBaseFare(dto.returnLeg2ScheduleId!,retL2ClassId, `${retL2Origin.station.code}-${retL2Dest.station.code}`, `${retL2Sched.originStation.code}-${retL2Sched.destinationStation.code}`, nat, dto.returnTransitStationId, dto.returnLeg2DestinationStationId), ]); const combinedBase = (obL1Fare + obL2Fare + retL1Fare + retL2Fare) * adultCount + @@ -951,17 +972,28 @@ export class GuestBookingService { segmentRoute?: string, fullRoute?: string, nationality?: string, + originStationId?: string, + destinationStationId?: string, ): Promise { const now = new Date(); - // 1. FareRule table — explicit override rules - const candidates = await this.prisma.fareRule.findMany({ - where: { - seatClassId, - validFrom: { lte: now }, - OR: [{ validUntil: null }, { validUntil: { gte: now } }], - }, - }); + // 1. FareRule table — explicit override rules (same priority logic as the fare engine) + const [candidates, seatClass] = await Promise.all([ + this.prisma.fareRule.findMany({ + where: { + seatClassId, + validFrom: { lte: now }, + OR: [{ validUntil: null }, { validUntil: { gte: now } }], + }, + }), + this.prisma.seatClass.findUnique({ + where: { id: seatClassId }, + select: { premiumMinor: true, insuranceFeeMinor: true }, + }), + ]); + + const premiumMinor = seatClass?.premiumMinor ?? 0; + const insuranceMinor = seatClass?.insuranceFeeMinor ?? 0; const priorities = [ { tripId: scheduleId, route: segmentRoute, nationality }, @@ -982,10 +1014,11 @@ export class GuestBookingService { const match = candidates.find( (c) => c.tripId === priority.tripId && c.route === priority.route && c.nationality === priority.nationality, ); - if (match) return match.baseFareMinor; + // Return base fare + seat-class surcharges so the booking total matches the quoted fare + if (match) return match.baseFareMinor + premiumMinor + insuranceMinor; } - // 2. FareEngine — distance × rate-per-km from the schedule's route + // 2. FareEngine — distance × rate-per-km from the booking's actual segment stations const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId }, select: { routeId: true, originStationId: true, destinationStationId: true }, @@ -995,12 +1028,15 @@ export class GuestBookingService { try { const fare = await this.fareEngine.calculate({ routeId: schedule.routeId, - originStationId: schedule.originStationId, - destinationStationId: schedule.destinationStationId, + // Use the booking's boarding/alighting stations so the distance reflects the + // passenger's actual segment, not the full schedule route. + originStationId: originStationId ?? schedule.originStationId, + destinationStationId: destinationStationId ?? schedule.destinationStationId, seatClassId, nationality, }); - return fare.baseFarePerPassengerMinor; + // farePerPassengerMinor already includes base + premiumMinor + insuranceFeeMinor + return fare.farePerPassengerMinor; } catch { // FareEngine throws if distanceKm is missing; fall through to error } 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 f2ce49791..847164551 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 @@ -284,13 +284,16 @@ export class FareEngineService { const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency); return fareRules.map(rule => { const seatClassId = rule.seatClassId; + const taxMinor = Math.round(rule.baseFareMinor * TAX_RATE); + const totalMinor = rule.baseFareMinor + taxMinor; return { seatClassId, seatClassName: 'Unknown', baseFareMinor: rule.baseFareMinor, - totalMinor: rule.baseFareMinor, + taxMinor, + totalMinor, billingCurrency, - totalInBillingCurrency: Math.round(rule.baseFareMinor * exchangeRate), + totalInBillingCurrency: Math.round(totalMinor * exchangeRate), exchangeRate, source: 'FARE_RULE', }; diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts index 97cf2600e..280ef2752 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -31,6 +31,7 @@ import { SupportedPaymentMethodDto, PaymentMethodTypeEnum, PaymentPlatformDto, + BookingAmountResponseDto, } from "./payments.dto"; import { PassengerStaff } from "../../common/passenger-guards"; import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; @@ -139,16 +140,33 @@ export class PaymentsController { @ApiOperation({ summary: "List payment systems supported by the platform", description: - "Returns the global catalog of accepted payment systems. Filter by `currency` (e.g. ETB, DJF, USD) to get methods that settle in that currency, and/or by `region` to match a passenger's nationality. Both filters can be combined.", + "Returns all enabled payment methods. Optionally filter by `region` to narrow to methods available for a passenger's nationality.", }) - @ApiQuery({ name: "currency", required: false, example: "DJF", description: "Settlement currency — ETB, DJF, USD, etc." }) @ApiQuery({ name: "region", enum: PaymentRegionEnum, required: false }) @ApiOkResponse({ type: [SupportedPaymentMethodDto] }) getMethods( - @Query("currency") currency?: string, @Query("region") region?: PaymentRegionEnum, ) { - return this.service.getSupportedPaymentMethods(region, currency); + return this.service.getSupportedPaymentMethods(region); + } + + @Get("booking-amount") + @SetMetadata('isPublic', true) + @ApiOperation({ + summary: "Get booking amount in a specific currency", + description: + "Returns the booking total converted from ETB to the requested currency using the latest exchange rate. " + + "If currency is ETB the stored amount is returned as-is (no conversion). " + + "Amounts are returned in major currency units (e.g. 162.50 DJF, not centimes).", + }) + @ApiQuery({ name: "bookingId", required: true, description: "Booking UUID" }) + @ApiQuery({ name: "currency", required: true, example: "DJF", description: "Target currency: ETB, DJF, or USD" }) + @ApiOkResponse({ type: BookingAmountResponseDto }) + getBookingAmount( + @Query("bookingId") bookingId: string, + @Query("currency") currency: string, + ) { + return this.service.getBookingAmountByCurrency(bookingId, currency); } @Get("checkout") diff --git a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts index c1b168138..410716b85 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts @@ -136,3 +136,9 @@ export class IntentStatusDto { @ApiPropertyOptional() failureCode?: string; @ApiPropertyOptional() failureMessage?: string; } + +export class BookingAmountResponseDto { + @ApiProperty({ example: 'booking-uuid' }) booking_id: string; + @ApiProperty({ example: 'DJF', description: 'Currency of the returned amount' }) currency: string; + @ApiProperty({ example: 162.5, description: 'Booking total converted to the requested currency (major units)' }) amount: number; +} 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 93bae88d3..fc6262bbc 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -491,7 +491,7 @@ export class PaymentsService { }); } - getSupportedPaymentMethods(region?: PaymentRegionEnum, currency?: string) { + getSupportedPaymentMethods(region?: PaymentRegionEnum) { return this.prisma.paymentMethod.findMany({ where: { enabled: true, @@ -505,12 +505,41 @@ export class PaymentsService { }, } : {}), - ...(currency ? { currency: currency.toUpperCase() } : {}), }, orderBy: [{ sortOrder: "asc" }, { displayName: "asc" }], }); } + async getBookingAmountByCurrency( + bookingId: string, + currency: string, + ): Promise<{ booking_id: string; currency: string; amount: number }> { + const booking = await this.prisma.booking.findUnique({ + where: { id: bookingId }, + select: { id: true, totalMinor: true }, + }); + if (!booking) throw new NotFoundException('Booking not found'); + + const requestedCurrency = currency.toUpperCase(); + const amountInETB = booking.totalMinor / 100; + + if (requestedCurrency === 'ETB') { + return { booking_id: bookingId, currency: 'ETB', amount: amountInETB }; + } + + const exchangeRate = await this.prisma.currencyExchangeRate.findFirst({ + where: { fromCurrency: 'ETB' as any, toCurrency: requestedCurrency as any }, + orderBy: { effectiveDate: 'desc' }, + }); + if (!exchangeRate) { + throw new NotFoundException(`Exchange rate not found for ETB → ${requestedCurrency}`); + } + + const rate = Number(exchangeRate.rate); + const converted = parseFloat((amountInETB * rate).toFixed(2)); + return { booking_id: bookingId, currency: requestedCurrency, amount: converted }; + } + /** * Guard against an implausible paidAt from a provider event (e.g. a Telebirr epoch parsed as * ms×1000 → year 58429), which Prisma/Postgres rejects and would otherwise dead-letter the 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 3a7b02682..e14db6b0a 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -474,8 +474,8 @@ export class SearchService { } const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * POINTS_TO_MINOR; - const taxesMinor = Math.round(totalBaseFareMinor * 0.05); - const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor); + const taxesMinor = 0; + const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor); const displayCurrency = dto.displayCurrency ?? resolveCurrencyFromNationality(dto.nationality); const displayTotalMinor = displayCurrency !== Currency.ETB diff --git a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts index 70009d1d8..bc52cc613 100644 --- a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts +++ b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts @@ -3,12 +3,19 @@ import { Cron } from '@nestjs/schedule'; import { PrismaService } from '../../common/prisma.service'; import { SmsClientService } from '../notifications/sms-client.service'; -/** Minutes before departure at which each action fires. */ -const REMINDER_MINUTES = 3 * 60; // 3 h → send payment reminder SMS -const DEADLINE_MINUTES = 2 * 60; // 2 h → cancel unpaid booking +/** Maximum time (hours) a passenger has to pay after booking. */ +const MAX_PAYMENT_HOURS = 2; +/** Minutes before departure: cutoff for new bookings and payment deadline. */ +const CUTOFF_MINUTES = 30; -/** Half-width of the reminder detection window (cron runs every 2 min). */ -const REMINDER_WINDOW_MINUTES = 2; +/** + * payment_deadline = MIN(booking_time + 2h, departure_time - 30min) + */ +function computePaymentDeadline(createdAt: Date, departureAt: Date): Date { + const maxDeadline = new Date(createdAt.getTime() + MAX_PAYMENT_HOURS * 60 * 60 * 1000); + const cutoffDeadline = new Date(departureAt.getTime() - CUTOFF_MINUTES * 60 * 1000); + return maxDeadline < cutoffDeadline ? maxDeadline : cutoffDeadline; +} function fmtTime(d: Date): string { return d.toLocaleTimeString('en-GB', { @@ -28,66 +35,72 @@ export class TasksService { ) {} // ───────────────────────────────────────────────────────────────────────── - // Every 2 min: advance TrainSchedule statuses (departure / arrival). + // Every 1 min: advance TrainSchedule statuses. + // + // SCHEDULED → BOARDING when departure ≤ 30 min away (closed to new bookings) + // BOARDING → EN_ROUTE at actual departure + // EN_ROUTE → ARRIVED at arrival time // ───────────────────────────────────────────────────────────────────────── - @Cron('*/2 * * * *') + @Cron('*/1 * * * *') async syncScheduleStatuses() { const now = new Date(); + const thirtyMinFromNow = new Date(now.getTime() + CUTOFF_MINUTES * 60 * 1000); - const [departed, arrived] = await Promise.all([ + const [boarding, departed, arrived] = await Promise.all([ this.prisma.trainSchedule.updateMany({ - where: { status: 'SCHEDULED', departureAt: { lte: now } }, + where: { status: 'SCHEDULED', departureAt: { lte: thirtyMinFromNow } }, + data: { status: 'BOARDING' }, + }), + this.prisma.trainSchedule.updateMany({ + where: { status: 'BOARDING', departureAt: { lte: now } }, data: { status: 'EN_ROUTE' }, }), this.prisma.trainSchedule.updateMany({ - where: { status: { in: ['EN_ROUTE', 'BOARDING'] }, arrivalAt: { lte: now } }, + where: { status: 'EN_ROUTE', arrivalAt: { lte: now } }, data: { status: 'ARRIVED' }, }), ]); - if (departed.count > 0 || arrived.count > 0) { + if (boarding.count > 0 || departed.count > 0 || arrived.count > 0) { this.logger.log( - `Schedule sync: ${departed.count} → EN_ROUTE, ${arrived.count} → ARRIVED`, + `Schedule sync: ${boarding.count} → BOARDING, ${departed.count} → EN_ROUTE, ${arrived.count} → ARRIVED`, ); } } // ───────────────────────────────────────────────────────────────────────── - // Every 2 min: payment deadline enforcement. + // Every 1 min: payment deadline enforcement. // - // • 3 h before departure → send one SMS reminder to complete payment. - // • 2 h before departure → cancel booking if payment is still pending - // and notify the passenger by SMS. + // Reminder — sent once at the midpoint of the booking's payment window: + // reminder_at = booking_time + total_window / 2 // - // Example: train departs 08:00 - // 05:00 → reminder SMS sent ("pay before 06:00 or booking is cancelled") - // 06:00 → booking auto-cancelled, cancellation SMS sent + // Cancel — when now ≥ payment_deadline + // payment_deadline = MIN(booking_time + 2h, departure_time - 30min) + // + // Examples (departure 10:00, cutoff 9:30): + // Booked 8:00 → deadline 9:30, window 1.5h, reminder at 8:45 + // Booked 9:00 → deadline 9:30, window 30min, reminder at 9:15 // ───────────────────────────────────────────────────────────────────────── - @Cron('*/2 * * * *') + @Cron('*/1 * * * *') async enforcePaymentDeadlines() { const now = new Date(); - await Promise.all([ this.sendPaymentReminders(now), this.cancelExpiredPendingBookings(now), ]); } - // ── 3-hour reminder ─────────────────────────────────────────────────────── + // ── Send reminder at the midpoint of each booking's payment window ──────── private async sendPaymentReminders(now: Date) { - // Narrow 4-minute window (±2 min around the 3-hour mark) so each booking - // is caught by exactly one cron tick and paymentReminderSentAt guards re-sends. - const windowMs = REMINDER_WINDOW_MINUTES * 60 * 1000; - const reminderMs = REMINDER_MINUTES * 60 * 1000; - - const windowStart = new Date(now.getTime() + reminderMs - windowMs); - const windowEnd = new Date(now.getTime() + reminderMs + windowMs); + // Only look at bookings created within the last 3 h with a future departure. + const threeHoursAgo = new Date(now.getTime() - 3 * 60 * 60 * 1000); const bookings = await this.prisma.booking.findMany({ where: { status: 'PENDING_PAYMENT', paymentReminderSentAt: null, - schedule: { departureAt: { gte: windowStart, lte: windowEnd } }, + createdAt: { gte: threeHoursAgo }, + schedule: { departureAt: { gte: now } }, } as any, include: { schedule: { @@ -101,15 +114,28 @@ export class TasksService { for (const booking of bookings) { try { - const dep = booking.schedule.departureAt as Date; - const deadline = new Date(dep.getTime() - DEADLINE_MINUTES * 60 * 1000); - const origin = booking.schedule.originStation?.name ?? ''; - const dest = booking.schedule.destinationStation?.name ?? ''; + const createdAt = booking.createdAt as Date; + const dep = booking.schedule.departureAt as Date; + const paymentDeadline = computePaymentDeadline(createdAt, dep); + const totalWindowMs = paymentDeadline.getTime() - createdAt.getTime(); + + // Skip degenerate windows (< 2 min) — the cancel job will handle these immediately + if (totalWindowMs < 2 * 60 * 1000) continue; + + // Remind once, at the midpoint of the total payment window + const reminderAt = new Date(createdAt.getTime() + totalWindowMs / 2); + if (now < reminderAt) continue; + + const origin = booking.schedule.originStation?.name ?? ''; + const dest = booking.schedule.destinationStation?.name ?? ''; + const remainingMs = Math.max(0, paymentDeadline.getTime() - now.getTime()); + const remainingMin = Math.round(remainingMs / 60_000); const message = `EDR: Your booking ${booking.bookingRef} ` + `(${origin} → ${dest}) departs at ${fmtTime(dep)}. ` + - `Complete payment by ${fmtTime(deadline)} or your booking will be cancelled.`; + `Complete payment within ${remainingMin} minute(s) (by ${fmtTime(paymentDeadline)}) ` + + `or your booking will be cancelled.`; if (booking.contactPhone) { await this.sms.sendSms({ to: booking.contactPhone, message }).catch(() => null); @@ -121,7 +147,8 @@ export class TasksService { }); this.logger.log( - `Payment reminder sent: ${booking.bookingRef} (departs ${fmtTime(dep)}, deadline ${fmtTime(deadline)})`, + `Payment reminder sent: ${booking.bookingRef} ` + + `(deadline ${fmtTime(paymentDeadline)}, ${remainingMin} min remaining)`, ); } catch (err) { this.logger.error( @@ -131,14 +158,22 @@ export class TasksService { } } - // ── 2-hour auto-cancel ──────────────────────────────────────────────────── + // ── Cancel bookings whose payment deadline has passed ───────────────────── private async cancelExpiredPendingBookings(now: Date) { - const cutoff = new Date(now.getTime() + DEADLINE_MINUTES * 60 * 1000); // now + 2 h + const twoHoursAgo = new Date(now.getTime() - MAX_PAYMENT_HOURS * 60 * 60 * 1000); + const departureCutoff = new Date(now.getTime() + CUTOFF_MINUTES * 60 * 1000); + // payment_deadline = MIN(createdAt + 2h, departureAt - 30min) + // Deadline is reached when either branch of the MIN is in the past: + // (a) createdAt ≤ now - 2h → 2-hour max window elapsed + // (b) departureAt ≤ now + 30min → departure within 30 min const expiredBookings = await this.prisma.booking.findMany({ where: { status: 'PENDING_PAYMENT', - schedule: { departureAt: { lte: cutoff } }, + OR: [ + { createdAt: { lte: twoHoursAgo } }, + { schedule: { departureAt: { lte: departureCutoff } } }, + ], }, include: { schedule: { @@ -151,8 +186,16 @@ export class TasksService { }, }); + let cancelledCount = 0; + for (const booking of expiredBookings) { try { + // Re-verify exact deadline to avoid racing with a concurrent payment confirmation + const createdAt = booking.createdAt as Date; + const dep = booking.schedule.departureAt as Date; + const paymentDeadline = computePaymentDeadline(createdAt, dep); + if (now < paymentDeadline) continue; + // 1. Release held seats (Journey rows are the occupancy source of truth) await this.prisma.journey.deleteMany({ where: { bookingId: booking.id } as any }); @@ -161,12 +204,12 @@ export class TasksService { data: { bookingId: booking.id, cancelledBy: 'SYSTEM', - reason: 'Payment not completed before departure deadline', + reason: 'Payment not completed before deadline', refundAmount: 0, refundMethod: booking.paymentIntent?.method ?? 'NONE', refundStatus: 'NOT_APPLICABLE', }, - }).catch(() => null); // booking may already have a cancellation record + }).catch(() => null); // 3. Mark cancelled await this.prisma.booking.update({ @@ -175,22 +218,20 @@ export class TasksService { }); // 4. Notify passenger - const dep = booking.schedule.departureAt as Date; const origin = booking.schedule.originStation?.name ?? ''; const dest = booking.schedule.destinationStation?.name ?? ''; const message = `EDR: Your booking ${booking.bookingRef} ` + `(${origin} → ${dest}, departs ${fmtTime(dep)}) has been cancelled ` + - `because payment was not completed before the deadline.`; + `because payment was not completed before the deadline (${fmtTime(paymentDeadline)}).`; if (booking.contactPhone) { await this.sms.sendSms({ to: booking.contactPhone, message }).catch(() => null); } - this.logger.log( - `Auto-cancelled: ${booking.bookingRef} (payment deadline expired, departs ${fmtTime(dep)})`, - ); + this.logger.log(`Auto-cancelled: ${booking.bookingRef} (deadline was ${fmtTime(paymentDeadline)})`); + cancelledCount++; } catch (err) { this.logger.error( `Auto-cancel failed for ${booking.bookingRef}: ${err instanceof Error ? err.message : String(err)}`, @@ -198,8 +239,8 @@ export class TasksService { } } - if (expiredBookings.length > 0) { - this.logger.log(`Auto-cancelled ${expiredBookings.length} expired pending booking(s)`); + if (cancelledCount > 0) { + this.logger.log(`Auto-cancelled ${cancelledCount} expired pending booking(s)`); } } } 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 29af2b29b..8c3479f83 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,6 @@ import { useQuery } from '@tanstack/react-query'; import { apiClient } from '@/lib/api-client'; import { useEffect, useState, useRef } from 'react'; import { CheckCircle, Download, Share2, Copy, Printer, Mail, Train, FileText } from 'lucide-react'; -import { QRCodeSVG } from 'qrcode.react'; import { format } from 'date-fns'; type BookingWithTicket = { @@ -77,54 +76,71 @@ export default function ConfirmationPage() { }; const handleDownloadVoucher = async () => { - if (!_booking || !pnr) { + if (!pnr) { alert('Booking data not available. Please try again.'); return; } + if (!passengers.length) { + alert('No passenger data found.'); + return; + } setIsGeneratingVoucher(true); try { - console.log('📄 Generating voucher with data:', { _booking, pnr, selectedSchedule, passengers }); - - const { generateVoucherPDF } = await import('@/lib/generate-voucher'); - - const voucherData = { - bookingRef: pnr, - status: _booking.status || 'CONFIRMED', - passengers: passengers.map(p => ({ - fullName: p.name, - category: 'ADULT', - seat: p.seatNumber ? { - number: p.seatNumber, - coach: 'N/A', - seatClass: selectedSchedule?.selectedSeatClassName || 'Standard', - } : undefined, - })), - schedule: { - trainNumber: selectedSchedule?.trainNumber || 'N/A', - trainName: 'EDR Express', - origin: { - name: selectedSchedule?.origin || 'Origin', - code: 'ORG', - city: selectedSchedule?.origin || 'Origin', - }, - destination: { - name: selectedSchedule?.destination || 'Destination', - code: 'DST', - city: selectedSchedule?.destination || 'Destination', - }, - departureAt: selectedSchedule?.departureTime || new Date().toISOString(), - arrivalAt: selectedSchedule?.arrivalTime || new Date().toISOString(), - }, - totalMinor: _booking.totalMinor || passengers.reduce((sum) => sum + (selectedSchedule?.baseFareAdult || 0), 0), - currency: 'ETB', - bookingType: 'ONE_WAY', - createdAt: new Date().toISOString(), + const { generatePassengerVoucherPDF } = await import('@/lib/generate-voucher'); + + const activeSchedule = isRoundTrip ? outboundSchedule : selectedSchedule; + const totalFare = _booking?.totalMinor + || passengers.reduce((s) => s + (activeSchedule?.baseFareAdult || 0), 0); + const farePerPassenger = Math.round(totalFare / passengers.length); + const createdAt = _booking?.createdAt || new Date().toISOString(); + const status = _booking?.status || 'CONFIRMED'; + + const outbound = { + trainNumber: activeSchedule?.trainNumber || 'N/A', + trainName: 'EDR Express', + origin: { name: activeSchedule?.origin || 'Origin', code: 'ORG', city: activeSchedule?.origin || 'Origin' }, + destination: { name: activeSchedule?.destination || 'Destination', code: 'DST', city: activeSchedule?.destination || 'Destination' }, + departureAt: activeSchedule?.departureTime || new Date().toISOString(), + arrivalAt: activeSchedule?.arrivalTime || new Date().toISOString(), + seatClass: activeSchedule?.selectedSeatClassName, }; - console.log('📄 Voucher data prepared:', voucherData); - await generateVoucherPDF(voucherData); - console.log('✅ Voucher generated successfully'); + const inbound = inboundSchedule ? { + trainNumber: inboundSchedule.trainNumber || 'N/A', + trainName: 'EDR Express', + origin: { name: inboundSchedule.origin, code: 'ORG', city: inboundSchedule.origin }, + destination: { name: inboundSchedule.destination, code: 'DST', city: inboundSchedule.destination }, + departureAt: inboundSchedule.departureTime || new Date().toISOString(), + arrivalAt: inboundSchedule.arrivalTime || new Date().toISOString(), + seatClass: inboundSchedule.selectedSeatClassName, + } : undefined; + + for (let i = 0; i < passengers.length; i++) { + const p = passengers[i]; + const ticketNumber = `TKT-${bookingId?.slice(0, 8).toUpperCase()}-${(i + 1).toString().padStart(2, '0')}`; + + await generatePassengerVoucherPDF({ + bookingRef: pnr, + ticketNumber, + passengerName: p.name || `Passenger ${i + 1}`, + dateOfBirth: p.dateOfBirth, + nationality: p.nationality, + seatNumber: p.seatNumber, + outboundSeatNumber: (p as any).outboundSeatNumber, + inboundSeatNumber: (p as any).inboundSeatNumber, + status, + outboundSchedule: outbound, + inboundSchedule: inbound, + isRoundTrip, + fareMinor: farePerPassenger, + currency: 'ETB', + createdAt, + }); + + // brief pause between downloads so browsers don't block them + if (i < passengers.length - 1) await new Promise(r => setTimeout(r, 400)); + } } catch (error) { console.error('❌ Failed to generate voucher:', error); alert(`Failed to generate voucher: ${error instanceof Error ? error.message : 'Unknown error'}`); @@ -191,17 +207,9 @@ export default function ConfirmationPage() { - {/* Trip Summary with QR Code */} + {/* Trip Details */}
-
- {/* QR Code Section */} -
- -

Scan at gate

-
- - {/* Trip Details */} -
+
@@ -302,7 +310,6 @@ export default function ConfirmationPage() {
)} -
diff --git a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx index 92b82d2cb..b38234732 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx @@ -332,12 +332,135 @@ function DobPickerModal({ ); } +// ─── phone validation ───────────────────────────────────────────────────────── + +type PhoneNat = 'ETHIOPIAN' | 'DJIBOUTIAN' | 'OTHER'; + +const PHONE_PRESETS: Record = { + ETHIOPIAN: { flag: '🇪🇹', code: '+251', example: '912345678', hint: '+251912345678 or 0912345678' }, + DJIBOUTIAN: { flag: '🇩🇯', code: '+253', example: '77123456', hint: '+25377123456' }, + OTHER: { flag: '🌐', code: '+', example: '14155552671', hint: 'International: +[country code][number]' }, +}; + +function getPhoneNat(nationality: string): PhoneNat { + const n = (nationality || '').toUpperCase(); + if (n === 'ETHIOPIAN') return 'ETHIOPIAN'; + if (n === 'DJIBOUTIAN') return 'DJIBOUTIAN'; + return 'OTHER'; +} + +function validatePhone(phone: string, nationality: string): string | null { + const normalized = (phone || '').replace(/[\s\-().]/g, ''); + if (!normalized) return 'Phone number is required'; + const nat = getPhoneNat(nationality); + if (nat === 'ETHIOPIAN') { + if (/^(\+251\d{9}|09\d{8})$/.test(normalized)) return null; + return 'Invalid Ethiopian phone number (e.g., +251912345678 or 0912345678)'; + } + if (nat === 'DJIBOUTIAN') { + if (/^\+253\d{8}$/.test(normalized)) return null; + return 'Invalid Djiboutian phone number (e.g., +25377123456)'; + } + if (/^\+[1-9]\d{7,14}$/.test(normalized)) return null; + return 'Invalid international phone number (e.g., +14155552671)'; +} + +function stripPhonePrefix(stored: string, nat: PhoneNat): string { + const code = PHONE_PRESETS[nat].code; + if (nat !== 'OTHER' && stored.startsWith(code)) return stored.slice(code.length); + if (nat === 'OTHER' && stored.startsWith('+')) return stored.slice(1); + return stored; +} + +function buildFullNumber(localInput: string, nat: PhoneNat): string { + const stripped = localInput.replace(/[\s\-().]/g, ''); + if (!stripped) return stripped; + if (nat === 'ETHIOPIAN') { + if (stripped.startsWith('+') || stripped.startsWith('0')) return stripped; + return '+251' + stripped; + } + if (nat === 'DJIBOUTIAN') { + if (stripped.startsWith('+')) return stripped; + return '+253' + stripped; + } + return stripped.startsWith('+') ? stripped : '+' + stripped; +} + +function PhoneInput({ + nationality, + storedValue, + onInterimChange, + onNormalized, + error, +}: { + nationality: string; + storedValue: string; + onInterimChange: (full: string) => void; + onNormalized: (full: string) => void; + error?: string; +}) { + const nat = getPhoneNat(nationality); + const preset = PHONE_PRESETS[nat]; + const [localInput, setLocalInput] = useState(() => stripPhonePrefix(storedValue || '', nat)); + const prevStoredRef = useRef(storedValue); + + useEffect(() => { + if (storedValue !== prevStoredRef.current) { + prevStoredRef.current = storedValue; + setLocalInput(stripPhonePrefix(storedValue || '', nat)); + } + }, [storedValue, nat]); + + const handleChange = (e: React.ChangeEvent) => { + const raw = e.target.value; + setLocalInput(raw); + onInterimChange(buildFullNumber(raw, nat)); + }; + + const handleBlur = () => { + const full = buildFullNumber(localInput, nat); + setLocalInput(stripPhonePrefix(full, nat)); + onNormalized(full); + }; + + return ( +
+
+
+ {preset.flag} + {preset.code} +
+ +
+ {error ? ( +

{error}

+ ) : ( +

Format: {preset.hint}

+ )} +
+ ); +} + +// ─── passenger zod schema ────────────────────────────────────────────────────── + const passengerSchema = z.object({ name: z.string().min(2, 'Full name is required (min 2 characters)'), dateOfBirth: z.string().min(1, 'Date of birth is required'), gender: z.string().min(1, 'Gender is required'), nationality: z.string().min(1, 'Nationality is required'), - phone: z.string().min(1, 'Phone number is required'), + phone: z.string(), email: z.string().optional(), nationalId: z.string().optional(), passportNumber: z.string().optional(), @@ -358,6 +481,10 @@ const passengerSchema = z.object({ ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid email format', path: ['email'] }); } } + const phoneError = validatePhone(data.phone, data.nationality); + if (phoneError) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: phoneError, path: ['phone'] }); + } const isNonEthiopian = data.nationality !== 'ETHIOPIAN' && data.nationality !== 'Ethiopian'; if (isNonEthiopian) { if (!data.passportNumber || data.passportNumber.trim().length === 0) { @@ -769,14 +896,13 @@ export default function PassengersPage() { {/* Phone */}
- setValue(`passengers.${index}.phone`, v)} + onNormalized={(v) => setValue(`passengers.${index}.phone`, v, { shouldValidate: true })} + error={errors.passengers?.[index]?.phone?.message} /> - {errors.passengers?.[index]?.phone && ( -

{errors.passengers[index]?.phone?.message}

- )}
{/* Email */} @@ -850,14 +976,13 @@ export default function PassengersPage() { {/* Phone */}
- setValue(`passengers.${index}.phone`, v)} + onNormalized={(v) => setValue(`passengers.${index}.phone`, v, { shouldValidate: true })} + error={errors.passengers?.[index]?.phone?.message} /> - {errors.passengers?.[index]?.phone && ( -

{errors.passengers[index]?.phone?.message}

- )}
{/* Email */} 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 6527a88cd..989516839 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 @@ -23,23 +23,19 @@ const getIconForMethod = (methodId: string) => { return Smartphone; }; -const NATIONALITY_TO_CURRENCY: Record = { - ETHIOPIAN: 'ETB', - DJIBOUTIAN: 'DJF', -}; export default function PaymentPage() { const router = useRouter(); const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, passengers, searchCriteria } = useBookingStore(); const { setPaymentIntent, updateStatus, setCurrency } = usePaymentStore(); const [selectedMethod, setSelectedMethod] = useState(null); + const [selectedMethodCurrency, setSelectedMethodCurrency] = useState(null); const [isProcessing, setIsProcessing] = useState(false); const [paymentError, setPaymentError] = useState(null); const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP'; - const displayCurrency: 'ETB' | 'DJF' | 'USD' = - NATIONALITY_TO_CURRENCY[searchCriteria?.nationality?.toUpperCase() ?? ''] ?? 'USD'; + const displayCurrency = 'ETB' as const; // Keep payment store in sync so the mutation picks up the right currency. useEffect(() => { @@ -54,7 +50,22 @@ export default function PaymentPage() { }, }); - // Calculate total amount + // Fetch actual booking amount from API when a payment method is selected + const amountCurrency = selectedMethodCurrency || displayCurrency; + + const { data: bookingAmountData, isLoading: loadingAmount } = useQuery<{ amount: number; currency: string; booking_id: string }>({ + queryKey: ['bookingAmount', bookingId, amountCurrency, selectedMethod], + queryFn: async () => { + const url = `/payments/booking-amount?bookingId=${bookingId}¤cy=${amountCurrency}`; + console.log('[BookingAmount] Request:', { url, bookingId, currency: amountCurrency, selectedMethod }); + const response: any = await apiClient.get(url); + console.log('[BookingAmount] Response:', response); + return response; + }, + enabled: !!selectedMethod && !!bookingId, + }); + + // Fallback: estimate from local store while API hasn't responded yet const outboundBaseFare = isRoundTrip && outboundSchedule ? passengers.reduce( (sum) => sum + (outboundSchedule.baseFareAdult || 0), 0, @@ -69,8 +80,12 @@ export default function PaymentPage() { (sum) => sum + (selectedSchedule?.baseFareAdult || 0), 0, ); - - const totalAmount = baseFare; + + // API returns amount in major units (e.g. 11602.5 DJF); convert to minor for display consistency + const totalAmount = bookingAmountData != null + ? Math.round(bookingAmountData.amount * 100) + : baseFare; + const confirmedCurrency = bookingAmountData?.currency || amountCurrency; const paymentMutation = useMutation({ mutationFn: async (data: any) => { @@ -248,7 +263,12 @@ export default function PaymentPage() {
Total - {displayCurrency} {(totalAmount / 100).toFixed(2)} + + {loadingAmount && ( + + )} + {confirmedCurrency} {(totalAmount / 100).toFixed(2)} +
@@ -259,15 +279,19 @@ export default function PaymentPage() { )} 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 10eab03e0..2f053d98c 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 @@ -144,8 +144,8 @@ export default function ResultsPage() { ? (outboundSchedules.length > 0 && inboundSchedules.length > 0) : outboundSchedules.length > 0; - const handleSelectCoachType = (scheduleId: string, coachTypeId: string, coachTypeCode: string, coachTypeName: string) => { - setSelectedCoachTypes(prev => ({ ...prev, [scheduleId]: { id: coachTypeId, code: coachTypeCode, name: coachTypeName } })); + const handleSelectCoachType = (scheduleId: string, coachTypeId: string, coachTypeCode: string, coachTypeName: string, seatClassName: string) => { + setSelectedCoachTypes(prev => ({ ...prev, [scheduleId]: { id: coachTypeId, code: coachTypeCode, name: coachTypeName, seatClassName } })); }; const handleSelect = (schedule: Schedule, isOutbound: boolean = false) => { @@ -161,10 +161,9 @@ export default function ResultsPage() { const coachType = schedule.coachTypes?.find(ct => ct.coachTypeCode === selectedCoachType.code); // Use displayAmountMinor (passenger's currency) so stored fare matches what the card showed. const minFare = coachType?.classes.length - ? Math.min(...coachType.classes.map(c => c.displayAmountMinor ?? c.baseFareMinor)) + ? Math.min(...coachType.classes.map(c => c.baseFareMinor)) : 0; - const fareCurrency: string = - coachType?.classes[0]?.displayCurrency ?? schedule.displayCurrency ?? 'ETB'; + const fareCurrency = 'ETB'; const hours = Math.floor((schedule.durationMinutes || 0) / 60); const minutes = (schedule.durationMinutes || 0) % 60; @@ -186,6 +185,7 @@ export default function ResultsPage() { selectedCoachTypeId: selectedCoachType.id, selectedCoachTypeCode: selectedCoachType.code, selectedCoachTypeName: selectedCoachType.name, + seatClassName: (selectedCoachType as any).seatClassName || selectedCoachType.name, }; // For round trip, store outbound and wait for inbound selection @@ -222,17 +222,13 @@ export default function ResultsPage() { // Calculate lowest fare and display currency from coach types / faresByClass. // Prefer displayAmountMinor (passenger's own currency) over baseFareMinor (ETB internal). let lowestFare = null; - let displayCurrency = schedule.displayCurrency || 'ETB'; + const displayCurrency = 'ETB'; if (schedule.coachTypes?.length) { const allClasses = schedule.coachTypes.flatMap(ct => ct.classes); - const allFares = allClasses.map(c => c.displayAmountMinor ?? c.baseFareMinor).filter(f => f > 0); + const allFares = allClasses.map(c => c.baseFareMinor).filter(f => f > 0); lowestFare = allFares.length ? Math.min(...allFares) : null; - const firstWithCurrency = allClasses.find(c => c.displayCurrency); - if (firstWithCurrency?.displayCurrency) displayCurrency = firstWithCurrency.displayCurrency; } else if (schedule.faresByClass?.length) { - lowestFare = Math.min(...schedule.faresByClass.map(f => f.displayAmountMinor ?? f.baseFareMinor).filter(f => f > 0)); - const firstWithCurrency = schedule.faresByClass.find(f => f.displayCurrency); - if (firstWithCurrency?.displayCurrency) displayCurrency = firstWithCurrency.displayCurrency; + lowestFare = Math.min(...schedule.faresByClass.map(f => f.baseFareMinor).filter(f => f > 0)); } else if (schedule.combinedMinFareDisplay) { lowestFare = schedule.combinedMinFareDisplay; } @@ -551,14 +547,14 @@ export default function ResultsPage() {
{coachTypes.map((coachType: any, index: number) => { const isSelected = selectedCoachType?.id === coachType.coachTypeId; - const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.displayAmountMinor ?? c.baseFareMinor)) : 0; - const coachCurrency: string = (coachType.classes[0] as any)?.displayCurrency ?? (classModal as any).displayCurrency ?? 'ETB'; + const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.baseFareMinor)) : 0; + const coachCurrency = 'ETB'; const CoachIcon = getCoachIcon(coachType.coachTypeName); return (
- {((cls.displayAmountMinor ?? cls.baseFareMinor) / 100).toFixed(2)} + {(cls.baseFareMinor / 100).toFixed(2)} - {cls.displayCurrency ?? coachCurrency} + {coachCurrency}
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 655f0a7c6..67422ad69 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 @@ -62,11 +62,7 @@ export default function ReviewPage() { // Prefer the currency already stored on the selected schedule (set from search results). // Fall back to deriving from nationality so the review page is never left with a stale value. - const NATIONALITY_TO_CURRENCY: Record = { ETHIOPIAN: 'ETB', DJIBOUTIAN: 'DJF' }; - const displayCurrency: string = - (isRoundTrip ? outboundSchedule?.displayCurrency : selectedSchedule?.displayCurrency) ?? - NATIONALITY_TO_CURRENCY[searchCriteria?.nationality?.toUpperCase() ?? ''] ?? - 'USD'; + const displayCurrency = 'ETB'; useEffect(() => { if (!seatHold?.expiresAt) return; @@ -201,19 +197,34 @@ export default function ReviewPage() { return; } - // Get seat class ID - let seatClassId = 'default-seat-class-id'; - let returnSeatClassId = 'default-seat-class-id'; + // Get seat class ID by name-matching against the /seat-classes list + let seatClassId = ''; + let returnSeatClassId = ''; try { - const seatClasses: any = await apiClient.get('/seat-classes'); - console.log('Seat classes:', seatClasses); + const seatClasses: any[] = await apiClient.get('/seat-classes'); if (seatClasses && seatClasses.length > 0) { - seatClassId = seatClasses[0].id; - returnSeatClassId = seatClasses[0].id; + const outboundClassName = isRoundTrip + ? (outboundSchedule as any)?.seatClassName + : (selectedSchedule as any)?.seatClassName; + const returnClassName = isRoundTrip + ? (inboundSchedule as any)?.seatClassName + : outboundClassName; + + const findByName = (name: string) => + seatClasses.find((sc: any) => sc.name === name)?.id || seatClasses[0].id; + + seatClassId = outboundClassName ? findByName(outboundClassName) : seatClasses[0].id; + returnSeatClassId = returnClassName ? findByName(returnClassName) : seatClasses[0].id; + console.log('Seat class lookup:', { outboundClassName, returnClassName, seatClassId, returnSeatClassId }); } } catch (err) { console.error('Failed to fetch seat classes:', err); } + + if (!seatClassId) { + alert('Unable to determine seat class. Please go back and re-select your seats.'); + return; + } let bookingData: any; if (isAuthenticated) { diff --git a/apps/edr-passenger-web/portal/src/lib/booking-store.ts b/apps/edr-passenger-web/portal/src/lib/booking-store.ts index 4aaef69a1..93262f5b8 100644 --- a/apps/edr-passenger-web/portal/src/lib/booking-store.ts +++ b/apps/edr-passenger-web/portal/src/lib/booking-store.ts @@ -54,6 +54,10 @@ export interface SelectedSchedule { displayCurrency: string; selectedSeatClass?: string; selectedSeatClassName?: string; + seatClassName?: string; + selectedCoachTypeId?: string; + selectedCoachTypeCode?: string; + selectedCoachTypeName?: string; } export interface SeatHold { diff --git a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts index f05db069b..37dfa5ce9 100644 --- a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts +++ b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts @@ -1,392 +1,301 @@ import jsPDF from 'jspdf'; import autoTable from 'jspdf-autotable'; -interface VoucherData { +interface ScheduleInfo { + trainNumber: string; + trainName?: string; + origin: { name: string; code: string; city: string }; + destination: { name: string; code: string; city: string }; + departureAt: string; + arrivalAt: string; + seatClass?: string; +} + +interface PassengerVoucherData { bookingRef: string; + ticketNumber: string; + passengerName: string; + dateOfBirth?: string; + nationality?: string; + seatNumber?: string; + outboundSeatNumber?: string; + inboundSeatNumber?: string; status: string; - passengers: Array<{ - fullName: string; - category: string; - seat?: { - number: string; - coach: string; - seatClass: string; - }; - }>; - schedule: { - trainNumber: string; - trainName?: string; - origin: { - name: string; - code: string; - city: string; - }; - destination: { - name: string; - code: string; - city: string; - }; - departureAt: string; - arrivalAt: string; - }; - totalMinor: number; + outboundSchedule: ScheduleInfo; + inboundSchedule?: ScheduleInfo; + isRoundTrip: boolean; + fareMinor: number; currency: string; - bookingType: string; createdAt: string; } -export const generateVoucherPDF = async (booking: VoucherData) => { - const doc = new jsPDF({ - orientation: 'portrait', - unit: 'mm', - format: 'a4', - }); +// ─── shared drawing helpers ─────────────────────────────────────────────────── +const PRIMARY = [20, 113, 76] as const; +const DARK = [51, 51, 51] as const; +const MED = [102, 102, 102] as const; +const LIGHT = [200, 200, 200] as const; + +async function drawHeader(doc: jsPDF, margin: number): Promise { const pageWidth = doc.internal.pageSize.getWidth(); - const pageHeight = doc.internal.pageSize.getHeight(); - const margin = 15; - let yPos = margin; - // Colors - const primaryColor = [20, 113, 76]; // EDR Green - const darkGray = [51, 51, 51]; - const mediumGray = [102, 102, 102]; - const lightGray = [200, 200, 200]; - - // ============ HEADER ============ - // Company branding strip - doc.setFillColor(primaryColor[0], primaryColor[1], primaryColor[2]); + doc.setFillColor(...PRIMARY); doc.rect(0, 0, pageWidth, 30, 'F'); - // Load and add logo try { - const logoImg = await fetch('/edr-logo.png'); + const logoImg = await fetch('/edr-logo.png'); const logoBlob = await logoImg.blob(); const logoDataUrl = await new Promise((resolve) => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result as string); reader.readAsDataURL(logoBlob); }); - - // Create image to get dimensions const img = new Image(); - await new Promise((resolve) => { - img.onload = resolve; - img.src = logoDataUrl; - }); - - // Calculate aspect ratio and dimensions - const logoHeight = 18; - const logoWidth = (img.width / img.height) * logoHeight; - - // Add logo on left side with proper aspect ratio - doc.addImage(logoDataUrl, 'PNG', margin, 6, logoWidth, logoHeight); - - // Company name next to logo + await new Promise((resolve) => { img.onload = resolve; img.src = logoDataUrl; }); + const logoH = 18; + const logoW = (img.width / img.height) * logoH; + doc.addImage(logoDataUrl, 'PNG', margin, 6, logoW, logoH); doc.setTextColor(255, 255, 255); - doc.setFontSize(20); - doc.setFont('helvetica', 'bold'); - doc.text('ETHIO-DJIBOUTI RAILWAY', margin + logoWidth + 5, 14); - - doc.setFontSize(9); - doc.setFont('helvetica', 'normal'); - doc.text('Premium Travel Experience', margin + logoWidth + 5, 20); - } catch (error) { - console.error('Failed to load logo:', error); - // Fallback: just show text centered + doc.setFontSize(18); doc.setFont('helvetica', 'bold'); + doc.text('ETHIO-DJIBOUTI RAILWAY', margin + logoW + 5, 14); + doc.setFontSize(9); doc.setFont('helvetica', 'normal'); + doc.text('Premium Travel Experience', margin + logoW + 5, 20); + } catch { doc.setTextColor(255, 255, 255); - doc.setFontSize(24); - doc.setFont('helvetica', 'bold'); - doc.text('ETHIO-DJIBOUTI RAILWAY', pageWidth / 2, 12, { align: 'center' }); - - doc.setFontSize(10); - doc.setFont('helvetica', 'normal'); - doc.text('Premium Travel Experience', pageWidth / 2, 18, { align: 'center' }); + doc.setFontSize(22); doc.setFont('helvetica', 'bold'); + doc.text('ETHIO-DJIBOUTI RAILWAY', pageWidth / 2, 13, { align: 'center' }); + doc.setFontSize(9); doc.setFont('helvetica', 'normal'); + doc.text('Premium Travel Experience', pageWidth / 2, 20, { align: 'center' }); } + return 40; +} - yPos = 40; - - // ============ TITLE & STATUS ============ - doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]); - doc.setFontSize(20); - doc.setFont('helvetica', 'bold'); - doc.text('BOOKING VOUCHER', pageWidth / 2, yPos, { align: 'center' }); - - yPos += 10; - - // Status badge (simplified) - const statusText = booking.status === 'TICKETED' || booking.status === 'CONFIRMED' ? 'CONFIRMED' : booking.status; - const statusColor = booking.status === 'TICKETED' || booking.status === 'CONFIRMED' ? [34, 197, 94] : [234, 179, 8]; - - doc.setFillColor(statusColor[0], statusColor[1], statusColor[2]); - doc.rect(pageWidth / 2 - 20, yPos - 4, 40, 8, 'F'); +function drawStatusBadge(doc: jsPDF, status: string, y: number, pageWidth: number): number { + const label = (status === 'TICKETED' || status === 'CONFIRMED') ? 'CONFIRMED' : status; + const color = (status === 'TICKETED' || status === 'CONFIRMED') ? [34, 197, 94] : [234, 179, 8]; + doc.setFillColor(color[0], color[1], color[2]); + doc.rect(pageWidth / 2 - 22, y - 4, 44, 8, 'F'); doc.setTextColor(255, 255, 255); - doc.setFontSize(9); - doc.setFont('helvetica', 'bold'); - doc.text(statusText, pageWidth / 2, yPos + 1, { align: 'center' }); + doc.setFontSize(9); doc.setFont('helvetica', 'bold'); + doc.text(label, pageWidth / 2, y + 1, { align: 'center' }); + return y + 12; +} - yPos += 12; - - // ============ QR CODE ============ - // Generate QR code data URL - const canvas = document.createElement('canvas'); - const QRCode = (await import('qrcode')).default; - - const qrSize = 35; // 35mm = 3.5cm - await QRCode.toCanvas(canvas, booking.bookingRef, { - width: 300, - margin: 2, - color: { - dark: '#000000', - light: '#FFFFFF', - }, - }); - - const qrDataUrl = canvas.toDataURL('image/png'); - - // Place QR code at top-right - const qrX = pageWidth - margin - qrSize; - const qrY = yPos; - - doc.addImage(qrDataUrl, 'PNG', qrX, qrY, qrSize, qrSize); - - doc.setFontSize(8); - doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); - doc.setFont('helvetica', 'normal'); - doc.text('SCAN AT TERMINAL', qrX + qrSize / 2, qrY + qrSize + 4, { align: 'center' }); - - // ============ BOOKING REFERENCE ============ +function drawBookingRefBox(doc: jsPDF, bookingRef: string, ticketNumber: string, y: number, margin: number, pageWidth: number): number { doc.setFillColor(245, 245, 245); - doc.rect(margin, yPos, pageWidth - margin * 2 - qrSize - 5, 18, 'F'); - - doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); - doc.setFontSize(9); - doc.setFont('helvetica', 'normal'); - doc.text('BOOKING REFERENCE', margin + 5, yPos + 6); - - doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]); - doc.setFontSize(18); - doc.setFont('helvetica', 'bold'); - doc.text(booking.bookingRef, margin + 5, yPos + 14); + doc.rect(margin, y, pageWidth - margin * 2, 22, 'F'); - yPos += 25; + doc.setTextColor(...MED); doc.setFontSize(8); doc.setFont('helvetica', 'normal'); + doc.text('BOOKING REFERENCE', margin + 5, y + 6); + doc.setTextColor(...PRIMARY); doc.setFontSize(16); doc.setFont('helvetica', 'bold'); + doc.text(bookingRef, margin + 5, y + 14); - // ============ JOURNEY DETAILS ============ - doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]); - doc.setFontSize(12); - doc.setFont('helvetica', 'bold'); - doc.text('JOURNEY DETAILS', margin, yPos); - - yPos += 8; + const rightX = pageWidth - margin - 5; + doc.setTextColor(...MED); doc.setFontSize(8); doc.setFont('helvetica', 'normal'); + doc.text('TICKET NUMBER', rightX, y + 6, { align: 'right' }); + doc.setTextColor(...DARK); doc.setFontSize(11); doc.setFont('helvetica', 'bold'); + doc.text(ticketNumber, rightX, y + 14, { align: 'right' }); - // Route box - doc.setDrawColor(lightGray[0], lightGray[1], lightGray[2]); - doc.setLineWidth(0.5); - doc.rect(margin, yPos, pageWidth - margin * 2, 40); + return y + 28; +} + +function drawJourneyLeg(doc: jsPDF, schedule: ScheduleInfo, label: string | null, y: number, margin: number, pageWidth: number): number { + doc.setTextColor(...DARK); doc.setFontSize(11); doc.setFont('helvetica', 'bold'); + doc.text(label ? `JOURNEY DETAILS — ${label.toUpperCase()}` : 'JOURNEY DETAILS', margin, y); + y += 7; + + doc.setDrawColor(...LIGHT); doc.setLineWidth(0.5); + doc.rect(margin, y, pageWidth - margin * 2, 40); // Origin - doc.setFontSize(9); - doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); - doc.setFont('helvetica', 'normal'); - doc.text('FROM', margin + 5, yPos + 6); - - doc.setFontSize(16); - doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]); - doc.setFont('helvetica', 'bold'); - doc.text(booking.schedule.origin.code, margin + 5, yPos + 14); - - doc.setFontSize(10); - doc.setFont('helvetica', 'normal'); - doc.text(booking.schedule.origin.name, margin + 5, yPos + 20); - - doc.setFontSize(8); - doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); - doc.text(booking.schedule.origin.city, margin + 5, yPos + 25); + doc.setFontSize(8); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal'); + doc.text('FROM', margin + 5, y + 6); + doc.setFontSize(15); doc.setTextColor(...DARK); doc.setFont('helvetica', 'bold'); + doc.text(schedule.origin.code, margin + 5, y + 14); + doc.setFontSize(9); doc.setFont('helvetica', 'normal'); + doc.text(schedule.origin.name, margin + 5, y + 20); + doc.setFontSize(8); doc.setTextColor(...MED); + doc.text(schedule.origin.city, margin + 5, y + 25); - // Departure time - const departureDate = new Date(booking.schedule.departureAt); - doc.setFontSize(14); - doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]); - doc.setFont('helvetica', 'bold'); - doc.text(departureDate.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }), margin + 5, yPos + 33); - - doc.setFontSize(8); - doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); - doc.setFont('helvetica', 'normal'); - doc.text(departureDate.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }), margin + 5, yPos + 38); + const dep = new Date(schedule.departureAt); + doc.setFontSize(13); doc.setTextColor(...PRIMARY); doc.setFont('helvetica', 'bold'); + doc.text(dep.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }), margin + 5, y + 33); + doc.setFontSize(7); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal'); + doc.text(dep.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }), margin + 5, y + 38); // Arrow - doc.setDrawColor(primaryColor[0], primaryColor[1], primaryColor[2]); - doc.setLineWidth(1); - const arrowStartX = pageWidth / 2 - 10; - const arrowEndX = pageWidth / 2 + 10; - const arrowY = yPos + 20; - - // Draw arrow line - doc.line(arrowStartX, arrowY, arrowEndX, arrowY); - - // Draw arrow head manually with lines - doc.line(arrowEndX, arrowY, arrowEndX - 3, arrowY - 2); - doc.line(arrowEndX, arrowY, arrowEndX - 3, arrowY + 2); + doc.setDrawColor(...PRIMARY); doc.setLineWidth(0.8); + const ax = pageWidth / 2, ay = y + 20; + doc.line(ax - 10, ay, ax + 10, ay); + doc.line(ax + 10, ay, ax + 7, ay - 2); + doc.line(ax + 10, ay, ax + 7, ay + 2); // Destination - const destX = pageWidth - margin - 50; - doc.setFontSize(9); - doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); - doc.setFont('helvetica', 'normal'); - doc.text('TO', destX, yPos + 6); - - doc.setFontSize(16); - doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]); - doc.setFont('helvetica', 'bold'); - doc.text(booking.schedule.destination.code, destX, yPos + 14); - - doc.setFontSize(10); - doc.setFont('helvetica', 'normal'); - doc.text(booking.schedule.destination.name, destX, yPos + 20); - - doc.setFontSize(8); - doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); - doc.text(booking.schedule.destination.city, destX, yPos + 25); + const dx = pageWidth - margin - 50; + doc.setFontSize(8); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal'); + doc.text('TO', dx, y + 6); + doc.setFontSize(15); doc.setTextColor(...DARK); doc.setFont('helvetica', 'bold'); + doc.text(schedule.destination.code, dx, y + 14); + doc.setFontSize(9); doc.setFont('helvetica', 'normal'); + doc.text(schedule.destination.name, dx, y + 20); + doc.setFontSize(8); doc.setTextColor(...MED); + doc.text(schedule.destination.city, dx, y + 25); - // Arrival time - const arrivalDate = new Date(booking.schedule.arrivalAt); - doc.setFontSize(14); - doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]); - doc.setFont('helvetica', 'bold'); - doc.text(arrivalDate.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }), destX, yPos + 33); - - doc.setFontSize(8); - doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); - doc.setFont('helvetica', 'normal'); - doc.text(arrivalDate.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }), destX, yPos + 38); + const arr = new Date(schedule.arrivalAt); + doc.setFontSize(13); doc.setTextColor(...PRIMARY); doc.setFont('helvetica', 'bold'); + doc.text(arr.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }), dx, y + 33); + doc.setFontSize(7); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal'); + doc.text(arr.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }), dx, y + 38); - yPos += 48; + y += 47; - // Train info - doc.setFillColor(250, 250, 250); - doc.rect(margin, yPos, pageWidth - margin * 2, 12, 'F'); - - doc.setFontSize(9); - doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); - doc.setFont('helvetica', 'normal'); - doc.text('TRAIN', margin + 5, yPos + 5); - - doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]); - doc.setFont('helvetica', 'bold'); - doc.text(booking.schedule.trainNumber, margin + 5, yPos + 9); - - if (booking.schedule.trainName) { - doc.setFont('helvetica', 'normal'); - doc.text(` - ${booking.schedule.trainName}`, margin + 25, yPos + 9); + // Train info bar + doc.setFillColor(248, 248, 248); + doc.rect(margin, y, pageWidth - margin * 2, 12, 'F'); + doc.setFontSize(8); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal'); + doc.text('TRAIN', margin + 5, y + 5); + doc.setTextColor(...DARK); doc.setFont('helvetica', 'bold'); + doc.text(schedule.trainNumber + (schedule.trainName ? ` — ${schedule.trainName}` : ''), margin + 20, y + 9); + if (schedule.seatClass) { + doc.setFont('helvetica', 'normal'); doc.setTextColor(...MED); + doc.text(schedule.seatClass, pageWidth - margin - 5, y + 9, { align: 'right' }); } - yPos += 18; + return y + 18; +} - // ============ PASSENGERS ============ - doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]); - doc.setFontSize(12); - doc.setFont('helvetica', 'bold'); - doc.text('PASSENGERS', margin, yPos); - - yPos += 8; +function drawPassengerDetails(doc: jsPDF, data: PassengerVoucherData, y: number, margin: number): number { + doc.setTextColor(...DARK); doc.setFontSize(11); doc.setFont('helvetica', 'bold'); + doc.text('PASSENGER DETAILS', margin, y); + y += 7; - // Passenger table - const passengerData = booking.passengers.map((p, idx) => [ - (idx + 1).toString(), - p.fullName, - p.category, - p.seat?.number || '-', - p.seat?.coach || '-', - p.seat?.seatClass || '-', - ]); + const rows: [string, string][] = [ + ['Full Name', data.passengerName || '—'], + ['Date of Birth', data.dateOfBirth ? new Date(data.dateOfBirth).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) : '—'], + ['Nationality', data.nationality || '—'], + ]; + + if (data.isRoundTrip) { + rows.push(['Outbound Seat', data.outboundSeatNumber || '—']); + rows.push(['Return Seat', data.inboundSeatNumber || '—']); + } else { + rows.push(['Seat', data.seatNumber || '—']); + } autoTable(doc, { - startY: yPos, - head: [['#', 'Passenger Name', 'Type', 'Seat', 'Coach', 'Class']], - body: passengerData, - theme: 'striped', - headStyles: { - fillColor: [primaryColor[0], primaryColor[1], primaryColor[2]], - textColor: [255, 255, 255], - fontSize: 9, - fontStyle: 'bold', - }, - bodyStyles: { - fontSize: 9, - textColor: [darkGray[0], darkGray[1], darkGray[2]], - }, - alternateRowStyles: { - fillColor: [250, 250, 250], + startY: y, + body: rows, + theme: 'plain', + styles: { fontSize: 9, cellPadding: 3 }, + columnStyles: { + 0: { fontStyle: 'bold', textColor: [MED[0], MED[1], MED[2]], cellWidth: 45 }, + 1: { textColor: [DARK[0], DARK[1], DARK[2]] }, }, + alternateRowStyles: { fillColor: [248, 248, 248] }, margin: { left: margin, right: margin }, }); - yPos = (doc as any).lastAutoTable.finalY + 10; + return (doc as any).lastAutoTable.finalY + 8; +} - // ============ PAYMENT SUMMARY ============ - doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]); - doc.setFontSize(12); - doc.setFont('helvetica', 'bold'); - doc.text('PAYMENT SUMMARY', margin, yPos); - - yPos += 8; +function drawFareSummary(doc: jsPDF, fareMinor: number, currency: string, y: number, margin: number, pageWidth: number): number { + doc.setFillColor(248, 248, 248); + doc.rect(margin, y, pageWidth - margin * 2, 20, 'F'); + doc.setFontSize(9); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal'); + doc.text('Fare', margin + 5, y + 7); + doc.setFontSize(15); doc.setTextColor(...PRIMARY); doc.setFont('helvetica', 'bold'); + doc.text(`${currency} ${(fareMinor / 100).toFixed(2)}`, pageWidth - margin - 5, y + 7, { align: 'right' }); + doc.setFontSize(9); doc.setTextColor(34, 197, 94); doc.setFont('helvetica', 'bold'); + doc.text('✓ PAID', margin + 5, y + 15); + return y + 26; +} - doc.setFillColor(250, 250, 250); - doc.rect(margin, yPos, pageWidth - margin * 2, 20, 'F'); - - doc.setFontSize(10); - doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); - doc.setFont('helvetica', 'normal'); - doc.text('Total Amount', margin + 5, yPos + 7); - - doc.setFontSize(16); - doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]); - doc.setFont('helvetica', 'bold'); - doc.text(`${booking.currency} ${(booking.totalMinor / 100).toFixed(2)}`, pageWidth - margin - 5, yPos + 7, { align: 'right' }); - - doc.setFontSize(9); - doc.setTextColor(34, 197, 94); - doc.setFont('helvetica', 'bold'); - doc.text('✓ PAID', margin + 5, yPos + 15); - - yPos += 28; - - // ============ INSTRUCTIONS ============ +function drawInstructions(doc: jsPDF, y: number, margin: number, pageWidth: number): number { doc.setFillColor(252, 211, 77); - doc.rect(margin, yPos, pageWidth - margin * 2, 18, 'F'); - - doc.setFontSize(9); - doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]); - doc.setFont('helvetica', 'bold'); - doc.text('⚠ IMPORTANT INSTRUCTIONS', margin + 5, yPos + 6); - - doc.setFont('helvetica', 'normal'); - doc.setFontSize(8); - doc.text('• Present this voucher at the terminal for boarding', margin + 5, yPos + 11); - doc.text('• Arrive at least 30 minutes before departure', margin + 5, yPos + 15); + doc.rect(margin, y, pageWidth - margin * 2, 18, 'F'); + doc.setFontSize(9); doc.setTextColor(...DARK); doc.setFont('helvetica', 'bold'); + doc.text('⚠ IMPORTANT INSTRUCTIONS', margin + 5, y + 6); + doc.setFont('helvetica', 'normal'); doc.setFontSize(8); + doc.text('• Present this voucher at the terminal for boarding', margin + 5, y + 11); + doc.text('• Arrive at least 30 minutes before departure', margin + 5, y + 15); + return y + 24; +} - // ============ FOOTER ============ - const footerY = pageHeight - 25; - - doc.setDrawColor(lightGray[0], lightGray[1], lightGray[2]); - doc.line(margin, footerY, pageWidth - margin, footerY); - - doc.setFontSize(8); - doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); - doc.setFont('helvetica', 'normal'); +function drawFooter(doc: jsPDF, createdAt: string): void { + const pageWidth = doc.internal.pageSize.getWidth(); + const pageHeight = doc.internal.pageSize.getHeight(); + const footerY = pageHeight - 22; + + doc.setDrawColor(...LIGHT); + doc.line(15, footerY, pageWidth - 15, footerY); + doc.setFontSize(8); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal'); doc.text('Support: support@edr.com | +251-11-XXX-XXXX', pageWidth / 2, footerY + 5, { align: 'center' }); doc.text('Terms & Conditions apply. Visit www.edr.com for details.', pageWidth / 2, footerY + 9, { align: 'center' }); - doc.setFontSize(7); - doc.text(`Generated: ${new Date().toLocaleString('en-US')}`, pageWidth / 2, footerY + 13, { align: 'center' }); + doc.text(`Generated: ${new Date(createdAt).toLocaleString('en-US')}`, pageWidth / 2, footerY + 13, { align: 'center' }); +} - // Watermark (removed rotation as it may cause issues) - doc.setTextColor(240, 240, 240); - doc.setFontSize(50); - doc.setFont('helvetica', 'bold'); - doc.text('EDR', pageWidth / 2, pageHeight / 2, { align: 'center' }); +// ─── public API ────────────────────────────────────────────────────────────── - // Save PDF - doc.save(`EDR-Voucher-${booking.bookingRef}.pdf`); +/** Generates and downloads one PDF voucher for a single passenger. */ +export const generatePassengerVoucherPDF = async (data: PassengerVoucherData): Promise => { + const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' }); + const pageW = doc.internal.pageSize.getWidth(); + const margin = 15; + + let y = await drawHeader(doc, margin); + + // Title + doc.setTextColor(...DARK); doc.setFontSize(18); doc.setFont('helvetica', 'bold'); + doc.text('PASSENGER VOUCHER', pageW / 2, y, { align: 'center' }); + y += 10; + + y = drawStatusBadge(doc, data.status, y, pageW); + y = drawBookingRefBox(doc, data.bookingRef, data.ticketNumber, y, margin, pageW); + y = drawJourneyLeg(doc, data.outboundSchedule, data.isRoundTrip ? 'Outbound' : null, y, margin, pageW); + + if (data.isRoundTrip && data.inboundSchedule) { + y = drawJourneyLeg(doc, data.inboundSchedule, 'Return', y, margin, pageW); + } + + y = drawPassengerDetails(doc, data, y, margin); + y = drawFareSummary(doc, data.fareMinor, data.currency, y, margin, pageW); + drawInstructions(doc, y, margin, pageW); + drawFooter(doc, data.createdAt); + + const safeName = (data.passengerName || 'Passenger').replace(/\s+/g, '_').replace(/[^a-zA-Z0-9_-]/g, ''); + doc.save(`Voucher_${safeName}.pdf`); +}; + +// ─── legacy combined voucher (kept for backward compat) ────────────────────── + +interface VoucherData { + bookingRef: string; + status: string; + passengers: Array<{ fullName: string; category: string; seat?: { number: string; coach: string; seatClass: string } }>; + schedule: { trainNumber: string; trainName?: string; origin: { name: string; code: string; city: string }; destination: { name: string; code: string; city: string }; departureAt: string; arrivalAt: string }; + totalMinor: number; + currency: string; + bookingType: string; + createdAt: string; +} + +export const generateVoucherPDF = async (booking: VoucherData): Promise => { + for (let i = 0; i < booking.passengers.length; i++) { + const p = booking.passengers[i]; + await generatePassengerVoucherPDF({ + bookingRef: booking.bookingRef, + ticketNumber: `TKT-${booking.bookingRef}-${(i + 1).toString().padStart(2, '0')}`, + passengerName: p.fullName, + seatNumber: p.seat?.number, + status: booking.status, + outboundSchedule: { ...booking.schedule, seatClass: p.seat?.seatClass }, + isRoundTrip: false, + fareMinor: Math.round(booking.totalMinor / booking.passengers.length), + currency: booking.currency, + createdAt: booking.createdAt, + }); + // small delay so browsers don't block multiple sequential downloads + if (i < booking.passengers.length - 1) await new Promise(r => setTimeout(r, 400)); + } };