Merge pull request #345 from Tria-plc/alpha

Update payment methods and cron job for payment cancellation
This commit is contained in:
Eyob T.
2026-06-29 18:06:08 +03:00
committed by GitHub
16 changed files with 790 additions and 562 deletions

View File

@@ -1,7 +1,7 @@
-- Migration: Add Configurable Fare Management System -- Migration: Add Configurable Fare Management System
-- Main fare configuration table -- Main fare configuration table
CREATE TABLE "fare_configurations" ( CREATE TABLE IF NOT EXISTS "fare_configurations" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"name" TEXT NOT NULL, "name" TEXT NOT NULL,
"description" TEXT, "description" TEXT,
@@ -19,7 +19,7 @@ CREATE TABLE "fare_configurations" (
); );
-- Rate structure by nationality and coach/position -- Rate structure by nationality and coach/position
CREATE TABLE "fare_rate_rules" ( CREATE TABLE IF NOT EXISTS "fare_rate_rules" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"fare_config_id" TEXT NOT NULL, "fare_config_id" TEXT NOT NULL,
"nationality_type" TEXT NOT NULL, -- 'LOCAL' or 'INTERNATIONAL' "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) -- Configurable fare components (insurance, premiums, service charges, taxes)
CREATE TABLE "fare_components" ( CREATE TABLE IF NOT EXISTS "fare_components" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"fare_config_id" TEXT NOT NULL, "fare_config_id" TEXT NOT NULL,
"component_type" TEXT NOT NULL, -- 'INSURANCE', 'PREMIUM', 'SERVICE_CHARGE', 'TAX', 'DEMAND' "component_type" TEXT NOT NULL, -- 'INSURANCE', 'PREMIUM', 'SERVICE_CHARGE', 'TAX', 'DEMAND'
@@ -52,7 +52,7 @@ CREATE TABLE "fare_components" (
); );
-- Age-based pricing rules -- Age-based pricing rules
CREATE TABLE "age_pricing_rules" ( CREATE TABLE IF NOT EXISTS "age_pricing_rules" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"fare_config_id" TEXT NOT NULL, "fare_config_id" TEXT NOT NULL,
"rule_name" TEXT NOT NULL, "rule_name" TEXT NOT NULL,
@@ -70,7 +70,7 @@ CREATE TABLE "age_pricing_rules" (
); );
-- Audit trail for configuration changes -- Audit trail for configuration changes
CREATE TABLE "fare_configuration_audit" ( CREATE TABLE IF NOT EXISTS "fare_configuration_audit" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"fare_config_id" TEXT NOT NULL, "fare_config_id" TEXT NOT NULL,
"action" TEXT NOT NULL, -- 'CREATED', 'UPDATED', 'ACTIVATED', 'DEACTIVATED' "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") CONSTRAINT "fare_configuration_audit_pkey" PRIMARY KEY ("id")
); );
-- Foreign key constraints -- Foreign key constraints (idempotent)
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; DO $$ BEGIN
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; IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fare_rate_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; 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_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;
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 -- Indexes for performance (idempotent)
CREATE INDEX "fare_configurations_effective_date_idx" ON "fare_configurations"("effective_date"); CREATE INDEX IF NOT EXISTS "fare_configurations_effective_date_idx" ON "fare_configurations"("effective_date");
CREATE INDEX "fare_configurations_is_active_idx" ON "fare_configurations"("is_active"); CREATE INDEX IF NOT EXISTS "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; 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 IF NOT EXISTS "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 IF NOT EXISTS "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 "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 -- Add legacy mode flag to existing fare tables for gradual migration (idempotent)
ALTER TABLE "FareRule" ADD COLUMN "migrated_to_config_id" TEXT; ALTER TABLE "passenger"."FareRule" ADD COLUMN IF NOT EXISTS "migrated_to_config_id" TEXT;
ALTER TABLE "SegmentFareRule" ADD COLUMN "migrated_to_config_id" TEXT; ALTER TABLE "passenger"."SegmentFareRule" ADD COLUMN IF NOT EXISTS "migrated_to_config_id" TEXT;
-- Add feature flag support -- Add feature flag support
CREATE TABLE "system_features" ( CREATE TABLE IF NOT EXISTS "system_features" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"feature_name" TEXT NOT NULL UNIQUE, "feature_name" TEXT NOT NULL UNIQUE,
"is_enabled" BOOLEAN NOT NULL DEFAULT false, "is_enabled" BOOLEAN NOT NULL DEFAULT false,
@@ -114,4 +124,4 @@ CREATE TABLE "system_features" (
-- Insert the configurable fares feature flag -- Insert the configurable fares feature flag
INSERT INTO "system_features" ("id", "feature_name", "is_enabled", "config", "updated_at") INSERT INTO "system_features" ("id", "feature_name", "is_enabled", "config", "updated_at")
VALUES ('cf-001', 'USE_CONFIGURABLE_FARES', false, '{"rollout_percentage": 0}', CURRENT_TIMESTAMP); VALUES ('cf-001', 'USE_CONFIGURABLE_FARES', false, '{"rollout_percentage": 0}', CURRENT_TIMESTAMP);

View File

@@ -483,8 +483,8 @@ export class BookingsService {
} }
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10; const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
const taxesMinor = Math.round(combinedBaseFareMinor * 0.05); const taxesMinor = 0;
const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor); const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB; const displayCurrency = dto.displayCurrency || Currency.ETB;
let displayTotalMinor = totalMinor; let displayTotalMinor = totalMinor;
@@ -660,8 +660,8 @@ export class BookingsService {
} }
} }
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10; const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
const taxesMinor = Math.round(combinedBase * 0.05); const taxesMinor = 0;
const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor + taxesMinor); const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB; const displayCurrency = dto.displayCurrency || Currency.ETB;
const displayTotalMinor = displayCurrency !== Currency.ETB const displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
@@ -854,8 +854,8 @@ export class BookingsService {
} }
} }
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10; const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
const taxesMinor = Math.round(combinedBase * 0.05); const taxesMinor = 0;
const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor + taxesMinor); const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB; const displayCurrency = dto.displayCurrency || Currency.ETB;
const displayTotalMinor = displayCurrency !== Currency.ETB const displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
@@ -1060,7 +1060,7 @@ export class BookingsService {
loyaltyRedemptionPoints?: number loyaltyRedemptionPoints?: number
) { ) {
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`; 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 adultFareMinor = baseFareMinor * adultCount;
const paidChildrenCount = Math.max(0, childCount - 1); const paidChildrenCount = Math.max(0, childCount - 1);
@@ -1076,8 +1076,8 @@ export class BookingsService {
} }
const loyaltyMinor = (loyaltyRedemptionPoints ?? 0) * 10; const loyaltyMinor = (loyaltyRedemptionPoints ?? 0) * 10;
const taxesMinor = Math.round(totalBaseFareMinor * 0.05); const taxesMinor = 0;
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor); const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor);
return { return {
baseFareMinor, baseFareMinor,
@@ -1103,6 +1103,8 @@ export class BookingsService {
nationality?: string, nationality?: string,
originStopSeq?: number, originStopSeq?: number,
destStopSeq?: number, destStopSeq?: number,
originStationId?: string,
destinationStationId?: string,
): Promise<number> { ): Promise<number> {
const now = new Date(); const now = new Date();
@@ -1149,13 +1151,13 @@ export class BookingsService {
const bestMatch = this.selectBestFareRule(candidates, scheduleId, segmentRoute, fullRoute, nationality); const bestMatch = this.selectBestFareRule(candidates, scheduleId, segmentRoute, fullRoute, nationality);
if (bestMatch) return bestMatch.baseFareMinor; 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) { if (schedule?.routeId) {
try { try {
const fare = await this.fareEngine.calculate({ const fare = await this.fareEngine.calculate({
routeId: schedule.routeId, routeId: schedule.routeId,
originStationId: schedule.originStationId, originStationId: originStationId ?? schedule.originStationId,
destinationStationId: schedule.destinationStationId, destinationStationId: destinationStationId ?? schedule.destinationStationId,
seatClassId, seatClassId,
nationality, nationality,
}); });

View File

@@ -9,6 +9,9 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
import { CreateGuestBookingDto, SavedPassengerProfileDto } from './guest-booking.dto'; import { CreateGuestBookingDto, SavedPassengerProfileDto } from './guest-booking.dto';
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client'; 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 { function generateRef(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
return 'EDR-' + Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join(''); 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 (!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 originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId);
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId); const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found'); if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found');
@@ -142,7 +149,9 @@ export class GuestBookingService {
dto.seatClassId, dto.seatClassId,
segmentRoute, segmentRoute,
fullRoute, fullRoute,
primaryNationality primaryNationality,
dto.originStationId,
dto.destinationStationId,
); );
const adultFareMinor = baseFareMinor * adultCount; const adultFareMinor = baseFareMinor * adultCount;
@@ -160,8 +169,8 @@ export class GuestBookingService {
} }
} }
const taxesMinor = Math.round(totalBaseFareMinor * 0.05); const taxesMinor = 0;
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor + taxesMinor); const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB; const displayCurrency = dto.displayCurrency || Currency.ETB;
let displayTotalMinor = totalMinor; let displayTotalMinor = totalMinor;
@@ -293,6 +302,10 @@ export class GuestBookingService {
if (!outboundSchedule) throw new NotFoundException('Outbound schedule not found'); if (!outboundSchedule) throw new NotFoundException('Outbound schedule not found');
if (!returnSchedule) throw new NotFoundException('Return 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 outboundOriginStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.originStationId);
const outboundDestStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.destinationStationId); const outboundDestStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
const returnOriginStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnOriginStationId); const returnOriginStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnOriginStationId);
@@ -350,8 +363,8 @@ export class GuestBookingService {
const primaryNationality = passengersData[0]?.nationality; const primaryNationality = passengersData[0]?.nationality;
const [outboundBaseFare, returnBaseFare] = await Promise.all([ const [outboundBaseFare, returnBaseFare] = await Promise.all([
this.getBaseFare(dto.scheduleId, dto.seatClassId, outboundSegmentRoute, outboundFullRoute, primaryNationality), this.getBaseFare(dto.scheduleId, dto.seatClassId, outboundSegmentRoute, outboundFullRoute, primaryNationality, dto.originStationId, dto.destinationStationId),
this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality), this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality, dto.returnOriginStationId, dto.returnDestinationStationId),
]); ]);
const paidChildrenCount = Math.max(0, childCount - 1); const paidChildrenCount = Math.max(0, childCount - 1);
@@ -369,8 +382,8 @@ export class GuestBookingService {
} }
} }
const taxesMinor = Math.round(combinedBaseFareMinor * 0.05); const taxesMinor = 0;
const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor + taxesMinor); const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB; const displayCurrency = dto.displayCurrency || Currency.ETB;
const displayTotalMinor = 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 (!leg1Schedule) throw new NotFoundException('Leg-1 schedule not found');
if (!leg2Schedule) throw new NotFoundException('Leg-2 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 leg1OriginStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.originStationId);
const leg1DestStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.transitStationId); const leg1DestStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.transitStationId);
const leg2OriginStop = leg2Schedule.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, this.getBaseFare(dto.scheduleId, dto.seatClassId,
`${leg1OriginStop.station.code}-${leg1DestStop.station.code}`, `${leg1OriginStop.station.code}-${leg1DestStop.station.code}`,
`${leg1Schedule.originStation.code}-${leg1Schedule.destinationStation.code}`, `${leg1Schedule.originStation.code}-${leg1Schedule.destinationStation.code}`,
primaryNationality), primaryNationality, dto.originStationId, dto.transitStationId),
this.getBaseFare(dto.leg2ScheduleId, leg2SeatClassId, this.getBaseFare(dto.leg2ScheduleId, leg2SeatClassId,
`${leg2OriginStop.station.code}-${leg2DestStop.station.code}`, `${leg2OriginStop.station.code}-${leg2DestStop.station.code}`,
`${leg2Schedule.originStation.code}-${leg2Schedule.destinationStation.code}`, `${leg2Schedule.originStation.code}-${leg2Schedule.destinationStation.code}`,
primaryNationality), primaryNationality, dto.transitStationId, dto.leg2DestinationStationId),
]); ]);
const leg1Total = leg1BaseFare * adultCount + leg1BaseFare * paidChildrenCount; 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); discountMinor = promo.percentOff ? Math.round(combinedBase * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
} }
} }
const taxesMinor = Math.round(combinedBase * 0.05); const taxesMinor = 0;
const totalMinor = Math.max(0, combinedBase - discountMinor + taxesMinor); const totalMinor = Math.max(0, combinedBase - discountMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB; const displayCurrency = dto.displayCurrency || Currency.ETB;
const displayTotalMinor = 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 (!retL1Sched) throw new NotFoundException('Return leg-1 schedule not found');
if (!retL2Sched) throw new NotFoundException('Return leg-2 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 obL1Origin = obL1Sched.stopTimes.find(s => s.stationId === dto.originStationId);
const obL1Dest = obL1Sched.stopTimes.find(s => s.stationId === dto.transitStationId); const obL1Dest = obL1Sched.stopTimes.find(s => s.stationId === dto.transitStationId);
const obL2Origin = obL2Sched.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 retL2ClassId = dto.returnLeg2SeatClassId ?? dto.seatClassId;
const [obL1Fare, obL2Fare, retL1Fare, retL2Fare] = await Promise.all([ 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.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), 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), 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), 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 + const combinedBase = (obL1Fare + obL2Fare + retL1Fare + retL2Fare) * adultCount +
@@ -951,17 +972,28 @@ export class GuestBookingService {
segmentRoute?: string, segmentRoute?: string,
fullRoute?: string, fullRoute?: string,
nationality?: string, nationality?: string,
originStationId?: string,
destinationStationId?: string,
): Promise<number> { ): Promise<number> {
const now = new Date(); const now = new Date();
// 1. FareRule table — explicit override rules // 1. FareRule table — explicit override rules (same priority logic as the fare engine)
const candidates = await this.prisma.fareRule.findMany({ const [candidates, seatClass] = await Promise.all([
where: { this.prisma.fareRule.findMany({
seatClassId, where: {
validFrom: { lte: now }, seatClassId,
OR: [{ validUntil: null }, { validUntil: { gte: now } }], 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 = [ const priorities = [
{ tripId: scheduleId, route: segmentRoute, nationality }, { tripId: scheduleId, route: segmentRoute, nationality },
@@ -982,10 +1014,11 @@ export class GuestBookingService {
const match = candidates.find( const match = candidates.find(
(c) => c.tripId === priority.tripId && c.route === priority.route && c.nationality === priority.nationality, (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({ const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId }, where: { id: scheduleId },
select: { routeId: true, originStationId: true, destinationStationId: true }, select: { routeId: true, originStationId: true, destinationStationId: true },
@@ -995,12 +1028,15 @@ export class GuestBookingService {
try { try {
const fare = await this.fareEngine.calculate({ const fare = await this.fareEngine.calculate({
routeId: schedule.routeId, routeId: schedule.routeId,
originStationId: schedule.originStationId, // Use the booking's boarding/alighting stations so the distance reflects the
destinationStationId: schedule.destinationStationId, // passenger's actual segment, not the full schedule route.
originStationId: originStationId ?? schedule.originStationId,
destinationStationId: destinationStationId ?? schedule.destinationStationId,
seatClassId, seatClassId,
nationality, nationality,
}); });
return fare.baseFarePerPassengerMinor; // farePerPassengerMinor already includes base + premiumMinor + insuranceFeeMinor
return fare.farePerPassengerMinor;
} catch { } catch {
// FareEngine throws if distanceKm is missing; fall through to error // FareEngine throws if distanceKm is missing; fall through to error
} }

View File

@@ -284,13 +284,16 @@ export class FareEngineService {
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency); const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency);
return fareRules.map(rule => { return fareRules.map(rule => {
const seatClassId = rule.seatClassId; const seatClassId = rule.seatClassId;
const taxMinor = Math.round(rule.baseFareMinor * TAX_RATE);
const totalMinor = rule.baseFareMinor + taxMinor;
return { return {
seatClassId, seatClassId,
seatClassName: 'Unknown', seatClassName: 'Unknown',
baseFareMinor: rule.baseFareMinor, baseFareMinor: rule.baseFareMinor,
totalMinor: rule.baseFareMinor, taxMinor,
totalMinor,
billingCurrency, billingCurrency,
totalInBillingCurrency: Math.round(rule.baseFareMinor * exchangeRate), totalInBillingCurrency: Math.round(totalMinor * exchangeRate),
exchangeRate, exchangeRate,
source: 'FARE_RULE', source: 'FARE_RULE',
}; };

View File

@@ -31,6 +31,7 @@ import {
SupportedPaymentMethodDto, SupportedPaymentMethodDto,
PaymentMethodTypeEnum, PaymentMethodTypeEnum,
PaymentPlatformDto, PaymentPlatformDto,
BookingAmountResponseDto,
} from "./payments.dto"; } from "./payments.dto";
import { PassengerStaff } from "../../common/passenger-guards"; import { PassengerStaff } from "../../common/passenger-guards";
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
@@ -139,16 +140,33 @@ export class PaymentsController {
@ApiOperation({ @ApiOperation({
summary: "List payment systems supported by the platform", summary: "List payment systems supported by the platform",
description: 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 }) @ApiQuery({ name: "region", enum: PaymentRegionEnum, required: false })
@ApiOkResponse({ type: [SupportedPaymentMethodDto] }) @ApiOkResponse({ type: [SupportedPaymentMethodDto] })
getMethods( getMethods(
@Query("currency") currency?: string,
@Query("region") region?: PaymentRegionEnum, @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") @Get("checkout")

View File

@@ -136,3 +136,9 @@ export class IntentStatusDto {
@ApiPropertyOptional() failureCode?: string; @ApiPropertyOptional() failureCode?: string;
@ApiPropertyOptional() failureMessage?: 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;
}

View File

@@ -476,7 +476,7 @@ export class PaymentsService {
}); });
} }
getSupportedPaymentMethods(region?: PaymentRegionEnum, currency?: string) { getSupportedPaymentMethods(region?: PaymentRegionEnum) {
return this.prisma.paymentMethod.findMany({ return this.prisma.paymentMethod.findMany({
where: { where: {
enabled: true, enabled: true,
@@ -490,12 +490,41 @@ export class PaymentsService {
}, },
} }
: {}), : {}),
...(currency ? { currency: currency.toUpperCase() } : {}),
}, },
orderBy: [{ sortOrder: "asc" }, { displayName: "asc" }], 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 * 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 * ms×1000 → year 58429), which Prisma/Postgres rejects and would otherwise dead-letter the

View File

@@ -474,8 +474,8 @@ export class SearchService {
} }
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * POINTS_TO_MINOR; const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * POINTS_TO_MINOR;
const taxesMinor = Math.round(totalBaseFareMinor * 0.05); const taxesMinor = 0;
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor); const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor);
const displayCurrency = dto.displayCurrency ?? resolveCurrencyFromNationality(dto.nationality); const displayCurrency = dto.displayCurrency ?? resolveCurrencyFromNationality(dto.nationality);
const displayTotalMinor = displayCurrency !== Currency.ETB const displayTotalMinor = displayCurrency !== Currency.ETB

View File

@@ -3,12 +3,19 @@ import { Cron } from '@nestjs/schedule';
import { PrismaService } from '../../common/prisma.service'; import { PrismaService } from '../../common/prisma.service';
import { SmsClientService } from '../notifications/sms-client.service'; import { SmsClientService } from '../notifications/sms-client.service';
/** Minutes before departure at which each action fires. */ /** Maximum time (hours) a passenger has to pay after booking. */
const REMINDER_MINUTES = 3 * 60; // 3 h → send payment reminder SMS const MAX_PAYMENT_HOURS = 2;
const DEADLINE_MINUTES = 2 * 60; // 2 h → cancel unpaid booking /** 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 { function fmtTime(d: Date): string {
return d.toLocaleTimeString('en-GB', { 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() { async syncScheduleStatuses() {
const now = new Date(); 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({ 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' }, data: { status: 'EN_ROUTE' },
}), }),
this.prisma.trainSchedule.updateMany({ this.prisma.trainSchedule.updateMany({
where: { status: { in: ['EN_ROUTE', 'BOARDING'] }, arrivalAt: { lte: now } }, where: { status: 'EN_ROUTE', arrivalAt: { lte: now } },
data: { status: 'ARRIVED' }, data: { status: 'ARRIVED' },
}), }),
]); ]);
if (departed.count > 0 || arrived.count > 0) { if (boarding.count > 0 || departed.count > 0 || arrived.count > 0) {
this.logger.log( 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. // Reminder — sent once at the midpoint of the booking's payment window:
// • 2 h before departure → cancel booking if payment is still pending // reminder_at = booking_time + total_window / 2
// and notify the passenger by SMS.
// //
// Example: train departs 08:00 // Cancel — when now ≥ payment_deadline
// 05:00 → reminder SMS sent ("pay before 06:00 or booking is cancelled") // payment_deadline = MIN(booking_time + 2h, departure_time - 30min)
// 06:00 → booking auto-cancelled, cancellation SMS sent //
// 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() { async enforcePaymentDeadlines() {
const now = new Date(); const now = new Date();
await Promise.all([ await Promise.all([
this.sendPaymentReminders(now), this.sendPaymentReminders(now),
this.cancelExpiredPendingBookings(now), this.cancelExpiredPendingBookings(now),
]); ]);
} }
// ── 3-hour reminder ─────────────────────────────────────────────────────── // ── Send reminder at the midpoint of each booking's payment window ────────
private async sendPaymentReminders(now: Date) { private async sendPaymentReminders(now: Date) {
// Narrow 4-minute window (±2 min around the 3-hour mark) so each booking // Only look at bookings created within the last 3 h with a future departure.
// is caught by exactly one cron tick and paymentReminderSentAt guards re-sends. const threeHoursAgo = new Date(now.getTime() - 3 * 60 * 60 * 1000);
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);
const bookings = await this.prisma.booking.findMany({ const bookings = await this.prisma.booking.findMany({
where: { where: {
status: 'PENDING_PAYMENT', status: 'PENDING_PAYMENT',
paymentReminderSentAt: null, paymentReminderSentAt: null,
schedule: { departureAt: { gte: windowStart, lte: windowEnd } }, createdAt: { gte: threeHoursAgo },
schedule: { departureAt: { gte: now } },
} as any, } as any,
include: { include: {
schedule: { schedule: {
@@ -101,15 +114,28 @@ export class TasksService {
for (const booking of bookings) { for (const booking of bookings) {
try { try {
const dep = booking.schedule.departureAt as Date; const createdAt = booking.createdAt as Date;
const deadline = new Date(dep.getTime() - DEADLINE_MINUTES * 60 * 1000); const dep = booking.schedule.departureAt as Date;
const origin = booking.schedule.originStation?.name ?? ''; const paymentDeadline = computePaymentDeadline(createdAt, dep);
const dest = booking.schedule.destinationStation?.name ?? ''; 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 = const message =
`EDR: Your booking ${booking.bookingRef} ` + `EDR: Your booking ${booking.bookingRef} ` +
`(${origin}${dest}) departs at ${fmtTime(dep)}. ` + `(${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) { if (booking.contactPhone) {
await this.sms.sendSms({ to: booking.contactPhone, message }).catch(() => null); await this.sms.sendSms({ to: booking.contactPhone, message }).catch(() => null);
@@ -121,7 +147,8 @@ export class TasksService {
}); });
this.logger.log( 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) { } catch (err) {
this.logger.error( 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) { 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({ const expiredBookings = await this.prisma.booking.findMany({
where: { where: {
status: 'PENDING_PAYMENT', status: 'PENDING_PAYMENT',
schedule: { departureAt: { lte: cutoff } }, OR: [
{ createdAt: { lte: twoHoursAgo } },
{ schedule: { departureAt: { lte: departureCutoff } } },
],
}, },
include: { include: {
schedule: { schedule: {
@@ -151,8 +186,16 @@ export class TasksService {
}, },
}); });
let cancelledCount = 0;
for (const booking of expiredBookings) { for (const booking of expiredBookings) {
try { 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) // 1. Release held seats (Journey rows are the occupancy source of truth)
await this.prisma.journey.deleteMany({ where: { bookingId: booking.id } as any }); await this.prisma.journey.deleteMany({ where: { bookingId: booking.id } as any });
@@ -161,12 +204,12 @@ export class TasksService {
data: { data: {
bookingId: booking.id, bookingId: booking.id,
cancelledBy: 'SYSTEM', cancelledBy: 'SYSTEM',
reason: 'Payment not completed before departure deadline', reason: 'Payment not completed before deadline',
refundAmount: 0, refundAmount: 0,
refundMethod: booking.paymentIntent?.method ?? 'NONE', refundMethod: booking.paymentIntent?.method ?? 'NONE',
refundStatus: 'NOT_APPLICABLE', refundStatus: 'NOT_APPLICABLE',
}, },
}).catch(() => null); // booking may already have a cancellation record }).catch(() => null);
// 3. Mark cancelled // 3. Mark cancelled
await this.prisma.booking.update({ await this.prisma.booking.update({
@@ -175,22 +218,20 @@ export class TasksService {
}); });
// 4. Notify passenger // 4. Notify passenger
const dep = booking.schedule.departureAt as Date;
const origin = booking.schedule.originStation?.name ?? ''; const origin = booking.schedule.originStation?.name ?? '';
const dest = booking.schedule.destinationStation?.name ?? ''; const dest = booking.schedule.destinationStation?.name ?? '';
const message = const message =
`EDR: Your booking ${booking.bookingRef} ` + `EDR: Your booking ${booking.bookingRef} ` +
`(${origin}${dest}, departs ${fmtTime(dep)}) has been cancelled ` + `(${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) { if (booking.contactPhone) {
await this.sms.sendSms({ to: booking.contactPhone, message }).catch(() => null); await this.sms.sendSms({ to: booking.contactPhone, message }).catch(() => null);
} }
this.logger.log( this.logger.log(`Auto-cancelled: ${booking.bookingRef} (deadline was ${fmtTime(paymentDeadline)})`);
`Auto-cancelled: ${booking.bookingRef} (payment deadline expired, departs ${fmtTime(dep)})`, cancelledCount++;
);
} catch (err) { } catch (err) {
this.logger.error( this.logger.error(
`Auto-cancel failed for ${booking.bookingRef}: ${err instanceof Error ? err.message : String(err)}`, `Auto-cancel failed for ${booking.bookingRef}: ${err instanceof Error ? err.message : String(err)}`,
@@ -198,8 +239,8 @@ export class TasksService {
} }
} }
if (expiredBookings.length > 0) { if (cancelledCount > 0) {
this.logger.log(`Auto-cancelled ${expiredBookings.length} expired pending booking(s)`); this.logger.log(`Auto-cancelled ${cancelledCount} expired pending booking(s)`);
} }
} }
} }

View File

@@ -8,7 +8,6 @@ import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { useEffect, useState, useRef } from 'react'; import { useEffect, useState, useRef } from 'react';
import { CheckCircle, Download, Share2, Copy, Printer, Mail, Train, FileText } from 'lucide-react'; import { CheckCircle, Download, Share2, Copy, Printer, Mail, Train, FileText } from 'lucide-react';
import { QRCodeSVG } from 'qrcode.react';
import { format } from 'date-fns'; import { format } from 'date-fns';
type BookingWithTicket = { type BookingWithTicket = {
@@ -77,54 +76,71 @@ export default function ConfirmationPage() {
}; };
const handleDownloadVoucher = async () => { const handleDownloadVoucher = async () => {
if (!_booking || !pnr) { if (!pnr) {
alert('Booking data not available. Please try again.'); alert('Booking data not available. Please try again.');
return; return;
} }
if (!passengers.length) {
alert('No passenger data found.');
return;
}
setIsGeneratingVoucher(true); setIsGeneratingVoucher(true);
try { try {
console.log('📄 Generating voucher with data:', { _booking, pnr, selectedSchedule, passengers }); const { generatePassengerVoucherPDF } = await import('@/lib/generate-voucher');
const { generateVoucherPDF } = await import('@/lib/generate-voucher'); const activeSchedule = isRoundTrip ? outboundSchedule : selectedSchedule;
const totalFare = _booking?.totalMinor
const voucherData = { || passengers.reduce((s) => s + (activeSchedule?.baseFareAdult || 0), 0);
bookingRef: pnr, const farePerPassenger = Math.round(totalFare / passengers.length);
status: _booking.status || 'CONFIRMED', const createdAt = _booking?.createdAt || new Date().toISOString();
passengers: passengers.map(p => ({ const status = _booking?.status || 'CONFIRMED';
fullName: p.name,
category: 'ADULT', const outbound = {
seat: p.seatNumber ? { trainNumber: activeSchedule?.trainNumber || 'N/A',
number: p.seatNumber, trainName: 'EDR Express',
coach: 'N/A', origin: { name: activeSchedule?.origin || 'Origin', code: 'ORG', city: activeSchedule?.origin || 'Origin' },
seatClass: selectedSchedule?.selectedSeatClassName || 'Standard', destination: { name: activeSchedule?.destination || 'Destination', code: 'DST', city: activeSchedule?.destination || 'Destination' },
} : undefined, departureAt: activeSchedule?.departureTime || new Date().toISOString(),
})), arrivalAt: activeSchedule?.arrivalTime || new Date().toISOString(),
schedule: { seatClass: activeSchedule?.selectedSeatClassName,
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(),
}; };
console.log('📄 Voucher data prepared:', voucherData); const inbound = inboundSchedule ? {
await generateVoucherPDF(voucherData); trainNumber: inboundSchedule.trainNumber || 'N/A',
console.log('✅ Voucher generated successfully'); 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) { } catch (error) {
console.error('❌ Failed to generate voucher:', error); console.error('❌ Failed to generate voucher:', error);
alert(`Failed to generate voucher: ${error instanceof Error ? error.message : 'Unknown error'}`); alert(`Failed to generate voucher: ${error instanceof Error ? error.message : 'Unknown error'}`);
@@ -191,17 +207,9 @@ export default function ConfirmationPage() {
</div> </div>
</div> </div>
{/* Trip Summary with QR Code */} {/* Trip Details */}
<div className="card mb-6"> <div className="card mb-6">
<div className="flex flex-col md:flex-row gap-6"> <div>
{/* QR Code Section */}
<div className="flex flex-col items-center justify-center bg-gray-50 dark:bg-gray-800 rounded-lg p-6 md:w-48 flex-shrink-0">
<QRCodeSVG value={pnr} size={160} level="H" includeMargin={true} />
<p className="text-xs text-gray-600 dark:text-gray-400 mt-2 text-center font-semibold">Scan at gate</p>
</div>
{/* Trip Details */}
<div className="flex-1">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-primary-100 dark:bg-primary-900/30 rounded-lg flex items-center justify-center"> <div className="w-10 h-10 bg-primary-100 dark:bg-primary-900/30 rounded-lg flex items-center justify-center">
<Train className="w-6 h-6 text-primary dark:text-primary-400" /> <Train className="w-6 h-6 text-primary dark:text-primary-400" />
@@ -302,7 +310,6 @@ export default function ConfirmationPage() {
</div> </div>
</div> </div>
)} )}
</div>
</div> </div>
</div> </div>

View File

@@ -332,12 +332,135 @@ function DobPickerModal({
); );
} }
// ─── phone validation ─────────────────────────────────────────────────────────
type PhoneNat = 'ETHIOPIAN' | 'DJIBOUTIAN' | 'OTHER';
const PHONE_PRESETS: Record<PhoneNat, { flag: string; code: string; example: string; hint: string }> = {
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<HTMLInputElement>) => {
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 (
<div>
<div className={`flex rounded-lg overflow-hidden border transition-colors focus-within:ring-1 ${
error
? 'border-red-500 focus-within:ring-red-500'
: 'border-gray-300 dark:border-gray-600 focus-within:border-primary focus-within:ring-primary'
}`}>
<div className="flex items-center gap-1.5 px-3 py-2.5 bg-gray-50 dark:bg-gray-800 border-r border-gray-300 dark:border-gray-600 select-none flex-shrink-0">
<span className="text-sm leading-none">{preset.flag}</span>
<span className="text-xs font-semibold text-gray-600 dark:text-gray-300">{preset.code}</span>
</div>
<input
type="tel"
value={localInput}
onChange={handleChange}
onBlur={handleBlur}
placeholder={preset.example}
autoComplete="tel"
className="flex-1 px-3 py-2.5 bg-white dark:bg-gray-900 text-sm text-gray-900 dark:text-white outline-none min-w-0"
/>
</div>
{error ? (
<p className="text-red-500 text-xs mt-1">{error}</p>
) : (
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">Format: {preset.hint}</p>
)}
</div>
);
}
// ─── passenger zod schema ──────────────────────────────────────────────────────
const passengerSchema = z.object({ const passengerSchema = z.object({
name: z.string().min(2, 'Full name is required (min 2 characters)'), name: z.string().min(2, 'Full name is required (min 2 characters)'),
dateOfBirth: z.string().min(1, 'Date of birth is required'), dateOfBirth: z.string().min(1, 'Date of birth is required'),
gender: z.string().min(1, 'Gender is required'), gender: z.string().min(1, 'Gender is required'),
nationality: z.string().min(1, 'Nationality 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(), email: z.string().optional(),
nationalId: z.string().optional(), nationalId: z.string().optional(),
passportNumber: 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'] }); 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'; const isNonEthiopian = data.nationality !== 'ETHIOPIAN' && data.nationality !== 'Ethiopian';
if (isNonEthiopian) { if (isNonEthiopian) {
if (!data.passportNumber || data.passportNumber.trim().length === 0) { if (!data.passportNumber || data.passportNumber.trim().length === 0) {
@@ -769,14 +896,13 @@ export default function PassengersPage() {
{/* Phone */} {/* Phone */}
<div> <div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone Number *</label> <label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone Number *</label>
<input <PhoneInput
{...register(`passengers.${index}.phone`)} nationality={passengers[index]?.nationality || 'ETHIOPIAN'}
className={`input-field ${errors.passengers?.[index]?.phone ? 'border-red-500' : ''}`} storedValue={passengers[index]?.phone || ''}
placeholder="+251911234567" onInterimChange={(v) => setValue(`passengers.${index}.phone`, v)}
onNormalized={(v) => setValue(`passengers.${index}.phone`, v, { shouldValidate: true })}
error={errors.passengers?.[index]?.phone?.message}
/> />
{errors.passengers?.[index]?.phone && (
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.phone?.message}</p>
)}
</div> </div>
{/* Email */} {/* Email */}
@@ -850,14 +976,13 @@ export default function PassengersPage() {
{/* Phone */} {/* Phone */}
<div> <div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone Number *</label> <label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone Number *</label>
<input <PhoneInput
{...register(`passengers.${index}.phone`)} nationality={passengers[index]?.nationality || 'OTHER'}
className={`input-field ${errors.passengers?.[index]?.phone ? 'border-red-500' : ''}`} storedValue={passengers[index]?.phone || ''}
placeholder="+254712345678" onInterimChange={(v) => setValue(`passengers.${index}.phone`, v)}
onNormalized={(v) => setValue(`passengers.${index}.phone`, v, { shouldValidate: true })}
error={errors.passengers?.[index]?.phone?.message}
/> />
{errors.passengers?.[index]?.phone && (
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.phone?.message}</p>
)}
</div> </div>
{/* Email */} {/* Email */}

View File

@@ -23,23 +23,19 @@ const getIconForMethod = (methodId: string) => {
return Smartphone; return Smartphone;
}; };
const NATIONALITY_TO_CURRENCY: Record<string, 'ETB' | 'DJF' | 'USD'> = {
ETHIOPIAN: 'ETB',
DJIBOUTIAN: 'DJF',
};
export default function PaymentPage() { export default function PaymentPage() {
const router = useRouter(); const router = useRouter();
const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, passengers, searchCriteria } = useBookingStore(); const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, passengers, searchCriteria } = useBookingStore();
const { setPaymentIntent, updateStatus, setCurrency } = usePaymentStore(); const { setPaymentIntent, updateStatus, setCurrency } = usePaymentStore();
const [selectedMethod, setSelectedMethod] = useState<string | null>(null); const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
const [selectedMethodCurrency, setSelectedMethodCurrency] = useState<string | null>(null);
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const [paymentError, setPaymentError] = useState<string | null>(null); const [paymentError, setPaymentError] = useState<string | null>(null);
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP'; const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
const displayCurrency: 'ETB' | 'DJF' | 'USD' = const displayCurrency = 'ETB' as const;
NATIONALITY_TO_CURRENCY[searchCriteria?.nationality?.toUpperCase() ?? ''] ?? 'USD';
// Keep payment store in sync so the mutation picks up the right currency. // Keep payment store in sync so the mutation picks up the right currency.
useEffect(() => { 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}&currency=${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( const outboundBaseFare = isRoundTrip && outboundSchedule ? passengers.reduce(
(sum) => sum + (outboundSchedule.baseFareAdult || 0), (sum) => sum + (outboundSchedule.baseFareAdult || 0),
0, 0,
@@ -69,8 +80,12 @@ export default function PaymentPage() {
(sum) => sum + (selectedSchedule?.baseFareAdult || 0), (sum) => sum + (selectedSchedule?.baseFareAdult || 0),
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({ const paymentMutation = useMutation({
mutationFn: async (data: any) => { mutationFn: async (data: any) => {
@@ -248,7 +263,12 @@ export default function PaymentPage() {
</div> </div>
<div className="flex justify-between items-center pt-1"> <div className="flex justify-between items-center pt-1">
<span className="font-bold text-gray-900 dark:text-gray-100">Total</span> <span className="font-bold text-gray-900 dark:text-gray-100">Total</span>
<span className="text-xl font-bold text-primary">{displayCurrency} {(totalAmount / 100).toFixed(2)}</span> <span className="text-xl font-bold text-primary flex items-center gap-1.5">
{loadingAmount && (
<Loader2 className="w-4 h-4 animate-spin text-primary" />
)}
{confirmedCurrency} {(totalAmount / 100).toFixed(2)}
</span>
</div> </div>
</div> </div>
@@ -259,15 +279,19 @@ export default function PaymentPage() {
)} )}
<button <button
onClick={handlePayment} onClick={handlePayment}
disabled={!selectedMethod || isProcessing} disabled={!selectedMethod || isProcessing || loadingAmount}
className="btn-primary w-full py-3 font-semibold disabled:opacity-50 disabled:cursor-not-allowed" className="btn-primary w-full py-3 font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
> >
{isProcessing ? ( {isProcessing ? (
<span className="flex items-center justify-center gap-2"> <span className="flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" /> Processing... <Loader2 className="w-4 h-4 animate-spin" /> Processing...
</span> </span>
) : loadingAmount ? (
<span className="flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" /> Calculating amount...
</span>
) : ( ) : (
`Pay ${displayCurrency} ${(totalAmount / 100).toFixed(2)}` `Pay ${confirmedCurrency} ${(totalAmount / 100).toFixed(2)}`
)} )}
</button> </button>
<button onClick={() => router.back()} disabled={isProcessing} className="btn-secondary w-full flex items-center justify-center gap-2"> <button onClick={() => router.back()} disabled={isProcessing} className="btn-secondary w-full flex items-center justify-center gap-2">
@@ -337,7 +361,7 @@ export default function PaymentPage() {
return ( return (
<button <button
key={method.id} key={method.id}
onClick={() => setSelectedMethod(method.type)} onClick={() => { setSelectedMethod(method.type); setSelectedMethodCurrency(method.currency ?? null); }}
disabled={isProcessing || !method.enabled} disabled={isProcessing || !method.enabled}
className={`w-full p-4 rounded-xl border-2 transition-all text-left ${ className={`w-full p-4 rounded-xl border-2 transition-all text-left ${
isSelected isSelected
@@ -385,7 +409,10 @@ export default function PaymentPage() {
<div className="lg:hidden fixed bottom-0 inset-x-0 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 px-4 py-3 z-40 shadow-lg"> <div className="lg:hidden fixed bottom-0 inset-x-0 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 px-4 py-3 z-40 shadow-lg">
<div className="flex items-center justify-between mb-2.5"> <div className="flex items-center justify-between mb-2.5">
<span className="text-sm text-gray-600 dark:text-gray-400">Total</span> <span className="text-sm text-gray-600 dark:text-gray-400">Total</span>
<span className="text-lg font-bold text-primary">{displayCurrency} {(totalAmount / 100).toFixed(2)}</span> <span className="text-lg font-bold text-primary flex items-center gap-1.5">
{loadingAmount && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
{confirmedCurrency} {(totalAmount / 100).toFixed(2)}
</span>
</div> </div>
{paymentError && ( {paymentError && (
<p className="text-red-600 dark:text-red-400 text-xs mb-2"> {paymentError}</p> <p className="text-red-600 dark:text-red-400 text-xs mb-2"> {paymentError}</p>
@@ -397,15 +424,19 @@ export default function PaymentPage() {
</button> </button>
<button <button
onClick={handlePayment} onClick={handlePayment}
disabled={!selectedMethod || isProcessing} disabled={!selectedMethod || isProcessing || loadingAmount}
className="btn-primary flex-1 py-2.5 disabled:opacity-50 disabled:cursor-not-allowed" className="btn-primary flex-1 py-2.5 disabled:opacity-50 disabled:cursor-not-allowed"
> >
{isProcessing ? ( {isProcessing ? (
<span className="flex items-center justify-center gap-1.5"> <span className="flex items-center justify-center gap-1.5">
<Loader2 className="w-4 h-4 animate-spin" /> Processing... <Loader2 className="w-4 h-4 animate-spin" /> Processing...
</span> </span>
) : loadingAmount ? (
<span className="flex items-center justify-center gap-1.5">
<Loader2 className="w-4 h-4 animate-spin" /> Calculating...
</span>
) : ( ) : (
`Pay ${displayCurrency} ${(totalAmount / 100).toFixed(2)}` `Pay ${confirmedCurrency} ${(totalAmount / 100).toFixed(2)}`
)} )}
</button> </button>
</div> </div>

View File

@@ -144,8 +144,8 @@ export default function ResultsPage() {
? (outboundSchedules.length > 0 && inboundSchedules.length > 0) ? (outboundSchedules.length > 0 && inboundSchedules.length > 0)
: outboundSchedules.length > 0; : outboundSchedules.length > 0;
const handleSelectCoachType = (scheduleId: string, coachTypeId: string, coachTypeCode: string, coachTypeName: string) => { const handleSelectCoachType = (scheduleId: string, coachTypeId: string, coachTypeCode: string, coachTypeName: string, seatClassName: string) => {
setSelectedCoachTypes(prev => ({ ...prev, [scheduleId]: { id: coachTypeId, code: coachTypeCode, name: coachTypeName } })); setSelectedCoachTypes(prev => ({ ...prev, [scheduleId]: { id: coachTypeId, code: coachTypeCode, name: coachTypeName, seatClassName } }));
}; };
const handleSelect = (schedule: Schedule, isOutbound: boolean = false) => { 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); const coachType = schedule.coachTypes?.find(ct => ct.coachTypeCode === selectedCoachType.code);
// Use displayAmountMinor (passenger's currency) so stored fare matches what the card showed. // Use displayAmountMinor (passenger's currency) so stored fare matches what the card showed.
const minFare = coachType?.classes.length const minFare = coachType?.classes.length
? Math.min(...coachType.classes.map(c => c.displayAmountMinor ?? c.baseFareMinor)) ? Math.min(...coachType.classes.map(c => c.baseFareMinor))
: 0; : 0;
const fareCurrency: string = const fareCurrency = 'ETB';
coachType?.classes[0]?.displayCurrency ?? schedule.displayCurrency ?? 'ETB';
const hours = Math.floor((schedule.durationMinutes || 0) / 60); const hours = Math.floor((schedule.durationMinutes || 0) / 60);
const minutes = (schedule.durationMinutes || 0) % 60; const minutes = (schedule.durationMinutes || 0) % 60;
@@ -186,6 +185,7 @@ export default function ResultsPage() {
selectedCoachTypeId: selectedCoachType.id, selectedCoachTypeId: selectedCoachType.id,
selectedCoachTypeCode: selectedCoachType.code, selectedCoachTypeCode: selectedCoachType.code,
selectedCoachTypeName: selectedCoachType.name, selectedCoachTypeName: selectedCoachType.name,
seatClassName: (selectedCoachType as any).seatClassName || selectedCoachType.name,
}; };
// For round trip, store outbound and wait for inbound selection // 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. // Calculate lowest fare and display currency from coach types / faresByClass.
// Prefer displayAmountMinor (passenger's own currency) over baseFareMinor (ETB internal). // Prefer displayAmountMinor (passenger's own currency) over baseFareMinor (ETB internal).
let lowestFare = null; let lowestFare = null;
let displayCurrency = schedule.displayCurrency || 'ETB'; const displayCurrency = 'ETB';
if (schedule.coachTypes?.length) { if (schedule.coachTypes?.length) {
const allClasses = schedule.coachTypes.flatMap(ct => ct.classes); 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; 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) { } else if (schedule.faresByClass?.length) {
lowestFare = Math.min(...schedule.faresByClass.map(f => f.displayAmountMinor ?? f.baseFareMinor).filter(f => f > 0)); lowestFare = Math.min(...schedule.faresByClass.map(f => f.baseFareMinor).filter(f => f > 0));
const firstWithCurrency = schedule.faresByClass.find(f => f.displayCurrency);
if (firstWithCurrency?.displayCurrency) displayCurrency = firstWithCurrency.displayCurrency;
} else if (schedule.combinedMinFareDisplay) { } else if (schedule.combinedMinFareDisplay) {
lowestFare = schedule.combinedMinFareDisplay; lowestFare = schedule.combinedMinFareDisplay;
} }
@@ -551,14 +547,14 @@ export default function ResultsPage() {
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4 pt-2"> <div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4 pt-2">
{coachTypes.map((coachType: any, index: number) => { {coachTypes.map((coachType: any, index: number) => {
const isSelected = selectedCoachType?.id === coachType.coachTypeId; const isSelected = selectedCoachType?.id === coachType.coachTypeId;
const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.displayAmountMinor ?? c.baseFareMinor)) : 0; const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.baseFareMinor)) : 0;
const coachCurrency: string = (coachType.classes[0] as any)?.displayCurrency ?? (classModal as any).displayCurrency ?? 'ETB'; const coachCurrency = 'ETB';
const CoachIcon = getCoachIcon(coachType.coachTypeName); const CoachIcon = getCoachIcon(coachType.coachTypeName);
return ( return (
<button <button
key={coachType.coachId} key={coachType.coachId}
onClick={() => handleSelectCoachType(scheduleId, coachType.coachTypeId, coachType.coachTypeCode, coachType.coachTypeName)} onClick={() => handleSelectCoachType(scheduleId, coachType.coachTypeId, coachType.coachTypeCode, coachType.coachTypeName, coachType.classes?.[0]?.name || coachType.coachTypeName)}
className={`group relative w-full p-5 rounded-2xl border-2 text-left transition-all duration-200 ${ className={`group relative w-full p-5 rounded-2xl border-2 text-left transition-all duration-200 ${
isSelected isSelected
? 'border-primary bg-gradient-to-br from-primary/8 to-primary/3 dark:from-primary/15 dark:to-primary/5 shadow-lg shadow-primary/20 scale-[1.02]' ? 'border-primary bg-gradient-to-br from-primary/8 to-primary/3 dark:from-primary/15 dark:to-primary/5 shadow-lg shadow-primary/20 scale-[1.02]'
@@ -631,10 +627,10 @@ export default function ResultsPage() {
</div> </div>
<div className="flex items-baseline gap-1"> <div className="flex items-baseline gap-1">
<span className="text-base font-bold tabular-nums text-gray-900 dark:text-white"> <span className="text-base font-bold tabular-nums text-gray-900 dark:text-white">
{((cls.displayAmountMinor ?? cls.baseFareMinor) / 100).toFixed(2)} {(cls.baseFareMinor / 100).toFixed(2)}
</span> </span>
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium"> <span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
{cls.displayCurrency ?? coachCurrency} {coachCurrency}
</span> </span>
</div> </div>
</div> </div>

View File

@@ -62,11 +62,7 @@ export default function ReviewPage() {
// Prefer the currency already stored on the selected schedule (set from search results). // 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. // Fall back to deriving from nationality so the review page is never left with a stale value.
const NATIONALITY_TO_CURRENCY: Record<string, string> = { ETHIOPIAN: 'ETB', DJIBOUTIAN: 'DJF' }; const displayCurrency = 'ETB';
const displayCurrency: string =
(isRoundTrip ? outboundSchedule?.displayCurrency : selectedSchedule?.displayCurrency) ??
NATIONALITY_TO_CURRENCY[searchCriteria?.nationality?.toUpperCase() ?? ''] ??
'USD';
useEffect(() => { useEffect(() => {
if (!seatHold?.expiresAt) return; if (!seatHold?.expiresAt) return;
@@ -201,19 +197,34 @@ export default function ReviewPage() {
return; return;
} }
// Get seat class ID // Get seat class ID by name-matching against the /seat-classes list
let seatClassId = 'default-seat-class-id'; let seatClassId = '';
let returnSeatClassId = 'default-seat-class-id'; let returnSeatClassId = '';
try { try {
const seatClasses: any = await apiClient.get('/seat-classes'); const seatClasses: any[] = await apiClient.get('/seat-classes');
console.log('Seat classes:', seatClasses);
if (seatClasses && seatClasses.length > 0) { if (seatClasses && seatClasses.length > 0) {
seatClassId = seatClasses[0].id; const outboundClassName = isRoundTrip
returnSeatClassId = seatClasses[0].id; ? (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) { } catch (err) {
console.error('Failed to fetch seat classes:', 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; let bookingData: any;
if (isAuthenticated) { if (isAuthenticated) {

View File

@@ -54,6 +54,10 @@ export interface SelectedSchedule {
displayCurrency: string; displayCurrency: string;
selectedSeatClass?: string; selectedSeatClass?: string;
selectedSeatClassName?: string; selectedSeatClassName?: string;
seatClassName?: string;
selectedCoachTypeId?: string;
selectedCoachTypeCode?: string;
selectedCoachTypeName?: string;
} }
export interface SeatHold { export interface SeatHold {

View File

@@ -1,392 +1,301 @@
import jsPDF from 'jspdf'; import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable'; 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; bookingRef: string;
ticketNumber: string;
passengerName: string;
dateOfBirth?: string;
nationality?: string;
seatNumber?: string;
outboundSeatNumber?: string;
inboundSeatNumber?: string;
status: string; status: string;
passengers: Array<{ outboundSchedule: ScheduleInfo;
fullName: string; inboundSchedule?: ScheduleInfo;
category: string; isRoundTrip: boolean;
seat?: { fareMinor: number;
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; currency: string;
bookingType: string;
createdAt: string; createdAt: string;
} }
export const generateVoucherPDF = async (booking: VoucherData) => { // ─── shared drawing helpers ───────────────────────────────────────────────────
const doc = new jsPDF({
orientation: 'portrait',
unit: 'mm',
format: 'a4',
});
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<number> {
const pageWidth = doc.internal.pageSize.getWidth(); const pageWidth = doc.internal.pageSize.getWidth();
const pageHeight = doc.internal.pageSize.getHeight();
const margin = 15;
let yPos = margin;
// Colors doc.setFillColor(...PRIMARY);
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.rect(0, 0, pageWidth, 30, 'F'); doc.rect(0, 0, pageWidth, 30, 'F');
// Load and add logo
try { try {
const logoImg = await fetch('/edr-logo.png'); const logoImg = await fetch('/edr-logo.png');
const logoBlob = await logoImg.blob(); const logoBlob = await logoImg.blob();
const logoDataUrl = await new Promise<string>((resolve) => { const logoDataUrl = await new Promise<string>((resolve) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string); reader.onloadend = () => resolve(reader.result as string);
reader.readAsDataURL(logoBlob); reader.readAsDataURL(logoBlob);
}); });
// Create image to get dimensions
const img = new Image(); const img = new Image();
await new Promise((resolve) => { await new Promise((resolve) => { img.onload = resolve; img.src = logoDataUrl; });
img.onload = resolve; const logoH = 18;
img.src = logoDataUrl; const logoW = (img.width / img.height) * logoH;
}); doc.addImage(logoDataUrl, 'PNG', margin, 6, logoW, logoH);
// 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
doc.setTextColor(255, 255, 255); doc.setTextColor(255, 255, 255);
doc.setFontSize(20); doc.setFontSize(18); doc.setFont('helvetica', 'bold');
doc.setFont('helvetica', 'bold'); doc.text('ETHIO-DJIBOUTI RAILWAY', margin + logoW + 5, 14);
doc.text('ETHIO-DJIBOUTI RAILWAY', margin + logoWidth + 5, 14); doc.setFontSize(9); doc.setFont('helvetica', 'normal');
doc.text('Premium Travel Experience', margin + logoW + 5, 20);
doc.setFontSize(9); } catch {
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.setTextColor(255, 255, 255); doc.setTextColor(255, 255, 255);
doc.setFontSize(24); doc.setFontSize(22); doc.setFont('helvetica', 'bold');
doc.setFont('helvetica', 'bold'); doc.text('ETHIO-DJIBOUTI RAILWAY', pageWidth / 2, 13, { align: 'center' });
doc.text('ETHIO-DJIBOUTI RAILWAY', pageWidth / 2, 12, { align: 'center' }); doc.setFontSize(9); doc.setFont('helvetica', 'normal');
doc.text('Premium Travel Experience', pageWidth / 2, 20, { align: 'center' });
doc.setFontSize(10);
doc.setFont('helvetica', 'normal');
doc.text('Premium Travel Experience', pageWidth / 2, 18, { align: 'center' });
} }
return 40;
}
yPos = 40; function drawStatusBadge(doc: jsPDF, status: string, y: number, pageWidth: number): number {
const label = (status === 'TICKETED' || status === 'CONFIRMED') ? 'CONFIRMED' : status;
// ============ TITLE & STATUS ============ const color = (status === 'TICKETED' || status === 'CONFIRMED') ? [34, 197, 94] : [234, 179, 8];
doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]); doc.setFillColor(color[0], color[1], color[2]);
doc.setFontSize(20); doc.rect(pageWidth / 2 - 22, y - 4, 44, 8, 'F');
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');
doc.setTextColor(255, 255, 255); doc.setTextColor(255, 255, 255);
doc.setFontSize(9); doc.setFontSize(9); doc.setFont('helvetica', 'bold');
doc.setFont('helvetica', 'bold'); doc.text(label, pageWidth / 2, y + 1, { align: 'center' });
doc.text(statusText, pageWidth / 2, yPos + 1, { align: 'center' }); return y + 12;
}
yPos += 12; function drawBookingRefBox(doc: jsPDF, bookingRef: string, ticketNumber: string, y: number, margin: number, pageWidth: number): number {
// ============ 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 ============
doc.setFillColor(245, 245, 245); doc.setFillColor(245, 245, 245);
doc.rect(margin, yPos, pageWidth - margin * 2 - qrSize - 5, 18, 'F'); doc.rect(margin, y, pageWidth - margin * 2, 22, '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);
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 ============ const rightX = pageWidth - margin - 5;
doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]); doc.setTextColor(...MED); doc.setFontSize(8); doc.setFont('helvetica', 'normal');
doc.setFontSize(12); doc.text('TICKET NUMBER', rightX, y + 6, { align: 'right' });
doc.setFont('helvetica', 'bold'); doc.setTextColor(...DARK); doc.setFontSize(11); doc.setFont('helvetica', 'bold');
doc.text('JOURNEY DETAILS', margin, yPos); doc.text(ticketNumber, rightX, y + 14, { align: 'right' });
yPos += 8;
// Route box return y + 28;
doc.setDrawColor(lightGray[0], lightGray[1], lightGray[2]); }
doc.setLineWidth(0.5);
doc.rect(margin, yPos, pageWidth - margin * 2, 40); 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 // Origin
doc.setFontSize(9); doc.setFontSize(8); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); doc.text('FROM', margin + 5, y + 6);
doc.setFont('helvetica', 'normal'); doc.setFontSize(15); doc.setTextColor(...DARK); doc.setFont('helvetica', 'bold');
doc.text('FROM', margin + 5, yPos + 6); doc.text(schedule.origin.code, margin + 5, y + 14);
doc.setFontSize(9); doc.setFont('helvetica', 'normal');
doc.setFontSize(16); doc.text(schedule.origin.name, margin + 5, y + 20);
doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]); doc.setFontSize(8); doc.setTextColor(...MED);
doc.setFont('helvetica', 'bold'); doc.text(schedule.origin.city, margin + 5, y + 25);
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);
// Departure time const dep = new Date(schedule.departureAt);
const departureDate = new Date(booking.schedule.departureAt); doc.setFontSize(13); doc.setTextColor(...PRIMARY); doc.setFont('helvetica', 'bold');
doc.setFontSize(14); doc.text(dep.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }), margin + 5, y + 33);
doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]); doc.setFontSize(7); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
doc.setFont('helvetica', 'bold'); doc.text(dep.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }), margin + 5, y + 38);
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);
// Arrow // Arrow
doc.setDrawColor(primaryColor[0], primaryColor[1], primaryColor[2]); doc.setDrawColor(...PRIMARY); doc.setLineWidth(0.8);
doc.setLineWidth(1); const ax = pageWidth / 2, ay = y + 20;
const arrowStartX = pageWidth / 2 - 10; doc.line(ax - 10, ay, ax + 10, ay);
const arrowEndX = pageWidth / 2 + 10; doc.line(ax + 10, ay, ax + 7, ay - 2);
const arrowY = yPos + 20; doc.line(ax + 10, ay, ax + 7, ay + 2);
// 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);
// Destination // Destination
const destX = pageWidth - margin - 50; const dx = pageWidth - margin - 50;
doc.setFontSize(9); doc.setFontSize(8); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); doc.text('TO', dx, y + 6);
doc.setFont('helvetica', 'normal'); doc.setFontSize(15); doc.setTextColor(...DARK); doc.setFont('helvetica', 'bold');
doc.text('TO', destX, yPos + 6); doc.text(schedule.destination.code, dx, y + 14);
doc.setFontSize(9); doc.setFont('helvetica', 'normal');
doc.setFontSize(16); doc.text(schedule.destination.name, dx, y + 20);
doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]); doc.setFontSize(8); doc.setTextColor(...MED);
doc.setFont('helvetica', 'bold'); doc.text(schedule.destination.city, dx, y + 25);
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);
// Arrival time const arr = new Date(schedule.arrivalAt);
const arrivalDate = new Date(booking.schedule.arrivalAt); doc.setFontSize(13); doc.setTextColor(...PRIMARY); doc.setFont('helvetica', 'bold');
doc.setFontSize(14); doc.text(arr.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }), dx, y + 33);
doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]); doc.setFontSize(7); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
doc.setFont('helvetica', 'bold'); doc.text(arr.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }), dx, y + 38);
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);
yPos += 48; y += 47;
// Train info // Train info bar
doc.setFillColor(250, 250, 250); doc.setFillColor(248, 248, 248);
doc.rect(margin, yPos, pageWidth - margin * 2, 12, 'F'); doc.rect(margin, y, pageWidth - margin * 2, 12, 'F');
doc.setFontSize(8); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
doc.setFontSize(9); doc.text('TRAIN', margin + 5, y + 5);
doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); doc.setTextColor(...DARK); doc.setFont('helvetica', 'bold');
doc.setFont('helvetica', 'normal'); doc.text(schedule.trainNumber + (schedule.trainName ? `${schedule.trainName}` : ''), margin + 20, y + 9);
doc.text('TRAIN', margin + 5, yPos + 5); if (schedule.seatClass) {
doc.setFont('helvetica', 'normal'); doc.setTextColor(...MED);
doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]); doc.text(schedule.seatClass, pageWidth - margin - 5, y + 9, { align: 'right' });
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);
} }
yPos += 18; return y + 18;
}
// ============ PASSENGERS ============ function drawPassengerDetails(doc: jsPDF, data: PassengerVoucherData, y: number, margin: number): number {
doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]); doc.setTextColor(...DARK); doc.setFontSize(11); doc.setFont('helvetica', 'bold');
doc.setFontSize(12); doc.text('PASSENGER DETAILS', margin, y);
doc.setFont('helvetica', 'bold'); y += 7;
doc.text('PASSENGERS', margin, yPos);
yPos += 8;
// Passenger table const rows: [string, string][] = [
const passengerData = booking.passengers.map((p, idx) => [ ['Full Name', data.passengerName || '—'],
(idx + 1).toString(), ['Date of Birth', data.dateOfBirth ? new Date(data.dateOfBirth).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) : '—'],
p.fullName, ['Nationality', data.nationality || '—'],
p.category, ];
p.seat?.number || '-',
p.seat?.coach || '-', if (data.isRoundTrip) {
p.seat?.seatClass || '-', rows.push(['Outbound Seat', data.outboundSeatNumber || '']);
]); rows.push(['Return Seat', data.inboundSeatNumber || '—']);
} else {
rows.push(['Seat', data.seatNumber || '—']);
}
autoTable(doc, { autoTable(doc, {
startY: yPos, startY: y,
head: [['#', 'Passenger Name', 'Type', 'Seat', 'Coach', 'Class']], body: rows,
body: passengerData, theme: 'plain',
theme: 'striped', styles: { fontSize: 9, cellPadding: 3 },
headStyles: { columnStyles: {
fillColor: [primaryColor[0], primaryColor[1], primaryColor[2]], 0: { fontStyle: 'bold', textColor: [MED[0], MED[1], MED[2]], cellWidth: 45 },
textColor: [255, 255, 255], 1: { textColor: [DARK[0], DARK[1], DARK[2]] },
fontSize: 9,
fontStyle: 'bold',
},
bodyStyles: {
fontSize: 9,
textColor: [darkGray[0], darkGray[1], darkGray[2]],
},
alternateRowStyles: {
fillColor: [250, 250, 250],
}, },
alternateRowStyles: { fillColor: [248, 248, 248] },
margin: { left: margin, right: margin }, margin: { left: margin, right: margin },
}); });
yPos = (doc as any).lastAutoTable.finalY + 10; return (doc as any).lastAutoTable.finalY + 8;
}
// ============ PAYMENT SUMMARY ============ function drawFareSummary(doc: jsPDF, fareMinor: number, currency: string, y: number, margin: number, pageWidth: number): number {
doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]); doc.setFillColor(248, 248, 248);
doc.setFontSize(12); doc.rect(margin, y, pageWidth - margin * 2, 20, 'F');
doc.setFont('helvetica', 'bold'); doc.setFontSize(9); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
doc.text('PAYMENT SUMMARY', margin, yPos); doc.text('Fare', margin + 5, y + 7);
doc.setFontSize(15); doc.setTextColor(...PRIMARY); doc.setFont('helvetica', 'bold');
yPos += 8; 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); function drawInstructions(doc: jsPDF, y: number, margin: number, pageWidth: number): number {
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 ============
doc.setFillColor(252, 211, 77); doc.setFillColor(252, 211, 77);
doc.rect(margin, yPos, pageWidth - margin * 2, 18, 'F'); doc.rect(margin, y, pageWidth - margin * 2, 18, 'F');
doc.setFontSize(9); doc.setTextColor(...DARK); doc.setFont('helvetica', 'bold');
doc.setFontSize(9); doc.text('⚠ IMPORTANT INSTRUCTIONS', margin + 5, y + 6);
doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]); doc.setFont('helvetica', 'normal'); doc.setFontSize(8);
doc.setFont('helvetica', 'bold'); doc.text('• Present this voucher at the terminal for boarding', margin + 5, y + 11);
doc.text('⚠ IMPORTANT INSTRUCTIONS', margin + 5, yPos + 6); doc.text('• Arrive at least 30 minutes before departure', margin + 5, y + 15);
return y + 24;
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);
// ============ FOOTER ============ function drawFooter(doc: jsPDF, createdAt: string): void {
const footerY = pageHeight - 25; const pageWidth = doc.internal.pageSize.getWidth();
const pageHeight = doc.internal.pageSize.getHeight();
doc.setDrawColor(lightGray[0], lightGray[1], lightGray[2]); const footerY = pageHeight - 22;
doc.line(margin, footerY, pageWidth - margin, footerY);
doc.setDrawColor(...LIGHT);
doc.setFontSize(8); doc.line(15, footerY, pageWidth - 15, footerY);
doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); doc.setFontSize(8); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
doc.setFont('helvetica', 'normal');
doc.text('Support: support@edr.com | +251-11-XXX-XXXX', pageWidth / 2, footerY + 5, { align: 'center' }); 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.text('Terms & Conditions apply. Visit www.edr.com for details.', pageWidth / 2, footerY + 9, { align: 'center' });
doc.setFontSize(7); 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) // ─── public API ──────────────────────────────────────────────────────────────
doc.setTextColor(240, 240, 240);
doc.setFontSize(50);
doc.setFont('helvetica', 'bold');
doc.text('EDR', pageWidth / 2, pageHeight / 2, { align: 'center' });
// Save PDF /** Generates and downloads one PDF voucher for a single passenger. */
doc.save(`EDR-Voucher-${booking.bookingRef}.pdf`); export const generatePassengerVoucherPDF = async (data: PassengerVoucherData): Promise<void> => {
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<void> => {
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));
}
}; };