mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Merge pull request #345 from Tria-plc/alpha
Update payment methods and cron job for payment cancellation
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
-- Migration: Add Configurable Fare Management System
|
||||
|
||||
-- Main fare configuration table
|
||||
CREATE TABLE "fare_configurations" (
|
||||
CREATE TABLE IF NOT EXISTS "fare_configurations" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
@@ -19,7 +19,7 @@ CREATE TABLE "fare_configurations" (
|
||||
);
|
||||
|
||||
-- Rate structure by nationality and coach/position
|
||||
CREATE TABLE "fare_rate_rules" (
|
||||
CREATE TABLE IF NOT EXISTS "fare_rate_rules" (
|
||||
"id" TEXT NOT NULL,
|
||||
"fare_config_id" TEXT NOT NULL,
|
||||
"nationality_type" TEXT NOT NULL, -- 'LOCAL' or 'INTERNATIONAL'
|
||||
@@ -34,7 +34,7 @@ CREATE TABLE "fare_rate_rules" (
|
||||
);
|
||||
|
||||
-- Configurable fare components (insurance, premiums, service charges, taxes)
|
||||
CREATE TABLE "fare_components" (
|
||||
CREATE TABLE IF NOT EXISTS "fare_components" (
|
||||
"id" TEXT NOT NULL,
|
||||
"fare_config_id" TEXT NOT NULL,
|
||||
"component_type" TEXT NOT NULL, -- 'INSURANCE', 'PREMIUM', 'SERVICE_CHARGE', 'TAX', 'DEMAND'
|
||||
@@ -52,7 +52,7 @@ CREATE TABLE "fare_components" (
|
||||
);
|
||||
|
||||
-- Age-based pricing rules
|
||||
CREATE TABLE "age_pricing_rules" (
|
||||
CREATE TABLE IF NOT EXISTS "age_pricing_rules" (
|
||||
"id" TEXT NOT NULL,
|
||||
"fare_config_id" TEXT NOT NULL,
|
||||
"rule_name" TEXT NOT NULL,
|
||||
@@ -70,7 +70,7 @@ CREATE TABLE "age_pricing_rules" (
|
||||
);
|
||||
|
||||
-- Audit trail for configuration changes
|
||||
CREATE TABLE "fare_configuration_audit" (
|
||||
CREATE TABLE IF NOT EXISTS "fare_configuration_audit" (
|
||||
"id" TEXT NOT NULL,
|
||||
"fare_config_id" TEXT NOT NULL,
|
||||
"action" TEXT NOT NULL, -- 'CREATED', 'UPDATED', 'ACTIVATED', 'DEACTIVATED'
|
||||
@@ -81,27 +81,37 @@ CREATE TABLE "fare_configuration_audit" (
|
||||
CONSTRAINT "fare_configuration_audit_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- Foreign key constraints
|
||||
ALTER TABLE "fare_rate_rules" ADD CONSTRAINT "fare_rate_rules_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "fare_components" ADD CONSTRAINT "fare_components_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "age_pricing_rules" ADD CONSTRAINT "age_pricing_rules_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "fare_configuration_audit" ADD CONSTRAINT "fare_configuration_audit_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
-- Foreign key constraints (idempotent)
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fare_rate_rules_fare_config_id_fkey') THEN
|
||||
ALTER TABLE "fare_rate_rules" ADD CONSTRAINT "fare_rate_rules_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fare_components_fare_config_id_fkey') THEN
|
||||
ALTER TABLE "fare_components" ADD CONSTRAINT "fare_components_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'age_pricing_rules_fare_config_id_fkey') THEN
|
||||
ALTER TABLE "age_pricing_rules" ADD CONSTRAINT "age_pricing_rules_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fare_configuration_audit_fare_config_id_fkey') THEN
|
||||
ALTER TABLE "fare_configuration_audit" ADD CONSTRAINT "fare_configuration_audit_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE INDEX "fare_configurations_effective_date_idx" ON "fare_configurations"("effective_date");
|
||||
CREATE INDEX "fare_configurations_is_active_idx" ON "fare_configurations"("is_active");
|
||||
CREATE UNIQUE INDEX "fare_configurations_default_unique_idx" ON "fare_configurations"("is_default") WHERE "is_default" = true;
|
||||
-- Indexes for performance (idempotent)
|
||||
CREATE INDEX IF NOT EXISTS "fare_configurations_effective_date_idx" ON "fare_configurations"("effective_date");
|
||||
CREATE INDEX IF NOT EXISTS "fare_configurations_is_active_idx" ON "fare_configurations"("is_active");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "fare_configurations_default_unique_idx" ON "fare_configurations"("is_default") WHERE "is_default" = true;
|
||||
|
||||
CREATE INDEX "fare_rate_rules_config_lookup_idx" ON "fare_rate_rules"("fare_config_id", "nationality_type", "coach_type", "bed_position");
|
||||
CREATE INDEX "fare_components_config_order_idx" ON "fare_components"("fare_config_id", "apply_order");
|
||||
CREATE INDEX "age_pricing_rules_age_lookup_idx" ON "age_pricing_rules"("fare_config_id", "min_age", "max_age");
|
||||
CREATE INDEX IF NOT EXISTS "fare_rate_rules_config_lookup_idx" ON "fare_rate_rules"("fare_config_id", "nationality_type", "coach_type", "bed_position");
|
||||
CREATE INDEX IF NOT EXISTS "fare_components_config_order_idx" ON "fare_components"("fare_config_id", "apply_order");
|
||||
CREATE INDEX IF NOT EXISTS "age_pricing_rules_age_lookup_idx" ON "age_pricing_rules"("fare_config_id", "min_age", "max_age");
|
||||
|
||||
-- Add legacy mode flag to existing fare tables for gradual migration
|
||||
ALTER TABLE "FareRule" ADD COLUMN "migrated_to_config_id" TEXT;
|
||||
ALTER TABLE "SegmentFareRule" ADD COLUMN "migrated_to_config_id" TEXT;
|
||||
-- Add legacy mode flag to existing fare tables for gradual migration (idempotent)
|
||||
ALTER TABLE "passenger"."FareRule" ADD COLUMN IF NOT EXISTS "migrated_to_config_id" TEXT;
|
||||
ALTER TABLE "passenger"."SegmentFareRule" ADD COLUMN IF NOT EXISTS "migrated_to_config_id" TEXT;
|
||||
|
||||
-- Add feature flag support
|
||||
CREATE TABLE "system_features" (
|
||||
CREATE TABLE IF NOT EXISTS "system_features" (
|
||||
"id" TEXT NOT NULL,
|
||||
"feature_name" TEXT NOT NULL UNIQUE,
|
||||
"is_enabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
@@ -114,4 +124,4 @@ CREATE TABLE "system_features" (
|
||||
|
||||
-- Insert the configurable fares feature flag
|
||||
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);
|
||||
|
||||
@@ -483,8 +483,8 @@ export class BookingsService {
|
||||
}
|
||||
|
||||
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
|
||||
const taxesMinor = Math.round(combinedBaseFareMinor * 0.05);
|
||||
const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor);
|
||||
const taxesMinor = 0;
|
||||
const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor);
|
||||
|
||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||
let displayTotalMinor = totalMinor;
|
||||
@@ -660,8 +660,8 @@ export class BookingsService {
|
||||
}
|
||||
}
|
||||
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
|
||||
const taxesMinor = Math.round(combinedBase * 0.05);
|
||||
const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor + taxesMinor);
|
||||
const taxesMinor = 0;
|
||||
const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor);
|
||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||
const displayTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||||
@@ -854,8 +854,8 @@ export class BookingsService {
|
||||
}
|
||||
}
|
||||
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
|
||||
const taxesMinor = Math.round(combinedBase * 0.05);
|
||||
const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor + taxesMinor);
|
||||
const taxesMinor = 0;
|
||||
const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor);
|
||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||
const displayTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||||
@@ -1060,7 +1060,7 @@ export class BookingsService {
|
||||
loyaltyRedemptionPoints?: number
|
||||
) {
|
||||
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
|
||||
const baseFareMinor = await this.getBaseFare(scheduleId, seatClassId, segmentRoute, undefined, nationality, originStop.sequence, destStop.sequence);
|
||||
const baseFareMinor = await this.getBaseFare(scheduleId, seatClassId, segmentRoute, undefined, nationality, originStop.sequence, destStop.sequence, originStop.stationId, destStop.stationId);
|
||||
|
||||
const adultFareMinor = baseFareMinor * adultCount;
|
||||
const paidChildrenCount = Math.max(0, childCount - 1);
|
||||
@@ -1076,8 +1076,8 @@ export class BookingsService {
|
||||
}
|
||||
|
||||
const loyaltyMinor = (loyaltyRedemptionPoints ?? 0) * 10;
|
||||
const taxesMinor = Math.round(totalBaseFareMinor * 0.05);
|
||||
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor);
|
||||
const taxesMinor = 0;
|
||||
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor);
|
||||
|
||||
return {
|
||||
baseFareMinor,
|
||||
@@ -1103,6 +1103,8 @@ export class BookingsService {
|
||||
nationality?: string,
|
||||
originStopSeq?: number,
|
||||
destStopSeq?: number,
|
||||
originStationId?: string,
|
||||
destinationStationId?: string,
|
||||
): Promise<number> {
|
||||
const now = new Date();
|
||||
|
||||
@@ -1149,13 +1151,13 @@ export class BookingsService {
|
||||
const bestMatch = this.selectBestFareRule(candidates, scheduleId, segmentRoute, fullRoute, nationality);
|
||||
if (bestMatch) return bestMatch.baseFareMinor;
|
||||
|
||||
// 3. FareEngine — distance × rate-per-km from the schedule's route
|
||||
// 3. FareEngine — distance × rate-per-km from the booking's actual segment stations
|
||||
if (schedule?.routeId) {
|
||||
try {
|
||||
const fare = await this.fareEngine.calculate({
|
||||
routeId: schedule.routeId,
|
||||
originStationId: schedule.originStationId,
|
||||
destinationStationId: schedule.destinationStationId,
|
||||
originStationId: originStationId ?? schedule.originStationId,
|
||||
destinationStationId: destinationStationId ?? schedule.destinationStationId,
|
||||
seatClassId,
|
||||
nationality,
|
||||
});
|
||||
|
||||
@@ -9,6 +9,9 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { CreateGuestBookingDto, SavedPassengerProfileDto } from './guest-booking.dto';
|
||||
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
|
||||
|
||||
/** Booking cutoff: reject new bookings within this many ms of departure. */
|
||||
const BOOKING_CUTOFF_MS = 30 * 60 * 1000;
|
||||
|
||||
function generateRef(): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
return 'EDR-' + Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
|
||||
@@ -74,6 +77,10 @@ export class GuestBookingService {
|
||||
});
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
|
||||
if (Date.now() >= schedule.departureAt.getTime() - BOOKING_CUTOFF_MS) {
|
||||
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
|
||||
}
|
||||
|
||||
const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId);
|
||||
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
|
||||
if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found');
|
||||
@@ -142,7 +149,9 @@ export class GuestBookingService {
|
||||
dto.seatClassId,
|
||||
segmentRoute,
|
||||
fullRoute,
|
||||
primaryNationality
|
||||
primaryNationality,
|
||||
dto.originStationId,
|
||||
dto.destinationStationId,
|
||||
);
|
||||
|
||||
const adultFareMinor = baseFareMinor * adultCount;
|
||||
@@ -160,8 +169,8 @@ export class GuestBookingService {
|
||||
}
|
||||
}
|
||||
|
||||
const taxesMinor = Math.round(totalBaseFareMinor * 0.05);
|
||||
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor + taxesMinor);
|
||||
const taxesMinor = 0;
|
||||
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor);
|
||||
|
||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||
let displayTotalMinor = totalMinor;
|
||||
@@ -293,6 +302,10 @@ export class GuestBookingService {
|
||||
if (!outboundSchedule) throw new NotFoundException('Outbound schedule not found');
|
||||
if (!returnSchedule) throw new NotFoundException('Return schedule not found');
|
||||
|
||||
if (Date.now() >= outboundSchedule.departureAt.getTime() - BOOKING_CUTOFF_MS) {
|
||||
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
|
||||
}
|
||||
|
||||
const outboundOriginStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.originStationId);
|
||||
const outboundDestStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
|
||||
const returnOriginStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnOriginStationId);
|
||||
@@ -350,8 +363,8 @@ export class GuestBookingService {
|
||||
const primaryNationality = passengersData[0]?.nationality;
|
||||
|
||||
const [outboundBaseFare, returnBaseFare] = await Promise.all([
|
||||
this.getBaseFare(dto.scheduleId, dto.seatClassId, outboundSegmentRoute, outboundFullRoute, primaryNationality),
|
||||
this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality),
|
||||
this.getBaseFare(dto.scheduleId, dto.seatClassId, outboundSegmentRoute, outboundFullRoute, primaryNationality, dto.originStationId, dto.destinationStationId),
|
||||
this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality, dto.returnOriginStationId, dto.returnDestinationStationId),
|
||||
]);
|
||||
|
||||
const paidChildrenCount = Math.max(0, childCount - 1);
|
||||
@@ -369,8 +382,8 @@ export class GuestBookingService {
|
||||
}
|
||||
}
|
||||
|
||||
const taxesMinor = Math.round(combinedBaseFareMinor * 0.05);
|
||||
const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor + taxesMinor);
|
||||
const taxesMinor = 0;
|
||||
const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor);
|
||||
|
||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||
const displayTotalMinor = displayCurrency !== Currency.ETB
|
||||
@@ -505,6 +518,10 @@ export class GuestBookingService {
|
||||
if (!leg1Schedule) throw new NotFoundException('Leg-1 schedule not found');
|
||||
if (!leg2Schedule) throw new NotFoundException('Leg-2 schedule not found');
|
||||
|
||||
if (Date.now() >= leg1Schedule.departureAt.getTime() - BOOKING_CUTOFF_MS) {
|
||||
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
|
||||
}
|
||||
|
||||
const leg1OriginStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.originStationId);
|
||||
const leg1DestStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.transitStationId);
|
||||
const leg2OriginStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.transitStationId);
|
||||
@@ -551,11 +568,11 @@ export class GuestBookingService {
|
||||
this.getBaseFare(dto.scheduleId, dto.seatClassId,
|
||||
`${leg1OriginStop.station.code}-${leg1DestStop.station.code}`,
|
||||
`${leg1Schedule.originStation.code}-${leg1Schedule.destinationStation.code}`,
|
||||
primaryNationality),
|
||||
primaryNationality, dto.originStationId, dto.transitStationId),
|
||||
this.getBaseFare(dto.leg2ScheduleId, leg2SeatClassId,
|
||||
`${leg2OriginStop.station.code}-${leg2DestStop.station.code}`,
|
||||
`${leg2Schedule.originStation.code}-${leg2Schedule.destinationStation.code}`,
|
||||
primaryNationality),
|
||||
primaryNationality, dto.transitStationId, dto.leg2DestinationStationId),
|
||||
]);
|
||||
|
||||
const leg1Total = leg1BaseFare * adultCount + leg1BaseFare * paidChildrenCount;
|
||||
@@ -569,8 +586,8 @@ export class GuestBookingService {
|
||||
discountMinor = promo.percentOff ? Math.round(combinedBase * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
|
||||
}
|
||||
}
|
||||
const taxesMinor = Math.round(combinedBase * 0.05);
|
||||
const totalMinor = Math.max(0, combinedBase - discountMinor + taxesMinor);
|
||||
const taxesMinor = 0;
|
||||
const totalMinor = Math.max(0, combinedBase - discountMinor);
|
||||
|
||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||
const displayTotalMinor = displayCurrency !== Currency.ETB
|
||||
@@ -702,6 +719,10 @@ export class GuestBookingService {
|
||||
if (!retL1Sched) throw new NotFoundException('Return leg-1 schedule not found');
|
||||
if (!retL2Sched) throw new NotFoundException('Return leg-2 schedule not found');
|
||||
|
||||
if (Date.now() >= obL1Sched.departureAt.getTime() - BOOKING_CUTOFF_MS) {
|
||||
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
|
||||
}
|
||||
|
||||
const obL1Origin = obL1Sched.stopTimes.find(s => s.stationId === dto.originStationId);
|
||||
const obL1Dest = obL1Sched.stopTimes.find(s => s.stationId === dto.transitStationId);
|
||||
const obL2Origin = obL2Sched.stopTimes.find(s => s.stationId === dto.transitStationId);
|
||||
@@ -750,10 +771,10 @@ export class GuestBookingService {
|
||||
const retL2ClassId = dto.returnLeg2SeatClassId ?? dto.seatClassId;
|
||||
|
||||
const [obL1Fare, obL2Fare, retL1Fare, retL2Fare] = await Promise.all([
|
||||
this.getBaseFare(dto.scheduleId, dto.seatClassId, `${obL1Origin.station.code}-${obL1Dest.station.code}`, `${obL1Sched.originStation.code}-${obL1Sched.destinationStation.code}`, nat),
|
||||
this.getBaseFare(dto.leg2ScheduleId!, obL2ClassId, `${obL2Origin.station.code}-${obL2Dest.station.code}`, `${obL2Sched.originStation.code}-${obL2Sched.destinationStation.code}`, nat),
|
||||
this.getBaseFare(dto.returnScheduleId!, retL1ClassId, `${retL1Origin.station.code}-${retL1Dest.station.code}`, `${retL1Sched.originStation.code}-${retL1Sched.destinationStation.code}`, nat),
|
||||
this.getBaseFare(dto.returnLeg2ScheduleId!,retL2ClassId, `${retL2Origin.station.code}-${retL2Dest.station.code}`, `${retL2Sched.originStation.code}-${retL2Sched.destinationStation.code}`, nat),
|
||||
this.getBaseFare(dto.scheduleId, dto.seatClassId, `${obL1Origin.station.code}-${obL1Dest.station.code}`, `${obL1Sched.originStation.code}-${obL1Sched.destinationStation.code}`, nat, dto.originStationId, dto.transitStationId),
|
||||
this.getBaseFare(dto.leg2ScheduleId!, obL2ClassId, `${obL2Origin.station.code}-${obL2Dest.station.code}`, `${obL2Sched.originStation.code}-${obL2Sched.destinationStation.code}`, nat, dto.transitStationId, dto.leg2DestinationStationId),
|
||||
this.getBaseFare(dto.returnScheduleId!, retL1ClassId, `${retL1Origin.station.code}-${retL1Dest.station.code}`, `${retL1Sched.originStation.code}-${retL1Sched.destinationStation.code}`, nat, dto.returnOriginStationId, dto.returnTransitStationId),
|
||||
this.getBaseFare(dto.returnLeg2ScheduleId!,retL2ClassId, `${retL2Origin.station.code}-${retL2Dest.station.code}`, `${retL2Sched.originStation.code}-${retL2Sched.destinationStation.code}`, nat, dto.returnTransitStationId, dto.returnLeg2DestinationStationId),
|
||||
]);
|
||||
|
||||
const combinedBase = (obL1Fare + obL2Fare + retL1Fare + retL2Fare) * adultCount +
|
||||
@@ -951,17 +972,28 @@ export class GuestBookingService {
|
||||
segmentRoute?: string,
|
||||
fullRoute?: string,
|
||||
nationality?: string,
|
||||
originStationId?: string,
|
||||
destinationStationId?: string,
|
||||
): Promise<number> {
|
||||
const now = new Date();
|
||||
|
||||
// 1. FareRule table — explicit override rules
|
||||
const candidates = await this.prisma.fareRule.findMany({
|
||||
where: {
|
||||
seatClassId,
|
||||
validFrom: { lte: now },
|
||||
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
|
||||
},
|
||||
});
|
||||
// 1. FareRule table — explicit override rules (same priority logic as the fare engine)
|
||||
const [candidates, seatClass] = await Promise.all([
|
||||
this.prisma.fareRule.findMany({
|
||||
where: {
|
||||
seatClassId,
|
||||
validFrom: { lte: now },
|
||||
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
|
||||
},
|
||||
}),
|
||||
this.prisma.seatClass.findUnique({
|
||||
where: { id: seatClassId },
|
||||
select: { premiumMinor: true, insuranceFeeMinor: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const premiumMinor = seatClass?.premiumMinor ?? 0;
|
||||
const insuranceMinor = seatClass?.insuranceFeeMinor ?? 0;
|
||||
|
||||
const priorities = [
|
||||
{ tripId: scheduleId, route: segmentRoute, nationality },
|
||||
@@ -982,10 +1014,11 @@ export class GuestBookingService {
|
||||
const match = candidates.find(
|
||||
(c) => c.tripId === priority.tripId && c.route === priority.route && c.nationality === priority.nationality,
|
||||
);
|
||||
if (match) return match.baseFareMinor;
|
||||
// Return base fare + seat-class surcharges so the booking total matches the quoted fare
|
||||
if (match) return match.baseFareMinor + premiumMinor + insuranceMinor;
|
||||
}
|
||||
|
||||
// 2. FareEngine — distance × rate-per-km from the schedule's route
|
||||
// 2. FareEngine — distance × rate-per-km from the booking's actual segment stations
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: scheduleId },
|
||||
select: { routeId: true, originStationId: true, destinationStationId: true },
|
||||
@@ -995,12 +1028,15 @@ export class GuestBookingService {
|
||||
try {
|
||||
const fare = await this.fareEngine.calculate({
|
||||
routeId: schedule.routeId,
|
||||
originStationId: schedule.originStationId,
|
||||
destinationStationId: schedule.destinationStationId,
|
||||
// Use the booking's boarding/alighting stations so the distance reflects the
|
||||
// passenger's actual segment, not the full schedule route.
|
||||
originStationId: originStationId ?? schedule.originStationId,
|
||||
destinationStationId: destinationStationId ?? schedule.destinationStationId,
|
||||
seatClassId,
|
||||
nationality,
|
||||
});
|
||||
return fare.baseFarePerPassengerMinor;
|
||||
// farePerPassengerMinor already includes base + premiumMinor + insuranceFeeMinor
|
||||
return fare.farePerPassengerMinor;
|
||||
} catch {
|
||||
// FareEngine throws if distanceKm is missing; fall through to error
|
||||
}
|
||||
|
||||
@@ -284,13 +284,16 @@ export class FareEngineService {
|
||||
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency);
|
||||
return fareRules.map(rule => {
|
||||
const seatClassId = rule.seatClassId;
|
||||
const taxMinor = Math.round(rule.baseFareMinor * TAX_RATE);
|
||||
const totalMinor = rule.baseFareMinor + taxMinor;
|
||||
return {
|
||||
seatClassId,
|
||||
seatClassName: 'Unknown',
|
||||
baseFareMinor: rule.baseFareMinor,
|
||||
totalMinor: rule.baseFareMinor,
|
||||
taxMinor,
|
||||
totalMinor,
|
||||
billingCurrency,
|
||||
totalInBillingCurrency: Math.round(rule.baseFareMinor * exchangeRate),
|
||||
totalInBillingCurrency: Math.round(totalMinor * exchangeRate),
|
||||
exchangeRate,
|
||||
source: 'FARE_RULE',
|
||||
};
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
SupportedPaymentMethodDto,
|
||||
PaymentMethodTypeEnum,
|
||||
PaymentPlatformDto,
|
||||
BookingAmountResponseDto,
|
||||
} from "./payments.dto";
|
||||
import { PassengerStaff } from "../../common/passenger-guards";
|
||||
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
||||
@@ -139,16 +140,33 @@ export class PaymentsController {
|
||||
@ApiOperation({
|
||||
summary: "List payment systems supported by the platform",
|
||||
description:
|
||||
"Returns the global catalog of accepted payment systems. Filter by `currency` (e.g. ETB, DJF, USD) to get methods that settle in that currency, and/or by `region` to match a passenger's nationality. Both filters can be combined.",
|
||||
"Returns all enabled payment methods. Optionally filter by `region` to narrow to methods available for a passenger's nationality.",
|
||||
})
|
||||
@ApiQuery({ name: "currency", required: false, example: "DJF", description: "Settlement currency — ETB, DJF, USD, etc." })
|
||||
@ApiQuery({ name: "region", enum: PaymentRegionEnum, required: false })
|
||||
@ApiOkResponse({ type: [SupportedPaymentMethodDto] })
|
||||
getMethods(
|
||||
@Query("currency") currency?: string,
|
||||
@Query("region") region?: PaymentRegionEnum,
|
||||
) {
|
||||
return this.service.getSupportedPaymentMethods(region, currency);
|
||||
return this.service.getSupportedPaymentMethods(region);
|
||||
}
|
||||
|
||||
@Get("booking-amount")
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
summary: "Get booking amount in a specific currency",
|
||||
description:
|
||||
"Returns the booking total converted from ETB to the requested currency using the latest exchange rate. " +
|
||||
"If currency is ETB the stored amount is returned as-is (no conversion). " +
|
||||
"Amounts are returned in major currency units (e.g. 162.50 DJF, not centimes).",
|
||||
})
|
||||
@ApiQuery({ name: "bookingId", required: true, description: "Booking UUID" })
|
||||
@ApiQuery({ name: "currency", required: true, example: "DJF", description: "Target currency: ETB, DJF, or USD" })
|
||||
@ApiOkResponse({ type: BookingAmountResponseDto })
|
||||
getBookingAmount(
|
||||
@Query("bookingId") bookingId: string,
|
||||
@Query("currency") currency: string,
|
||||
) {
|
||||
return this.service.getBookingAmountByCurrency(bookingId, currency);
|
||||
}
|
||||
|
||||
@Get("checkout")
|
||||
|
||||
@@ -136,3 +136,9 @@ export class IntentStatusDto {
|
||||
@ApiPropertyOptional() failureCode?: string;
|
||||
@ApiPropertyOptional() failureMessage?: string;
|
||||
}
|
||||
|
||||
export class BookingAmountResponseDto {
|
||||
@ApiProperty({ example: 'booking-uuid' }) booking_id: string;
|
||||
@ApiProperty({ example: 'DJF', description: 'Currency of the returned amount' }) currency: string;
|
||||
@ApiProperty({ example: 162.5, description: 'Booking total converted to the requested currency (major units)' }) amount: number;
|
||||
}
|
||||
|
||||
@@ -476,7 +476,7 @@ export class PaymentsService {
|
||||
});
|
||||
}
|
||||
|
||||
getSupportedPaymentMethods(region?: PaymentRegionEnum, currency?: string) {
|
||||
getSupportedPaymentMethods(region?: PaymentRegionEnum) {
|
||||
return this.prisma.paymentMethod.findMany({
|
||||
where: {
|
||||
enabled: true,
|
||||
@@ -490,12 +490,41 @@ export class PaymentsService {
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(currency ? { currency: currency.toUpperCase() } : {}),
|
||||
},
|
||||
orderBy: [{ sortOrder: "asc" }, { displayName: "asc" }],
|
||||
});
|
||||
}
|
||||
|
||||
async getBookingAmountByCurrency(
|
||||
bookingId: string,
|
||||
currency: string,
|
||||
): Promise<{ booking_id: string; currency: string; amount: number }> {
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
select: { id: true, totalMinor: true },
|
||||
});
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
|
||||
const requestedCurrency = currency.toUpperCase();
|
||||
const amountInETB = booking.totalMinor / 100;
|
||||
|
||||
if (requestedCurrency === 'ETB') {
|
||||
return { booking_id: bookingId, currency: 'ETB', amount: amountInETB };
|
||||
}
|
||||
|
||||
const exchangeRate = await this.prisma.currencyExchangeRate.findFirst({
|
||||
where: { fromCurrency: 'ETB' as any, toCurrency: requestedCurrency as any },
|
||||
orderBy: { effectiveDate: 'desc' },
|
||||
});
|
||||
if (!exchangeRate) {
|
||||
throw new NotFoundException(`Exchange rate not found for ETB → ${requestedCurrency}`);
|
||||
}
|
||||
|
||||
const rate = Number(exchangeRate.rate);
|
||||
const converted = parseFloat((amountInETB * rate).toFixed(2));
|
||||
return { booking_id: bookingId, currency: requestedCurrency, amount: converted };
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard against an implausible paidAt from a provider event (e.g. a Telebirr epoch parsed as
|
||||
* ms×1000 → year 58429), which Prisma/Postgres rejects and would otherwise dead-letter the
|
||||
|
||||
@@ -474,8 +474,8 @@ export class SearchService {
|
||||
}
|
||||
|
||||
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * POINTS_TO_MINOR;
|
||||
const taxesMinor = Math.round(totalBaseFareMinor * 0.05);
|
||||
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor);
|
||||
const taxesMinor = 0;
|
||||
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor);
|
||||
|
||||
const displayCurrency = dto.displayCurrency ?? resolveCurrencyFromNationality(dto.nationality);
|
||||
const displayTotalMinor = displayCurrency !== Currency.ETB
|
||||
|
||||
@@ -3,12 +3,19 @@ import { Cron } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { SmsClientService } from '../notifications/sms-client.service';
|
||||
|
||||
/** Minutes before departure at which each action fires. */
|
||||
const REMINDER_MINUTES = 3 * 60; // 3 h → send payment reminder SMS
|
||||
const DEADLINE_MINUTES = 2 * 60; // 2 h → cancel unpaid booking
|
||||
/** Maximum time (hours) a passenger has to pay after booking. */
|
||||
const MAX_PAYMENT_HOURS = 2;
|
||||
/** Minutes before departure: cutoff for new bookings and payment deadline. */
|
||||
const CUTOFF_MINUTES = 30;
|
||||
|
||||
/** Half-width of the reminder detection window (cron runs every 2 min). */
|
||||
const REMINDER_WINDOW_MINUTES = 2;
|
||||
/**
|
||||
* payment_deadline = MIN(booking_time + 2h, departure_time - 30min)
|
||||
*/
|
||||
function computePaymentDeadline(createdAt: Date, departureAt: Date): Date {
|
||||
const maxDeadline = new Date(createdAt.getTime() + MAX_PAYMENT_HOURS * 60 * 60 * 1000);
|
||||
const cutoffDeadline = new Date(departureAt.getTime() - CUTOFF_MINUTES * 60 * 1000);
|
||||
return maxDeadline < cutoffDeadline ? maxDeadline : cutoffDeadline;
|
||||
}
|
||||
|
||||
function fmtTime(d: Date): string {
|
||||
return d.toLocaleTimeString('en-GB', {
|
||||
@@ -28,66 +35,72 @@ export class TasksService {
|
||||
) {}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Every 2 min: advance TrainSchedule statuses (departure / arrival).
|
||||
// Every 1 min: advance TrainSchedule statuses.
|
||||
//
|
||||
// SCHEDULED → BOARDING when departure ≤ 30 min away (closed to new bookings)
|
||||
// BOARDING → EN_ROUTE at actual departure
|
||||
// EN_ROUTE → ARRIVED at arrival time
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@Cron('*/2 * * * *')
|
||||
@Cron('*/1 * * * *')
|
||||
async syncScheduleStatuses() {
|
||||
const now = new Date();
|
||||
const thirtyMinFromNow = new Date(now.getTime() + CUTOFF_MINUTES * 60 * 1000);
|
||||
|
||||
const [departed, arrived] = await Promise.all([
|
||||
const [boarding, departed, arrived] = await Promise.all([
|
||||
this.prisma.trainSchedule.updateMany({
|
||||
where: { status: 'SCHEDULED', departureAt: { lte: now } },
|
||||
where: { status: 'SCHEDULED', departureAt: { lte: thirtyMinFromNow } },
|
||||
data: { status: 'BOARDING' },
|
||||
}),
|
||||
this.prisma.trainSchedule.updateMany({
|
||||
where: { status: 'BOARDING', departureAt: { lte: now } },
|
||||
data: { status: 'EN_ROUTE' },
|
||||
}),
|
||||
this.prisma.trainSchedule.updateMany({
|
||||
where: { status: { in: ['EN_ROUTE', 'BOARDING'] }, arrivalAt: { lte: now } },
|
||||
where: { status: 'EN_ROUTE', arrivalAt: { lte: now } },
|
||||
data: { status: 'ARRIVED' },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (departed.count > 0 || arrived.count > 0) {
|
||||
if (boarding.count > 0 || departed.count > 0 || arrived.count > 0) {
|
||||
this.logger.log(
|
||||
`Schedule sync: ${departed.count} → EN_ROUTE, ${arrived.count} → ARRIVED`,
|
||||
`Schedule sync: ${boarding.count} → BOARDING, ${departed.count} → EN_ROUTE, ${arrived.count} → ARRIVED`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Every 2 min: payment deadline enforcement.
|
||||
// Every 1 min: payment deadline enforcement.
|
||||
//
|
||||
// • 3 h before departure → send one SMS reminder to complete payment.
|
||||
// • 2 h before departure → cancel booking if payment is still pending
|
||||
// and notify the passenger by SMS.
|
||||
// Reminder — sent once at the midpoint of the booking's payment window:
|
||||
// reminder_at = booking_time + total_window / 2
|
||||
//
|
||||
// Example: train departs 08:00
|
||||
// 05:00 → reminder SMS sent ("pay before 06:00 or booking is cancelled")
|
||||
// 06:00 → booking auto-cancelled, cancellation SMS sent
|
||||
// Cancel — when now ≥ payment_deadline
|
||||
// payment_deadline = MIN(booking_time + 2h, departure_time - 30min)
|
||||
//
|
||||
// Examples (departure 10:00, cutoff 9:30):
|
||||
// Booked 8:00 → deadline 9:30, window 1.5h, reminder at 8:45
|
||||
// Booked 9:00 → deadline 9:30, window 30min, reminder at 9:15
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@Cron('*/2 * * * *')
|
||||
@Cron('*/1 * * * *')
|
||||
async enforcePaymentDeadlines() {
|
||||
const now = new Date();
|
||||
|
||||
await Promise.all([
|
||||
this.sendPaymentReminders(now),
|
||||
this.cancelExpiredPendingBookings(now),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── 3-hour reminder ───────────────────────────────────────────────────────
|
||||
// ── Send reminder at the midpoint of each booking's payment window ────────
|
||||
private async sendPaymentReminders(now: Date) {
|
||||
// Narrow 4-minute window (±2 min around the 3-hour mark) so each booking
|
||||
// is caught by exactly one cron tick and paymentReminderSentAt guards re-sends.
|
||||
const windowMs = REMINDER_WINDOW_MINUTES * 60 * 1000;
|
||||
const reminderMs = REMINDER_MINUTES * 60 * 1000;
|
||||
|
||||
const windowStart = new Date(now.getTime() + reminderMs - windowMs);
|
||||
const windowEnd = new Date(now.getTime() + reminderMs + windowMs);
|
||||
// Only look at bookings created within the last 3 h with a future departure.
|
||||
const threeHoursAgo = new Date(now.getTime() - 3 * 60 * 60 * 1000);
|
||||
|
||||
const bookings = await this.prisma.booking.findMany({
|
||||
where: {
|
||||
status: 'PENDING_PAYMENT',
|
||||
paymentReminderSentAt: null,
|
||||
schedule: { departureAt: { gte: windowStart, lte: windowEnd } },
|
||||
createdAt: { gte: threeHoursAgo },
|
||||
schedule: { departureAt: { gte: now } },
|
||||
} as any,
|
||||
include: {
|
||||
schedule: {
|
||||
@@ -101,15 +114,28 @@ export class TasksService {
|
||||
|
||||
for (const booking of bookings) {
|
||||
try {
|
||||
const dep = booking.schedule.departureAt as Date;
|
||||
const deadline = new Date(dep.getTime() - DEADLINE_MINUTES * 60 * 1000);
|
||||
const origin = booking.schedule.originStation?.name ?? '';
|
||||
const dest = booking.schedule.destinationStation?.name ?? '';
|
||||
const createdAt = booking.createdAt as Date;
|
||||
const dep = booking.schedule.departureAt as Date;
|
||||
const paymentDeadline = computePaymentDeadline(createdAt, dep);
|
||||
const totalWindowMs = paymentDeadline.getTime() - createdAt.getTime();
|
||||
|
||||
// Skip degenerate windows (< 2 min) — the cancel job will handle these immediately
|
||||
if (totalWindowMs < 2 * 60 * 1000) continue;
|
||||
|
||||
// Remind once, at the midpoint of the total payment window
|
||||
const reminderAt = new Date(createdAt.getTime() + totalWindowMs / 2);
|
||||
if (now < reminderAt) continue;
|
||||
|
||||
const origin = booking.schedule.originStation?.name ?? '';
|
||||
const dest = booking.schedule.destinationStation?.name ?? '';
|
||||
const remainingMs = Math.max(0, paymentDeadline.getTime() - now.getTime());
|
||||
const remainingMin = Math.round(remainingMs / 60_000);
|
||||
|
||||
const message =
|
||||
`EDR: Your booking ${booking.bookingRef} ` +
|
||||
`(${origin} → ${dest}) departs at ${fmtTime(dep)}. ` +
|
||||
`Complete payment by ${fmtTime(deadline)} or your booking will be cancelled.`;
|
||||
`Complete payment within ${remainingMin} minute(s) (by ${fmtTime(paymentDeadline)}) ` +
|
||||
`or your booking will be cancelled.`;
|
||||
|
||||
if (booking.contactPhone) {
|
||||
await this.sms.sendSms({ to: booking.contactPhone, message }).catch(() => null);
|
||||
@@ -121,7 +147,8 @@ export class TasksService {
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Payment reminder sent: ${booking.bookingRef} (departs ${fmtTime(dep)}, deadline ${fmtTime(deadline)})`,
|
||||
`Payment reminder sent: ${booking.bookingRef} ` +
|
||||
`(deadline ${fmtTime(paymentDeadline)}, ${remainingMin} min remaining)`,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
@@ -131,14 +158,22 @@ export class TasksService {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2-hour auto-cancel ────────────────────────────────────────────────────
|
||||
// ── Cancel bookings whose payment deadline has passed ─────────────────────
|
||||
private async cancelExpiredPendingBookings(now: Date) {
|
||||
const cutoff = new Date(now.getTime() + DEADLINE_MINUTES * 60 * 1000); // now + 2 h
|
||||
const twoHoursAgo = new Date(now.getTime() - MAX_PAYMENT_HOURS * 60 * 60 * 1000);
|
||||
const departureCutoff = new Date(now.getTime() + CUTOFF_MINUTES * 60 * 1000);
|
||||
|
||||
// payment_deadline = MIN(createdAt + 2h, departureAt - 30min)
|
||||
// Deadline is reached when either branch of the MIN is in the past:
|
||||
// (a) createdAt ≤ now - 2h → 2-hour max window elapsed
|
||||
// (b) departureAt ≤ now + 30min → departure within 30 min
|
||||
const expiredBookings = await this.prisma.booking.findMany({
|
||||
where: {
|
||||
status: 'PENDING_PAYMENT',
|
||||
schedule: { departureAt: { lte: cutoff } },
|
||||
OR: [
|
||||
{ createdAt: { lte: twoHoursAgo } },
|
||||
{ schedule: { departureAt: { lte: departureCutoff } } },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
schedule: {
|
||||
@@ -151,8 +186,16 @@ export class TasksService {
|
||||
},
|
||||
});
|
||||
|
||||
let cancelledCount = 0;
|
||||
|
||||
for (const booking of expiredBookings) {
|
||||
try {
|
||||
// Re-verify exact deadline to avoid racing with a concurrent payment confirmation
|
||||
const createdAt = booking.createdAt as Date;
|
||||
const dep = booking.schedule.departureAt as Date;
|
||||
const paymentDeadline = computePaymentDeadline(createdAt, dep);
|
||||
if (now < paymentDeadline) continue;
|
||||
|
||||
// 1. Release held seats (Journey rows are the occupancy source of truth)
|
||||
await this.prisma.journey.deleteMany({ where: { bookingId: booking.id } as any });
|
||||
|
||||
@@ -161,12 +204,12 @@ export class TasksService {
|
||||
data: {
|
||||
bookingId: booking.id,
|
||||
cancelledBy: 'SYSTEM',
|
||||
reason: 'Payment not completed before departure deadline',
|
||||
reason: 'Payment not completed before deadline',
|
||||
refundAmount: 0,
|
||||
refundMethod: booking.paymentIntent?.method ?? 'NONE',
|
||||
refundStatus: 'NOT_APPLICABLE',
|
||||
},
|
||||
}).catch(() => null); // booking may already have a cancellation record
|
||||
}).catch(() => null);
|
||||
|
||||
// 3. Mark cancelled
|
||||
await this.prisma.booking.update({
|
||||
@@ -175,22 +218,20 @@ export class TasksService {
|
||||
});
|
||||
|
||||
// 4. Notify passenger
|
||||
const dep = booking.schedule.departureAt as Date;
|
||||
const origin = booking.schedule.originStation?.name ?? '';
|
||||
const dest = booking.schedule.destinationStation?.name ?? '';
|
||||
|
||||
const message =
|
||||
`EDR: Your booking ${booking.bookingRef} ` +
|
||||
`(${origin} → ${dest}, departs ${fmtTime(dep)}) has been cancelled ` +
|
||||
`because payment was not completed before the deadline.`;
|
||||
`because payment was not completed before the deadline (${fmtTime(paymentDeadline)}).`;
|
||||
|
||||
if (booking.contactPhone) {
|
||||
await this.sms.sendSms({ to: booking.contactPhone, message }).catch(() => null);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Auto-cancelled: ${booking.bookingRef} (payment deadline expired, departs ${fmtTime(dep)})`,
|
||||
);
|
||||
this.logger.log(`Auto-cancelled: ${booking.bookingRef} (deadline was ${fmtTime(paymentDeadline)})`);
|
||||
cancelledCount++;
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Auto-cancel failed for ${booking.bookingRef}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
@@ -198,8 +239,8 @@ export class TasksService {
|
||||
}
|
||||
}
|
||||
|
||||
if (expiredBookings.length > 0) {
|
||||
this.logger.log(`Auto-cancelled ${expiredBookings.length} expired pending booking(s)`);
|
||||
if (cancelledCount > 0) {
|
||||
this.logger.log(`Auto-cancelled ${cancelledCount} expired pending booking(s)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { CheckCircle, Download, Share2, Copy, Printer, Mail, Train, FileText } from 'lucide-react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
type BookingWithTicket = {
|
||||
@@ -77,54 +76,71 @@ export default function ConfirmationPage() {
|
||||
};
|
||||
|
||||
const handleDownloadVoucher = async () => {
|
||||
if (!_booking || !pnr) {
|
||||
if (!pnr) {
|
||||
alert('Booking data not available. Please try again.');
|
||||
return;
|
||||
}
|
||||
if (!passengers.length) {
|
||||
alert('No passenger data found.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGeneratingVoucher(true);
|
||||
try {
|
||||
console.log('📄 Generating voucher with data:', { _booking, pnr, selectedSchedule, passengers });
|
||||
|
||||
const { generateVoucherPDF } = await import('@/lib/generate-voucher');
|
||||
|
||||
const voucherData = {
|
||||
bookingRef: pnr,
|
||||
status: _booking.status || 'CONFIRMED',
|
||||
passengers: passengers.map(p => ({
|
||||
fullName: p.name,
|
||||
category: 'ADULT',
|
||||
seat: p.seatNumber ? {
|
||||
number: p.seatNumber,
|
||||
coach: 'N/A',
|
||||
seatClass: selectedSchedule?.selectedSeatClassName || 'Standard',
|
||||
} : undefined,
|
||||
})),
|
||||
schedule: {
|
||||
trainNumber: selectedSchedule?.trainNumber || 'N/A',
|
||||
trainName: 'EDR Express',
|
||||
origin: {
|
||||
name: selectedSchedule?.origin || 'Origin',
|
||||
code: 'ORG',
|
||||
city: selectedSchedule?.origin || 'Origin',
|
||||
},
|
||||
destination: {
|
||||
name: selectedSchedule?.destination || 'Destination',
|
||||
code: 'DST',
|
||||
city: selectedSchedule?.destination || 'Destination',
|
||||
},
|
||||
departureAt: selectedSchedule?.departureTime || new Date().toISOString(),
|
||||
arrivalAt: selectedSchedule?.arrivalTime || new Date().toISOString(),
|
||||
},
|
||||
totalMinor: _booking.totalMinor || passengers.reduce((sum) => sum + (selectedSchedule?.baseFareAdult || 0), 0),
|
||||
currency: 'ETB',
|
||||
bookingType: 'ONE_WAY',
|
||||
createdAt: new Date().toISOString(),
|
||||
const { generatePassengerVoucherPDF } = await import('@/lib/generate-voucher');
|
||||
|
||||
const activeSchedule = isRoundTrip ? outboundSchedule : selectedSchedule;
|
||||
const totalFare = _booking?.totalMinor
|
||||
|| passengers.reduce((s) => s + (activeSchedule?.baseFareAdult || 0), 0);
|
||||
const farePerPassenger = Math.round(totalFare / passengers.length);
|
||||
const createdAt = _booking?.createdAt || new Date().toISOString();
|
||||
const status = _booking?.status || 'CONFIRMED';
|
||||
|
||||
const outbound = {
|
||||
trainNumber: activeSchedule?.trainNumber || 'N/A',
|
||||
trainName: 'EDR Express',
|
||||
origin: { name: activeSchedule?.origin || 'Origin', code: 'ORG', city: activeSchedule?.origin || 'Origin' },
|
||||
destination: { name: activeSchedule?.destination || 'Destination', code: 'DST', city: activeSchedule?.destination || 'Destination' },
|
||||
departureAt: activeSchedule?.departureTime || new Date().toISOString(),
|
||||
arrivalAt: activeSchedule?.arrivalTime || new Date().toISOString(),
|
||||
seatClass: activeSchedule?.selectedSeatClassName,
|
||||
};
|
||||
|
||||
console.log('📄 Voucher data prepared:', voucherData);
|
||||
await generateVoucherPDF(voucherData);
|
||||
console.log('✅ Voucher generated successfully');
|
||||
const inbound = inboundSchedule ? {
|
||||
trainNumber: inboundSchedule.trainNumber || 'N/A',
|
||||
trainName: 'EDR Express',
|
||||
origin: { name: inboundSchedule.origin, code: 'ORG', city: inboundSchedule.origin },
|
||||
destination: { name: inboundSchedule.destination, code: 'DST', city: inboundSchedule.destination },
|
||||
departureAt: inboundSchedule.departureTime || new Date().toISOString(),
|
||||
arrivalAt: inboundSchedule.arrivalTime || new Date().toISOString(),
|
||||
seatClass: inboundSchedule.selectedSeatClassName,
|
||||
} : undefined;
|
||||
|
||||
for (let i = 0; i < passengers.length; i++) {
|
||||
const p = passengers[i];
|
||||
const ticketNumber = `TKT-${bookingId?.slice(0, 8).toUpperCase()}-${(i + 1).toString().padStart(2, '0')}`;
|
||||
|
||||
await generatePassengerVoucherPDF({
|
||||
bookingRef: pnr,
|
||||
ticketNumber,
|
||||
passengerName: p.name || `Passenger ${i + 1}`,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
nationality: p.nationality,
|
||||
seatNumber: p.seatNumber,
|
||||
outboundSeatNumber: (p as any).outboundSeatNumber,
|
||||
inboundSeatNumber: (p as any).inboundSeatNumber,
|
||||
status,
|
||||
outboundSchedule: outbound,
|
||||
inboundSchedule: inbound,
|
||||
isRoundTrip,
|
||||
fareMinor: farePerPassenger,
|
||||
currency: 'ETB',
|
||||
createdAt,
|
||||
});
|
||||
|
||||
// brief pause between downloads so browsers don't block them
|
||||
if (i < passengers.length - 1) await new Promise(r => setTimeout(r, 400));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to generate voucher:', error);
|
||||
alert(`Failed to generate voucher: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
@@ -191,17 +207,9 @@ export default function ConfirmationPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trip Summary with QR Code */}
|
||||
{/* Trip Details */}
|
||||
<div className="card mb-6">
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
{/* 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>
|
||||
<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">
|
||||
<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>
|
||||
|
||||
|
||||
@@ -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({
|
||||
name: z.string().min(2, 'Full name is required (min 2 characters)'),
|
||||
dateOfBirth: z.string().min(1, 'Date of birth is required'),
|
||||
gender: z.string().min(1, 'Gender is required'),
|
||||
nationality: z.string().min(1, 'Nationality is required'),
|
||||
phone: z.string().min(1, 'Phone number is required'),
|
||||
phone: z.string(),
|
||||
email: z.string().optional(),
|
||||
nationalId: z.string().optional(),
|
||||
passportNumber: z.string().optional(),
|
||||
@@ -358,6 +481,10 @@ const passengerSchema = z.object({
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid email format', path: ['email'] });
|
||||
}
|
||||
}
|
||||
const phoneError = validatePhone(data.phone, data.nationality);
|
||||
if (phoneError) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: phoneError, path: ['phone'] });
|
||||
}
|
||||
const isNonEthiopian = data.nationality !== 'ETHIOPIAN' && data.nationality !== 'Ethiopian';
|
||||
if (isNonEthiopian) {
|
||||
if (!data.passportNumber || data.passportNumber.trim().length === 0) {
|
||||
@@ -769,14 +896,13 @@ export default function PassengersPage() {
|
||||
{/* Phone */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone Number *</label>
|
||||
<input
|
||||
{...register(`passengers.${index}.phone`)}
|
||||
className={`input-field ${errors.passengers?.[index]?.phone ? 'border-red-500' : ''}`}
|
||||
placeholder="+251911234567"
|
||||
<PhoneInput
|
||||
nationality={passengers[index]?.nationality || 'ETHIOPIAN'}
|
||||
storedValue={passengers[index]?.phone || ''}
|
||||
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>
|
||||
|
||||
{/* Email */}
|
||||
@@ -850,14 +976,13 @@ export default function PassengersPage() {
|
||||
{/* Phone */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone Number *</label>
|
||||
<input
|
||||
{...register(`passengers.${index}.phone`)}
|
||||
className={`input-field ${errors.passengers?.[index]?.phone ? 'border-red-500' : ''}`}
|
||||
placeholder="+254712345678"
|
||||
<PhoneInput
|
||||
nationality={passengers[index]?.nationality || 'OTHER'}
|
||||
storedValue={passengers[index]?.phone || ''}
|
||||
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>
|
||||
|
||||
{/* Email */}
|
||||
|
||||
@@ -23,23 +23,19 @@ const getIconForMethod = (methodId: string) => {
|
||||
return Smartphone;
|
||||
};
|
||||
|
||||
const NATIONALITY_TO_CURRENCY: Record<string, 'ETB' | 'DJF' | 'USD'> = {
|
||||
ETHIOPIAN: 'ETB',
|
||||
DJIBOUTIAN: 'DJF',
|
||||
};
|
||||
|
||||
export default function PaymentPage() {
|
||||
const router = useRouter();
|
||||
const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, passengers, searchCriteria } = useBookingStore();
|
||||
const { setPaymentIntent, updateStatus, setCurrency } = usePaymentStore();
|
||||
const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
|
||||
const [selectedMethodCurrency, setSelectedMethodCurrency] = useState<string | null>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [paymentError, setPaymentError] = useState<string | null>(null);
|
||||
|
||||
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
|
||||
|
||||
const displayCurrency: 'ETB' | 'DJF' | 'USD' =
|
||||
NATIONALITY_TO_CURRENCY[searchCriteria?.nationality?.toUpperCase() ?? ''] ?? 'USD';
|
||||
const displayCurrency = 'ETB' as const;
|
||||
|
||||
// Keep payment store in sync so the mutation picks up the right currency.
|
||||
useEffect(() => {
|
||||
@@ -54,7 +50,22 @@ export default function PaymentPage() {
|
||||
},
|
||||
});
|
||||
|
||||
// Calculate total amount
|
||||
// Fetch actual booking amount from API when a payment method is selected
|
||||
const amountCurrency = selectedMethodCurrency || displayCurrency;
|
||||
|
||||
const { data: bookingAmountData, isLoading: loadingAmount } = useQuery<{ amount: number; currency: string; booking_id: string }>({
|
||||
queryKey: ['bookingAmount', bookingId, amountCurrency, selectedMethod],
|
||||
queryFn: async () => {
|
||||
const url = `/payments/booking-amount?bookingId=${bookingId}¤cy=${amountCurrency}`;
|
||||
console.log('[BookingAmount] Request:', { url, bookingId, currency: amountCurrency, selectedMethod });
|
||||
const response: any = await apiClient.get(url);
|
||||
console.log('[BookingAmount] Response:', response);
|
||||
return response;
|
||||
},
|
||||
enabled: !!selectedMethod && !!bookingId,
|
||||
});
|
||||
|
||||
// Fallback: estimate from local store while API hasn't responded yet
|
||||
const outboundBaseFare = isRoundTrip && outboundSchedule ? passengers.reduce(
|
||||
(sum) => sum + (outboundSchedule.baseFareAdult || 0),
|
||||
0,
|
||||
@@ -69,8 +80,12 @@ export default function PaymentPage() {
|
||||
(sum) => sum + (selectedSchedule?.baseFareAdult || 0),
|
||||
0,
|
||||
);
|
||||
|
||||
const totalAmount = baseFare;
|
||||
|
||||
// API returns amount in major units (e.g. 11602.5 DJF); convert to minor for display consistency
|
||||
const totalAmount = bookingAmountData != null
|
||||
? Math.round(bookingAmountData.amount * 100)
|
||||
: baseFare;
|
||||
const confirmedCurrency = bookingAmountData?.currency || amountCurrency;
|
||||
|
||||
const paymentMutation = useMutation({
|
||||
mutationFn: async (data: any) => {
|
||||
@@ -248,7 +263,12 @@ export default function PaymentPage() {
|
||||
</div>
|
||||
<div className="flex justify-between items-center pt-1">
|
||||
<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>
|
||||
|
||||
@@ -259,15 +279,19 @@ export default function PaymentPage() {
|
||||
)}
|
||||
<button
|
||||
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"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Processing...
|
||||
</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 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 (
|
||||
<button
|
||||
key={method.id}
|
||||
onClick={() => setSelectedMethod(method.type)}
|
||||
onClick={() => { setSelectedMethod(method.type); setSelectedMethodCurrency(method.currency ?? null); }}
|
||||
disabled={isProcessing || !method.enabled}
|
||||
className={`w-full p-4 rounded-xl border-2 transition-all text-left ${
|
||||
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="flex items-center justify-between mb-2.5">
|
||||
<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>
|
||||
{paymentError && (
|
||||
<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
|
||||
onClick={handlePayment}
|
||||
disabled={!selectedMethod || isProcessing}
|
||||
disabled={!selectedMethod || isProcessing || loadingAmount}
|
||||
className="btn-primary flex-1 py-2.5 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<span className="flex items-center justify-center gap-1.5">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Processing...
|
||||
</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>
|
||||
</div>
|
||||
|
||||
@@ -144,8 +144,8 @@ export default function ResultsPage() {
|
||||
? (outboundSchedules.length > 0 && inboundSchedules.length > 0)
|
||||
: outboundSchedules.length > 0;
|
||||
|
||||
const handleSelectCoachType = (scheduleId: string, coachTypeId: string, coachTypeCode: string, coachTypeName: string) => {
|
||||
setSelectedCoachTypes(prev => ({ ...prev, [scheduleId]: { id: coachTypeId, code: coachTypeCode, name: coachTypeName } }));
|
||||
const handleSelectCoachType = (scheduleId: string, coachTypeId: string, coachTypeCode: string, coachTypeName: string, seatClassName: string) => {
|
||||
setSelectedCoachTypes(prev => ({ ...prev, [scheduleId]: { id: coachTypeId, code: coachTypeCode, name: coachTypeName, seatClassName } }));
|
||||
};
|
||||
|
||||
const handleSelect = (schedule: Schedule, isOutbound: boolean = false) => {
|
||||
@@ -161,10 +161,9 @@ export default function ResultsPage() {
|
||||
const coachType = schedule.coachTypes?.find(ct => ct.coachTypeCode === selectedCoachType.code);
|
||||
// Use displayAmountMinor (passenger's currency) so stored fare matches what the card showed.
|
||||
const minFare = coachType?.classes.length
|
||||
? Math.min(...coachType.classes.map(c => c.displayAmountMinor ?? c.baseFareMinor))
|
||||
? Math.min(...coachType.classes.map(c => c.baseFareMinor))
|
||||
: 0;
|
||||
const fareCurrency: string =
|
||||
coachType?.classes[0]?.displayCurrency ?? schedule.displayCurrency ?? 'ETB';
|
||||
const fareCurrency = 'ETB';
|
||||
|
||||
const hours = Math.floor((schedule.durationMinutes || 0) / 60);
|
||||
const minutes = (schedule.durationMinutes || 0) % 60;
|
||||
@@ -186,6 +185,7 @@ export default function ResultsPage() {
|
||||
selectedCoachTypeId: selectedCoachType.id,
|
||||
selectedCoachTypeCode: selectedCoachType.code,
|
||||
selectedCoachTypeName: selectedCoachType.name,
|
||||
seatClassName: (selectedCoachType as any).seatClassName || selectedCoachType.name,
|
||||
};
|
||||
|
||||
// For round trip, store outbound and wait for inbound selection
|
||||
@@ -222,17 +222,13 @@ export default function ResultsPage() {
|
||||
// Calculate lowest fare and display currency from coach types / faresByClass.
|
||||
// Prefer displayAmountMinor (passenger's own currency) over baseFareMinor (ETB internal).
|
||||
let lowestFare = null;
|
||||
let displayCurrency = schedule.displayCurrency || 'ETB';
|
||||
const displayCurrency = 'ETB';
|
||||
if (schedule.coachTypes?.length) {
|
||||
const allClasses = schedule.coachTypes.flatMap(ct => ct.classes);
|
||||
const allFares = allClasses.map(c => c.displayAmountMinor ?? c.baseFareMinor).filter(f => f > 0);
|
||||
const allFares = allClasses.map(c => c.baseFareMinor).filter(f => f > 0);
|
||||
lowestFare = allFares.length ? Math.min(...allFares) : null;
|
||||
const firstWithCurrency = allClasses.find(c => c.displayCurrency);
|
||||
if (firstWithCurrency?.displayCurrency) displayCurrency = firstWithCurrency.displayCurrency;
|
||||
} else if (schedule.faresByClass?.length) {
|
||||
lowestFare = Math.min(...schedule.faresByClass.map(f => f.displayAmountMinor ?? f.baseFareMinor).filter(f => f > 0));
|
||||
const firstWithCurrency = schedule.faresByClass.find(f => f.displayCurrency);
|
||||
if (firstWithCurrency?.displayCurrency) displayCurrency = firstWithCurrency.displayCurrency;
|
||||
lowestFare = Math.min(...schedule.faresByClass.map(f => f.baseFareMinor).filter(f => f > 0));
|
||||
} else if (schedule.combinedMinFareDisplay) {
|
||||
lowestFare = schedule.combinedMinFareDisplay;
|
||||
}
|
||||
@@ -551,14 +547,14 @@ export default function ResultsPage() {
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4 pt-2">
|
||||
{coachTypes.map((coachType: any, index: number) => {
|
||||
const isSelected = selectedCoachType?.id === coachType.coachTypeId;
|
||||
const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.displayAmountMinor ?? c.baseFareMinor)) : 0;
|
||||
const coachCurrency: string = (coachType.classes[0] as any)?.displayCurrency ?? (classModal as any).displayCurrency ?? 'ETB';
|
||||
const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.baseFareMinor)) : 0;
|
||||
const coachCurrency = 'ETB';
|
||||
const CoachIcon = getCoachIcon(coachType.coachTypeName);
|
||||
|
||||
return (
|
||||
<button
|
||||
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 ${
|
||||
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]'
|
||||
@@ -631,10 +627,10 @@ export default function ResultsPage() {
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<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 className="text-xs text-gray-500 dark:text-gray-400 font-medium">
|
||||
{cls.displayCurrency ?? coachCurrency}
|
||||
{coachCurrency}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -62,11 +62,7 @@ export default function ReviewPage() {
|
||||
|
||||
// Prefer the currency already stored on the selected schedule (set from search results).
|
||||
// Fall back to deriving from nationality so the review page is never left with a stale value.
|
||||
const NATIONALITY_TO_CURRENCY: Record<string, string> = { ETHIOPIAN: 'ETB', DJIBOUTIAN: 'DJF' };
|
||||
const displayCurrency: string =
|
||||
(isRoundTrip ? outboundSchedule?.displayCurrency : selectedSchedule?.displayCurrency) ??
|
||||
NATIONALITY_TO_CURRENCY[searchCriteria?.nationality?.toUpperCase() ?? ''] ??
|
||||
'USD';
|
||||
const displayCurrency = 'ETB';
|
||||
|
||||
useEffect(() => {
|
||||
if (!seatHold?.expiresAt) return;
|
||||
@@ -201,19 +197,34 @@ export default function ReviewPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get seat class ID
|
||||
let seatClassId = 'default-seat-class-id';
|
||||
let returnSeatClassId = 'default-seat-class-id';
|
||||
// Get seat class ID by name-matching against the /seat-classes list
|
||||
let seatClassId = '';
|
||||
let returnSeatClassId = '';
|
||||
try {
|
||||
const seatClasses: any = await apiClient.get('/seat-classes');
|
||||
console.log('Seat classes:', seatClasses);
|
||||
const seatClasses: any[] = await apiClient.get('/seat-classes');
|
||||
if (seatClasses && seatClasses.length > 0) {
|
||||
seatClassId = seatClasses[0].id;
|
||||
returnSeatClassId = seatClasses[0].id;
|
||||
const outboundClassName = isRoundTrip
|
||||
? (outboundSchedule as any)?.seatClassName
|
||||
: (selectedSchedule as any)?.seatClassName;
|
||||
const returnClassName = isRoundTrip
|
||||
? (inboundSchedule as any)?.seatClassName
|
||||
: outboundClassName;
|
||||
|
||||
const findByName = (name: string) =>
|
||||
seatClasses.find((sc: any) => sc.name === name)?.id || seatClasses[0].id;
|
||||
|
||||
seatClassId = outboundClassName ? findByName(outboundClassName) : seatClasses[0].id;
|
||||
returnSeatClassId = returnClassName ? findByName(returnClassName) : seatClasses[0].id;
|
||||
console.log('Seat class lookup:', { outboundClassName, returnClassName, seatClassId, returnSeatClassId });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch seat classes:', err);
|
||||
}
|
||||
|
||||
if (!seatClassId) {
|
||||
alert('Unable to determine seat class. Please go back and re-select your seats.');
|
||||
return;
|
||||
}
|
||||
|
||||
let bookingData: any;
|
||||
if (isAuthenticated) {
|
||||
|
||||
@@ -54,6 +54,10 @@ export interface SelectedSchedule {
|
||||
displayCurrency: string;
|
||||
selectedSeatClass?: string;
|
||||
selectedSeatClassName?: string;
|
||||
seatClassName?: string;
|
||||
selectedCoachTypeId?: string;
|
||||
selectedCoachTypeCode?: string;
|
||||
selectedCoachTypeName?: string;
|
||||
}
|
||||
|
||||
export interface SeatHold {
|
||||
|
||||
@@ -1,392 +1,301 @@
|
||||
import jsPDF from 'jspdf';
|
||||
import autoTable from 'jspdf-autotable';
|
||||
|
||||
interface VoucherData {
|
||||
interface ScheduleInfo {
|
||||
trainNumber: string;
|
||||
trainName?: string;
|
||||
origin: { name: string; code: string; city: string };
|
||||
destination: { name: string; code: string; city: string };
|
||||
departureAt: string;
|
||||
arrivalAt: string;
|
||||
seatClass?: string;
|
||||
}
|
||||
|
||||
interface PassengerVoucherData {
|
||||
bookingRef: string;
|
||||
ticketNumber: string;
|
||||
passengerName: string;
|
||||
dateOfBirth?: string;
|
||||
nationality?: string;
|
||||
seatNumber?: string;
|
||||
outboundSeatNumber?: string;
|
||||
inboundSeatNumber?: string;
|
||||
status: string;
|
||||
passengers: Array<{
|
||||
fullName: string;
|
||||
category: string;
|
||||
seat?: {
|
||||
number: string;
|
||||
coach: string;
|
||||
seatClass: string;
|
||||
};
|
||||
}>;
|
||||
schedule: {
|
||||
trainNumber: string;
|
||||
trainName?: string;
|
||||
origin: {
|
||||
name: string;
|
||||
code: string;
|
||||
city: string;
|
||||
};
|
||||
destination: {
|
||||
name: string;
|
||||
code: string;
|
||||
city: string;
|
||||
};
|
||||
departureAt: string;
|
||||
arrivalAt: string;
|
||||
};
|
||||
totalMinor: number;
|
||||
outboundSchedule: ScheduleInfo;
|
||||
inboundSchedule?: ScheduleInfo;
|
||||
isRoundTrip: boolean;
|
||||
fareMinor: number;
|
||||
currency: string;
|
||||
bookingType: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export const generateVoucherPDF = async (booking: VoucherData) => {
|
||||
const doc = new jsPDF({
|
||||
orientation: 'portrait',
|
||||
unit: 'mm',
|
||||
format: 'a4',
|
||||
});
|
||||
// ─── shared drawing helpers ───────────────────────────────────────────────────
|
||||
|
||||
const PRIMARY = [20, 113, 76] as const;
|
||||
const DARK = [51, 51, 51] as const;
|
||||
const MED = [102, 102, 102] as const;
|
||||
const LIGHT = [200, 200, 200] as const;
|
||||
|
||||
async function drawHeader(doc: jsPDF, margin: number): Promise<number> {
|
||||
const pageWidth = doc.internal.pageSize.getWidth();
|
||||
const pageHeight = doc.internal.pageSize.getHeight();
|
||||
const margin = 15;
|
||||
let yPos = margin;
|
||||
|
||||
// Colors
|
||||
const primaryColor = [20, 113, 76]; // EDR Green
|
||||
const darkGray = [51, 51, 51];
|
||||
const mediumGray = [102, 102, 102];
|
||||
const lightGray = [200, 200, 200];
|
||||
|
||||
// ============ HEADER ============
|
||||
// Company branding strip
|
||||
doc.setFillColor(primaryColor[0], primaryColor[1], primaryColor[2]);
|
||||
doc.setFillColor(...PRIMARY);
|
||||
doc.rect(0, 0, pageWidth, 30, 'F');
|
||||
|
||||
// Load and add logo
|
||||
try {
|
||||
const logoImg = await fetch('/edr-logo.png');
|
||||
const logoImg = await fetch('/edr-logo.png');
|
||||
const logoBlob = await logoImg.blob();
|
||||
const logoDataUrl = await new Promise<string>((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(reader.result as string);
|
||||
reader.readAsDataURL(logoBlob);
|
||||
});
|
||||
|
||||
// Create image to get dimensions
|
||||
const img = new Image();
|
||||
await new Promise((resolve) => {
|
||||
img.onload = resolve;
|
||||
img.src = logoDataUrl;
|
||||
});
|
||||
|
||||
// Calculate aspect ratio and dimensions
|
||||
const logoHeight = 18;
|
||||
const logoWidth = (img.width / img.height) * logoHeight;
|
||||
|
||||
// Add logo on left side with proper aspect ratio
|
||||
doc.addImage(logoDataUrl, 'PNG', margin, 6, logoWidth, logoHeight);
|
||||
|
||||
// Company name next to logo
|
||||
await new Promise((resolve) => { img.onload = resolve; img.src = logoDataUrl; });
|
||||
const logoH = 18;
|
||||
const logoW = (img.width / img.height) * logoH;
|
||||
doc.addImage(logoDataUrl, 'PNG', margin, 6, logoW, logoH);
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.setFontSize(20);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('ETHIO-DJIBOUTI RAILWAY', margin + logoWidth + 5, 14);
|
||||
|
||||
doc.setFontSize(9);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text('Premium Travel Experience', margin + logoWidth + 5, 20);
|
||||
} catch (error) {
|
||||
console.error('Failed to load logo:', error);
|
||||
// Fallback: just show text centered
|
||||
doc.setFontSize(18); doc.setFont('helvetica', 'bold');
|
||||
doc.text('ETHIO-DJIBOUTI RAILWAY', margin + logoW + 5, 14);
|
||||
doc.setFontSize(9); doc.setFont('helvetica', 'normal');
|
||||
doc.text('Premium Travel Experience', margin + logoW + 5, 20);
|
||||
} catch {
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.setFontSize(24);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('ETHIO-DJIBOUTI RAILWAY', pageWidth / 2, 12, { align: 'center' });
|
||||
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text('Premium Travel Experience', pageWidth / 2, 18, { align: 'center' });
|
||||
doc.setFontSize(22); doc.setFont('helvetica', 'bold');
|
||||
doc.text('ETHIO-DJIBOUTI RAILWAY', pageWidth / 2, 13, { align: 'center' });
|
||||
doc.setFontSize(9); doc.setFont('helvetica', 'normal');
|
||||
doc.text('Premium Travel Experience', pageWidth / 2, 20, { align: 'center' });
|
||||
}
|
||||
return 40;
|
||||
}
|
||||
|
||||
yPos = 40;
|
||||
|
||||
// ============ TITLE & STATUS ============
|
||||
doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]);
|
||||
doc.setFontSize(20);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('BOOKING VOUCHER', pageWidth / 2, yPos, { align: 'center' });
|
||||
|
||||
yPos += 10;
|
||||
|
||||
// Status badge (simplified)
|
||||
const statusText = booking.status === 'TICKETED' || booking.status === 'CONFIRMED' ? 'CONFIRMED' : booking.status;
|
||||
const statusColor = booking.status === 'TICKETED' || booking.status === 'CONFIRMED' ? [34, 197, 94] : [234, 179, 8];
|
||||
|
||||
doc.setFillColor(statusColor[0], statusColor[1], statusColor[2]);
|
||||
doc.rect(pageWidth / 2 - 20, yPos - 4, 40, 8, 'F');
|
||||
function drawStatusBadge(doc: jsPDF, status: string, y: number, pageWidth: number): number {
|
||||
const label = (status === 'TICKETED' || status === 'CONFIRMED') ? 'CONFIRMED' : status;
|
||||
const color = (status === 'TICKETED' || status === 'CONFIRMED') ? [34, 197, 94] : [234, 179, 8];
|
||||
doc.setFillColor(color[0], color[1], color[2]);
|
||||
doc.rect(pageWidth / 2 - 22, y - 4, 44, 8, 'F');
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.setFontSize(9);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(statusText, pageWidth / 2, yPos + 1, { align: 'center' });
|
||||
doc.setFontSize(9); doc.setFont('helvetica', 'bold');
|
||||
doc.text(label, pageWidth / 2, y + 1, { align: 'center' });
|
||||
return y + 12;
|
||||
}
|
||||
|
||||
yPos += 12;
|
||||
|
||||
// ============ QR CODE ============
|
||||
// Generate QR code data URL
|
||||
const canvas = document.createElement('canvas');
|
||||
const QRCode = (await import('qrcode')).default;
|
||||
|
||||
const qrSize = 35; // 35mm = 3.5cm
|
||||
await QRCode.toCanvas(canvas, booking.bookingRef, {
|
||||
width: 300,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#FFFFFF',
|
||||
},
|
||||
});
|
||||
|
||||
const qrDataUrl = canvas.toDataURL('image/png');
|
||||
|
||||
// Place QR code at top-right
|
||||
const qrX = pageWidth - margin - qrSize;
|
||||
const qrY = yPos;
|
||||
|
||||
doc.addImage(qrDataUrl, 'PNG', qrX, qrY, qrSize, qrSize);
|
||||
|
||||
doc.setFontSize(8);
|
||||
doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text('SCAN AT TERMINAL', qrX + qrSize / 2, qrY + qrSize + 4, { align: 'center' });
|
||||
|
||||
// ============ BOOKING REFERENCE ============
|
||||
function drawBookingRefBox(doc: jsPDF, bookingRef: string, ticketNumber: string, y: number, margin: number, pageWidth: number): number {
|
||||
doc.setFillColor(245, 245, 245);
|
||||
doc.rect(margin, yPos, pageWidth - margin * 2 - qrSize - 5, 18, 'F');
|
||||
|
||||
doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
|
||||
doc.setFontSize(9);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text('BOOKING REFERENCE', margin + 5, yPos + 6);
|
||||
|
||||
doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]);
|
||||
doc.setFontSize(18);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(booking.bookingRef, margin + 5, yPos + 14);
|
||||
doc.rect(margin, y, pageWidth - margin * 2, 22, 'F');
|
||||
|
||||
yPos += 25;
|
||||
doc.setTextColor(...MED); doc.setFontSize(8); doc.setFont('helvetica', 'normal');
|
||||
doc.text('BOOKING REFERENCE', margin + 5, y + 6);
|
||||
doc.setTextColor(...PRIMARY); doc.setFontSize(16); doc.setFont('helvetica', 'bold');
|
||||
doc.text(bookingRef, margin + 5, y + 14);
|
||||
|
||||
// ============ JOURNEY DETAILS ============
|
||||
doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]);
|
||||
doc.setFontSize(12);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('JOURNEY DETAILS', margin, yPos);
|
||||
|
||||
yPos += 8;
|
||||
const rightX = pageWidth - margin - 5;
|
||||
doc.setTextColor(...MED); doc.setFontSize(8); doc.setFont('helvetica', 'normal');
|
||||
doc.text('TICKET NUMBER', rightX, y + 6, { align: 'right' });
|
||||
doc.setTextColor(...DARK); doc.setFontSize(11); doc.setFont('helvetica', 'bold');
|
||||
doc.text(ticketNumber, rightX, y + 14, { align: 'right' });
|
||||
|
||||
// Route box
|
||||
doc.setDrawColor(lightGray[0], lightGray[1], lightGray[2]);
|
||||
doc.setLineWidth(0.5);
|
||||
doc.rect(margin, yPos, pageWidth - margin * 2, 40);
|
||||
return y + 28;
|
||||
}
|
||||
|
||||
function drawJourneyLeg(doc: jsPDF, schedule: ScheduleInfo, label: string | null, y: number, margin: number, pageWidth: number): number {
|
||||
doc.setTextColor(...DARK); doc.setFontSize(11); doc.setFont('helvetica', 'bold');
|
||||
doc.text(label ? `JOURNEY DETAILS — ${label.toUpperCase()}` : 'JOURNEY DETAILS', margin, y);
|
||||
y += 7;
|
||||
|
||||
doc.setDrawColor(...LIGHT); doc.setLineWidth(0.5);
|
||||
doc.rect(margin, y, pageWidth - margin * 2, 40);
|
||||
|
||||
// Origin
|
||||
doc.setFontSize(9);
|
||||
doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text('FROM', margin + 5, yPos + 6);
|
||||
|
||||
doc.setFontSize(16);
|
||||
doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(booking.schedule.origin.code, margin + 5, yPos + 14);
|
||||
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text(booking.schedule.origin.name, margin + 5, yPos + 20);
|
||||
|
||||
doc.setFontSize(8);
|
||||
doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
|
||||
doc.text(booking.schedule.origin.city, margin + 5, yPos + 25);
|
||||
doc.setFontSize(8); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
|
||||
doc.text('FROM', margin + 5, y + 6);
|
||||
doc.setFontSize(15); doc.setTextColor(...DARK); doc.setFont('helvetica', 'bold');
|
||||
doc.text(schedule.origin.code, margin + 5, y + 14);
|
||||
doc.setFontSize(9); doc.setFont('helvetica', 'normal');
|
||||
doc.text(schedule.origin.name, margin + 5, y + 20);
|
||||
doc.setFontSize(8); doc.setTextColor(...MED);
|
||||
doc.text(schedule.origin.city, margin + 5, y + 25);
|
||||
|
||||
// Departure time
|
||||
const departureDate = new Date(booking.schedule.departureAt);
|
||||
doc.setFontSize(14);
|
||||
doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(departureDate.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }), margin + 5, yPos + 33);
|
||||
|
||||
doc.setFontSize(8);
|
||||
doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text(departureDate.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }), margin + 5, yPos + 38);
|
||||
const dep = new Date(schedule.departureAt);
|
||||
doc.setFontSize(13); doc.setTextColor(...PRIMARY); doc.setFont('helvetica', 'bold');
|
||||
doc.text(dep.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }), margin + 5, y + 33);
|
||||
doc.setFontSize(7); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
|
||||
doc.text(dep.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }), margin + 5, y + 38);
|
||||
|
||||
// Arrow
|
||||
doc.setDrawColor(primaryColor[0], primaryColor[1], primaryColor[2]);
|
||||
doc.setLineWidth(1);
|
||||
const arrowStartX = pageWidth / 2 - 10;
|
||||
const arrowEndX = pageWidth / 2 + 10;
|
||||
const arrowY = yPos + 20;
|
||||
|
||||
// Draw arrow line
|
||||
doc.line(arrowStartX, arrowY, arrowEndX, arrowY);
|
||||
|
||||
// Draw arrow head manually with lines
|
||||
doc.line(arrowEndX, arrowY, arrowEndX - 3, arrowY - 2);
|
||||
doc.line(arrowEndX, arrowY, arrowEndX - 3, arrowY + 2);
|
||||
doc.setDrawColor(...PRIMARY); doc.setLineWidth(0.8);
|
||||
const ax = pageWidth / 2, ay = y + 20;
|
||||
doc.line(ax - 10, ay, ax + 10, ay);
|
||||
doc.line(ax + 10, ay, ax + 7, ay - 2);
|
||||
doc.line(ax + 10, ay, ax + 7, ay + 2);
|
||||
|
||||
// Destination
|
||||
const destX = pageWidth - margin - 50;
|
||||
doc.setFontSize(9);
|
||||
doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text('TO', destX, yPos + 6);
|
||||
|
||||
doc.setFontSize(16);
|
||||
doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(booking.schedule.destination.code, destX, yPos + 14);
|
||||
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text(booking.schedule.destination.name, destX, yPos + 20);
|
||||
|
||||
doc.setFontSize(8);
|
||||
doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
|
||||
doc.text(booking.schedule.destination.city, destX, yPos + 25);
|
||||
const dx = pageWidth - margin - 50;
|
||||
doc.setFontSize(8); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
|
||||
doc.text('TO', dx, y + 6);
|
||||
doc.setFontSize(15); doc.setTextColor(...DARK); doc.setFont('helvetica', 'bold');
|
||||
doc.text(schedule.destination.code, dx, y + 14);
|
||||
doc.setFontSize(9); doc.setFont('helvetica', 'normal');
|
||||
doc.text(schedule.destination.name, dx, y + 20);
|
||||
doc.setFontSize(8); doc.setTextColor(...MED);
|
||||
doc.text(schedule.destination.city, dx, y + 25);
|
||||
|
||||
// Arrival time
|
||||
const arrivalDate = new Date(booking.schedule.arrivalAt);
|
||||
doc.setFontSize(14);
|
||||
doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(arrivalDate.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }), destX, yPos + 33);
|
||||
|
||||
doc.setFontSize(8);
|
||||
doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text(arrivalDate.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }), destX, yPos + 38);
|
||||
const arr = new Date(schedule.arrivalAt);
|
||||
doc.setFontSize(13); doc.setTextColor(...PRIMARY); doc.setFont('helvetica', 'bold');
|
||||
doc.text(arr.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }), dx, y + 33);
|
||||
doc.setFontSize(7); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
|
||||
doc.text(arr.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }), dx, y + 38);
|
||||
|
||||
yPos += 48;
|
||||
y += 47;
|
||||
|
||||
// Train info
|
||||
doc.setFillColor(250, 250, 250);
|
||||
doc.rect(margin, yPos, pageWidth - margin * 2, 12, 'F');
|
||||
|
||||
doc.setFontSize(9);
|
||||
doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text('TRAIN', margin + 5, yPos + 5);
|
||||
|
||||
doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(booking.schedule.trainNumber, margin + 5, yPos + 9);
|
||||
|
||||
if (booking.schedule.trainName) {
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text(` - ${booking.schedule.trainName}`, margin + 25, yPos + 9);
|
||||
// Train info bar
|
||||
doc.setFillColor(248, 248, 248);
|
||||
doc.rect(margin, y, pageWidth - margin * 2, 12, 'F');
|
||||
doc.setFontSize(8); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
|
||||
doc.text('TRAIN', margin + 5, y + 5);
|
||||
doc.setTextColor(...DARK); doc.setFont('helvetica', 'bold');
|
||||
doc.text(schedule.trainNumber + (schedule.trainName ? ` — ${schedule.trainName}` : ''), margin + 20, y + 9);
|
||||
if (schedule.seatClass) {
|
||||
doc.setFont('helvetica', 'normal'); doc.setTextColor(...MED);
|
||||
doc.text(schedule.seatClass, pageWidth - margin - 5, y + 9, { align: 'right' });
|
||||
}
|
||||
|
||||
yPos += 18;
|
||||
return y + 18;
|
||||
}
|
||||
|
||||
// ============ PASSENGERS ============
|
||||
doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]);
|
||||
doc.setFontSize(12);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('PASSENGERS', margin, yPos);
|
||||
|
||||
yPos += 8;
|
||||
function drawPassengerDetails(doc: jsPDF, data: PassengerVoucherData, y: number, margin: number): number {
|
||||
doc.setTextColor(...DARK); doc.setFontSize(11); doc.setFont('helvetica', 'bold');
|
||||
doc.text('PASSENGER DETAILS', margin, y);
|
||||
y += 7;
|
||||
|
||||
// Passenger table
|
||||
const passengerData = booking.passengers.map((p, idx) => [
|
||||
(idx + 1).toString(),
|
||||
p.fullName,
|
||||
p.category,
|
||||
p.seat?.number || '-',
|
||||
p.seat?.coach || '-',
|
||||
p.seat?.seatClass || '-',
|
||||
]);
|
||||
const rows: [string, string][] = [
|
||||
['Full Name', data.passengerName || '—'],
|
||||
['Date of Birth', data.dateOfBirth ? new Date(data.dateOfBirth).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) : '—'],
|
||||
['Nationality', data.nationality || '—'],
|
||||
];
|
||||
|
||||
if (data.isRoundTrip) {
|
||||
rows.push(['Outbound Seat', data.outboundSeatNumber || '—']);
|
||||
rows.push(['Return Seat', data.inboundSeatNumber || '—']);
|
||||
} else {
|
||||
rows.push(['Seat', data.seatNumber || '—']);
|
||||
}
|
||||
|
||||
autoTable(doc, {
|
||||
startY: yPos,
|
||||
head: [['#', 'Passenger Name', 'Type', 'Seat', 'Coach', 'Class']],
|
||||
body: passengerData,
|
||||
theme: 'striped',
|
||||
headStyles: {
|
||||
fillColor: [primaryColor[0], primaryColor[1], primaryColor[2]],
|
||||
textColor: [255, 255, 255],
|
||||
fontSize: 9,
|
||||
fontStyle: 'bold',
|
||||
},
|
||||
bodyStyles: {
|
||||
fontSize: 9,
|
||||
textColor: [darkGray[0], darkGray[1], darkGray[2]],
|
||||
},
|
||||
alternateRowStyles: {
|
||||
fillColor: [250, 250, 250],
|
||||
startY: y,
|
||||
body: rows,
|
||||
theme: 'plain',
|
||||
styles: { fontSize: 9, cellPadding: 3 },
|
||||
columnStyles: {
|
||||
0: { fontStyle: 'bold', textColor: [MED[0], MED[1], MED[2]], cellWidth: 45 },
|
||||
1: { textColor: [DARK[0], DARK[1], DARK[2]] },
|
||||
},
|
||||
alternateRowStyles: { fillColor: [248, 248, 248] },
|
||||
margin: { left: margin, right: margin },
|
||||
});
|
||||
|
||||
yPos = (doc as any).lastAutoTable.finalY + 10;
|
||||
return (doc as any).lastAutoTable.finalY + 8;
|
||||
}
|
||||
|
||||
// ============ PAYMENT SUMMARY ============
|
||||
doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]);
|
||||
doc.setFontSize(12);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('PAYMENT SUMMARY', margin, yPos);
|
||||
|
||||
yPos += 8;
|
||||
function drawFareSummary(doc: jsPDF, fareMinor: number, currency: string, y: number, margin: number, pageWidth: number): number {
|
||||
doc.setFillColor(248, 248, 248);
|
||||
doc.rect(margin, y, pageWidth - margin * 2, 20, 'F');
|
||||
doc.setFontSize(9); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
|
||||
doc.text('Fare', margin + 5, y + 7);
|
||||
doc.setFontSize(15); doc.setTextColor(...PRIMARY); doc.setFont('helvetica', 'bold');
|
||||
doc.text(`${currency} ${(fareMinor / 100).toFixed(2)}`, pageWidth - margin - 5, y + 7, { align: 'right' });
|
||||
doc.setFontSize(9); doc.setTextColor(34, 197, 94); doc.setFont('helvetica', 'bold');
|
||||
doc.text('✓ PAID', margin + 5, y + 15);
|
||||
return y + 26;
|
||||
}
|
||||
|
||||
doc.setFillColor(250, 250, 250);
|
||||
doc.rect(margin, yPos, pageWidth - margin * 2, 20, 'F');
|
||||
|
||||
doc.setFontSize(10);
|
||||
doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text('Total Amount', margin + 5, yPos + 7);
|
||||
|
||||
doc.setFontSize(16);
|
||||
doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(`${booking.currency} ${(booking.totalMinor / 100).toFixed(2)}`, pageWidth - margin - 5, yPos + 7, { align: 'right' });
|
||||
|
||||
doc.setFontSize(9);
|
||||
doc.setTextColor(34, 197, 94);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('✓ PAID', margin + 5, yPos + 15);
|
||||
|
||||
yPos += 28;
|
||||
|
||||
// ============ INSTRUCTIONS ============
|
||||
function drawInstructions(doc: jsPDF, y: number, margin: number, pageWidth: number): number {
|
||||
doc.setFillColor(252, 211, 77);
|
||||
doc.rect(margin, yPos, pageWidth - margin * 2, 18, 'F');
|
||||
|
||||
doc.setFontSize(9);
|
||||
doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('⚠ IMPORTANT INSTRUCTIONS', margin + 5, yPos + 6);
|
||||
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(8);
|
||||
doc.text('• Present this voucher at the terminal for boarding', margin + 5, yPos + 11);
|
||||
doc.text('• Arrive at least 30 minutes before departure', margin + 5, yPos + 15);
|
||||
doc.rect(margin, y, pageWidth - margin * 2, 18, 'F');
|
||||
doc.setFontSize(9); doc.setTextColor(...DARK); doc.setFont('helvetica', 'bold');
|
||||
doc.text('⚠ IMPORTANT INSTRUCTIONS', margin + 5, y + 6);
|
||||
doc.setFont('helvetica', 'normal'); doc.setFontSize(8);
|
||||
doc.text('• Present this voucher at the terminal for boarding', margin + 5, y + 11);
|
||||
doc.text('• Arrive at least 30 minutes before departure', margin + 5, y + 15);
|
||||
return y + 24;
|
||||
}
|
||||
|
||||
// ============ FOOTER ============
|
||||
const footerY = pageHeight - 25;
|
||||
|
||||
doc.setDrawColor(lightGray[0], lightGray[1], lightGray[2]);
|
||||
doc.line(margin, footerY, pageWidth - margin, footerY);
|
||||
|
||||
doc.setFontSize(8);
|
||||
doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
function drawFooter(doc: jsPDF, createdAt: string): void {
|
||||
const pageWidth = doc.internal.pageSize.getWidth();
|
||||
const pageHeight = doc.internal.pageSize.getHeight();
|
||||
const footerY = pageHeight - 22;
|
||||
|
||||
doc.setDrawColor(...LIGHT);
|
||||
doc.line(15, footerY, pageWidth - 15, footerY);
|
||||
doc.setFontSize(8); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
|
||||
doc.text('Support: support@edr.com | +251-11-XXX-XXXX', pageWidth / 2, footerY + 5, { align: 'center' });
|
||||
doc.text('Terms & Conditions apply. Visit www.edr.com for details.', pageWidth / 2, footerY + 9, { align: 'center' });
|
||||
|
||||
doc.setFontSize(7);
|
||||
doc.text(`Generated: ${new Date().toLocaleString('en-US')}`, pageWidth / 2, footerY + 13, { align: 'center' });
|
||||
doc.text(`Generated: ${new Date(createdAt).toLocaleString('en-US')}`, pageWidth / 2, footerY + 13, { align: 'center' });
|
||||
}
|
||||
|
||||
// Watermark (removed rotation as it may cause issues)
|
||||
doc.setTextColor(240, 240, 240);
|
||||
doc.setFontSize(50);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('EDR', pageWidth / 2, pageHeight / 2, { align: 'center' });
|
||||
// ─── public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
// Save PDF
|
||||
doc.save(`EDR-Voucher-${booking.bookingRef}.pdf`);
|
||||
/** Generates and downloads one PDF voucher for a single passenger. */
|
||||
export const generatePassengerVoucherPDF = async (data: PassengerVoucherData): Promise<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));
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user