Merge branch 'alpha' into passenger/feat/iam

This commit is contained in:
Abubeker Yasin
2026-06-30 11:16:11 +03:00
61 changed files with 1264 additions and 2148 deletions

View File

@@ -0,0 +1,15 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddPostPaymentCompletedColumn1719667261000 implements MigrationInterface {
name = 'AddPostPaymentCompletedColumn1719667261000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE freight.first_mile_deliveries ADD COLUMN IF NOT EXISTS is_post_payment_completed BOOLEAN NOT NULL DEFAULT false;`);
await queryRunner.query(`ALTER TABLE freight.last_mile_deliveries ADD COLUMN IF NOT EXISTS is_post_payment_completed BOOLEAN NOT NULL DEFAULT false;`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE freight.last_mile_deliveries DROP COLUMN IF EXISTS is_post_payment_completed;`);
await queryRunner.query(`ALTER TABLE freight.first_mile_deliveries DROP COLUMN IF EXISTS is_post_payment_completed;`);
}
}

View File

@@ -34,6 +34,9 @@ export class FirstMile extends BaseEntity {
@Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 })
remainingPayment!: number;
@Column({ type: 'boolean', default: false })
isPostPaymentCompleted!: boolean;
@Column({ name: 'estimated_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
estimatedKm?: number | null;

View File

@@ -3,7 +3,7 @@ import { FindOptionsWhere } from 'typeorm';
import { BookingsRepository } from '../bookings/bookings.repository';
import { DriversService } from '../drivers/drivers.service';
import { NotificationsService } from '../notifications/notifications.service';
import { SmsClientService } from '../notifications/sms-client.service';
import { VehiclesService } from '../vehicles/vehicles.service';
import { CreateFirstMileDto } from './dto/create-first-mile.dto';
import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
@@ -36,7 +36,7 @@ export class FirstMileService {
private readonly bookingsRepository: BookingsRepository,
private readonly vehiclesService: VehiclesService,
private readonly driversService: DriversService,
private readonly notificationsService: NotificationsService,
private readonly smsClient: SmsClientService,
) {}
/**
@@ -251,16 +251,19 @@ export class FirstMileService {
const booking = (record as FirstMile & { booking?: { reference?: string; firstMilePickupAddress?: string | null; originYard?: { label?: string } | null } }).booking;
await this.notificationsService.notifyDriverVehicleAssignment({
driverPhone: driver.phoneNumber,
driverName: `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(),
vehiclePlateNumber: vehicle.plateNumber ?? vehicleId,
bookingReference: booking?.reference ?? record.bookingId,
pickupAddress: booking?.firstMilePickupAddress,
destinationYard: booking?.originYard?.label,
const driverName = `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim();
const message =
`Dear ${driverName}, you have been assigned to a first-mile pickup. ` +
`Booking: ${booking?.reference ?? record.bookingId}. Vehicle: ${vehicle.plateNumber ?? vehicleId}. ` +
(booking?.firstMilePickupAddress ? `Pickup: ${booking.firstMilePickupAddress}. ` : '') +
(booking?.originYard?.label ? `Destination: ${booking.originYard.label}.` : '');
void this.smsClient.sendSms({
to: driver.phoneNumber,
message,
});
this.logger.log(`SMS sent to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`);
this.logger.log(`SMS queued to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`);
} catch (err) {
this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`);
}

View File

@@ -34,6 +34,9 @@ export class LastMile extends BaseEntity {
@Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 })
remainingPayment!: number;
@Column({ type: 'boolean', default: false })
isPostPaymentCompleted!: boolean;
@Column({ name: 'estimated_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
estimatedKm?: number | null;

View File

@@ -3,7 +3,7 @@ import { FindOptionsWhere } from 'typeorm';
import { BookingsRepository } from '../bookings/bookings.repository';
import { DriversService } from '../drivers/drivers.service';
import { NotificationsService } from '../notifications/notifications.service';
import { SmsClientService } from '../notifications/sms-client.service';
import { VehiclesService } from '../vehicles/vehicles.service';
import { CreateLastMileDto } from './dto/create-last-mile.dto';
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
@@ -32,12 +32,11 @@ export class LastMileService {
private readonly logger = new Logger(LastMileService.name);
constructor(
private readonly lastMileRepository: LastMileRepository,
private readonly bookingsRepository: BookingsRepository,
private readonly vehiclesService: VehiclesService,
private readonly driversService: DriversService,
private readonly notificationsService: NotificationsService,
private readonly smsClient: SmsClientService,
) {}
async acceptBooking(bookingReference: string): Promise<LastMile | null> {
@@ -185,16 +184,19 @@ export class LastMileService {
};
const booking = (record as LastMile & { booking?: BookingWithYards }).booking;
await this.notificationsService.notifyDriverVehicleAssignment({
driverPhone: driver.phoneNumber,
driverName: `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(),
vehiclePlateNumber: vehicle.plateNumber ?? vehicleId,
bookingReference: booking?.reference ?? record.bookingId,
pickupAddress: booking?.destinationYard?.label,
destinationYard: booking?.lastMileDeliveryAddress,
const driverName = `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim();
const message =
`Dear ${driverName}, you have been assigned to a last-mile delivery. ` +
`Booking: ${booking?.reference ?? record.bookingId}. Vehicle: ${vehicle.plateNumber ?? vehicleId}. ` +
(booking?.destinationYard?.label ? `Pickup: ${booking.destinationYard.label}. ` : '') +
(booking?.lastMileDeliveryAddress ? `Destination: ${booking.lastMileDeliveryAddress}.` : '');
void this.smsClient.sendSms({
to: driver.phoneNumber,
message,
});
this.logger.log(`SMS sent to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`);
this.logger.log(`SMS queued to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`);
} catch (err) {
this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`);
}

View File

@@ -20,6 +20,7 @@ export class PaymentClientService {
private readonly baseUrl = (
// process.env.PAYMENT_API_URL ??
"https://paymentcallback.triaplc.com"
// "http://localhost:3003"
).replace(/\/$/, "");
private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? "";

View File

@@ -163,6 +163,10 @@ export class PaymentService {
failureUrl: 'https://edrfreight.triaplc.com/payment/failure',
});
await this.datasource.getRepository(Booking).update(
{ id: dto.bookingId },
{ paymentStatus: "PAID", status: "PAID" },
);
const intent = await this.syncIntentProjection(booking.id, booking, snapshot);
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
@@ -289,20 +293,19 @@ export class PaymentService {
providerTxnId?: string;
paidAt?: Date;
}): Promise<{ alreadyFinalized: boolean }> {
const intent = await this.paymentRepo.findOneBy({ id: input.intentId });
if (!intent) throw new NotFoundException("PaymentIntent not found");
if (intent.status === "success") return { alreadyFinalized: true };
const paidAt = input.paidAt ?? new Date();
// const intent = await this.paymentRepo.findOneBy({ id: input.intentId });
// if (!intent) throw new NotFoundException("PaymentIntent not found");
// if (intent.status === "success") return { alreadyFinalized: true };
// Every booking is a real shipment now (contracts are a separate aggregate),
// so payment always settles the booking to PAID and enters allocation.
await this.datasource.transaction(async (mg) => {
await mg.update(
PaymentEntity,
{ id: intent.id },
{ status: "success", paidAt, transactionId: input.providerTxnId ?? intent.transactionId },
);
// await mg.update(
// PaymentEntity,
// // { id: intent.id },
// {id:input.intentId},
// { status: "success", paidAt, transactionId: input.providerTxnId ?? intent.transactionId },
// );
await mg.update(
Booking,
{ id: input.bookingId },
@@ -398,34 +401,46 @@ export class PaymentService {
failureCode?: string;
failureMessage?: string;
}): Promise<{ processed: boolean; alreadyFinalized?: boolean; reason?: string }> {
if (event.eventType === "payment.succeeded") {
const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" });
if (!intent) {
return { processed: false, reason: `No local intent for booking ${event.referenceId}` };
}
const { alreadyFinalized } = await this.finalizePaymentSuccess({
intentId: intent.id,
const { alreadyFinalized } = await this.finalizePaymentSuccess({
intentId:event.intentId,
bookingId: event.referenceId,
providerTxnId: event.providerTxnId,
paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
});
// console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`);
return { processed: true, alreadyFinalized };
}
// console.log(`Received payment event: ${JSON.stringify(event)}`);
// if (event.eventType === "payment.succeeded") {
// console.log(`Received payment.succeeded event for booking ${event.referenceId}, intent ${event.intentId}`);
// const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" });
// if (!intent) {
// return { processed: false, reason: `No local intent for booking ${event.referenceId}` };
// }
// console.log(`Processing payment.succeeded event for booking ${event.referenceId}, intent ${intent.id}`);
// const { alreadyFinalized } = await this.finalizePaymentSuccess({
// intentId: intent.id,
// bookingId: event.referenceId,
// providerTxnId: event.providerTxnId,
// paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
// });
// console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`);
// return { processed: true, alreadyFinalized };
// }
if (event.eventType === "payment.failed") {
const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" });
if (!intent) {
return { processed: false, reason: `No local intent for booking ${event.referenceId}` };
}
await this.markPaymentFailed({
intentId: intent.id,
failureCode: event.failureCode,
failureMessage: event.failureMessage,
});
return { processed: true };
}
// if (event.eventType === "payment.failed") {
// const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" });
// if (!intent) {
// return { processed: false, reason: `No local intent for booking ${event.referenceId}` };
// }
// await this.markPaymentFailed({
// intentId: intent.id,
// failureCode: event.failureCode,
// failureMessage: event.failureMessage,
// });
// return { processed: true };
// }
return { processed: false, reason: `Unknown event type: ${event.eventType}` };
// return { processed: false, reason: `Unknown event type: ${event.eventType}` };
}
private toLocalStatus(status: ProviderPaymentStatus): PaymentEntity["status"] {

View File

@@ -314,6 +314,7 @@ const FirstMilePage = () => {
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("ALL");
const [filterPostPaymentPending, setFilterPostPaymentPending] = useState(false);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const [assignOpen, setAssignOpen] = useState(false);
@@ -529,6 +530,7 @@ const FirstMilePage = () => {
};
const matchesFilter = (r: FirstMileRecord) => {
if (filterPostPaymentPending && r.isPostPaymentCompleted) return false;
switch (statusFilter) {
case "ALL": return true;
case "ASSIGNED": return isAssigned(r);
@@ -566,7 +568,7 @@ const FirstMilePage = () => {
.includes(term);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [records, search, statusFilter]);
}, [records, search, statusFilter, filterPostPaymentPending]);
const pageCount = Math.max(1, Math.ceil(filteredRecords.length / pagination.pageSize));
const pagedRecords = useMemo(() => {
@@ -887,6 +889,17 @@ const FirstMilePage = () => {
</Button>
);
})}
<Button
size="xs"
variant={filterPostPaymentPending ? "filled" : "default"}
styles={{ label: { fontWeight: 500 } }}
onClick={() => {
setFilterPostPaymentPending(!filterPostPaymentPending);
setPagination((p) => ({ ...p, pageIndex: 0 }));
}}
>
Post Payment Pending
</Button>
</Group>
</Stack>
</Box>

View File

@@ -298,6 +298,7 @@ const LastMilePage = () => {
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("ALL");
const [filterPostPaymentPending, setFilterPostPaymentPending] = useState(false);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const [assignOpen, setAssignOpen] = useState(false);
@@ -508,6 +509,7 @@ const LastMilePage = () => {
);
const matchesFilter = (r: LastMileRecord) => {
if (filterPostPaymentPending && r.isPostPaymentCompleted) return false;
switch (statusFilter) {
case "ALL": return true;
case "ASSIGNED": return isAssigned(r);
@@ -545,7 +547,7 @@ const LastMilePage = () => {
.includes(term);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [records, search, statusFilter]);
}, [records, search, statusFilter, filterPostPaymentPending]);
const pageCount = Math.max(1, Math.ceil(filteredRecords.length / pagination.pageSize));
const pagedRecords = useMemo(() => {
@@ -866,6 +868,17 @@ const LastMilePage = () => {
</Button>
);
})}
<Button
size="xs"
variant={filterPostPaymentPending ? "filled" : "default"}
styles={{ label: { fontWeight: 500 } }}
onClick={() => {
setFilterPostPaymentPending(!filterPostPaymentPending);
setPagination((p) => ({ ...p, pageIndex: 0 }));
}}
>
Post Payment Pending
</Button>
</Group>
</Stack>
</Box>

View File

@@ -23,10 +23,10 @@ RUN pnpm turbo build --filter="@edr/passenger-api..."
FROM base AS deployer
COPY --from=builder /app/ .
RUN pnpm deploy --filter="@edr/passenger-api" --legacy /deploy
RUN if [ -d node_modules/.prisma ]; then \
mkdir -p /deploy/node_modules && \
cp -r node_modules/.prisma /deploy/node_modules/.prisma; \
fi
# Copy Prisma schema and generated client to deployment directory
RUN mkdir -p /deploy/node_modules/.prisma /deploy/node_modules/@prisma && \
cp -r node_modules/.prisma/client /deploy/node_modules/.prisma/ 2>/dev/null || true && \
cp -r node_modules/@prisma/client /deploy/node_modules/@prisma/ 2>/dev/null || true
# --- Migration image: built in CI, run as a one-shot `docker run --rm --env-file ...`
# against the real DB, as its own gated step *before* the app image is built/deployed.
@@ -37,7 +37,10 @@ WORKDIR /deploy
RUN corepack enable && corepack prepare pnpm@11.1.1 --activate
ENV CI=true
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
CMD ["sh", "-c", "npm run prisma:generate && npm run prisma:migrate && npm run prisma:seed"]
# Copy the resolution script
COPY apps/edr-passenger-api/scripts/resolve-migrations.sh /deploy/scripts/
RUN chmod +x /deploy/scripts/resolve-migrations.sh
CMD ["sh", "-c", "/deploy/scripts/resolve-migrations.sh && npm run prisma:generate && npm run prisma:migrate && npm run prisma:seed"]
FROM node:24.15.0-alpine AS runner
RUN apk add --no-cache libc6-compat

View File

@@ -1,46 +0,0 @@
-- DropForeignKey
ALTER TABLE "passenger"."TicketSeat" DROP CONSTRAINT IF EXISTS "TicketSeat_seatId_fkey";
-- DropForeignKey
ALTER TABLE "passenger"."TicketSeat" DROP CONSTRAINT IF EXISTS "TicketSeat_ticketId_fkey";
-- DropIndex
DROP INDEX IF EXISTS "passenger"."Ticket_bookingId_key";
-- AlterTable: Station
ALTER TABLE "passenger"."Station" DROP COLUMN IF EXISTS "timezone";
-- AlterTable: Ticket — add columns with safe defaults
ALTER TABLE "passenger"."Ticket"
ADD COLUMN IF NOT EXISTS "leg" INTEGER NOT NULL DEFAULT 1,
ADD COLUMN IF NOT EXISTS "passengerName" TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS "scheduleId" TEXT,
ADD COLUMN IF NOT EXISTS "seatId" TEXT NOT NULL DEFAULT '';
-- DropTable
DROP TABLE IF EXISTS "passenger"."TicketSeat";
-- Remove GateValidationLog rows referencing orphan tickets first
DELETE FROM "passenger"."GateValidationLog"
WHERE "ticketId" IN (
SELECT "id" FROM "passenger"."Ticket"
WHERE "seatId" = ''
OR "seatId" NOT IN (SELECT "id" FROM "passenger"."Seat")
);
-- Remove orphan ticket rows
DELETE FROM "passenger"."Ticket"
WHERE "seatId" = ''
OR "seatId" NOT IN (SELECT "id" FROM "passenger"."Seat");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "Ticket_bookingId_idx" ON "passenger"."Ticket"("bookingId");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "Ticket_seatId_idx" ON "passenger"."Ticket"("seatId");
-- AddForeignKey
ALTER TABLE "passenger"."Ticket"
ADD CONSTRAINT "Ticket_seatId_fkey"
FOREIGN KEY ("seatId") REFERENCES "passenger"."Seat"("id")
ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -1,3 +0,0 @@
-- Drop temporary defaults that were only needed for the backfill
ALTER TABLE "passenger"."Ticket" ALTER COLUMN "passengerName" DROP DEFAULT;
ALTER TABLE "passenger"."Ticket" ALTER COLUMN "seatId" DROP DEFAULT;

View File

@@ -1,2 +0,0 @@
-- Remove timezone column if it still exists
ALTER TABLE "passenger"."Station" DROP COLUMN IF EXISTS "timezone";

View File

@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "passenger"."TravelerProfile" ADD COLUMN "gender" TEXT;

View File

@@ -1,117 +0,0 @@
-- Migration: Add Configurable Fare Management System
-- Main fare configuration table
CREATE TABLE "fare_configurations" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"effective_date" TIMESTAMP(3) NOT NULL,
"expiry_date" TIMESTAMP(3),
"is_active" BOOLEAN NOT NULL DEFAULT false,
"is_default" BOOLEAN NOT NULL DEFAULT false,
"created_by" TEXT,
"approved_by" TEXT,
"approved_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "fare_configurations_pkey" PRIMARY KEY ("id")
);
-- Rate structure by nationality and coach/position
CREATE TABLE "fare_rate_rules" (
"id" TEXT NOT NULL,
"fare_config_id" TEXT NOT NULL,
"nationality_type" TEXT NOT NULL, -- 'LOCAL' or 'INTERNATIONAL'
"coach_type" TEXT NOT NULL, -- 'REGULAR_SEAT', 'ECONOMY_BED', 'VIP_BED'
"bed_position" TEXT, -- 'UPPER', 'MIDDLE', 'LOWER', NULL for seats
"rate_per_km_minor" INTEGER NOT NULL,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "fare_rate_rules_pkey" PRIMARY KEY ("id")
);
-- Configurable fare components (insurance, premiums, service charges, taxes)
CREATE TABLE "fare_components" (
"id" TEXT NOT NULL,
"fare_config_id" TEXT NOT NULL,
"component_type" TEXT NOT NULL, -- 'INSURANCE', 'PREMIUM', 'SERVICE_CHARGE', 'TAX', 'DEMAND'
"component_name" TEXT NOT NULL,
"calculation_method" TEXT NOT NULL, -- 'MULTIPLIER', 'PERCENTAGE', 'FIXED_AMOUNT'
"value_minor" INTEGER, -- For fixed amounts
"percentage_value" DECIMAL(10,6), -- For percentages (e.g., 0.02 for 2%)
"applies_to" TEXT NOT NULL DEFAULT 'SUBTOTAL', -- 'BASE_FARE', 'SUBTOTAL', 'TOTAL'
"apply_order" INTEGER NOT NULL DEFAULT 1, -- Order of application
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "fare_components_pkey" PRIMARY KEY ("id")
);
-- Age-based pricing rules
CREATE TABLE "age_pricing_rules" (
"id" TEXT NOT NULL,
"fare_config_id" TEXT NOT NULL,
"rule_name" TEXT NOT NULL,
"min_age" INTEGER NOT NULL,
"max_age" INTEGER,
"pricing_type" TEXT NOT NULL, -- 'FREE', 'FULL_FARE', 'DISCOUNTED'
"discount_percentage" DECIMAL(5,4), -- For discounted fares
"max_free_passengers" INTEGER, -- For free fares (e.g., 1 free child)
"applies_to_components" BOOLEAN NOT NULL DEFAULT false, -- Whether discount applies to components too
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "age_pricing_rules_pkey" PRIMARY KEY ("id")
);
-- Audit trail for configuration changes
CREATE TABLE "fare_configuration_audit" (
"id" TEXT NOT NULL,
"fare_config_id" TEXT NOT NULL,
"action" TEXT NOT NULL, -- 'CREATED', 'UPDATED', 'ACTIVATED', 'DEACTIVATED'
"changed_by" TEXT,
"changes" JSONB, -- Store the actual changes made
"timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
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;
-- 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;
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");
-- 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 feature flag support
CREATE TABLE "system_features" (
"id" TEXT NOT NULL,
"feature_name" TEXT NOT NULL UNIQUE,
"is_enabled" BOOLEAN NOT NULL DEFAULT false,
"config" JSONB,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "system_features_pkey" PRIMARY KEY ("id")
);
-- Insert the configurable fares feature flag
INSERT INTO "system_features" ("id", "feature_name", "is_enabled", "config")
VALUES ('cf-001', 'USE_CONFIGURABLE_FARES', false, '{"rollout_percentage": 0}');

View File

@@ -1,14 +0,0 @@
-- AddColumn: iamUserId to Passenger (cross-schema reference to iam.users — no FK enforced)
ALTER TABLE "passenger"."Passenger" ADD COLUMN "iamUserId" TEXT;
-- Unique constraint: one IAM user maps to exactly one Passenger
ALTER TABLE "passenger"."Passenger" ADD CONSTRAINT "Passenger_iamUserId_key" UNIQUE ("iamUserId");
-- Index for fast lookup by iamUserId on every protected request
CREATE INDEX "Passenger_iamUserId_idx" ON "passenger"."Passenger"("iamUserId");
-- AddColumn: iamUserId to FaydaVerificationSession (no FK — cross-schema reference to iam.users)
ALTER TABLE "passenger"."FaydaVerificationSession" ADD COLUMN "iamUserId" TEXT;
-- Index for Fayda callback to resolve IAM user
CREATE INDEX "FaydaVerificationSession_iamUserId_idx" ON "passenger"."FaydaVerificationSession"("iamUserId");

View File

@@ -1,28 +0,0 @@
-- CreateTable
CREATE TABLE "SegmentFareRule" (
"id" TEXT NOT NULL,
"routeId" TEXT NOT NULL,
"originStopSequence" INTEGER NOT NULL,
"destinationStopSequence" INTEGER NOT NULL,
"seatClassId" TEXT NOT NULL,
"baseFareMinor" INTEGER NOT NULL,
"nationality" TEXT,
"currency" TEXT NOT NULL DEFAULT 'ETB',
"validFrom" TIMESTAMP(3) NOT NULL,
"validUntil" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SegmentFareRule_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "SegmentFareRule_routeId_seatClassId_idx" ON "SegmentFareRule"("routeId", "seatClassId");
-- CreateIndex
CREATE UNIQUE INDEX "SegmentFareRule_routeId_originStopSequence_destinationStopS_key" ON "SegmentFareRule"("routeId", "originStopSequence", "destinationStopSequence", "seatClassId", "nationality");
-- AddForeignKey
ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -1,54 +0,0 @@
-- DropForeignKey
ALTER TABLE "passenger"."Passenger" DROP CONSTRAINT IF EXISTS "Passenger_userId_fkey";
-- AlterTable
ALTER TABLE "passenger"."Passenger" ALTER COLUMN "userId" DROP NOT NULL;
-- CreateTable
CREATE TABLE IF NOT EXISTS "passenger"."TicketSeat" (
"id" TEXT NOT NULL,
"ticketId" TEXT NOT NULL,
"seatId" TEXT NOT NULL,
"seatIndex" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "TicketSeat_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX IF NOT EXISTS "TicketSeat_ticketId_idx" ON "passenger"."TicketSeat"("ticketId");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "TicketSeat_seatId_idx" ON "passenger"."TicketSeat"("seatId");
-- AddForeignKey
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'Passenger_userId_fkey'
AND conrelid = 'passenger."Passenger"'::regclass
) THEN
ALTER TABLE "passenger"."Passenger" ADD CONSTRAINT "Passenger_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "passenger"."User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
END IF;
END $$;
-- AddForeignKey
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'TicketSeat_ticketId_fkey'
AND conrelid = 'passenger."TicketSeat"'::regclass
) THEN
ALTER TABLE "passenger"."TicketSeat" ADD CONSTRAINT "TicketSeat_ticketId_fkey"
FOREIGN KEY ("ticketId") REFERENCES "passenger"."Ticket"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
-- AddForeignKey
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'TicketSeat_seatId_fkey'
AND conrelid = 'passenger."TicketSeat"'::regclass
) THEN
ALTER TABLE "passenger"."TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey"
FOREIGN KEY ("seatId") REFERENCES "passenger"."Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
END IF;
END $$;

View File

@@ -1,13 +0,0 @@
-- Drop FK constraints (they reference iam.users indirectly via local User, but these are within passenger schema)
ALTER TABLE passenger."UserPreferences" DROP CONSTRAINT IF EXISTS "UserPreferences_userId_fkey";
ALTER TABLE passenger."Device" DROP CONSTRAINT IF EXISTS "Device_userId_fkey";
ALTER TABLE passenger."FraudAlert" DROP CONSTRAINT IF EXISTS "FraudAlert_userId_fkey";
-- Rename columns (preserves all existing data)
ALTER TABLE passenger."UserPreferences" RENAME COLUMN "userId" TO "iamUserId";
ALTER TABLE passenger."Device" RENAME COLUMN "userId" TO "iamUserId";
ALTER TABLE passenger."FraudAlert" RENAME COLUMN "userId" TO "iamUserId";
-- Rename indexes on FraudAlert to match new column name
DROP INDEX IF EXISTS passenger."FraudAlert_userId_createdAt_idx";
CREATE INDEX "FraudAlert_iamUserId_createdAt_idx" ON passenger."FraudAlert"("iamUserId", "createdAt");

View File

@@ -1,10 +0,0 @@
-- AuditLog: drop FK, rename column, update index
ALTER TABLE passenger."AuditLog" DROP CONSTRAINT IF EXISTS "AuditLog_userId_fkey";
ALTER TABLE passenger."AuditLog" RENAME COLUMN "userId" TO "iamUserId";
DROP INDEX IF EXISTS passenger."AuditLog_userId_createdAt_idx";
CREATE INDEX IF NOT EXISTS "AuditLog_iamUserId_createdAt_idx" ON passenger."AuditLog"("iamUserId", "createdAt");
-- FaydaVerificationSession: drop userId column and FK (iamUserId already carries this data)
ALTER TABLE passenger."FaydaVerificationSession" DROP CONSTRAINT IF EXISTS "FaydaVerificationSession_userId_fkey";
ALTER TABLE passenger."FaydaVerificationSession" DROP COLUMN IF EXISTS "userId";
DROP INDEX IF EXISTS passenger."FaydaVerificationSession_userId_idx";

View File

@@ -1,5 +0,0 @@
-- AlterTable
ALTER TABLE "Passenger" ADD COLUMN "blockedUntil" TIMESTAMP(3);
-- RenameIndex
ALTER INDEX "UserPreferences_userId_key" RENAME TO "UserPreferences_iamUserId_key";

View File

@@ -1,2 +0,0 @@
-- AlterEnum
ALTER TYPE "PaymentMethodType" ADD VALUE 'DMONEY';

View File

@@ -1,275 +0,0 @@
-- DropForeignKey
ALTER TABLE "AgentBooking" DROP CONSTRAINT "AgentBooking_agentId_fkey";
-- DropForeignKey
ALTER TABLE "AgentBooking" DROP CONSTRAINT "AgentBooking_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "AgentCommission" DROP CONSTRAINT "AgentCommission_agentId_fkey";
-- DropForeignKey
ALTER TABLE "AgentShift" DROP CONSTRAINT "AgentShift_agentId_fkey";
-- DropForeignKey
ALTER TABLE "BaggageBooking" DROP CONSTRAINT "BaggageBooking_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "Booking" DROP CONSTRAINT "Booking_passengerId_fkey";
-- DropForeignKey
ALTER TABLE "Booking" DROP CONSTRAINT "Booking_scheduleId_fkey";
-- DropForeignKey
ALTER TABLE "BookingCancellation" DROP CONSTRAINT "BookingCancellation_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "BookingModification" DROP CONSTRAINT "BookingModification_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "BookingSeat" DROP CONSTRAINT "BookingSeat_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "BookingSeat" DROP CONSTRAINT "BookingSeat_seatId_fkey";
-- DropForeignKey
ALTER TABLE "Coach" DROP CONSTRAINT "Coach_coachTypeId_fkey";
-- DropForeignKey
ALTER TABLE "CoachAssignment" DROP CONSTRAINT "CoachAssignment_coachId_fkey";
-- DropForeignKey
ALTER TABLE "CoachAssignment" DROP CONSTRAINT "CoachAssignment_scheduleId_fkey";
-- DropForeignKey
ALTER TABLE "FaqArticle" DROP CONSTRAINT "FaqArticle_categoryId_fkey";
-- DropForeignKey
ALTER TABLE "FareRule" DROP CONSTRAINT "FareRule_seatClassId_fkey";
-- DropForeignKey
ALTER TABLE "FoodOrder" DROP CONSTRAINT "FoodOrder_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "FoodOrderItem" DROP CONSTRAINT "FoodOrderItem_orderId_fkey";
-- DropForeignKey
ALTER TABLE "GateValidationLog" DROP CONSTRAINT "GateValidationLog_ticketId_fkey";
-- DropForeignKey
ALTER TABLE "JourneySegment" DROP CONSTRAINT "JourneySegment_journeyId_fkey";
-- DropForeignKey
ALTER TABLE "JourneySegment" DROP CONSTRAINT "JourneySegment_scheduleId_fkey";
-- DropForeignKey
ALTER TABLE "LoyaltyLedgerEntry" DROP CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey";
-- DropForeignKey
ALTER TABLE "LoyaltyReward" DROP CONSTRAINT "LoyaltyReward_accountId_fkey";
-- DropForeignKey
ALTER TABLE "MenuItem" DROP CONSTRAINT "MenuItem_categoryId_fkey";
-- DropForeignKey
ALTER TABLE "MenuItem" DROP CONSTRAINT "MenuItem_scheduleId_fkey";
-- DropForeignKey
ALTER TABLE "Notification" DROP CONSTRAINT "Notification_passengerId_fkey";
-- DropForeignKey
ALTER TABLE "PaymentIntent" DROP CONSTRAINT "PaymentIntent_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "PaymentRefund" DROP CONSTRAINT "PaymentRefund_paymentIntentId_fkey";
-- DropForeignKey
ALTER TABLE "RouteFareRule" DROP CONSTRAINT "RouteFareRule_seatClassId_fkey";
-- DropForeignKey
ALTER TABLE "SavedRoute" DROP CONSTRAINT "SavedRoute_passengerId_fkey";
-- DropForeignKey
ALTER TABLE "SeatBlock" DROP CONSTRAINT "SeatBlock_seatId_fkey";
-- DropForeignKey
ALTER TABLE "SegmentFareRule" DROP CONSTRAINT "SegmentFareRule_seatClassId_fkey";
-- DropForeignKey
ALTER TABLE "StationCrowdSignal" DROP CONSTRAINT "StationCrowdSignal_stationId_fkey";
-- DropForeignKey
ALTER TABLE "SupportMessage" DROP CONSTRAINT "SupportMessage_conversationId_fkey";
-- DropForeignKey
ALTER TABLE "Ticket" DROP CONSTRAINT "Ticket_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "TicketSeat" DROP CONSTRAINT "TicketSeat_seatId_fkey";
-- DropForeignKey
ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_destinationStationId_fkey";
-- DropForeignKey
ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_originStationId_fkey";
-- DropForeignKey
ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_routeId_fkey";
-- DropForeignKey
ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_trainId_fkey";
-- DropForeignKey
ALTER TABLE "TripLiveStatus" DROP CONSTRAINT "TripLiveStatus_scheduleId_fkey";
-- DropForeignKey
ALTER TABLE "TripStopTime" DROP CONSTRAINT "TripStopTime_scheduleId_fkey";
-- DropForeignKey
ALTER TABLE "WalletLedgerEntry" DROP CONSTRAINT "WalletLedgerEntry_walletId_fkey";
-- AlterTable
ALTER TABLE "Booking" ADD COLUMN "returnDestinationStationId" TEXT,
ADD COLUMN "returnHoldId" TEXT,
ADD COLUMN "returnOriginStationId" TEXT,
ADD COLUMN "returnScheduleId" TEXT,
ADD COLUMN "returnSeatClassId" TEXT;
-- AlterTable
ALTER TABLE "SeatClass" ALTER COLUMN "baseFareMinor" SET DEFAULT 0;
-- AlterTable
ALTER TABLE "Ticket" ALTER COLUMN "status" SET DEFAULT 'ACTIVE';
-- gender column already TEXT from init migration
-- CreateIndex
CREATE INDEX "Booking_bookingType_idx" ON "Booking"("bookingType");
-- AddForeignKey
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_trainId_fkey" FOREIGN KEY ("trainId") REFERENCES "Train"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_originStationId_fkey" FOREIGN KEY ("originStationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_destinationStationId_fkey" FOREIGN KEY ("destinationStationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TripStopTime" ADD CONSTRAINT "TripStopTime_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TripLiveStatus" ADD CONSTRAINT "TripLiveStatus_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Coach" ADD CONSTRAINT "Coach_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "CoachType"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "Coach"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FareRule" ADD CONSTRAINT "FareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PaymentIntent" ADD CONSTRAINT "PaymentIntent_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PaymentRefund" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey" FOREIGN KEY ("paymentIntentId") REFERENCES "PaymentIntent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LoyaltyLedgerEntry" ADD CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LoyaltyReward" ADD CONSTRAINT "LoyaltyReward_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WalletLedgerEntry" ADD CONSTRAINT "WalletLedgerEntry_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "WalletAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StationCrowdSignal" ADD CONSTRAINT "StationCrowdSignal_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "MenuCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FoodOrder" ADD CONSTRAINT "FoodOrder_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FoodOrderItem" ADD CONSTRAINT "FoodOrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "FoodOrder"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FaqArticle" ADD CONSTRAINT "FaqArticle_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "FaqCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SupportMessage" ADD CONSTRAINT "SupportMessage_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "SupportConversation"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SavedRoute" ADD CONSTRAINT "SavedRoute_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_journeyId_fkey" FOREIGN KEY ("journeyId") REFERENCES "Journey"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RouteFareRule" ADD CONSTRAINT "RouteFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentShift" ADD CONSTRAINT "AgentShift_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentCommission" ADD CONSTRAINT "AgentCommission_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BookingModification" ADD CONSTRAINT "BookingModification_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BookingCancellation" ADD CONSTRAINT "BookingCancellation_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GateValidationLog" ADD CONSTRAINT "GateValidationLog_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BaggageBooking" ADD CONSTRAINT "BaggageBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SeatBlock" ADD CONSTRAINT "SeatBlock_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -1,2 +0,0 @@
-- Empty placeholder migration
SELECT 1;

View File

@@ -1,9 +0,0 @@
CREATE INDEX IF NOT EXISTS "Station_sequence_idx" ON "Station"("sequence");
CREATE INDEX IF NOT EXISTS "Coach_sequence_idx" ON "Coach"("sequence");
-- Ensure all indexes exist
CREATE INDEX IF NOT EXISTS "Station_city_countryCode_idx" ON "Station"("city", "countryCode");
CREATE INDEX IF NOT EXISTS "Coach_coachTypeId_idx" ON "Coach"("coachTypeId");
CREATE INDEX IF NOT EXISTS "TrainSchedule_departureAt_originStationId_idx" ON "TrainSchedule"("departureAt", "originStationId");
CREATE INDEX IF NOT EXISTS "Booking_passengerId_status_idx" ON "Booking"("passengerId", "status");

View File

@@ -1,164 +0,0 @@
-- Add CASCADE delete to all foreign key constraints that are missing it
-- TrainSchedule relations
ALTER TABLE "TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_trainId_fkey";
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_trainId_fkey" FOREIGN KEY ("trainId") REFERENCES "Train"("id") ON DELETE CASCADE;
ALTER TABLE "TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_routeId_fkey";
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE CASCADE;
ALTER TABLE "TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_originStationId_fkey";
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_originStationId_fkey" FOREIGN KEY ("originStationId") REFERENCES "Station"("id") ON DELETE CASCADE;
ALTER TABLE "TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_destinationStationId_fkey";
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_destinationStationId_fkey" FOREIGN KEY ("destinationStationId") REFERENCES "Station"("id") ON DELETE CASCADE;
-- Coach relation
ALTER TABLE "Coach" DROP CONSTRAINT IF EXISTS "Coach_coachTypeId_fkey";
ALTER TABLE "Coach" ADD CONSTRAINT "Coach_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "CoachType"("id") ON DELETE CASCADE;
-- CoachAssignment relations
ALTER TABLE "CoachAssignment" DROP CONSTRAINT IF EXISTS "CoachAssignment_scheduleId_fkey";
ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE;
ALTER TABLE "CoachAssignment" DROP CONSTRAINT IF EXISTS "CoachAssignment_coachId_fkey";
ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "Coach"("id") ON DELETE CASCADE;
-- Booking relations
ALTER TABLE "Booking" DROP CONSTRAINT IF EXISTS "Booking_passengerId_fkey";
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE CASCADE;
ALTER TABLE "Booking" DROP CONSTRAINT IF EXISTS "Booking_scheduleId_fkey";
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE;
-- BookingSeat relations
ALTER TABLE "BookingSeat" DROP CONSTRAINT IF EXISTS "BookingSeat_bookingId_fkey";
ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE;
ALTER TABLE "BookingSeat" DROP CONSTRAINT IF EXISTS "BookingSeat_seatId_fkey";
ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE CASCADE;
-- PaymentIntent
ALTER TABLE "PaymentIntent" DROP CONSTRAINT IF EXISTS "PaymentIntent_bookingId_fkey";
ALTER TABLE "PaymentIntent" ADD CONSTRAINT "PaymentIntent_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE;
-- PaymentRefund
ALTER TABLE "PaymentRefund" DROP CONSTRAINT IF EXISTS "PaymentRefund_paymentIntentId_fkey";
ALTER TABLE "PaymentRefund" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey" FOREIGN KEY ("paymentIntentId") REFERENCES "PaymentIntent"("id") ON DELETE CASCADE;
-- Ticket
ALTER TABLE "Ticket" DROP CONSTRAINT IF EXISTS "Ticket_bookingId_fkey";
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE;
-- TicketSeat
ALTER TABLE "TicketSeat" DROP CONSTRAINT IF EXISTS "TicketSeat_seatId_fkey";
ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE CASCADE;
-- WalletLedgerEntry
ALTER TABLE "WalletLedgerEntry" DROP CONSTRAINT IF EXISTS "WalletLedgerEntry_walletId_fkey";
ALTER TABLE "WalletLedgerEntry" ADD CONSTRAINT "WalletLedgerEntry_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "WalletAccount"("id") ON DELETE CASCADE;
-- Notification
ALTER TABLE "Notification" DROP CONSTRAINT IF EXISTS "Notification_passengerId_fkey";
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE CASCADE;
-- MenuItem
ALTER TABLE "MenuItem" DROP CONSTRAINT IF EXISTS "MenuItem_scheduleId_fkey";
ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE;
ALTER TABLE "MenuItem" DROP CONSTRAINT IF EXISTS "MenuItem_categoryId_fkey";
ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "MenuCategory"("id") ON DELETE CASCADE;
-- FoodOrder
ALTER TABLE "FoodOrder" DROP CONSTRAINT IF EXISTS "FoodOrder_bookingId_fkey";
ALTER TABLE "FoodOrder" ADD CONSTRAINT "FoodOrder_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE;
-- FoodOrderItem
ALTER TABLE "FoodOrderItem" DROP CONSTRAINT IF EXISTS "FoodOrderItem_orderId_fkey";
ALTER TABLE "FoodOrderItem" ADD CONSTRAINT "FoodOrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "FoodOrder"("id") ON DELETE CASCADE;
-- FaqArticle
ALTER TABLE "FaqArticle" DROP CONSTRAINT IF EXISTS "FaqArticle_categoryId_fkey";
ALTER TABLE "FaqArticle" ADD CONSTRAINT "FaqArticle_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "FaqCategory"("id") ON DELETE CASCADE;
-- SupportMessage
ALTER TABLE "SupportMessage" DROP CONSTRAINT IF EXISTS "SupportMessage_conversationId_fkey";
ALTER TABLE "SupportMessage" ADD CONSTRAINT "SupportMessage_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "SupportConversation"("id") ON DELETE CASCADE;
-- TripStopTime
ALTER TABLE "TripStopTime" DROP CONSTRAINT IF EXISTS "TripStopTime_scheduleId_fkey";
ALTER TABLE "TripStopTime" ADD CONSTRAINT "TripStopTime_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE;
-- TripLiveStatus
ALTER TABLE "TripLiveStatus" DROP CONSTRAINT IF EXISTS "TripLiveStatus_scheduleId_fkey";
ALTER TABLE "TripLiveStatus" ADD CONSTRAINT "TripLiveStatus_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE;
-- JourneySegment
ALTER TABLE "JourneySegment" DROP CONSTRAINT IF EXISTS "JourneySegment_journeyId_fkey";
ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_journeyId_fkey" FOREIGN KEY ("journeyId") REFERENCES "Journey"("id") ON DELETE CASCADE;
ALTER TABLE "JourneySegment" DROP CONSTRAINT IF EXISTS "JourneySegment_scheduleId_fkey";
ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE;
-- AgentBooking
ALTER TABLE "AgentBooking" DROP CONSTRAINT IF EXISTS "AgentBooking_agentId_fkey";
ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE CASCADE;
ALTER TABLE "AgentBooking" DROP CONSTRAINT IF EXISTS "AgentBooking_bookingId_fkey";
ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE;
-- AgentShift
ALTER TABLE "AgentShift" DROP CONSTRAINT IF EXISTS "AgentShift_agentId_fkey";
ALTER TABLE "AgentShift" ADD CONSTRAINT "AgentShift_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE CASCADE;
-- AgentCommission
ALTER TABLE "AgentCommission" DROP CONSTRAINT IF EXISTS "AgentCommission_agentId_fkey";
ALTER TABLE "AgentCommission" ADD CONSTRAINT "AgentCommission_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE CASCADE;
-- BookingModification
ALTER TABLE "BookingModification" DROP CONSTRAINT IF EXISTS "BookingModification_bookingId_fkey";
ALTER TABLE "BookingModification" ADD CONSTRAINT "BookingModification_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE;
-- BookingCancellation
ALTER TABLE "BookingCancellation" DROP CONSTRAINT IF EXISTS "BookingCancellation_bookingId_fkey";
ALTER TABLE "BookingCancellation" ADD CONSTRAINT "BookingCancellation_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE;
-- GateValidationLog
ALTER TABLE "GateValidationLog" DROP CONSTRAINT IF EXISTS "GateValidationLog_ticketId_fkey";
ALTER TABLE "GateValidationLog" ADD CONSTRAINT "GateValidationLog_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE CASCADE;
-- BaggageBooking
ALTER TABLE "BaggageBooking" DROP CONSTRAINT IF EXISTS "BaggageBooking_bookingId_fkey";
ALTER TABLE "BaggageBooking" ADD CONSTRAINT "BaggageBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE;
-- RouteFareRule
ALTER TABLE "RouteFareRule" DROP CONSTRAINT IF EXISTS "RouteFareRule_seatClassId_fkey";
ALTER TABLE "RouteFareRule" ADD CONSTRAINT "RouteFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE CASCADE;
-- SegmentFareRule
ALTER TABLE "SegmentFareRule" DROP CONSTRAINT IF EXISTS "SegmentFareRule_seatClassId_fkey";
ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE CASCADE;
-- StationCrowdSignal
ALTER TABLE "StationCrowdSignal" DROP CONSTRAINT IF EXISTS "StationCrowdSignal_stationId_fkey";
ALTER TABLE "StationCrowdSignal" ADD CONSTRAINT "StationCrowdSignal_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "Station"("id") ON DELETE CASCADE;
-- SeatBlock
ALTER TABLE "SeatBlock" DROP CONSTRAINT IF EXISTS "SeatBlock_seatId_fkey";
ALTER TABLE "SeatBlock" ADD CONSTRAINT "SeatBlock_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE CASCADE;
-- SavedRoute
ALTER TABLE "SavedRoute" DROP CONSTRAINT IF EXISTS "SavedRoute_passengerId_fkey";
ALTER TABLE "SavedRoute" ADD CONSTRAINT "SavedRoute_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE CASCADE;
-- LoyaltyLedgerEntry
ALTER TABLE "LoyaltyLedgerEntry" DROP CONSTRAINT IF EXISTS "LoyaltyLedgerEntry_accountId_fkey";
ALTER TABLE "LoyaltyLedgerEntry" ADD CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE CASCADE;
-- LoyaltyReward
ALTER TABLE "LoyaltyReward" DROP CONSTRAINT IF EXISTS "LoyaltyReward_accountId_fkey";
ALTER TABLE "LoyaltyReward" ADD CONSTRAINT "LoyaltyReward_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE CASCADE;
-- FareRule
ALTER TABLE "FareRule" DROP CONSTRAINT IF EXISTS "FareRule_seatClassId_fkey";
ALTER TABLE "FareRule" ADD CONSTRAINT "FareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE CASCADE;

View File

@@ -1,82 +0,0 @@
-- Catch-up migration: earlier migrations (20260606, 20260608) targeted passenger.*
-- but ran when tables were still in public schema (before 20260626 moved them).
-- All statements use IF NOT EXISTS / conditional blocks so this is safe to re-run.
-- ────────────────────────────────────────────────────────────
-- 1. Passenger.iamUserId
-- ────────────────────────────────────────────────────────────
ALTER TABLE passenger."Passenger" ADD COLUMN IF NOT EXISTS "iamUserId" TEXT;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'Passenger_iamUserId_key'
AND conrelid = 'passenger."Passenger"'::regclass
) THEN
ALTER TABLE passenger."Passenger" ADD CONSTRAINT "Passenger_iamUserId_key" UNIQUE ("iamUserId");
END IF;
END $$;
CREATE INDEX IF NOT EXISTS "Passenger_iamUserId_idx" ON passenger."Passenger"("iamUserId");
-- ────────────────────────────────────────────────────────────
-- 2. FaydaVerificationSession.iamUserId
-- ────────────────────────────────────────────────────────────
ALTER TABLE passenger."FaydaVerificationSession" ADD COLUMN IF NOT EXISTS "iamUserId" TEXT;
CREATE INDEX IF NOT EXISTS "FaydaVerificationSession_iamUserId_idx" ON passenger."FaydaVerificationSession"("iamUserId");
-- ────────────────────────────────────────────────────────────
-- 3. UserPreferences: rename userId → iamUserId (if not yet renamed)
-- ────────────────────────────────────────────────────────────
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'passenger' AND table_name = 'UserPreferences' AND column_name = 'userId'
) THEN
ALTER TABLE passenger."UserPreferences" DROP CONSTRAINT IF EXISTS "UserPreferences_userId_fkey";
ALTER TABLE passenger."UserPreferences" RENAME COLUMN "userId" TO "iamUserId";
END IF;
END $$;
-- ────────────────────────────────────────────────────────────
-- 4. Device: rename userId → iamUserId (if not yet renamed)
-- ────────────────────────────────────────────────────────────
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'passenger' AND table_name = 'Device' AND column_name = 'userId'
) THEN
ALTER TABLE passenger."Device" DROP CONSTRAINT IF EXISTS "Device_userId_fkey";
ALTER TABLE passenger."Device" RENAME COLUMN "userId" TO "iamUserId";
END IF;
END $$;
-- ────────────────────────────────────────────────────────────
-- 5. FraudAlert: rename userId → iamUserId + fix index (if not yet renamed)
-- ────────────────────────────────────────────────────────────
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'passenger' AND table_name = 'FraudAlert' AND column_name = 'userId'
) THEN
ALTER TABLE passenger."FraudAlert" DROP CONSTRAINT IF EXISTS "FraudAlert_userId_fkey";
ALTER TABLE passenger."FraudAlert" RENAME COLUMN "userId" TO "iamUserId";
DROP INDEX IF EXISTS passenger."FraudAlert_userId_createdAt_idx";
CREATE INDEX "FraudAlert_iamUserId_createdAt_idx" ON passenger."FraudAlert"("iamUserId", "createdAt");
END IF;
END $$;
-- ────────────────────────────────────────────────────────────
-- 6. AuditLog: rename userId → iamUserId + fix index (if not yet renamed)
-- ────────────────────────────────────────────────────────────
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'passenger' AND table_name = 'AuditLog' AND column_name = 'userId'
) THEN
ALTER TABLE passenger."AuditLog" DROP CONSTRAINT IF EXISTS "AuditLog_userId_fkey";
ALTER TABLE passenger."AuditLog" RENAME COLUMN "userId" TO "iamUserId";
DROP INDEX IF EXISTS passenger."AuditLog_userId_createdAt_idx";
CREATE INDEX IF NOT EXISTS "AuditLog_iamUserId_createdAt_idx" ON passenger."AuditLog"("iamUserId", "createdAt");
END IF;
END $$;

View File

@@ -1,5 +0,0 @@
-- 20260608061918 was marked-as-applied without running (it failed on CREATE TABLE TicketSeat).
-- The two ALTER TABLE statements it contained never executed, so userId is still NOT NULL.
ALTER TABLE passenger."Passenger" DROP CONSTRAINT IF EXISTS "Passenger_userId_fkey";
ALTER TABLE passenger."Passenger" ALTER COLUMN "userId" DROP NOT NULL;

View File

@@ -1,46 +0,0 @@
-- ────────────────────────────────────────────────────────────
-- 1. Add iamUserId to Agent
-- ────────────────────────────────────────────────────────────
ALTER TABLE passenger."Agent" ADD COLUMN IF NOT EXISTS "iamUserId" TEXT;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'Agent_iamUserId_key'
AND conrelid = 'passenger."Agent"'::regclass
) THEN
ALTER TABLE passenger."Agent" ADD CONSTRAINT "Agent_iamUserId_key" UNIQUE ("iamUserId");
END IF;
END $$;
CREATE INDEX IF NOT EXISTS "Agent_iamUserId_idx" ON passenger."Agent"("iamUserId");
-- ────────────────────────────────────────────────────────────
-- 2. Populate iamUserId for existing agent records
-- Match via User.email → iam.users.email (skip if iam schema absent)
-- ────────────────────────────────────────────────────────────
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'iam' AND table_name = 'users'
) THEN
UPDATE passenger."Agent" a
SET "iamUserId" = iu.id
FROM passenger."User" u
JOIN iam.users iu ON iu.email = u.email
WHERE a."userId" = u.id
AND a."iamUserId" IS NULL;
END IF;
END $$;
-- ────────────────────────────────────────────────────────────
-- 3. Drop Agent.userId FK and column — iamUserId replaces it entirely
-- ────────────────────────────────────────────────────────────
ALTER TABLE passenger."Agent" DROP CONSTRAINT IF EXISTS "Agent_userId_fkey";
DROP INDEX IF EXISTS passenger."Agent_userId_key";
ALTER TABLE passenger."Agent" DROP COLUMN IF EXISTS "userId";
-- ────────────────────────────────────────────────────────────
-- 4. Drop Passenger.userId FK (column stays as plain nullable string)
-- ────────────────────────────────────────────────────────────
ALTER TABLE passenger."Passenger" DROP CONSTRAINT IF EXISTS "Passenger_userId_fkey";

View File

@@ -1,290 +0,0 @@
-- DropForeignKey
ALTER TABLE "AgentBooking" DROP CONSTRAINT "AgentBooking_agentId_fkey";
-- DropForeignKey
ALTER TABLE "AgentBooking" DROP CONSTRAINT "AgentBooking_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "AgentCommission" DROP CONSTRAINT "AgentCommission_agentId_fkey";
-- DropForeignKey
ALTER TABLE "AgentShift" DROP CONSTRAINT "AgentShift_agentId_fkey";
-- DropForeignKey
ALTER TABLE "BaggageBooking" DROP CONSTRAINT "BaggageBooking_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "Booking" DROP CONSTRAINT "Booking_passengerId_fkey";
-- DropForeignKey
ALTER TABLE "Booking" DROP CONSTRAINT "Booking_scheduleId_fkey";
-- DropForeignKey
ALTER TABLE "BookingCancellation" DROP CONSTRAINT "BookingCancellation_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "BookingModification" DROP CONSTRAINT "BookingModification_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "BookingSeat" DROP CONSTRAINT "BookingSeat_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "BookingSeat" DROP CONSTRAINT "BookingSeat_seatId_fkey";
-- DropForeignKey
ALTER TABLE "Coach" DROP CONSTRAINT "Coach_coachTypeId_fkey";
-- DropForeignKey
ALTER TABLE "CoachAssignment" DROP CONSTRAINT "CoachAssignment_coachId_fkey";
-- DropForeignKey
ALTER TABLE "CoachAssignment" DROP CONSTRAINT "CoachAssignment_scheduleId_fkey";
-- DropForeignKey
ALTER TABLE "FaqArticle" DROP CONSTRAINT "FaqArticle_categoryId_fkey";
-- DropForeignKey
ALTER TABLE "FareRule" DROP CONSTRAINT "FareRule_seatClassId_fkey";
-- DropForeignKey
ALTER TABLE "FoodOrder" DROP CONSTRAINT "FoodOrder_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "FoodOrderItem" DROP CONSTRAINT "FoodOrderItem_orderId_fkey";
-- DropForeignKey
ALTER TABLE "GateValidationLog" DROP CONSTRAINT "GateValidationLog_ticketId_fkey";
-- DropForeignKey
ALTER TABLE "JourneySegment" DROP CONSTRAINT "JourneySegment_journeyId_fkey";
-- DropForeignKey
ALTER TABLE "JourneySegment" DROP CONSTRAINT "JourneySegment_scheduleId_fkey";
-- DropForeignKey
ALTER TABLE "LoyaltyLedgerEntry" DROP CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey";
-- DropForeignKey
ALTER TABLE "LoyaltyReward" DROP CONSTRAINT "LoyaltyReward_accountId_fkey";
-- DropForeignKey
ALTER TABLE "MenuItem" DROP CONSTRAINT "MenuItem_categoryId_fkey";
-- DropForeignKey
ALTER TABLE "MenuItem" DROP CONSTRAINT "MenuItem_scheduleId_fkey";
-- DropForeignKey
ALTER TABLE "Notification" DROP CONSTRAINT "Notification_passengerId_fkey";
-- DropForeignKey
ALTER TABLE "PaymentIntent" DROP CONSTRAINT "PaymentIntent_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "PaymentRefund" DROP CONSTRAINT "PaymentRefund_paymentIntentId_fkey";
-- DropForeignKey
ALTER TABLE "RouteFareRule" DROP CONSTRAINT "RouteFareRule_seatClassId_fkey";
-- DropForeignKey
ALTER TABLE "SavedRoute" DROP CONSTRAINT "SavedRoute_passengerId_fkey";
-- DropForeignKey
ALTER TABLE "SeatBlock" DROP CONSTRAINT "SeatBlock_seatId_fkey";
-- DropForeignKey
ALTER TABLE "SegmentFareRule" DROP CONSTRAINT "SegmentFareRule_seatClassId_fkey";
-- DropForeignKey
ALTER TABLE "StationCrowdSignal" DROP CONSTRAINT "StationCrowdSignal_stationId_fkey";
-- DropForeignKey
ALTER TABLE "SupportMessage" DROP CONSTRAINT "SupportMessage_conversationId_fkey";
-- DropForeignKey
ALTER TABLE "Ticket" DROP CONSTRAINT "Ticket_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "TicketSeat" DROP CONSTRAINT "TicketSeat_seatId_fkey";
-- DropForeignKey
ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_destinationStationId_fkey";
-- DropForeignKey
ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_originStationId_fkey";
-- DropForeignKey
ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_routeId_fkey";
-- DropForeignKey
ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_trainId_fkey";
-- DropForeignKey
ALTER TABLE "TripLiveStatus" DROP CONSTRAINT "TripLiveStatus_scheduleId_fkey";
-- DropForeignKey
ALTER TABLE "TripStopTime" DROP CONSTRAINT "TripStopTime_scheduleId_fkey";
-- DropForeignKey
ALTER TABLE "WalletLedgerEntry" DROP CONSTRAINT "WalletLedgerEntry_walletId_fkey";
-- DropIndex
DROP INDEX IF EXISTS "Journey_bookingId_idx";
-- AlterTable
ALTER TABLE "FaydaVerificationSession" ALTER COLUMN "purpose" SET DEFAULT 'VERIFY';
-- CreateTable
CREATE TABLE "SystemConfig" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"value" TEXT NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SystemConfig_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "SystemConfig_key_key" ON "SystemConfig"("key");
-- AddForeignKey
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_trainId_fkey" FOREIGN KEY ("trainId") REFERENCES "Train"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_originStationId_fkey" FOREIGN KEY ("originStationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_destinationStationId_fkey" FOREIGN KEY ("destinationStationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TripStopTime" ADD CONSTRAINT "TripStopTime_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TripLiveStatus" ADD CONSTRAINT "TripLiveStatus_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Coach" ADD CONSTRAINT "Coach_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "CoachType"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "Coach"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FareRule" ADD CONSTRAINT "FareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_returnScheduleId_fkey" FOREIGN KEY ("returnScheduleId") REFERENCES "TrainSchedule"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PaymentIntent" ADD CONSTRAINT "PaymentIntent_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PaymentRefund" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey" FOREIGN KEY ("paymentIntentId") REFERENCES "PaymentIntent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LoyaltyLedgerEntry" ADD CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LoyaltyReward" ADD CONSTRAINT "LoyaltyReward_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WalletLedgerEntry" ADD CONSTRAINT "WalletLedgerEntry_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "WalletAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StationCrowdSignal" ADD CONSTRAINT "StationCrowdSignal_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "MenuCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FoodOrder" ADD CONSTRAINT "FoodOrder_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FoodOrderItem" ADD CONSTRAINT "FoodOrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "FoodOrder"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FaqArticle" ADD CONSTRAINT "FaqArticle_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "FaqCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SupportMessage" ADD CONSTRAINT "SupportMessage_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "SupportConversation"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SavedRoute" ADD CONSTRAINT "SavedRoute_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'passenger' AND table_name = 'Journey' AND column_name = 'bookingId'
) THEN
ALTER TABLE "Journey" ADD CONSTRAINT "Journey_bookingId_fkey"
FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE SET NULL ON UPDATE CASCADE;
END IF;
END $$;
-- AddForeignKey
ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_journeyId_fkey" FOREIGN KEY ("journeyId") REFERENCES "Journey"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RouteFareRule" ADD CONSTRAINT "RouteFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentShift" ADD CONSTRAINT "AgentShift_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentCommission" ADD CONSTRAINT "AgentCommission_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BookingModification" ADD CONSTRAINT "BookingModification_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BookingCancellation" ADD CONSTRAINT "BookingCancellation_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GateValidationLog" ADD CONSTRAINT "GateValidationLog_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BaggageBooking" ADD CONSTRAINT "BaggageBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SeatBlock" ADD CONSTRAINT "SeatBlock_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -1,18 +0,0 @@
-- CreateEnum
CREATE TYPE "ReturnLegStatus" AS ENUM ('NOT_APPLICABLE', 'BOTH_USED', 'OUTBOUND_ONLY', 'INBOUND_ONLY', 'NEITHER_USED');
-- AlterTable: add return leg tracking columns to Booking
ALTER TABLE "Booking"
ADD COLUMN "returnLegStatus" "ReturnLegStatus" NOT NULL DEFAULT 'NOT_APPLICABLE',
ADD COLUMN "outboundBoardedAt" TIMESTAMP(3),
ADD COLUMN "returnBoardedAt" TIMESTAMP(3);
-- Set NEITHER_USED for existing confirmed round-trip bookings
UPDATE "Booking"
SET "returnLegStatus" = 'NEITHER_USED'
WHERE "bookingType" = 'ROUND_TRIP'
AND "status" IN ('CONFIRMED', 'BOARDED');
-- AlterTable: add leg column to GateValidationLog
ALTER TABLE "GateValidationLog"
ADD COLUMN "leg" TEXT;

View File

@@ -1,70 +0,0 @@
-- Create passenger schema if it doesn't exist
CREATE SCHEMA IF NOT EXISTS passenger;
-- Move enums from public to passenger schema (only if they exist in public)
DO $$
DECLARE
e text;
BEGIN
FOR e IN
SELECT typname FROM pg_type
JOIN pg_namespace ON pg_namespace.oid = pg_type.typnamespace
WHERE pg_namespace.nspname = 'public' AND pg_type.typtype = 'e'
LOOP
EXECUTE format('ALTER TYPE public.%I SET SCHEMA passenger', e);
END LOOP;
EXCEPTION WHEN others THEN NULL;
END $$;
-- Move tables from public to passenger schema (only if they exist in public)
DO $$
DECLARE
t text;
BEGIN
FOR t IN
SELECT tablename FROM pg_tables
WHERE schemaname = 'public' AND tablename NOT IN ('_prisma_migrations')
LOOP
EXECUTE format('ALTER TABLE public.%I SET SCHEMA passenger', t);
END LOOP;
EXCEPTION WHEN others THEN NULL;
END $$;
-- Add missing columns to Booking
ALTER TABLE "passenger"."Booking"
ADD COLUMN IF NOT EXISTS "returnScheduleId" TEXT,
ADD COLUMN IF NOT EXISTS "returnOriginStationId" TEXT,
ADD COLUMN IF NOT EXISTS "returnDestinationStationId" TEXT,
ADD COLUMN IF NOT EXISTS "returnHoldId" TEXT,
ADD COLUMN IF NOT EXISTS "returnSeatClassId" TEXT,
ADD COLUMN IF NOT EXISTS "leg2ScheduleId" TEXT,
ADD COLUMN IF NOT EXISTS "leg2OriginStationId" TEXT,
ADD COLUMN IF NOT EXISTS "leg2DestinationStationId" TEXT,
ADD COLUMN IF NOT EXISTS "leg2SeatClassId" TEXT,
ADD COLUMN IF NOT EXISTS "returnLeg2ScheduleId" TEXT,
ADD COLUMN IF NOT EXISTS "returnLeg2OriginStationId" TEXT,
ADD COLUMN IF NOT EXISTS "returnLeg2DestStationId" TEXT,
ADD COLUMN IF NOT EXISTS "returnLeg2SeatClassId" TEXT,
ADD COLUMN IF NOT EXISTS "outboundBoardedAt" TIMESTAMP(3),
ADD COLUMN IF NOT EXISTS "returnBoardedAt" TIMESTAMP(3);
-- Add ReturnLegStatus enum and column
DO $$ BEGIN
CREATE TYPE "passenger"."ReturnLegStatus" AS ENUM (
'NOT_APPLICABLE', 'BOTH_USED', 'OUTBOUND_ONLY', 'INBOUND_ONLY', 'NEITHER_USED'
);
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
ALTER TABLE "passenger"."Booking"
ADD COLUMN IF NOT EXISTS "returnLegStatus" "passenger"."ReturnLegStatus" NOT NULL DEFAULT 'NOT_APPLICABLE';
-- Add missing columns to other tables
ALTER TABLE "passenger"."GateValidationLog" ADD COLUMN IF NOT EXISTS "leg" TEXT;
ALTER TABLE "passenger"."BookingSeat" ADD COLUMN IF NOT EXISTS "leg" INTEGER NOT NULL DEFAULT 1;
ALTER TABLE "passenger"."BookingSeat" ADD COLUMN IF NOT EXISTS "scheduleId" TEXT;
ALTER TABLE "passenger"."Ticket" ADD COLUMN IF NOT EXISTS "boardedAt" TIMESTAMP(3);
ALTER TABLE "passenger"."SeatClass" ALTER COLUMN "baseFareMinor" SET DEFAULT 0;
ALTER TABLE "passenger"."Ticket" ALTER COLUMN "status" SET DEFAULT 'ACTIVE';
CREATE INDEX IF NOT EXISTS "Booking_bookingType_idx" ON "passenger"."Booking"("bookingType");

View File

@@ -1,21 +0,0 @@
-- Add bookingId to Journey for per-booking segment release
ALTER TABLE "passenger"."Journey"
ADD COLUMN IF NOT EXISTS "bookingId" TEXT;
CREATE UNIQUE INDEX IF NOT EXISTS "Journey_bookingId_key" ON "passenger"."Journey"("bookingId");
CREATE INDEX IF NOT EXISTS "Journey_bookingId_idx" ON "passenger"."Journey"("bookingId");
-- AddForeignKey (column created above; FK was misplaced in 20260623073543_config)
ALTER TABLE "passenger"."Journey"
DROP CONSTRAINT IF EXISTS "Journey_bookingId_fkey";
ALTER TABLE "passenger"."Journey"
ADD CONSTRAINT "Journey_bookingId_fkey"
FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- Ensure JourneySegment cascades on Journey delete
ALTER TABLE "passenger"."JourneySegment"
DROP CONSTRAINT IF EXISTS "JourneySegment_journeyId_fkey";
ALTER TABLE "passenger"."JourneySegment"
ADD CONSTRAINT "JourneySegment_journeyId_fkey"
FOREIGN KEY ("journeyId") REFERENCES "passenger"."Journey"("id") ON DELETE CASCADE;

View File

@@ -1,2 +0,0 @@
-- Migration already applied directly to the database.
-- This file exists only to satisfy Prisma's migration directory check (P3015).

View File

@@ -1,153 +0,0 @@
-- Add iamUserId to Agent (migration 20260622000002 was skipped due to missing iam schema)
ALTER TABLE passenger."Agent" ADD COLUMN IF NOT EXISTS "iamUserId" TEXT;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'Agent_iamUserId_key'
AND conrelid = 'passenger."Agent"'::regclass
) THEN
ALTER TABLE passenger."Agent" ADD CONSTRAINT "Agent_iamUserId_key" UNIQUE ("iamUserId");
END IF;
END $$;
CREATE INDEX IF NOT EXISTS "Agent_iamUserId_idx" ON passenger."Agent"("iamUserId");
-- Drop old Agent.userId FK and column if they still exist
ALTER TABLE passenger."Agent" DROP CONSTRAINT IF EXISTS "Agent_userId_fkey";
DROP INDEX IF EXISTS passenger."Agent_userId_key";
ALTER TABLE passenger."Agent" DROP COLUMN IF EXISTS "userId";
-- Drop old Passenger.userId FK (column stays as plain nullable string)
ALTER TABLE passenger."Passenger" DROP CONSTRAINT IF EXISTS "Passenger_userId_fkey";
-- TravelPackage
CREATE TABLE IF NOT EXISTS passenger."TravelPackage" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"status" TEXT NOT NULL DEFAULT 'DRAFT',
"outboundScheduleId" TEXT NOT NULL,
"returnScheduleId" TEXT NOT NULL,
"originStationId" TEXT NOT NULL,
"destinationStationId" TEXT NOT NULL,
"boardingTime" TIMESTAMP(3) NOT NULL,
"departureTime" TIMESTAMP(3) NOT NULL,
"arrivalTime" TIMESTAMP(3) NOT NULL,
"totalCapacity" INTEGER NOT NULL,
"bookedCount" INTEGER NOT NULL DEFAULT 0,
"includedServices" JSONB NOT NULL,
"coachConfiguration" TEXT,
"busTransferIncluded" BOOLEAN NOT NULL DEFAULT false,
"busTransferRoute" TEXT,
"validFrom" TIMESTAMP(3) NOT NULL,
"validUntil" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TravelPackage_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "TravelPackage_code_key" ON passenger."TravelPackage"("code");
CREATE INDEX IF NOT EXISTS "TravelPackage_status_validFrom_idx" ON passenger."TravelPackage"("status","validFrom");
-- PackagePriceTier
CREATE TABLE IF NOT EXISTS passenger."PackagePriceTier" (
"id" TEXT NOT NULL,
"packageId" TEXT NOT NULL,
"seatType" TEXT NOT NULL,
"label" TEXT NOT NULL,
"priceMinor" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'ETB',
"availableSeats" INTEGER NOT NULL DEFAULT 0,
"bookedSeats" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "PackagePriceTier_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "PackagePriceTier_packageId_seatType_key" ON passenger."PackagePriceTier"("packageId","seatType");
-- PackageBooking
CREATE TABLE IF NOT EXISTS passenger."PackageBooking" (
"id" TEXT NOT NULL,
"bookingRef" TEXT NOT NULL,
"packageId" TEXT NOT NULL,
"priceTierId" TEXT NOT NULL,
"passengerId" TEXT,
"contactEmail" TEXT,
"contactPhone" TEXT,
"status" TEXT NOT NULL DEFAULT 'PENDING_PAYMENT',
"passengerCount" INTEGER NOT NULL DEFAULT 1,
"totalMinor" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'ETB',
"displayCurrency" TEXT,
"displayTotalMinor" INTEGER,
"promoCode" TEXT,
"source" TEXT NOT NULL DEFAULT 'WEB',
"paidAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PackageBooking_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "PackageBooking_bookingRef_key" ON passenger."PackageBooking"("bookingRef");
CREATE INDEX IF NOT EXISTS "PackageBooking_packageId_status_idx" ON passenger."PackageBooking"("packageId","status");
-- PackageBookingPassenger
CREATE TABLE IF NOT EXISTS passenger."PackageBookingPassenger" (
"id" TEXT NOT NULL,
"bookingId" TEXT NOT NULL,
"passengerName" TEXT NOT NULL,
"dateOfBirth" TIMESTAMP(3),
"idDocumentType" TEXT,
"idDocumentNumber" TEXT,
"passportNumber" TEXT,
"passportCountry" TEXT,
"seatLabel" TEXT,
CONSTRAINT "PackageBookingPassenger_pkey" PRIMARY KEY ("id")
);
-- PackagePaymentIntent
CREATE TABLE IF NOT EXISTS passenger."PackagePaymentIntent" (
"id" TEXT NOT NULL,
"packageBookingId" TEXT NOT NULL,
"amountMinor" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'ETB',
"method" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'REQUIRES_ACTION',
"providerRef" TEXT,
"paidAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PackagePaymentIntent_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "PackagePaymentIntent_packageBookingId_key" ON passenger."PackagePaymentIntent"("packageBookingId");
-- Foreign keys
ALTER TABLE passenger."TravelPackage"
ADD CONSTRAINT "TravelPackage_outboundScheduleId_fkey"
FOREIGN KEY ("outboundScheduleId") REFERENCES passenger."TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE passenger."TravelPackage"
ADD CONSTRAINT "TravelPackage_returnScheduleId_fkey"
FOREIGN KEY ("returnScheduleId") REFERENCES passenger."TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE passenger."PackagePriceTier"
ADD CONSTRAINT "PackagePriceTier_packageId_fkey"
FOREIGN KEY ("packageId") REFERENCES passenger."TravelPackage"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE passenger."PackageBooking"
ADD CONSTRAINT "PackageBooking_packageId_fkey"
FOREIGN KEY ("packageId") REFERENCES passenger."TravelPackage"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE passenger."PackageBooking"
ADD CONSTRAINT "PackageBooking_priceTierId_fkey"
FOREIGN KEY ("priceTierId") REFERENCES passenger."PackagePriceTier"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE passenger."PackageBooking"
ADD CONSTRAINT "PackageBooking_passengerId_fkey"
FOREIGN KEY ("passengerId") REFERENCES passenger."Passenger"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE passenger."PackageBookingPassenger"
ADD CONSTRAINT "PackageBookingPassenger_bookingId_fkey"
FOREIGN KEY ("bookingId") REFERENCES passenger."PackageBooking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE passenger."PackagePaymentIntent"
ADD CONSTRAINT "PackagePaymentIntent_packageBookingId_fkey"
FOREIGN KEY ("packageBookingId") REFERENCES passenger."PackageBooking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -1,12 +0,0 @@
-- Create PackageStatus enum
DO $$ BEGIN
CREATE TYPE passenger."PackageStatus" AS ENUM ('DRAFT','ACTIVE','SOLD_OUT','EXPIRED','CANCELLED');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
-- Drop default, cast column to enum, restore default
ALTER TABLE passenger."TravelPackage" ALTER COLUMN "status" DROP DEFAULT;
ALTER TABLE passenger."TravelPackage"
ALTER COLUMN "status" TYPE passenger."PackageStatus"
USING "status"::passenger."PackageStatus";
ALTER TABLE passenger."TravelPackage" ALTER COLUMN "status" SET DEFAULT 'DRAFT'::passenger."PackageStatus";

View File

@@ -1,42 +0,0 @@
-- CreateTable
CREATE TABLE "passenger"."ExcessBaggageCharge" (
"id" TEXT NOT NULL,
"bookingId" TEXT NOT NULL,
"agentId" TEXT NOT NULL,
"excessWeightKg" INTEGER NOT NULL,
"feePerKgMinor" INTEGER NOT NULL,
"totalMinor" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'ETB',
"status" TEXT NOT NULL DEFAULT 'PENDING',
"paymentToken" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"paidAt" TIMESTAMP(3),
"waivedBy" TEXT,
"waivedReason" TEXT,
"contactPhone" TEXT,
"contactEmail" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ExcessBaggageCharge_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ExcessBaggageCharge_paymentToken_key" ON "passenger"."ExcessBaggageCharge"("paymentToken");
-- CreateIndex
CREATE INDEX "ExcessBaggageCharge_bookingId_idx" ON "passenger"."ExcessBaggageCharge"("bookingId");
-- CreateIndex
CREATE INDEX "ExcessBaggageCharge_paymentToken_idx" ON "passenger"."ExcessBaggageCharge"("paymentToken");
-- CreateIndex
CREATE INDEX "ExcessBaggageCharge_status_idx" ON "passenger"."ExcessBaggageCharge"("status");
-- AddForeignKey
ALTER TABLE "passenger"."ExcessBaggageCharge"
ADD CONSTRAINT "ExcessBaggageCharge_bookingId_fkey"
FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id")
ON DELETE RESTRICT ON UPDATE CASCADE;
-- Seed default paymentToken using gen_random_uuid() for any rows that may exist
UPDATE "passenger"."ExcessBaggageCharge" SET "paymentToken" = gen_random_uuid()::text WHERE "paymentToken" = '';

View File

@@ -1 +0,0 @@
ALTER TABLE passenger."Booking" ADD COLUMN IF NOT EXISTS "paymentReminderSentAt" TIMESTAMP(3);

View File

@@ -22,11 +22,14 @@ CREATE TYPE "Currency" AS ENUM ('ETB', 'DJF', 'USD');
-- CreateEnum
CREATE TYPE "BookingStatus" AS ENUM ('DRAFT', 'PENDING_PAYMENT', 'CONFIRMED', 'CANCELLED', 'BOARDED', 'NO_SHOW', 'REFUNDED');
-- CreateEnum
CREATE TYPE "ReturnLegStatus" AS ENUM ('NOT_APPLICABLE', 'BOTH_USED', 'OUTBOUND_ONLY', 'INBOUND_ONLY', 'NEITHER_USED');
-- CreateEnum
CREATE TYPE "PaymentRegion" AS ENUM ('ETHIOPIA', 'DJIBOUTI', 'INTERNATIONAL', 'GLOBAL');
-- CreateEnum
CREATE TYPE "PaymentMethodType" AS ENUM ('TELEBIRR', 'CBE_BIRR', 'EBIRR', 'CARD', 'WALLET', 'WAAFI');
CREATE TYPE "PaymentMethodType" AS ENUM ('TELEBIRR', 'CBE_BIRR', 'EBIRR', 'CARD', 'WALLET', 'WAAFI', 'DMONEY');
-- CreateEnum
CREATE TYPE "PaymentIntentStatus" AS ENUM ('REQUIRES_ACTION', 'PROCESSING', 'SUCCEEDED', 'FAILED', 'CANCELLED', 'REFUNDED');
@@ -58,6 +61,9 @@ CREATE TYPE "FoodOrderStatus" AS ENUM ('PENDING', 'PREPARING', 'READY', 'DELIVER
-- CreateEnum
CREATE TYPE "DevicePlatform" AS ENUM ('IOS', 'ANDROID', 'WEB');
-- CreateEnum
CREATE TYPE "PackageStatus" AS ENUM ('DRAFT', 'ACTIVE', 'SOLD_OUT', 'EXPIRED', 'CANCELLED');
-- CreateTable
CREATE TABLE "CoachType" (
"id" TEXT NOT NULL,
@@ -130,9 +136,11 @@ CREATE TABLE "Session" (
-- CreateTable
CREATE TABLE "Passenger" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"userId" TEXT,
"iamUserId" TEXT,
"defaultTravelerProfileId" TEXT,
"preferredLanguage" TEXT,
"blockedUntil" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Passenger_pkey" PRIMARY KEY ("id")
@@ -143,6 +151,7 @@ CREATE TABLE "TravelerProfile" (
"id" TEXT NOT NULL,
"passengerId" TEXT NOT NULL,
"fullName" TEXT NOT NULL,
"gender" TEXT,
"relationship" TEXT NOT NULL,
"dateOfBirth" TIMESTAMP(3),
"nationalId" TEXT,
@@ -161,7 +170,6 @@ CREATE TABLE "Station" (
"countryCode" TEXT,
"sequence" INTEGER NOT NULL DEFAULT 0,
"isOperational" BOOLEAN NOT NULL DEFAULT true,
"timezone" TEXT NOT NULL DEFAULT 'Africa/Addis_Ababa',
"lat" DECIMAL(9,6),
"lng" DECIMAL(9,6),
@@ -314,6 +322,7 @@ CREATE TABLE "Booking" (
"bookingRef" TEXT NOT NULL,
"passengerId" TEXT NOT NULL,
"scheduleId" TEXT NOT NULL,
"bookingType" TEXT NOT NULL DEFAULT 'ONE_WAY',
"status" "BookingStatus" NOT NULL DEFAULT 'DRAFT',
"currency" TEXT NOT NULL DEFAULT 'ETB',
"totalMinor" INTEGER NOT NULL,
@@ -321,13 +330,29 @@ CREATE TABLE "Booking" (
"childCount" INTEGER NOT NULL DEFAULT 0,
"displayCurrency" "Currency",
"displayTotalMinor" INTEGER,
"bookingType" TEXT NOT NULL DEFAULT 'ONE_WAY',
"returnScheduleId" TEXT,
"returnOriginStationId" TEXT,
"returnDestinationStationId" TEXT,
"returnHoldId" TEXT,
"returnSeatClassId" TEXT,
"returnLegStatus" "ReturnLegStatus" NOT NULL DEFAULT 'NOT_APPLICABLE',
"leg2ScheduleId" TEXT,
"leg2OriginStationId" TEXT,
"leg2DestinationStationId" TEXT,
"leg2SeatClassId" TEXT,
"returnLeg2ScheduleId" TEXT,
"returnLeg2OriginStationId" TEXT,
"returnLeg2DestStationId" TEXT,
"returnLeg2SeatClassId" TEXT,
"outboundBoardedAt" TIMESTAMP(3),
"returnBoardedAt" TIMESTAMP(3),
"contactEmail" TEXT,
"contactPhone" TEXT,
"userAgent" TEXT,
"source" TEXT NOT NULL DEFAULT 'WEB',
"promoCode" TEXT,
"paidAt" TIMESTAMP(3),
"paymentReminderSentAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
@@ -339,6 +364,8 @@ CREATE TABLE "BookingSeat" (
"id" TEXT NOT NULL,
"bookingId" TEXT NOT NULL,
"seatId" TEXT NOT NULL,
"leg" INTEGER NOT NULL DEFAULT 1,
"scheduleId" TEXT,
"passengerName" TEXT NOT NULL,
"dateOfBirth" TIMESTAMP(3),
"passengerCategory" "PassengerCategory" NOT NULL DEFAULT 'ADULT',
@@ -438,7 +465,11 @@ CREATE TABLE "Ticket" (
"id" TEXT NOT NULL,
"bookingId" TEXT NOT NULL,
"bookingRef" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'CONFIRMED',
"passengerName" TEXT NOT NULL,
"seatId" TEXT NOT NULL,
"leg" INTEGER NOT NULL DEFAULT 1,
"scheduleId" TEXT,
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
"qrPayload" TEXT NOT NULL,
"barcodePayload" TEXT,
"pdfUrl" TEXT,
@@ -446,20 +477,11 @@ CREATE TABLE "Ticket" (
"issuedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"validatedAt" TIMESTAMP(3),
"validatorId" TEXT,
"boardedAt" TIMESTAMP(3),
CONSTRAINT "Ticket_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TicketSeat" (
"id" TEXT NOT NULL,
"ticketId" TEXT NOT NULL,
"seatId" TEXT NOT NULL,
"seatIndex" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "TicketSeat_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LoyaltyAccount" (
"id" TEXT NOT NULL,
@@ -679,7 +701,7 @@ CREATE TABLE "SupportMessage" (
-- CreateTable
CREATE TABLE "UserPreferences" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"iamUserId" TEXT NOT NULL,
"pushEnabled" BOOLEAN NOT NULL DEFAULT true,
"emailEnabled" BOOLEAN NOT NULL DEFAULT true,
"smsEnabled" BOOLEAN NOT NULL DEFAULT false,
@@ -699,7 +721,7 @@ CREATE TABLE "UserPreferences" (
-- CreateTable
CREATE TABLE "Device" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"iamUserId" TEXT NOT NULL,
"platform" "DevicePlatform" NOT NULL,
"name" TEXT NOT NULL,
"pushToken" TEXT,
@@ -727,6 +749,7 @@ CREATE TABLE "SavedRoute" (
CREATE TABLE "Journey" (
"id" TEXT NOT NULL,
"passengerId" TEXT NOT NULL,
"bookingId" TEXT,
"status" TEXT NOT NULL,
"totalMinor" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'ETB',
@@ -796,7 +819,7 @@ CREATE TABLE "RouteStop" (
"routeId" TEXT NOT NULL,
"stationId" TEXT NOT NULL,
"sequence" INTEGER NOT NULL,
"distanceKm" INTEGER,
"distanceKm" DOUBLE PRECISION,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RouteStop_pkey" PRIMARY KEY ("id")
@@ -820,10 +843,27 @@ CREATE TABLE "RouteFareRule" (
CONSTRAINT "RouteFareRule_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SegmentFareRule" (
"id" TEXT NOT NULL,
"routeId" TEXT NOT NULL,
"originStopSequence" INTEGER NOT NULL,
"destinationStopSequence" INTEGER NOT NULL,
"seatClassId" TEXT NOT NULL,
"baseFareMinor" INTEGER NOT NULL,
"nationality" TEXT,
"currency" TEXT NOT NULL DEFAULT 'ETB',
"validFrom" TIMESTAMP(3) NOT NULL,
"validUntil" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SegmentFareRule_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Agent" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"iamUserId" TEXT,
"agentCode" TEXT NOT NULL,
"stationId" TEXT,
"commissionRate" INTEGER NOT NULL DEFAULT 5,
@@ -910,6 +950,7 @@ CREATE TABLE "GateValidationLog" (
"ticketId" TEXT NOT NULL,
"validatorId" TEXT NOT NULL,
"gateId" TEXT,
"leg" TEXT,
"status" TEXT NOT NULL,
"reason" TEXT,
"validatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -943,10 +984,32 @@ CREATE TABLE "BaggageBooking" (
CONSTRAINT "BaggageBooking_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ExcessBaggageCharge" (
"id" TEXT NOT NULL,
"bookingId" TEXT NOT NULL,
"agentId" TEXT NOT NULL,
"excessWeightKg" INTEGER NOT NULL,
"feePerKgMinor" INTEGER NOT NULL,
"totalMinor" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'ETB',
"status" TEXT NOT NULL DEFAULT 'PENDING',
"paymentToken" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"paidAt" TIMESTAMP(3),
"waivedBy" TEXT,
"waivedReason" TEXT,
"contactPhone" TEXT,
"contactEmail" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ExcessBaggageCharge_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AuditLog" (
"id" TEXT NOT NULL,
"userId" TEXT,
"iamUserId" TEXT,
"action" TEXT NOT NULL,
"entityType" TEXT NOT NULL,
"entityId" TEXT,
@@ -1014,7 +1077,7 @@ CREATE TABLE "FraudRule" (
-- CreateTable
CREATE TABLE "FraudAlert" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"iamUserId" TEXT NOT NULL,
"eventType" TEXT NOT NULL,
"triggeredRules" TEXT[],
"context" JSONB NOT NULL,
@@ -1077,7 +1140,7 @@ CREATE TABLE "FaydaVerificationSession" (
"id" TEXT NOT NULL,
"state" TEXT NOT NULL,
"codeVerifier" TEXT NOT NULL,
"purpose" TEXT NOT NULL DEFAULT 'PURCHASE',
"purpose" TEXT NOT NULL DEFAULT 'VERIFY',
"platform" TEXT NOT NULL DEFAULT 'WEB',
"saveToAccount" BOOLEAN NOT NULL DEFAULT false,
"status" TEXT NOT NULL DEFAULT 'PENDING',
@@ -1087,12 +1150,137 @@ CREATE TABLE "FaydaVerificationSession" (
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
"completedAt" TIMESTAMP(3),
"userId" TEXT,
"iamUserId" TEXT,
"bookingId" TEXT,
CONSTRAINT "FaydaVerificationSession_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SystemConfig" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"value" TEXT NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SystemConfig_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TravelPackage" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"status" "PackageStatus" NOT NULL DEFAULT 'DRAFT',
"outboundScheduleId" TEXT NOT NULL,
"returnScheduleId" TEXT NOT NULL,
"originStationId" TEXT NOT NULL,
"destinationStationId" TEXT NOT NULL,
"boardingTime" TIMESTAMP(3) NOT NULL,
"departureTime" TIMESTAMP(3) NOT NULL,
"arrivalTime" TIMESTAMP(3) NOT NULL,
"totalCapacity" INTEGER NOT NULL,
"bookedCount" INTEGER NOT NULL DEFAULT 0,
"includedServices" JSONB NOT NULL,
"coachConfiguration" TEXT,
"busTransferIncluded" BOOLEAN NOT NULL DEFAULT false,
"busTransferRoute" TEXT,
"validFrom" TIMESTAMP(3) NOT NULL,
"validUntil" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TravelPackage_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PackagePriceTier" (
"id" TEXT NOT NULL,
"packageId" TEXT NOT NULL,
"seatType" TEXT NOT NULL,
"label" TEXT NOT NULL,
"priceMinor" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'ETB',
"availableSeats" INTEGER NOT NULL DEFAULT 0,
"bookedSeats" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "PackagePriceTier_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PackageBooking" (
"id" TEXT NOT NULL,
"bookingRef" TEXT NOT NULL,
"packageId" TEXT NOT NULL,
"priceTierId" TEXT NOT NULL,
"passengerId" TEXT,
"contactEmail" TEXT,
"contactPhone" TEXT,
"status" "BookingStatus" NOT NULL DEFAULT 'PENDING_PAYMENT',
"passengerCount" INTEGER NOT NULL DEFAULT 1,
"totalMinor" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'ETB',
"displayCurrency" "Currency",
"displayTotalMinor" INTEGER,
"promoCode" TEXT,
"source" TEXT NOT NULL DEFAULT 'WEB',
"paidAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PackageBooking_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PackageBookingPassenger" (
"id" TEXT NOT NULL,
"bookingId" TEXT NOT NULL,
"passengerName" TEXT NOT NULL,
"dateOfBirth" TIMESTAMP(3),
"idDocumentType" "IdDocumentType",
"idDocumentNumber" TEXT,
"passportNumber" TEXT,
"passportCountry" TEXT,
"seatLabel" TEXT,
CONSTRAINT "PackageBookingPassenger_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PackagePaymentIntent" (
"id" TEXT NOT NULL,
"packageBookingId" TEXT NOT NULL,
"amountMinor" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'ETB',
"method" "PaymentMethodType" NOT NULL,
"status" "PaymentIntentStatus" NOT NULL DEFAULT 'REQUIRES_ACTION',
"providerRef" TEXT,
"paidAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PackagePaymentIntent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PackageInquiry" (
"id" TEXT NOT NULL,
"packageId" TEXT NOT NULL,
"priceTierId" TEXT,
"travelerCount" INTEGER NOT NULL,
"contactName" TEXT NOT NULL,
"contactEmail" TEXT,
"contactPhone" TEXT,
"notes" TEXT,
"status" TEXT NOT NULL DEFAULT 'NEW',
"enquiredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PackageInquiry_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "SeatClass_coachTypeId_idx" ON "SeatClass"("coachTypeId");
@@ -1114,15 +1302,24 @@ CREATE UNIQUE INDEX "Session_token_key" ON "Session"("token");
-- CreateIndex
CREATE UNIQUE INDEX "Passenger_userId_key" ON "Passenger"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Passenger_iamUserId_key" ON "Passenger"("iamUserId");
-- CreateIndex
CREATE INDEX "Passenger_userId_idx" ON "Passenger"("userId");
-- CreateIndex
CREATE INDEX "Passenger_iamUserId_idx" ON "Passenger"("iamUserId");
-- CreateIndex
CREATE UNIQUE INDEX "Station_code_key" ON "Station"("code");
-- CreateIndex
CREATE INDEX "Station_city_countryCode_idx" ON "Station"("city", "countryCode");
-- CreateIndex
CREATE INDEX "Station_sequence_idx" ON "Station"("sequence");
-- CreateIndex
CREATE UNIQUE INDEX "Train_number_key" ON "Train"("number");
@@ -1141,6 +1338,9 @@ CREATE UNIQUE INDEX "Coach_number_key" ON "Coach"("number");
-- CreateIndex
CREATE INDEX "Coach_coachTypeId_idx" ON "Coach"("coachTypeId");
-- CreateIndex
CREATE INDEX "Coach_sequence_idx" ON "Coach"("sequence");
-- CreateIndex
CREATE INDEX "CoachAssignment_scheduleId_idx" ON "CoachAssignment"("scheduleId");
@@ -1165,6 +1365,9 @@ CREATE UNIQUE INDEX "Booking_bookingRef_key" ON "Booking"("bookingRef");
-- CreateIndex
CREATE INDEX "Booking_passengerId_status_idx" ON "Booking"("passengerId", "status");
-- CreateIndex
CREATE INDEX "Booking_bookingType_idx" ON "Booking"("bookingType");
-- CreateIndex
CREATE UNIQUE INDEX "PaymentMethod_type_key" ON "PaymentMethod"("type");
@@ -1187,13 +1390,10 @@ CREATE INDEX "PaymentWebhookEvent_merchantOrderId_idx" ON "PaymentWebhookEvent"(
CREATE UNIQUE INDEX "PaymentWebhookEvent_provider_externalEventId_key" ON "PaymentWebhookEvent"("provider", "externalEventId");
-- CreateIndex
CREATE UNIQUE INDEX "Ticket_bookingId_key" ON "Ticket"("bookingId");
CREATE INDEX "Ticket_bookingId_idx" ON "Ticket"("bookingId");
-- CreateIndex
CREATE INDEX "TicketSeat_ticketId_idx" ON "TicketSeat"("ticketId");
-- CreateIndex
CREATE INDEX "TicketSeat_seatId_idx" ON "TicketSeat"("seatId");
CREATE INDEX "Ticket_seatId_idx" ON "Ticket"("seatId");
-- CreateIndex
CREATE UNIQUE INDEX "LoyaltyAccount_passengerId_key" ON "LoyaltyAccount"("passengerId");
@@ -1208,7 +1408,10 @@ CREATE INDEX "WalletAccount_passengerId_idx" ON "WalletAccount"("passengerId");
CREATE UNIQUE INDEX "Promotion_code_key" ON "Promotion"("code");
-- CreateIndex
CREATE UNIQUE INDEX "UserPreferences_userId_key" ON "UserPreferences"("userId");
CREATE UNIQUE INDEX "UserPreferences_iamUserId_key" ON "UserPreferences"("iamUserId");
-- CreateIndex
CREATE UNIQUE INDEX "Journey_bookingId_key" ON "Journey"("bookingId");
-- CreateIndex
CREATE INDEX "OtpCode_email_phone_idx" ON "OtpCode"("email", "phone");
@@ -1232,11 +1435,20 @@ CREATE UNIQUE INDEX "RouteStop_routeId_sequence_key" ON "RouteStop"("routeId", "
CREATE INDEX "RouteFareRule_routeId_seatClassId_idx" ON "RouteFareRule"("routeId", "seatClassId");
-- CreateIndex
CREATE UNIQUE INDEX "Agent_userId_key" ON "Agent"("userId");
CREATE INDEX "SegmentFareRule_routeId_seatClassId_idx" ON "SegmentFareRule"("routeId", "seatClassId");
-- CreateIndex
CREATE UNIQUE INDEX "SegmentFareRule_routeId_originStopSequence_destinationStopS_key" ON "SegmentFareRule"("routeId", "originStopSequence", "destinationStopSequence", "seatClassId", "nationality");
-- CreateIndex
CREATE UNIQUE INDEX "Agent_iamUserId_key" ON "Agent"("iamUserId");
-- CreateIndex
CREATE UNIQUE INDEX "Agent_agentCode_key" ON "Agent"("agentCode");
-- CreateIndex
CREATE INDEX "Agent_iamUserId_idx" ON "Agent"("iamUserId");
-- CreateIndex
CREATE UNIQUE INDEX "AgentBooking_bookingId_key" ON "AgentBooking"("bookingId");
@@ -1262,7 +1474,19 @@ CREATE INDEX "GateValidationLog_validatorId_idx" ON "GateValidationLog"("validat
CREATE INDEX "BaggageBooking_bookingId_idx" ON "BaggageBooking"("bookingId");
-- CreateIndex
CREATE INDEX "AuditLog_userId_createdAt_idx" ON "AuditLog"("userId", "createdAt");
CREATE UNIQUE INDEX "ExcessBaggageCharge_paymentToken_key" ON "ExcessBaggageCharge"("paymentToken");
-- CreateIndex
CREATE INDEX "ExcessBaggageCharge_bookingId_idx" ON "ExcessBaggageCharge"("bookingId");
-- CreateIndex
CREATE INDEX "ExcessBaggageCharge_paymentToken_idx" ON "ExcessBaggageCharge"("paymentToken");
-- CreateIndex
CREATE INDEX "ExcessBaggageCharge_status_idx" ON "ExcessBaggageCharge"("status");
-- CreateIndex
CREATE INDEX "AuditLog_iamUserId_createdAt_idx" ON "AuditLog"("iamUserId", "createdAt");
-- CreateIndex
CREATE INDEX "AuditLog_entityType_entityId_idx" ON "AuditLog"("entityType", "entityId");
@@ -1280,7 +1504,7 @@ CREATE INDEX "OperationalReport_reportType_dateFrom_idx" ON "OperationalReport"(
CREATE UNIQUE INDEX "FraudRule_type_key" ON "FraudRule"("type");
-- CreateIndex
CREATE INDEX "FraudAlert_userId_createdAt_idx" ON "FraudAlert"("userId", "createdAt");
CREATE INDEX "FraudAlert_iamUserId_createdAt_idx" ON "FraudAlert"("iamUserId", "createdAt");
-- CreateIndex
CREATE INDEX "FraudAlert_acknowledged_idx" ON "FraudAlert"("acknowledged");
@@ -1307,7 +1531,7 @@ CREATE INDEX "SavedPassengerProfile_deviceId_idx" ON "SavedPassengerProfile"("de
CREATE UNIQUE INDEX "FaydaVerificationSession_state_key" ON "FaydaVerificationSession"("state");
-- CreateIndex
CREATE INDEX "FaydaVerificationSession_userId_idx" ON "FaydaVerificationSession"("userId");
CREATE INDEX "FaydaVerificationSession_iamUserId_idx" ON "FaydaVerificationSession"("iamUserId");
-- CreateIndex
CREATE INDEX "FaydaVerificationSession_bookingId_idx" ON "FaydaVerificationSession"("bookingId");
@@ -1318,6 +1542,30 @@ CREATE INDEX "FaydaVerificationSession_state_idx" ON "FaydaVerificationSession"(
-- CreateIndex
CREATE INDEX "FaydaVerificationSession_expiresAt_idx" ON "FaydaVerificationSession"("expiresAt");
-- CreateIndex
CREATE UNIQUE INDEX "SystemConfig_key_key" ON "SystemConfig"("key");
-- CreateIndex
CREATE UNIQUE INDEX "TravelPackage_code_key" ON "TravelPackage"("code");
-- CreateIndex
CREATE INDEX "TravelPackage_status_validFrom_idx" ON "TravelPackage"("status", "validFrom");
-- CreateIndex
CREATE UNIQUE INDEX "PackagePriceTier_packageId_seatType_key" ON "PackagePriceTier"("packageId", "seatType");
-- CreateIndex
CREATE UNIQUE INDEX "PackageBooking_bookingRef_key" ON "PackageBooking"("bookingRef");
-- CreateIndex
CREATE INDEX "PackageBooking_packageId_status_idx" ON "PackageBooking"("packageId", "status");
-- CreateIndex
CREATE UNIQUE INDEX "PackagePaymentIntent_packageBookingId_key" ON "PackagePaymentIntent"("packageBookingId");
-- CreateIndex
CREATE INDEX "PackageInquiry_packageId_idx" ON "PackageInquiry"("packageId");
-- AddForeignKey
ALTER TABLE "SeatClass" ADD CONSTRAINT "SeatClass_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "CoachType"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -1325,7 +1573,7 @@ ALTER TABLE "SeatClass" ADD CONSTRAINT "SeatClass_coachTypeId_fkey" FOREIGN KEY
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Passenger" ADD CONSTRAINT "Passenger_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "Passenger" ADD CONSTRAINT "Passenger_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TravelerProfile" ADD CONSTRAINT "TravelerProfile_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -1372,6 +1620,9 @@ ALTER TABLE "Booking" ADD CONSTRAINT "Booking_passengerId_fkey" FOREIGN KEY ("pa
-- AddForeignKey
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_returnScheduleId_fkey" FOREIGN KEY ("returnScheduleId") REFERENCES "TrainSchedule"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -1388,10 +1639,7 @@ ALTER TABLE "PaymentRefund" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey"
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LoyaltyAccount" ADD CONSTRAINT "LoyaltyAccount_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -1432,15 +1680,12 @@ ALTER TABLE "FaqArticle" ADD CONSTRAINT "FaqArticle_categoryId_fkey" FOREIGN KEY
-- AddForeignKey
ALTER TABLE "SupportMessage" ADD CONSTRAINT "SupportMessage_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "SupportConversation"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserPreferences" ADD CONSTRAINT "UserPreferences_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Device" ADD CONSTRAINT "Device_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SavedRoute" ADD CONSTRAINT "SavedRoute_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Journey" ADD CONSTRAINT "Journey_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_journeyId_fkey" FOREIGN KEY ("journeyId") REFERENCES "Journey"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -1457,7 +1702,10 @@ ALTER TABLE "RouteFareRule" ADD CONSTRAINT "RouteFareRule_routeId_fkey" FOREIGN
ALTER TABLE "RouteFareRule" ADD CONSTRAINT "RouteFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Agent" ADD CONSTRAINT "Agent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -1484,13 +1732,37 @@ ALTER TABLE "GateValidationLog" ADD CONSTRAINT "GateValidationLog_ticketId_fkey"
ALTER TABLE "BaggageBooking" ADD CONSTRAINT "BaggageBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "ExcessBaggageCharge" ADD CONSTRAINT "ExcessBaggageCharge_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SeatBlock" ADD CONSTRAINT "SeatBlock_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FraudAlert" ADD CONSTRAINT "FraudAlert_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "TravelPackage" ADD CONSTRAINT "TravelPackage_outboundScheduleId_fkey" FOREIGN KEY ("outboundScheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FaydaVerificationSession" ADD CONSTRAINT "FaydaVerificationSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "TravelPackage" ADD CONSTRAINT "TravelPackage_returnScheduleId_fkey" FOREIGN KEY ("returnScheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PackagePriceTier" ADD CONSTRAINT "PackagePriceTier_packageId_fkey" FOREIGN KEY ("packageId") REFERENCES "TravelPackage"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PackageBooking" ADD CONSTRAINT "PackageBooking_packageId_fkey" FOREIGN KEY ("packageId") REFERENCES "TravelPackage"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PackageBooking" ADD CONSTRAINT "PackageBooking_priceTierId_fkey" FOREIGN KEY ("priceTierId") REFERENCES "PackagePriceTier"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PackageBooking" ADD CONSTRAINT "PackageBooking_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PackageBookingPassenger" ADD CONSTRAINT "PackageBookingPassenger_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "PackageBooking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PackagePaymentIntent" ADD CONSTRAINT "PackagePaymentIntent_packageBookingId_fkey" FOREIGN KEY ("packageBookingId") REFERENCES "PackageBooking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PackageInquiry" ADD CONSTRAINT "PackageInquiry_packageId_fkey" FOREIGN KEY ("packageId") REFERENCES "TravelPackage"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PackageInquiry" ADD CONSTRAINT "PackageInquiry_priceTierId_fkey" FOREIGN KEY ("priceTierId") REFERENCES "PackagePriceTier"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,13 @@
#!/bin/sh
set -e
echo "🔍 Checking for failed migrations..."
# Mark legacy migrations as applied
npx prisma migrate resolve --applied "20240100000000_fix_failed_migration_state" || true
npx prisma migrate resolve --applied "20240101000000_individual_tickets_no_timezone" || true
npx prisma migrate resolve --applied "20240102000000_drop_ticket_column_defaults" || true
npx prisma migrate resolve --applied "20241201000000_remove_station_timezone" || true
npx prisma migrate resolve --applied "20260101000000_add_configurable_fare_system" || true
echo "✅ Migration resolution complete"

View File

@@ -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,
});

View File

@@ -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
}

View File

@@ -4,14 +4,14 @@ import { Type } from 'class-transformer';
import { Currency } from '@prisma/client';
// Nationality → home currency mapping (keys are uppercase for case-insensitive lookup)
export const NATIONALITY_CURRENCY_MAP: Record<string, Currency> = {
ETHIOPIAN: Currency.ETB,
DJIBOUTIAN: Currency.DJF,
export const NATIONALITY_CURRENCY_MAP: Record<string, string> = {
ETHIOPIAN: 'ETB',
DJIBOUTIAN: 'DJF',
};
export function resolveCurrencyFromNationality(nationality?: string): Currency {
if (!nationality) return Currency.ETB;
return NATIONALITY_CURRENCY_MAP[nationality.toUpperCase()] ?? Currency.USD;
if (!nationality) return 'ETB' as Currency;
return (NATIONALITY_CURRENCY_MAP[nationality.toUpperCase()] ?? 'USD') as Currency;
}
export class FareCalculateDto {

View File

@@ -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',
};

View File

@@ -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")

View File

@@ -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;
}

View File

@@ -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

View File

@@ -1,11 +1,11 @@
import { IsString, IsInt, IsOptional, IsArray, ValidateNested, IsBoolean, IsDateString, Min } from 'class-validator';
import { IsString, IsInt, IsNumber, IsOptional, IsArray, ValidateNested, IsBoolean, IsDateString, Min } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
export class RouteStopInputDto {
@ApiProperty({ example: 'station-uuid', description: 'Station UUID' }) @IsString() stationId: string;
@ApiProperty({ example: 1, description: 'Stop order (1 = origin, ascending)' }) @IsInt() @Min(1) sequence: number;
@ApiPropertyOptional({ example: 120, description: 'Distance in km from previous stop' }) @IsOptional() @IsInt() distanceKm?: number;
@ApiPropertyOptional({ example: 120.5, description: 'Distance in km from previous stop' }) @IsOptional() @IsNumber() distanceKm?: number;
}
export class CreateRouteDto {
@@ -34,7 +34,7 @@ export class CreateRouteDto {
export class AddRouteStopDto {
@ApiProperty({ example: 'station-uuid' }) @IsString() stationId: string;
@ApiProperty({ example: 3 }) @IsInt() @Min(1) sequence: number;
@ApiPropertyOptional({ example: 75 }) @IsOptional() @IsInt() distanceKm?: number;
@ApiPropertyOptional({ example: 75.5 }) @IsOptional() @IsNumber() distanceKm?: number;
}
export class UpdateRouteDto {

View File

@@ -34,7 +34,7 @@ export class RoutesService {
create: dto.stops.map(s => ({
stationId: s.stationId,
sequence: s.sequence,
distanceKm: s.distanceKm,
distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null,
})),
},
},
@@ -135,7 +135,12 @@ export class RoutesService {
if (existing) throw new ConflictException(`Sequence ${dto.sequence} already exists on this route`);
return this.prisma.routeStop.create({
data: { routeId, stationId: dto.stationId, sequence: dto.sequence, distanceKm: dto.distanceKm },
data: {
routeId,
stationId: dto.stationId,
sequence: dto.sequence,
distanceKm: dto.distanceKm != null ? parseFloat(String(dto.distanceKm)) : null,
},
});
}

View File

@@ -2,9 +2,8 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Query, ParseIntPipe,
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { SchedulesService } from './schedules.service';
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto } from './schedules.dto';
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto, TripStatus } from './schedules.dto';
import { JwtGuard } from '../../common/jwt.guard';
import { TripStatus } from '@prisma/client';
@ApiTags('Schedule')
@Controller('schedules')

View File

@@ -1,7 +1,27 @@
import { IsString, IsDateString, IsInt, IsOptional, IsEnum, IsArray, ValidateNested, IsObject, Min } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { TripStatus, StopStatus, PassengerCategory } from '@prisma/client';
export enum TripStatus {
SCHEDULED = 'SCHEDULED',
BOARDING = 'BOARDING',
EN_ROUTE = 'EN_ROUTE',
ARRIVED = 'ARRIVED',
CANCELLED = 'CANCELLED',
DELAYED = 'DELAYED',
}
export enum StopStatus {
COMPLETED = 'COMPLETED',
APPROACHING = 'APPROACHING',
CURRENT = 'CURRENT',
UPCOMING = 'UPCOMING',
}
export enum PassengerCategory {
ADULT = 'ADULT',
CHILD = 'CHILD',
}
export class PlannedStopTimeDto {
@ApiProperty({ example: 1, description: 'Route stop sequence number this timing applies to' }) @IsInt() @Min(1) sequence: number;

View File

@@ -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

View File

@@ -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)`);
}
}
}

View File

@@ -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>

View File

@@ -332,12 +332,135 @@ function DobPickerModal({
);
}
// ─── phone validation ─────────────────────────────────────────────────────────
type PhoneNat = 'ETHIOPIAN' | 'DJIBOUTIAN' | 'OTHER';
const PHONE_PRESETS: Record<PhoneNat, { flag: string; code: string; example: string; hint: string }> = {
ETHIOPIAN: { flag: '🇪🇹', code: '+251', example: '912345678', hint: '+251912345678 or 0912345678' },
DJIBOUTIAN: { flag: '🇩🇯', code: '+253', example: '77123456', hint: '+25377123456' },
OTHER: { flag: '🌐', code: '+', example: '14155552671', hint: 'International: +[country code][number]' },
};
function getPhoneNat(nationality: string): PhoneNat {
const n = (nationality || '').toUpperCase();
if (n === 'ETHIOPIAN') return 'ETHIOPIAN';
if (n === 'DJIBOUTIAN') return 'DJIBOUTIAN';
return 'OTHER';
}
function validatePhone(phone: string, nationality: string): string | null {
const normalized = (phone || '').replace(/[\s\-().]/g, '');
if (!normalized) return 'Phone number is required';
const nat = getPhoneNat(nationality);
if (nat === 'ETHIOPIAN') {
if (/^(\+251\d{9}|09\d{8})$/.test(normalized)) return null;
return 'Invalid Ethiopian phone number (e.g., +251912345678 or 0912345678)';
}
if (nat === 'DJIBOUTIAN') {
if (/^\+253\d{8}$/.test(normalized)) return null;
return 'Invalid Djiboutian phone number (e.g., +25377123456)';
}
if (/^\+[1-9]\d{7,14}$/.test(normalized)) return null;
return 'Invalid international phone number (e.g., +14155552671)';
}
function stripPhonePrefix(stored: string, nat: PhoneNat): string {
const code = PHONE_PRESETS[nat].code;
if (nat !== 'OTHER' && stored.startsWith(code)) return stored.slice(code.length);
if (nat === 'OTHER' && stored.startsWith('+')) return stored.slice(1);
return stored;
}
function buildFullNumber(localInput: string, nat: PhoneNat): string {
const stripped = localInput.replace(/[\s\-().]/g, '');
if (!stripped) return stripped;
if (nat === 'ETHIOPIAN') {
if (stripped.startsWith('+') || stripped.startsWith('0')) return stripped;
return '+251' + stripped;
}
if (nat === 'DJIBOUTIAN') {
if (stripped.startsWith('+')) return stripped;
return '+253' + stripped;
}
return stripped.startsWith('+') ? stripped : '+' + stripped;
}
function PhoneInput({
nationality,
storedValue,
onInterimChange,
onNormalized,
error,
}: {
nationality: string;
storedValue: string;
onInterimChange: (full: string) => void;
onNormalized: (full: string) => void;
error?: string;
}) {
const nat = getPhoneNat(nationality);
const preset = PHONE_PRESETS[nat];
const [localInput, setLocalInput] = useState(() => stripPhonePrefix(storedValue || '', nat));
const prevStoredRef = useRef(storedValue);
useEffect(() => {
if (storedValue !== prevStoredRef.current) {
prevStoredRef.current = storedValue;
setLocalInput(stripPhonePrefix(storedValue || '', nat));
}
}, [storedValue, nat]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const raw = e.target.value;
setLocalInput(raw);
onInterimChange(buildFullNumber(raw, nat));
};
const handleBlur = () => {
const full = buildFullNumber(localInput, nat);
setLocalInput(stripPhonePrefix(full, nat));
onNormalized(full);
};
return (
<div>
<div className={`flex rounded-lg overflow-hidden border transition-colors focus-within:ring-1 ${
error
? 'border-red-500 focus-within:ring-red-500'
: 'border-gray-300 dark:border-gray-600 focus-within:border-primary focus-within:ring-primary'
}`}>
<div className="flex items-center gap-1.5 px-3 py-2.5 bg-gray-50 dark:bg-gray-800 border-r border-gray-300 dark:border-gray-600 select-none flex-shrink-0">
<span className="text-sm leading-none">{preset.flag}</span>
<span className="text-xs font-semibold text-gray-600 dark:text-gray-300">{preset.code}</span>
</div>
<input
type="tel"
value={localInput}
onChange={handleChange}
onBlur={handleBlur}
placeholder={preset.example}
autoComplete="tel"
className="flex-1 px-3 py-2.5 bg-white dark:bg-gray-900 text-sm text-gray-900 dark:text-white outline-none min-w-0"
/>
</div>
{error ? (
<p className="text-red-500 text-xs mt-1">{error}</p>
) : (
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">Format: {preset.hint}</p>
)}
</div>
);
}
// ─── passenger zod schema ──────────────────────────────────────────────────────
const passengerSchema = z.object({
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 */}

View File

@@ -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}&currency=${amountCurrency}`;
console.log('[BookingAmount] Request:', { url, bookingId, currency: amountCurrency, selectedMethod });
const response: any = await apiClient.get(url);
console.log('[BookingAmount] Response:', response);
return response;
},
enabled: !!selectedMethod && !!bookingId,
});
// Fallback: estimate from local store while API hasn't responded yet
const outboundBaseFare = isRoundTrip && outboundSchedule ? passengers.reduce(
(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>

View File

@@ -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>

View File

@@ -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) {

View File

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

View File

@@ -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));
}
};

View File

@@ -131,6 +131,12 @@ export class WebhooksController {
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "D-Money payment notification callback (Djibouti)" })
async receiveDMoney(@Body() payload: DMoneyWebhookPayload) {
this.logger.log(
`D-Money webhook hit: merchOrderId=${payload?.merch_order_id ?? "n/a"} ` +
`paymentOrderId=${payload?.payment_order_id ?? "n/a"} ` +
`tradeStatus=${payload?.trade_status ?? "n/a"}`,
);
this.logger.log(`D-Money webhook payload: ${JSON.stringify(payload)}`);
try {
await this.dMoney.handle(payload);
} catch (err) {