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