+ )}
+ />
+
+ );
+}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts
index dbf943068..ea4b1214a 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts
+++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts
@@ -14,9 +14,7 @@ export const STEPS = [
/**
* Shipment documents collected during booking creation. The fileKeys mirror
- * `REQUIRED_DOC_FIELDS` in BookingDetailPage/constants.ts so anything attached
- * here shows up as "Uploaded" on the booking detail page. All optional in this
- * flow — the detail page remains the catch-all for uploading them later.
+ * `REQUIRED_DOC_FIELDS` in BookingDetailPage/constants.ts.
*/
const DOC_SETTING_TS = "2024-01-01T00:00:00.000Z";
@@ -34,7 +32,7 @@ function docField(
fileKey,
fileLabel,
helpText: null,
- isRequired: false,
+ isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
@@ -51,7 +49,7 @@ export const BOOKING_DOCS_SETTING: Freight.IFileUploadSetting = {
code: "booking_documents",
label: "Booking Documents",
description:
- "Attach your shipment documents now, or skip and upload them later from the booking page.",
+ "Attach all four required shipment documents before submitting your booking.",
entity: "booking",
fields: [
docField("commercial_invoice", "Commercial Invoice", 1),
@@ -63,11 +61,32 @@ export const BOOKING_DOCS_SETTING: Freight.IFileUploadSetting = {
export type BookingDocuments = Record;
+export const PAYMENT_CURRENCIES = ["USD", "ETB"] as const;
+export type PaymentCurrency = (typeof PAYMENT_CURRENCIES)[number];
+
+export const PAYMENT_CURRENCY_OPTIONS: Array<{
+ value: PaymentCurrency;
+ label: string;
+ description: string;
+}> = [
+ {
+ value: "USD",
+ label: "USD",
+ description: "US Dollar — international pricing and invoicing.",
+ },
+ {
+ value: "ETB",
+ label: "ETB",
+ description: "Ethiopian Birr — local pricing and invoicing.",
+ },
+];
+
export const bookingFormSchema = z
.object({
contractType: z.enum(["new", "renewal"], "Select a contract type."),
previousContractRef: z.string(),
serviceTypeId: z.string("Select a service type."),
+ paymentCurrency: z.enum(PAYMENT_CURRENCIES, "Select a payment currency."),
firstMile: z
.object({
@@ -202,6 +221,7 @@ export const initialBookingFormValues: DeepPartial = {
previousContractRef: "",
serviceTypeId: "",
+ paymentCurrency: "USD",
firstMile: {
enabled: false,
pickUpAddress: "",
@@ -232,6 +252,7 @@ export const stepFields: Record>> = {
1: ["contractType", "previousContractRef"],
2: [
"serviceTypeId",
+ "paymentCurrency",
"firstMile",
"lastMile",
"equipmentReturn",
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx
index 55b6eda44..b2ee1fd7e 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx
@@ -1,48 +1,167 @@
-import { Alert, Combobox, Input, InputBase, Select, Text, Title, useCombobox } from "@mantine/core";
-import { AlertTriangle, Check, CheckCircle2, Info, Loader, XCircle } from "lucide-react";
+import {
+ Alert,
+ Box,
+ Combobox,
+ Group,
+ Input,
+ InputBase,
+ Paper,
+ Select,
+ Text,
+ Title,
+ useCombobox,
+} from "@mantine/core";
+import {
+ AlertTriangle,
+ Check,
+ CheckCircle2,
+ Info,
+ Loader,
+ XCircle,
+} from "lucide-react";
import type { ReactNode } from "react";
import { useMemo } from "react";
-import type { ControllerRenderProps, FieldError as RhfFieldError } from "react-hook-form";
+import type {
+ ControllerRenderProps,
+ FieldError as RhfFieldError,
+} from "react-hook-form";
import type { BookingFormInputValues } from "./schema";
+// Brand tokens (kept local so the form reads consistently with the booking
+// detail page and the scheduling step).
+const INK = "#10202F";
+const MUTED = "#6B7C8E";
+const GREEN = "#0EA371";
+const GREEN_DARK = "#0A6F4D";
+const BORDER = "#E6ECF2";
+
export function OptionFieldError({ error }: { error?: { message?: string } }) {
if (!error?.message) return null;
return (
-
+
{error.message}
);
}
+/**
+ * Premium selectable option card with an icon tile, title, and description.
+ * Pass `icon`/`iconBg`/`iconColor` for the leading tile, or compose freely via
+ * `children` (legacy callers still work).
+ */
export function OptionCard({
selected,
onClick,
disabled,
+ icon,
+ iconBg = "#ECF6F1",
+ iconColor = GREEN_DARK,
+ title,
+ description,
children,
}: {
selected: boolean;
onClick?: () => void;
disabled?: boolean;
- children: ReactNode;
+ icon?: ReactNode;
+ iconBg?: string;
+ iconColor?: string;
+ title?: ReactNode;
+ description?: ReactNode;
+ children?: ReactNode;
}) {
return (
{
+ if (!disabled && !selected) {
+ e.currentTarget.style.borderColor = "#BFE3D2";
+ e.currentTarget.style.boxShadow = "0 6px 16px rgba(16,24,40,0.07)";
+ }
+ }}
+ onMouseLeave={(e) => {
+ if (!disabled && !selected) {
+ e.currentTarget.style.borderColor = BORDER;
+ e.currentTarget.style.boxShadow = "0 1px 2px rgba(16,24,40,0.04)";
+ }
+ }}
>
{selected && !disabled && (
-
-
+
+
)}
+
+ {/* Structured form (icon + title + description) */}
+ {(icon || title || description) && (
+
+ {icon && (
+
+ {icon}
+
+ )}
+ {title && (
+
+ {title}
+
+ )}
+ {description && (
+
+ {description}
+
+ )}
+
+ )}
+
{children}
);
@@ -63,7 +182,7 @@ export function AlertBox({
};
const { color, icon } = map[tone];
return (
-
+
{children}
);
@@ -71,31 +190,89 @@ export function AlertBox({
export function StepLabel({ children }: { children: ReactNode }) {
return (
-
+
{children}
);
}
+/**
+ * Card shell that wraps a step's body. Gives every step the same premium
+ * surface, padding, and an optional eyebrow.
+ */
+export function StepCard({
+ children,
+ eyebrow,
+}: {
+ children: ReactNode;
+ eyebrow?: ReactNode;
+}) {
+ return (
+
+ {eyebrow}
+ {children}
+
+ );
+}
+
export function StepHeader({
title,
description,
+ icon,
}: {
title: string;
description: string;
+ icon?: ReactNode;
}) {
return (
-
-
- {title}
-
-
- {description}
-
-
+
+ {icon && (
+
+ {icon}
+
+ )}
+
+
+ {title}
+
+
+ {description}
+
+
+
);
}
+/** Shared Mantine input styling so every field in the form matches. */
+export const fieldStyles = {
+ label: { fontWeight: 600, fontSize: 13, color: INK, marginBottom: 6 },
+ input: { borderRadius: 10, minHeight: 44, height: 44, borderColor: BORDER },
+} as const;
+
export function SelectField({
field,
error,
@@ -103,6 +280,7 @@ export function SelectField({
placeholder,
disabled,
data,
+ leftSection,
}: {
field: ControllerRenderProps;
error?: RhfFieldError;
@@ -110,6 +288,7 @@ export function SelectField({
placeholder: string;
disabled?: boolean;
data: string[] | { value: string; label: string }[];
+ leftSection?: ReactNode;
}) {
return (
);
}
@@ -166,12 +350,14 @@ export function AsyncComboboxField({
};
return (
-
-
+
+ {
onSearchChange(e.currentTarget.value);
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-documents.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-documents.tsx
index f600d10d7..07a6d197c 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-documents.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-documents.tsx
@@ -1,6 +1,6 @@
import { Box, Group, Text } from "@mantine/core";
import { SmartFileInput } from "@edr/ui-common";
-import { CheckCircle2 } from "lucide-react";
+import { CheckCircle2, FileUp } from "lucide-react";
import { Controller, type UseFormReturn } from "react-hook-form";
import {
@@ -9,7 +9,7 @@ import {
type BookingDocuments,
type BookingFormValues,
} from "./schema";
-import { StepHeader } from "./shared";
+import { StepCard, StepHeader } from "./shared";
type BookingForm = UseFormReturn<
BookingFormInputValues,
@@ -30,10 +30,11 @@ export function StepDocuments({ form }: { form: BookingForm }) {
const total = BOOKING_DOCS_SETTING.fields.length;
return (
-
+ }
title="Shipment Documents"
- description="Attach your shipment documents now, or skip this step and upload them later from the booking page."
+ description="Attach your shipment documents now, or skip and upload them later from the booking page."
/>
)}
/>
-
+ }
title="Contract Type"
- description="New contract or renewal of an existing one."
+ description="Start a new contract or renew an existing one to reuse its details."
/>
(
+
{error && (
Failed to load previous contracts. Please try again later.
@@ -263,8 +259,8 @@ export function Step1ContractType({
details will be pre-filled.
)}
-
+
)}
-
+
);
}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx
index e01e2f879..b53571c03 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx
@@ -1,9 +1,18 @@
-import { Switch, TextInput } from "@mantine/core";
-import { FileText, Train, Truck } from "lucide-react";
+import { Box, Group, Stack, Switch, Text, TextInput } from "@mantine/core";
+import type { ReactNode } from "react";
+import { FileText, Layers, Train, Truck } from "lucide-react";
import { useEffect, useRef } from "react";
import { Controller, type UseFormReturn } from "react-hook-form";
import { BookingFormInputValues, type BookingFormValues } from "./schema";
-import { OptionCard, OptionFieldError, StepHeader } from "./shared";
+import {
+ fieldStyles,
+ OptionCard,
+ OptionFieldError,
+ StepCard,
+ StepHeader,
+ StepLabel,
+} from "./shared";
+import { PaymentCurrencyField } from "./payment-currency-field";
import type { Freight } from "@edr/types";
@@ -61,10 +70,11 @@ export function Step2ServiceType({
const showServiceSections =
includesCustoms || includesFirstMile || includesLastMile;
return (
-
+ }
title="Service Type"
- description="Select the service combination and configure trucking options."
+ description="Choose the service combination, then configure your trucking options."
/>
(
+ }
title="Route"
- description="Select the origin and destination yards."
+ description="Choose the origin and destination yards for your shipment."
/>
{isLoading ? (
@@ -96,7 +96,7 @@ export function Step4Route({
) : (
+ Your payment didn't go through and you haven't been charged. You can
+ try again from your booking using "Pay now".
+
+
+ navigate("/bookings")}>
+ Back to My Bookings
+
+ navigate("/")}
+ >
+ Back to home
+
+
+
+
+
+ );
+}
diff --git a/apps/edr-freight-web/portal/src/pages/payments/PaymentSuccessPage.tsx b/apps/edr-freight-web/portal/src/pages/payments/PaymentSuccessPage.tsx
new file mode 100644
index 000000000..4b2d766e7
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/pages/payments/PaymentSuccessPage.tsx
@@ -0,0 +1,43 @@
+import { CheckCircle2 } from "lucide-react";
+import { useNavigate } from "react-router-dom";
+import { Button } from "@edr/ui-common";
+
+/**
+ * Public page the payment provider redirects the browser to after a successful
+ * payment (PAYMENT_RETURN_URL). Generic — it confirms success and points the
+ * customer back to their bookings, where the booking reflects the paid state.
+ */
+export default function PaymentSuccessPage() {
+ const navigate = useNavigate();
+
+ return (
+
+
+
+
+
+
+
+ Payment successful
+
+
+ Thank you — your payment has been received. Your booking will be
+ updated shortly and is now confirmed for scheduling.
+
+
+ navigate("/bookings")}>
+ Go to My Bookings
+
+ navigate("/")}
+ >
+ Back to home
+
+
+
+
+
+ );
+}
diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts
index 80ebd4da0..189a7c1f4 100644
--- a/apps/edr-freight-web/portal/src/services/api.ts
+++ b/apps/edr-freight-web/portal/src/services/api.ts
@@ -13,7 +13,9 @@ import {
BookingListFilter,
CreateBookingPayload,
GeneratePriceResponse,
+ SubmitBookingResponse,
} from "./bookings.service";
+import type { BookingDocuments } from "@/pages/bookings/new-booking-form/schema";
import {
paymentsService,
InitiatePaymentPayload,
@@ -147,16 +149,29 @@ export const api = {
({ id }) => bookingsService.get(id),
),
- create: endpoint(
+ tracking: endpoint<{ id: string }, Freight.IBookingTracking>(
"bookings",
- "create",
- bookingsService.create,
+ "tracking",
+ ({ id }) => bookingsService.tracking(id),
+ ),
+
+ create: endpoint<
+ { payload: CreateBookingPayload; documents?: BookingDocuments },
+ Freight.IBooking
+ >("bookings", "create", ({ payload, documents }) =>
+ bookingsService.create(payload, documents),
),
update: endpoint<
- { id: string; dto: Partial },
+ {
+ id: string;
+ dto: Partial;
+ documents?: BookingDocuments;
+ },
{ booking: Freight.IBooking; warnings: string[] }
- >("bookings", "update", ({ id, dto }) => bookingsService.update(id, dto)),
+ >("bookings", "update", ({ id, dto, documents }) =>
+ bookingsService.update(id, dto, documents),
+ ),
referenceData: endpoint(
"bookings",
@@ -180,12 +195,18 @@ export const api = {
({ id }) => bookingsService.generatePrice(id),
),
- submit: endpoint<{ id: string }, Freight.IBooking>(
+ submit: endpoint<{ id: string }, SubmitBookingResponse>(
"bookings",
"submit",
({ id }) => bookingsService.submit(id),
),
+ confirmSubmit: endpoint<{ id: string }, SubmitBookingResponse>(
+ "bookings",
+ "confirmSubmit",
+ ({ id }) => bookingsService.confirmSubmit(id),
+ ),
+
uploadDocuments: endpoint<
{ id: string; files: Record },
Freight.IBooking
diff --git a/apps/edr-freight-web/portal/src/services/booking-form-data.ts b/apps/edr-freight-web/portal/src/services/booking-form-data.ts
new file mode 100644
index 000000000..52616b09e
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/services/booking-form-data.ts
@@ -0,0 +1,88 @@
+import type { CreateBookingPayload } from "./bookings.service";
+import { BOOKING_DOCS_SETTING, type BookingDocuments } from "@/pages/bookings/new-booking-form/schema";
+
+function appendValue(formData: FormData, key: string, value: unknown) {
+ if (value === undefined || value === null) return;
+ if (typeof value === "boolean") {
+ formData.append(key, value ? "true" : "false");
+ return;
+ }
+ if (typeof value === "number") {
+ formData.append(key, String(value));
+ return;
+ }
+ if (typeof value === "string") {
+ formData.append(key, value);
+ return;
+ }
+}
+
+function appendContainers(
+ formData: FormData,
+ containers: NonNullable,
+) {
+ containers.forEach((container, index) => {
+ formData.append(
+ `containers[${index}][containerTypeId]`,
+ container.containerTypeId,
+ );
+ formData.append(
+ `containers[${index}][quantity]`,
+ String(container.quantity),
+ );
+ formData.append(
+ `containers[${index}][vgmPerUnitTons]`,
+ String(container.vgmPerUnitTons),
+ );
+ });
+}
+
+function appendDocuments(
+ formData: FormData,
+ documents?: Record,
+) {
+ if (!documents) return;
+ for (const [key, fileOrFiles] of Object.entries(documents)) {
+ if (!fileOrFiles) continue;
+ if (Array.isArray(fileOrFiles)) {
+ for (const file of fileOrFiles) {
+ formData.append(key, file);
+ }
+ } else {
+ formData.append(key, fileOrFiles);
+ }
+ }
+}
+
+/** Flatten a booking payload (and optional document files) into multipart FormData. */
+export function buildBookingFormData(
+ payload: Partial,
+ documents?: BookingDocuments,
+): FormData {
+ const formData = new FormData();
+ const skipKeys = new Set(["containers", "freightShapeValidation"]);
+
+ for (const [key, value] of Object.entries(payload)) {
+ if (skipKeys.has(key)) continue;
+ appendValue(formData, key, value);
+ }
+
+ if (payload.containers?.length) {
+ appendContainers(formData, payload.containers);
+ }
+
+ appendDocuments(formData, documents);
+ return formData;
+}
+
+/** Returns true when every required booking document field has a file attached. */
+export function hasAllRequiredDocuments(
+ documents: BookingDocuments | undefined | null,
+): boolean {
+ const docs = documents ?? {};
+ return BOOKING_DOCS_SETTING.fields.every((field) => {
+ const value = docs[field.fileKey];
+ if (Array.isArray(value)) return value.length > 0;
+ return Boolean(value);
+ });
+}
diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts
index 9933fa227..115edef6d 100644
--- a/apps/edr-freight-web/portal/src/services/bookings.service.ts
+++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts
@@ -1,6 +1,8 @@
import type { Freight, PaginatedResponse } from "@edr/types";
import { URL_CONSTANTS } from "@/constants/URLS";
+import type { BookingDocuments } from "@/pages/bookings/new-booking-form/schema";
+import { buildBookingFormData } from "./booking-form-data";
import { client } from "../utils/api";
const B = URL_CONSTANTS.BOOKINGS;
@@ -45,6 +47,17 @@ export interface GeneratePriceResponse {
warnings: string[];
}
+export interface SubmitBookingResponse {
+ bookingId: string;
+ status: string;
+ priceChanged: boolean;
+ previousTotalAmount?: number;
+ totalAmount: number;
+ currency: string;
+ lineItems?: PriceLineItem[];
+ message?: string;
+}
+
export interface SignContractPayload {
role: "CUSTOMER" | "STAFF";
signatureImageBase64: string;
@@ -54,6 +67,8 @@ export interface SignContractPayload {
export interface BookingListFilter {
status?: string;
+ /** Comma-separated statuses (overrides `status` when set). */
+ statuses?: string;
page?: number;
pageSize?: number;
sortBy?: string;
@@ -71,8 +86,18 @@ export const bookingsService = {
const { data } = await client.get(`/api/bookings/${id}`);
return data.data;
},
- create: async (payload: CreateBookingPayload): Promise => {
- const { data } = await client.post("/api/bookings", payload);
+ tracking: async (id: string): Promise => {
+ const { data } = await client.get(`/api/bookings/${id}/tracking`);
+ return data.data;
+ },
+ create: async (
+ payload: CreateBookingPayload,
+ documents?: BookingDocuments,
+ ): Promise => {
+ const formData = buildBookingFormData(payload, documents);
+ const { data } = await client.post("/api/bookings", formData, {
+ headers: { "Content-Type": "multipart/form-data" },
+ });
return data.data.booking;
},
getReferenceData: async (): Promise => {
@@ -82,8 +107,12 @@ export const bookingsService = {
update: async (
id: string,
payload: Partial,
+ documents?: BookingDocuments,
): Promise<{ booking: Freight.IBooking; warnings: string[] }> => {
- const { data } = await client.patch(`/api/bookings/${id}`, payload);
+ const formData = buildBookingFormData(payload, documents);
+ const { data } = await client.patch(`/api/bookings/${id}`, formData, {
+ headers: { "Content-Type": "multipart/form-data" },
+ });
return data.data;
},
@@ -101,11 +130,16 @@ export const bookingsService = {
return data.data;
},
- submit: async (id: string): Promise => {
+ submit: async (id: string): Promise => {
const { data } = await client.post(`/api/bookings/${id}/submit`);
return data.data;
},
+ confirmSubmit: async (id: string): Promise => {
+ const { data } = await client.post(`/api/bookings/${id}/confirm-submit`);
+ return data.data;
+ },
+
uploadDocuments: async (
id: string,
files: Record,
diff --git a/apps/edr-passenger-api/.env.example b/apps/edr-passenger-api/.env.example
index b2500160d..482a0df2c 100644
--- a/apps/edr-passenger-api/.env.example
+++ b/apps/edr-passenger-api/.env.example
@@ -130,6 +130,13 @@ FAYDA_SESSION_TTL_MINUTES=10
GITHUB_PACKAGE_TOKEN=
+# --- Notification broker (RabbitMQ) -----------------------------------------------------------------
+# Set RABBITMQ_ENABLED=false to skip connection entirely (dev without a local broker).
+RABBITMQ_ENABLED=false
+RABBITMQ_URL=amqp://localhost:5672
+EMAIL_QUEUE=email_queue
+SMS_QUEUE=sms_queue
+
# --- Payment event consumer (RabbitMQ) -------------------------------------------------------
# Consumes payment.succeeded / payment.failed events from the payment microservice. Separate
# from any RABBITMQ_URL used by the IAM/notification modules so the two connections are
diff --git a/apps/edr-passenger-api/prisma/migrations/20260625_add_return_leg_status/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260625_add_return_leg_status/migration.sql
new file mode 100644
index 000000000..e93fb8320
--- /dev/null
+++ b/apps/edr-passenger-api/prisma/migrations/20260625_add_return_leg_status/migration.sql
@@ -0,0 +1,18 @@
+-- CreateEnum
+CREATE TYPE "passenger"."ReturnLegStatus" AS ENUM ('NOT_APPLICABLE', 'BOTH_USED', 'OUTBOUND_ONLY', 'INBOUND_ONLY', 'NEITHER_USED');
+
+-- AlterTable: add return leg tracking columns to Booking
+ALTER TABLE "passenger"."Booking"
+ ADD COLUMN "returnLegStatus" "passenger"."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 "passenger"."Booking"
+SET "returnLegStatus" = 'NEITHER_USED'
+WHERE "bookingType" = 'ROUND_TRIP'
+ AND "status" IN ('CONFIRMED', 'COMPLETED');
+
+-- AlterTable: add leg column to GateValidationLog
+ALTER TABLE "passenger"."GateValidationLog"
+ ADD COLUMN "leg" TEXT;
diff --git a/apps/edr-passenger-api/prisma/migrations/20260626_fix_missing_booking_columns/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260626_fix_missing_booking_columns/migration.sql
new file mode 100644
index 000000000..4f828f075
--- /dev/null
+++ b/apps/edr-passenger-api/prisma/migrations/20260626_fix_missing_booking_columns/migration.sql
@@ -0,0 +1,38 @@
+-- Fix missing columns from 20260617 migration (failed due to missing schema prefix)
+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;
+
+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");
+
+-- Transit leg-2 columns (never migrated)
+ALTER TABLE "passenger"."Booking"
+ 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;
+
+-- ReturnLegStatus enum + columns (from 20260625 migration, may have also failed)
+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 COLUMN IF NOT EXISTS "outboundBoardedAt" TIMESTAMP(3),
+ ADD COLUMN IF NOT EXISTS "returnBoardedAt" TIMESTAMP(3);
+
+ALTER TABLE "passenger"."GateValidationLog"
+ ADD COLUMN IF NOT EXISTS "leg" TEXT;
diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma
index b2259a448..1b98bd3e1 100644
--- a/apps/edr-passenger-api/prisma/schema.prisma
+++ b/apps/edr-passenger-api/prisma/schema.prisma
@@ -115,6 +115,16 @@ enum BookingStatus {
@@schema("passenger")
}
+enum ReturnLegStatus {
+ NOT_APPLICABLE // one-way booking
+ BOTH_USED // passenger used both legs
+ OUTBOUND_ONLY // return leg not used (no-show on return)
+ INBOUND_ONLY // outbound leg not used, return leg used
+ NEITHER_USED // neither leg boarded yet
+
+ @@schema("passenger")
+}
+
enum PaymentRegion {
ETHIOPIA
DJIBOUTI
@@ -364,7 +374,8 @@ model TrainSchedule {
originStation Station @relation("OriginTrips", fields: [originStationId], references: [id])
destinationStation Station @relation("DestinationTrips", fields: [destinationStationId], references: [id])
coachAssignments CoachAssignment[]
- bookings Booking[]
+ bookings Booking[] @relation("OutboundSchedule")
+ returnBookings Booking[] @relation("ReturnSchedule")
stopTimes TripStopTime[]
liveStatus TripLiveStatus?
menuItems MenuItem[]
@@ -511,6 +522,19 @@ model Booking {
returnDestinationStationId String?
returnHoldId String?
returnSeatClassId String?
+ returnLegStatus ReturnLegStatus @default(NOT_APPLICABLE)
+ // Transit leg-2 fields (single-booking transit)
+ leg2ScheduleId String?
+ leg2OriginStationId String?
+ leg2DestinationStationId String?
+ leg2SeatClassId String?
+ // Round-trip transit: return journey transit fields
+ returnLeg2ScheduleId String?
+ returnLeg2OriginStationId String?
+ returnLeg2DestStationId String?
+ returnLeg2SeatClassId String?
+ outboundBoardedAt DateTime?
+ returnBoardedAt DateTime?
contactEmail String?
contactPhone String?
userAgent String?
@@ -520,7 +544,8 @@ model Booking {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
passenger Passenger @relation(fields: [passengerId], references: [id])
- schedule TrainSchedule @relation(fields: [scheduleId], references: [id])
+ schedule TrainSchedule @relation("OutboundSchedule", fields: [scheduleId], references: [id])
+ returnSchedule TrainSchedule? @relation("ReturnSchedule", fields: [returnScheduleId], references: [id])
seats BookingSeat[]
paymentIntent PaymentIntent?
ticket Ticket?
@@ -539,6 +564,8 @@ model BookingSeat {
id String @id @default(uuid())
bookingId String
seatId String
+ leg Int @default(1) // 1=outbound/leg-1, 2=return/leg-2
+ scheduleId String? // which schedule this seat belongs to
passengerName String
dateOfBirth DateTime?
passengerCategory PassengerCategory @default(ADULT)
@@ -1159,6 +1186,7 @@ model GateValidationLog {
ticketId String
validatorId String
gateId String?
+ leg String? // 'OUTBOUND' | 'RETURN' — for round-trip tickets
status String
reason String?
validatedAt DateTime @default(now())
diff --git a/apps/edr-passenger-api/src/main.ts b/apps/edr-passenger-api/src/main.ts
index f1972b0e6..928789c04 100644
--- a/apps/edr-passenger-api/src/main.ts
+++ b/apps/edr-passenger-api/src/main.ts
@@ -34,19 +34,25 @@ async function bootstrap() {
## Overview
Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and management platform. Built with NestJS, TypeScript, PostgreSQL, and Prisma ORM.
-## 🆕 Latest Updates
-- **Sequence Ordering:** Stations and coaches now sorted by sequence field for consistent UI display
-- **User Profile Data:** Gender, DOB, passport, and national ID fields for comprehensive passenger profiles
-- **Seat Class Fees:** Premium charges and insurance fees per seat class for transparent pricing
-- **Booking Types:** Support for ONE_WAY and ROUND_TRIP booking categories
-- **Multi-Currency Display:** Bookings track display currency and converted amounts
-- **Ticket Lifecycle:** Tickets now include validatedAt and boardedAt timestamps for complete audit trail
+## Latest Updates
+- **TRANSIT & ROUND_TRIP_TRANSIT Booking Types:** Full multi-leg booking support. TRANSIT = single journey via connecting train (single PNR). ROUND_TRIP_TRANSIT = round trip where one or both directions use a connecting train (4 holds, 4 seat sets).
+- **returnSeatId on Passenger Payloads:** For ROUND_TRIP and ROUND_TRIP_TRANSIT bookings each passenger object must include \`returnSeatId\` (the seat on the return leg-1). Guest and authenticated booking endpoints both enforce this.
+- **Unified Booking Type Matrix:** bookingType field on Booking now accepts ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT across all create endpoints (POST /bookings and POST /bookings/guest).
+- **Round-Trip Leg Tracking:** returnLegStatus on every booking tracks outbound/return leg usage (NEITHER_USED, OUTBOUND_ONLY, INBOUND_ONLY, BOTH_USED). Gate validation accepts a leg field (OUTBOUND | RETURN | LEG1 | LEG2 | OUTBOUND_LEG1 | OUTBOUND_LEG2 | RETURN_LEG1 | RETURN_LEG2).
+- **Auto No-Show Detection:** Cron marks OUTBOUND_ONLY 30 min after return departure when return leg was never scanned.
+- **Offline Batch Validation:** validateOfflineBatch now accepts leg per entry and handles both legs of a round-trip in one batch.
+- **Booking Filters:** GET /bookings now accepts ?returnLegStatus= to filter no-show/inbound-only cases in back-office.
+- **Sequence Ordering:** Stations and coaches now sorted by sequence field for consistent UI display.
+- **User Profile Data:** Gender, DOB, passport, and national ID fields for comprehensive passenger profiles.
+- **Seat Class Fees:** Premium charges and insurance fees per seat class for transparent pricing.
+- **Multi-Currency Display:** Bookings track display currency and converted amounts.
+- **Ticket Lifecycle:** Tickets now include validatedAt, outboundBoardedAt, returnBoardedAt for complete audit trail.
## Key Features
-### 🎫 Booking Lifecycle
+### Booking Lifecycle
- Search trips with real-time availability
-- Age-based passenger categorization (Adult ≥5 years, Child <5 years)
+- Age-based passenger categorization (Adult 5+ years, Child under 5)
- Nationality-based verification (Ethiopian Fayda, International Passport)
- Passenger information collection with verification
- Coach and seat selection with real-time availability
@@ -55,130 +61,143 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m
- Modify bookings (seat changes, passenger updates)
- Cancel bookings with automatic refunds
- Multi-segment journey support
-- Cross-border journeys via Dire Dawa transit (Ethiopia → Djibouti)
+- Cross-border journeys via Dire Dawa transit (Ethiopia to Djibouti)
- Round-trip booking with return journey scheduling
+- Transit booking (single journey via connecting train, single PNR, single ticket)
+- Round-trip transit booking (round trip where one or both directions use a connecting train)
- Coach type selection with seat class and pricing options
-- **NEW:** Booking type tracking (ONE_WAY vs ROUND_TRIP)
-- **NEW:** Display currency and converted pricing per booking
+- Booking type field: ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT
+- Display currency and converted pricing per booking
+- returnLegStatus field tracks which legs of a round-trip were used
+- GET /bookings?returnLegStatus=OUTBOUND_ONLY filters no-show returns in back-office
-### 👤 Passenger Verification
-1. **Ethiopian Nationals:**
-- Automatic Fayda verification for adults (≥5 years)
+### Passenger Verification
+1. Ethiopian Nationals:
+- Automatic Fayda verification for adults (5+ years)
- Real-time national ID verification via government database
- Retrieves verified passenger data (name, DOB, gender)
- National IDs not stored (policy compliant)
-2. **International Passengers:**
+2. International Passengers:
- Passport information collection
- Manual verification for Djiboutian and other nationals
- No government database verification required
-### 💰 Age-Based Pricing
-- **ADULT** (≥5 years): Pay 100% of base fare
-- **CHILD** (<5 years): First child travels FREE, subsequent children pay 100%
+### Age-Based Pricing
+- ADULT (5+ years): Pay 100% of base fare
+- CHILD (under 5): First child travels FREE, subsequent children pay 100%
- Automatic age calculation from date of birth
-- Example: 2 adults + 3 children = 4× base fare (first child free)
-- **NEW:** Premium charges and insurance fees per seat class
-- **NEW:** Transparent fee breakdown in pricing calculations
+- Example: 2 adults + 3 children = 4x base fare (first child free)
+- NEW: Premium charges and insurance fees per seat class
+- NEW: Transparent fee breakdown in pricing calculations
-### 💳 Payment Integration
-1. **Ethiopian Payment Methods:**
-- **Telebirr** - Ethiopia's leading mobile money
-- **CBE Birr** - Commercial Bank of Ethiopia
+### Payment Integration
+1. Ethiopian Payment Methods: Telebirr, CBE Birr
+2. Djiboutian Payment Methods: Waafi
+3. International Payment Methods: Card, Wallet
-2. **Djiboutian Payment Methods:**
-- **Waafi** - Djibouti's mobile money service
-
-3. **International Payment Methods:**
-- **Card** - International card payments (Visa, Mastercard)
-- **Wallet** - Internal wallet system
-
-### 🪑 Seat Management
+### Seat Management
- Real-time seat availability by coach and class
- Seat holds with 15-minute expiry
- Auto-assign seats with contiguous algorithm
- Seat blocking for maintenance
- Coach-level seat maps (ordered by sequence)
- Class-based seating (Economy Regular, Economy Bed, VIP Bed)
-- **NEW:** Sequence-based coach ordering for consistent display
+- NEW: Sequence-based coach ordering for consistent display
-### 🎟️ Ticketing
+### Ticketing
- QR code and barcode generation
- PDF ticket generation
- Gate validation with audit logs
- Offline validation support
- Multi-passenger tickets
-- **NEW:** Ticket lifecycle tracking (validatedAt, boardedAt timestamps)
-- **NEW:** Complete audit trail for compliance and reporting
+- NEW: Ticket lifecycle tracking (validatedAt, outboundBoardedAt, returnBoardedAt timestamps)
+- NEW: Gate validation accepts leg (OUTBOUND or RETURN) for round-trip tickets
+- NEW: Complete audit trail per leg for compliance and reporting
-### 🏆 Loyalty Program
+### Booking Type Matrix
+
+| bookingType | Holds required | Passenger seat fields | Legs in DB |
+|---|---|---|---|
+| ONE_WAY | holdId | seatId | 1 |
+| ROUND_TRIP | holdId + returnHoldId | seatId + returnSeatId | 2 (leg=1 outbound, leg=2 return) |
+| TRANSIT | holdId + leg2HoldId | seatId + leg2SeatId | 2 (leg=1, leg=2 on same direction) |
+| ROUND_TRIP_TRANSIT | holdId + leg2HoldId + returnHoldId + returnLeg2HoldId | seatId + leg2SeatId + returnSeatId + returnLeg2SeatId | 4 |
+
+### Round-Trip Leg Tracking
+- returnLegStatus on Booking: NOT_APPLICABLE, NEITHER_USED, OUTBOUND_ONLY, INBOUND_ONLY, BOTH_USED
+- Gate validation POST /tickets/:ref/validate accepts optional leg field:
+ - ONE_WAY: omit
+ - TRANSIT: LEG1 | LEG2
+ - ROUND_TRIP: OUTBOUND | RETURN
+ - ROUND_TRIP_TRANSIT: OUTBOUND_LEG1 | OUTBOUND_LEG2 | RETURN_LEG1 | RETURN_LEG2
+- Auto no-show cron: sets OUTBOUND_ONLY 30 min after return departure when return leg unscanned
+- Back-office filter: GET /bookings?returnLegStatus=OUTBOUND_ONLY surfaces no-shows
+- Offline batch: validateOfflineBatch accepts leg per entry, handles both legs of same booking
+
+### Round-Trip & Transit Bookings
+- ONE_WAY and ROUND_TRIP for direct routes
+- TRANSIT for single connecting journey (Dire Dawa hub), single PNR
+- ROUND_TRIP_TRANSIT for round trips via connecting trains
+- Combined pricing: total = sum of all leg base fares, single promo/loyalty deduction
+- Separate seat management per leg; each leg stored with its scheduleId and leg number
+- returnLegStatus tracks which legs have been boarded for no-show management
+
+### Loyalty Program
- 4 tiers: Bronze, Silver, Gold, Platinum
- Points accumulation on trips
- Reward redemption
- Tier-based benefits
-### 💰 Wallet System
+### Wallet System
- Top-up via payment methods
- Pay with wallet balance
- Transaction ledger
- Refund to wallet
-### 📍 Live Tracking
+### Live Tracking
- Real-time trip status
- Location updates
- Delay notifications
- Station crowd signals
-### 🔒 Fraud Detection
+### Fraud Detection
- Velocity checks (multiple bookings)
- High-value transaction monitoring
- Failed payment pattern detection
- Automatic user blocking
-### 👤 Passenger Profiles
+### Passenger Profiles
- Comprehensive profile data: gender, date of birth, nationality
- National ID for Ethiopian citizens (Fayda verified)
- Passport information for international passengers
-- **NEW:** Complete demographic data for personalized services
-- **NEW:** Improved user targeting and communications
+- NEW: Complete demographic data for personalized services
-### 🌍 Internationalization
+### Internationalization
- Multi-language support (English, Amharic, French, Oromo)
- Locale-based responses
- Currency formatting (ETB, DJF, USD)
-- **NEW:** Multi-currency display per booking (ETB, DJF, USD)
+- NEW: Multi-currency display per booking (ETB, DJF, USD)
-### 🚌 Transit Stop Management
-- Automatic detection of cross-border journeys (Ethiopia → Djibouti)
+### Transit Stop Management
+- Automatic detection of cross-border journeys (Ethiopia to Djibouti)
- Dire Dawa as mandatory transit hub for international journeys
- Dual-leg fare calculation (domestic + international)
- Age-based pricing applied independently per leg
- Seamless multi-segment booking workflow
-- Transit stop optimization and route planning
-### 🔄 Round-Trip Booking
-- One-way and round-trip journey options
-- Flexible return date selection
-- Combined pricing for outbound + return legs
-- Separate seat management per leg
-- Independent modification/cancellation per leg
-- Return journey tracking and notifications
-- **NEW:** Booking type stored for analytics and reporting
-
-### 🚐 Coach Type & Class Selection
-- Browse available coach types per route (standard coaches, premium coaches)
+### Coach Type & Class Selection
+- Browse available coach types per route
- View seat classes per coach (Economy Regular, Economy Bed, VIP Bed)
- Compare base prices by coach type and class
- Real-time availability per coach configuration
-- Deferred pricing at seat selection stage
-- Coach amenities and features display
-- **NEW:** Sequence-based coach ordering for consistent UI
-- **NEW:** Premium and insurance fee transparency per class
+- NEW: Sequence-based coach ordering for consistent UI
+- NEW: Premium and insurance fee transparency per class
-### 📊 Data Organization
-- **Stations:** Ordered by sequence (1-15) for consistent route display
-- **Coaches:** Ordered by sequence (1+) per type for predictable configuration
-- **Booking History:** Sorted chronologically with filtering options
+### Data Organization
+- Stations ordered by sequence (1-15) for consistent route display
+- Coaches ordered by sequence (1+) per type for predictable configuration
+- Booking history sorted chronologically with filtering options
## Authentication
@@ -195,26 +214,37 @@ Used for agent, fraud, and reporting endpoints. Requires corporate IAM token.
## Passenger Booking Flow
### Step 1: Search Trips
-\`POST /search\` with origin, destination, date, passenger counts, and nationality
+\`POST /search\` with origin, destination, date, passenger counts, and nationality.
+For round-trips also pass \`journeyType=ROUND_TRIP\` and \`returnDate\`.
### Step 2: Get Fare Quote
-\`POST /search/fare-quote\` with passenger counts and display currency
+\`POST /search/fare-quote\` with passenger counts and display currency.
+For round-trips also pass \`returnScheduleId\`, \`returnOriginStationId\`, \`returnDestinationStationId\`.
### Step 3: Passenger Information & Verification
**For Ethiopian Passengers:**
-\`POST /passengers/verify-fayda\` - Automatic Fayda verification for adults (≥5 years)
+\`POST /passengers/verify-fayda\` — Automatic Fayda verification for adults (5+ years)
**For International Passengers:**
-\`POST /passengers/register-international\` - Passport information collection
+\`POST /passengers/register-international\` — Passport information collection
### Step 4: View Seat Map
-\`GET /seats/seatmap/{scheduleId}\` - Show available coaches and seats
+\`GET /seats/seatmap/{scheduleId}\` — Show available coaches and seats.
+For round-trips, call this twice: once for outbound scheduleId, once for return scheduleId.
-### Step 5: Login & Hold Seats
-\`POST /auth/login\` then \`POST /seats/hold\` to reserve seats for 15 minutes
+### Step 5: Hold Seats
+\`POST /seats/hold\` to reserve seats for 15 minutes.
+- ONE_WAY / TRANSIT outbound leg: one hold call → \`holdId\`
+- TRANSIT leg-2: second hold call → \`leg2HoldId\`
+- ROUND_TRIP return: second hold call → \`returnHoldId\`
+- ROUND_TRIP_TRANSIT: four hold calls → \`holdId\`, \`leg2HoldId\`, \`returnHoldId\`, \`returnLeg2HoldId\`
### Step 6: Create Booking
-\`POST /bookings/guest\` with verified passenger details and held seats
+Choose the right endpoint and bookingType:
+- **ONE_WAY** → \`POST /bookings/guest\` or \`POST /bookings\` with \`bookingType: ONE_WAY\`, passenger \`seatId\`
+- **ROUND_TRIP** → same endpoint with \`bookingType: ROUND_TRIP\`, \`returnScheduleId/returnHoldId/returnOriginStationId/returnDestinationStationId\`, passenger \`seatId + returnSeatId\`
+- **TRANSIT** → same endpoint with \`bookingType: TRANSIT\`, \`leg2ScheduleId/leg2HoldId/transitStationId/leg2DestinationStationId\`, passenger \`seatId + leg2SeatId\`
+- **ROUND_TRIP_TRANSIT** → same endpoint with \`bookingType: ROUND_TRIP_TRANSIT\`, all 4 sets of schedule/hold/station fields, passenger \`seatId + leg2SeatId + returnSeatId + returnLeg2SeatId\`
### Step 7: Process Payment
\`POST /payments/telebirr\` (Ethiopian) or \`POST /payments/waafi\` (Djiboutian)
@@ -265,7 +295,7 @@ Payment providers send notifications to:
.addTag("Agents", "Counter booking, shift management, commission tracking, and reconciliation")
.addTag("Audit", "User activity logging, system changes, compliance tracking, and audit trails")
.addTag("Auth", "Passenger registration, login, OTP, password reset, and profile management")
- .addTag("Booking", "Complete booking lifecycle: create, modify, cancel, guest checkout")
+ .addTag("Booking", "Complete booking lifecycle: create, modify, cancel, guest checkout. Supports ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT booking types. returnLegStatus filter for round-trip no-show management")
.addTag("Config", "System settings, feature flags, and configuration management")
.addTag("Currencies", "Multi-currency support, exchange rates, and currency conversion")
.addTag("Dashboard", "Home screen aggregations: trips, loyalty, wallet, notifications")
@@ -282,7 +312,6 @@ Payment providers send notifications to:
.addTag("Payment Webhooks", "Payment provider webhook handlers and transaction confirmation")
.addTag("Promotions", "Promo codes, campaigns, discounts, and redemption tracking")
.addTag("Reports", "Revenue analytics, occupancy reports, agent sales, and KPI dashboards")
- .addTag("Round Trip", "Round-trip bookings, return scheduling, combined pricing, and management (NEW)")
.addTag("Routes", "Route templates with ordered stops, fare rules, and baggage allowance")
.addTag("Schedule", "Trip schedules, availability windows, status tracking, and timing")
.addTag("Search", "Trip search, fare quotes, coach types, and real-time availability")
@@ -291,8 +320,8 @@ Payment providers send notifications to:
.addTag("Segment-based Seats", "Multi-leg journey seats, segment allocation, and per-leg availability")
.addTag("Stations", "Station directory, location data, baggage facilities, and amenities")
.addTag("Support", "FAQ management, search, live chat conversations, and ticket resolution")
- .addTag("Tickets", "QR/barcode generation, PDF tickets, gate validation, and audit trails")
- .addTag("Transit Stops", "Cross-border journey management, Dire Dawa hub, multi-leg routing (NEW)")
+ .addTag("Tickets", "QR/barcode generation, PDF tickets, gate validation with per-leg tracking (OUTBOUND/RETURN/LEG1/LEG2/OUTBOUND_LEG1/OUTBOUND_LEG2/RETURN_LEG1/RETURN_LEG2), and audit trails")
+ .addTag("Transit Stops", "Cross-border journey management, Dire Dawa hub, TRANSIT and ROUND_TRIP_TRANSIT bookings")
.addTag("Wallet", "Balance management, top-ups, withdrawals, and transaction ledger")
//.addServer('http://localhost:4000', 'Development')
// .addServer("https://api.edr-platform.com", "Production")
diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts
index 9622fa656..54dcea344 100644
--- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts
@@ -1,5 +1,5 @@
import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query, Req, BadRequestException } from '@nestjs/common';
-import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger';
+import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery, ApiBody } from '@nestjs/swagger';
import { BookingsService } from './bookings.service';
import { GuestBookingService } from './guest-booking.service';
import { CreateBookingDto, ModifyBookingDto, CancelBookingDto } from './bookings.dto';
@@ -75,21 +75,24 @@ export class BookingsController {
@Get()
@ApiOperation({
summary: 'List all bookings with filters (Admin/Agent)',
- description: 'Returns paginated list of bookings with search and status filters'
+ description: 'Returns paginated list of bookings. Use `returnLegStatus=OUTBOUND_ONLY` to find round-trip no-shows on the return leg, `INBOUND_ONLY` for passengers who only used the return leg, `BOTH_USED` for fully completed round-trips, and `NEITHER_USED` for confirmed but not yet boarded.'
})
@ApiQuery({ name: 'search', required: false, description: 'Search by booking reference, email, or phone' })
@ApiQuery({ name: 'status', required: false, description: 'Filter by booking status' })
+ @ApiQuery({ name: 'returnLegStatus', required: false, description: 'Filter round-trip leg usage: NEITHER_USED | OUTBOUND_ONLY | INBOUND_ONLY | BOTH_USED | NOT_APPLICABLE' })
@ApiQuery({ name: 'page', required: false, description: 'Page number' })
@ApiQuery({ name: 'pageSize', required: false, description: 'Items per page' })
findAll(
@Query('search') search?: string,
@Query('status') status?: string,
+ @Query('returnLegStatus') returnLegStatus?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.service.findAll({
search,
- status,
+ status,
+ returnLegStatus,
page: page ? parseInt(page) : 1,
pageSize: pageSize ? parseInt(pageSize) : 20
});
@@ -97,35 +100,153 @@ export class BookingsController {
@Post('guest')
@ApiOperation({
- summary: 'Create guest booking without login (optional account creation)',
- description: `Creates a booking without requiring login. Features:
-
-**Guest Checkout:**
-- No login required
-- Contact details from first passenger
-- Booking confirmation sent to email/phone
+ summary: 'Create guest booking — ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT (no login required)',
+ description: `Creates a booking without requiring login. Supports all four booking types.
-**Optional Account Creation:**
-- Set createAccount=true with password
-- Account created using first passenger details
-- Automatic login after booking
-- Loyalty points and wallet created
+**bookingType: ONE_WAY (default)**
+- scheduleId, holdId, originStationId, destinationStationId, seatClassId
+- passengers[]: { seatId, passengerName, dateOfBirth, idDocumentType, … }
-**Passenger Details Storage:**
-- savePassengerDetails=true: Save for future bookings
-- Stored by userId (if account created) or deviceId
-- Retrieve saved passengers for quick booking
+**bookingType: ROUND_TRIP**
+- Above + returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId, returnSeatClassId
+- passengers[]: each must include returnSeatId (seat on the return leg)
-**Verifayda Verification:**
-- Ethiopian nationals: National ID verified via Verifayda
-- Other nationals: Passport details (no verification)
+**bookingType: TRANSIT**
+- scheduleId/holdId (leg-1) + leg2ScheduleId, leg2HoldId, transitStationId, leg2DestinationStationId
+- passengers[]: each must include leg2SeatId
-**Age-Based Pricing:**
-- ADULT (≥5 years): Full fare
-- CHILD (<5 years): First child FREE, subsequent children full fare`
+**bookingType: ROUND_TRIP_TRANSIT**
+- All TRANSIT outbound fields + returnScheduleId/returnHoldId/returnOriginStationId/returnDestinationStationId/returnLeg2ScheduleId/returnLeg2HoldId/returnTransitStationId/returnLeg2DestinationStationId
+- passengers[]: each must include leg2SeatId, returnSeatId, returnLeg2SeatId
+
+**Optional account creation:** set createAccount=true with password — creates account from first passenger details, loyalty + wallet initialised.
+
+**Verifayda:** Ethiopian nationals verified; international passengers require passportNumber + passportCountry.`
})
- @ApiResponse({ status: 201, description: 'Booking created successfully' })
- @ApiResponse({ status: 400, description: 'Verifayda verification failed or invalid data' })
+ @ApiBody({
+ type: CreateGuestBookingDto,
+ examples: {
+ ONE_WAY: {
+ summary: 'ONE_WAY — single direct journey (guest)',
+ value: {
+ scheduleId: 'schedule-uuid',
+ holdId: 'hold-uuid',
+ originStationId: 'station-uuid',
+ destinationStationId: 'station-uuid',
+ seatClassId: 'seat-class-uuid',
+ bookingType: 'ONE_WAY',
+ displayCurrency: 'ETB',
+ passengers: [{
+ seatId: 'seat-uuid',
+ passengerName: 'Abebe Kebede',
+ dateOfBirth: '1990-05-15',
+ idDocumentType: 'NATIONAL_ID',
+ idDocumentNumber: 'ET123456789',
+ nationality: 'Ethiopian',
+ phone: '+251911234567',
+ email: 'abebe@email.com',
+ }],
+ savePassengerDetails: true,
+ deviceId: 'device-uuid-123',
+ },
+ },
+ ROUND_TRIP: {
+ summary: 'ROUND_TRIP — outbound + return, single PNR (guest)',
+ value: {
+ scheduleId: 'outbound-schedule-uuid',
+ holdId: 'outbound-hold-uuid',
+ originStationId: 'addis-station-uuid',
+ destinationStationId: 'djibouti-station-uuid',
+ seatClassId: 'seat-class-uuid',
+ bookingType: 'ROUND_TRIP',
+ returnScheduleId: 'return-schedule-uuid',
+ returnHoldId: 'return-hold-uuid',
+ returnOriginStationId: 'djibouti-station-uuid',
+ returnDestinationStationId: 'addis-station-uuid',
+ returnSeatClassId: 'seat-class-uuid',
+ displayCurrency: 'ETB',
+ passengers: [{
+ seatId: 'outbound-seat-uuid',
+ returnSeatId: 'return-seat-uuid',
+ passengerName: 'Abebe Kebede',
+ dateOfBirth: '1990-05-15',
+ idDocumentType: 'NATIONAL_ID',
+ idDocumentNumber: 'ET123456789',
+ nationality: 'Ethiopian',
+ phone: '+251911234567',
+ }],
+ savePassengerDetails: true,
+ deviceId: 'device-uuid-123',
+ },
+ },
+ TRANSIT: {
+ summary: 'TRANSIT — connecting train, single PNR (guest)',
+ value: {
+ scheduleId: 'leg1-schedule-uuid',
+ holdId: 'leg1-hold-uuid',
+ originStationId: 'addis-station-uuid',
+ destinationStationId: 'diredawa-station-uuid',
+ seatClassId: 'seat-class-uuid',
+ bookingType: 'TRANSIT',
+ leg2ScheduleId: 'leg2-schedule-uuid',
+ leg2HoldId: 'leg2-hold-uuid',
+ transitStationId: 'diredawa-station-uuid',
+ leg2DestinationStationId: 'djibouti-station-uuid',
+ displayCurrency: 'ETB',
+ passengers: [{
+ seatId: 'leg1-seat-uuid',
+ leg2SeatId: 'leg2-seat-uuid',
+ passengerName: 'Abebe Kebede',
+ dateOfBirth: '1990-05-15',
+ idDocumentType: 'NATIONAL_ID',
+ idDocumentNumber: 'ET123456789',
+ nationality: 'Ethiopian',
+ phone: '+251911234567',
+ }],
+ deviceId: 'device-uuid-123',
+ },
+ },
+ ROUND_TRIP_TRANSIT: {
+ summary: 'ROUND_TRIP_TRANSIT — round trip via connecting trains, 4 holds (guest)',
+ value: {
+ scheduleId: 'ob-leg1-schedule-uuid',
+ holdId: 'ob-leg1-hold-uuid',
+ originStationId: 'addis-station-uuid',
+ destinationStationId: 'diredawa-station-uuid',
+ seatClassId: 'seat-class-uuid',
+ bookingType: 'ROUND_TRIP_TRANSIT',
+ leg2ScheduleId: 'ob-leg2-schedule-uuid',
+ leg2HoldId: 'ob-leg2-hold-uuid',
+ transitStationId: 'diredawa-station-uuid',
+ leg2DestinationStationId: 'djibouti-station-uuid',
+ returnScheduleId: 'ret-leg1-schedule-uuid',
+ returnHoldId: 'ret-leg1-hold-uuid',
+ returnOriginStationId: 'djibouti-station-uuid',
+ returnDestinationStationId: 'diredawa-station-uuid',
+ returnLeg2ScheduleId: 'ret-leg2-schedule-uuid',
+ returnLeg2HoldId: 'ret-leg2-hold-uuid',
+ returnTransitStationId: 'diredawa-station-uuid',
+ returnLeg2DestinationStationId: 'addis-station-uuid',
+ displayCurrency: 'ETB',
+ passengers: [{
+ seatId: 'ob-leg1-seat-uuid',
+ leg2SeatId: 'ob-leg2-seat-uuid',
+ returnSeatId: 'ret-leg1-seat-uuid',
+ returnLeg2SeatId: 'ret-leg2-seat-uuid',
+ passengerName: 'Abebe Kebede',
+ dateOfBirth: '1990-05-15',
+ idDocumentType: 'NATIONAL_ID',
+ idDocumentNumber: 'ET123456789',
+ nationality: 'Ethiopian',
+ phone: '+251911234567',
+ }],
+ deviceId: 'device-uuid-123',
+ },
+ },
+ },
+ })
+ @ApiResponse({ status: 201, description: 'Booking created successfully with fareBreakdown' })
+ @ApiResponse({ status: 400, description: 'Missing required seat IDs for bookingType, or Verifayda verification failed' })
createGuest(@Body() dto: CreateGuestBookingDto) {
return this.guestService.createGuestBooking(dto);
}
@@ -144,24 +265,149 @@ export class BookingsController {
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
- summary: 'Create booking (one-way or round-trip)',
- description: `Creates a one-way or round-trip booking for logged-in users.
-
-ONE_WAY booking:
-- scheduleId, holdId, originStationId, destinationStationId
-- passengers: array of PassengerInputDto with seatId
-- Single PNR, single payment
+ summary: 'Create booking — ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT',
+ description: `Creates a booking for a logged-in passenger. bookingType controls which fields are required.
-ROUND_TRIP booking:
-- Outbound: scheduleId, holdId, originStationId, destinationStationId, seatClassId
-- Return: returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId, returnSeatClassId
-- passengers: array of RoundTripPassengerDto with outboundSeatId and returnSeatId
-- Combined PNR, single payment for both legs
-- Fare = outbound_fare + return_fare, single total, single promo, single loyalty deduction`
+**ONE_WAY**
+- scheduleId, holdId, originStationId, destinationStationId, seatClassId
+- passengers[]: { seatId, passengerName, dateOfBirth, idDocumentType, … }
+
+**ROUND_TRIP**
+- Above + returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId, returnSeatClassId
+- passengers[]: { seatId (outbound), returnSeatId (return), passengerName, … }
+- Combined fare = outbound fare + return fare; single promo/loyalty deduction
+
+**TRANSIT** (connecting train, single PNR)
+- scheduleId/holdId for leg-1 + leg2ScheduleId, leg2HoldId, transitStationId, leg2DestinationStationId
+- passengers[]: { seatId (leg-1), leg2SeatId (leg-2), passengerName, … }
+
+**ROUND_TRIP_TRANSIT** (round trip, each direction via connecting train)
+- All TRANSIT outbound fields + returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId, returnLeg2ScheduleId, returnLeg2HoldId, returnTransitStationId, returnLeg2DestinationStationId
+- passengers[]: { seatId, leg2SeatId, returnSeatId, returnLeg2SeatId, passengerName, … }
+- 4 holds required, 4 seat sets per passenger, single PNR, single payment
+
+**Age-Based Pricing (all types)**
+- ADULT (≥5 years): full fare per leg
+- CHILD (<5 years): first child FREE per booking, subsequent children full fare`
+ })
+ @ApiBody({
+ type: CreateBookingDto,
+ examples: {
+ ONE_WAY: {
+ summary: 'ONE_WAY — single direct journey',
+ value: {
+ passengerId: 'passenger-uuid',
+ scheduleId: 'schedule-uuid',
+ holdId: 'hold-uuid',
+ originStationId: 'station-uuid',
+ destinationStationId: 'station-uuid',
+ seatClassId: 'seat-class-uuid',
+ bookingType: 'ONE_WAY',
+ displayCurrency: 'ETB',
+ passengers: [{
+ seatId: 'seat-uuid',
+ passengerName: 'Abebe Kebede',
+ dateOfBirth: '1990-05-15',
+ idDocumentType: 'NATIONAL_ID',
+ idDocumentNumber: 'ET123456789',
+ nationality: 'Ethiopian',
+ }],
+ },
+ },
+ ROUND_TRIP: {
+ summary: 'ROUND_TRIP — outbound + return, single PNR',
+ value: {
+ passengerId: 'passenger-uuid',
+ scheduleId: 'outbound-schedule-uuid',
+ holdId: 'outbound-hold-uuid',
+ originStationId: 'addis-station-uuid',
+ destinationStationId: 'djibouti-station-uuid',
+ seatClassId: 'seat-class-uuid',
+ bookingType: 'ROUND_TRIP',
+ returnScheduleId: 'return-schedule-uuid',
+ returnHoldId: 'return-hold-uuid',
+ returnOriginStationId: 'djibouti-station-uuid',
+ returnDestinationStationId: 'addis-station-uuid',
+ returnSeatClassId: 'seat-class-uuid',
+ displayCurrency: 'ETB',
+ passengers: [{
+ seatId: 'outbound-seat-uuid',
+ returnSeatId: 'return-seat-uuid',
+ passengerName: 'Abebe Kebede',
+ dateOfBirth: '1990-05-15',
+ idDocumentType: 'NATIONAL_ID',
+ idDocumentNumber: 'ET123456789',
+ nationality: 'Ethiopian',
+ }],
+ },
+ },
+ TRANSIT: {
+ summary: 'TRANSIT — connecting train, single PNR',
+ value: {
+ passengerId: 'passenger-uuid',
+ scheduleId: 'leg1-schedule-uuid',
+ holdId: 'leg1-hold-uuid',
+ originStationId: 'addis-station-uuid',
+ destinationStationId: 'diredawa-station-uuid',
+ seatClassId: 'seat-class-uuid',
+ bookingType: 'TRANSIT',
+ leg2ScheduleId: 'leg2-schedule-uuid',
+ leg2HoldId: 'leg2-hold-uuid',
+ transitStationId: 'diredawa-station-uuid',
+ leg2DestinationStationId: 'djibouti-station-uuid',
+ displayCurrency: 'ETB',
+ passengers: [{
+ seatId: 'leg1-seat-uuid',
+ leg2SeatId: 'leg2-seat-uuid',
+ passengerName: 'Abebe Kebede',
+ dateOfBirth: '1990-05-15',
+ idDocumentType: 'NATIONAL_ID',
+ idDocumentNumber: 'ET123456789',
+ nationality: 'Ethiopian',
+ }],
+ },
+ },
+ ROUND_TRIP_TRANSIT: {
+ summary: 'ROUND_TRIP_TRANSIT — round trip via connecting trains, 4 holds',
+ value: {
+ passengerId: 'passenger-uuid',
+ scheduleId: 'ob-leg1-schedule-uuid',
+ holdId: 'ob-leg1-hold-uuid',
+ originStationId: 'addis-station-uuid',
+ destinationStationId: 'diredawa-station-uuid',
+ seatClassId: 'seat-class-uuid',
+ bookingType: 'ROUND_TRIP_TRANSIT',
+ leg2ScheduleId: 'ob-leg2-schedule-uuid',
+ leg2HoldId: 'ob-leg2-hold-uuid',
+ transitStationId: 'diredawa-station-uuid',
+ leg2DestinationStationId: 'djibouti-station-uuid',
+ returnScheduleId: 'ret-leg1-schedule-uuid',
+ returnHoldId: 'ret-leg1-hold-uuid',
+ returnOriginStationId: 'djibouti-station-uuid',
+ returnDestinationStationId: 'diredawa-station-uuid',
+ returnLeg2ScheduleId: 'ret-leg2-schedule-uuid',
+ returnLeg2HoldId: 'ret-leg2-hold-uuid',
+ returnTransitStationId: 'diredawa-station-uuid',
+ returnLeg2DestinationStationId: 'addis-station-uuid',
+ displayCurrency: 'ETB',
+ passengers: [{
+ seatId: 'ob-leg1-seat-uuid',
+ leg2SeatId: 'ob-leg2-seat-uuid',
+ returnSeatId: 'ret-leg1-seat-uuid',
+ returnLeg2SeatId: 'ret-leg2-seat-uuid',
+ passengerName: 'Abebe Kebede',
+ dateOfBirth: '1990-05-15',
+ idDocumentType: 'NATIONAL_ID',
+ idDocumentNumber: 'ET123456789',
+ nationality: 'Ethiopian',
+ }],
+ },
+ },
+ },
})
@ApiResponse({ status: 201, description: 'Booking created with fare breakdown' })
- @ApiResponse({ status: 400, description: 'Verifayda verification failed or invalid passenger data' })
- @ApiResponse({ status: 404, description: 'Trip or seat hold not found' })
+ @ApiResponse({ status: 400, description: 'Missing required fields for bookingType, or Verifayda verification failed' })
+ @ApiResponse({ status: 404, description: 'Schedule or seat hold not found' })
create(@Body() dto: CreateBookingDto) {
return this.service.create(dto);
}
diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts
index 875bf40a2..52603e776 100644
--- a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts
@@ -4,7 +4,10 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Currency, IdDocumentType } from '@prisma/client';
export class PassengerInputDto {
- @ApiProperty() @IsString() seatId: string;
+ @ApiProperty({ example: 'seat-uuid', description: 'Outbound / leg-1 seat ID (all booking types)' }) @IsString() seatId: string;
+ @ApiPropertyOptional({ example: 'leg2-seat-uuid', description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Outbound leg-2 seat ID' }) @IsOptional() @IsString() leg2SeatId?: string;
+ @ApiPropertyOptional({ example: 'return-seat-uuid', description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return leg-1 seat ID' }) @IsOptional() @IsString() returnSeatId?: string;
+ @ApiPropertyOptional({ example: 'ret-leg2-seat-uuid', description: '**ROUND_TRIP_TRANSIT:** Return leg-2 seat ID' }) @IsOptional() @IsString() returnLeg2SeatId?: string;
@ApiProperty({ example: 'Abebe Kebede' }) @IsString() passengerName: string;
@ApiProperty({ example: '1990-05-15', description: 'Date of birth (YYYY-MM-DD) for age calculation. Age <5 = CHILD (first free), Age ≥5 = ADULT (full fare)' }) @IsDateString() dateOfBirth: string;
@ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType, description: 'NATIONAL_ID for Ethiopians (Verifayda verified), PASSPORT for others' }) @IsEnum(IdDocumentType) idDocumentType: IdDocumentType;
@@ -15,17 +18,17 @@ export class PassengerInputDto {
}
export class RoundTripPassengerDto {
- @ApiProperty({
- description: 'Outbound journey seat ID',
- example: 'seat-uuid-outbound'
- })
+ @ApiProperty({ description: 'Outbound journey seat ID', example: 'seat-uuid-outbound' })
@IsString() outboundSeatId: string;
- @ApiProperty({
- description: 'Return journey seat ID',
- example: 'seat-uuid-return'
- })
+ @ApiProperty({ description: 'Return journey seat ID', example: 'seat-uuid-return' })
@IsString() returnSeatId: string;
+
+ @ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Outbound leg-2 seat ID' })
+ @IsOptional() @IsString() outboundLeg2SeatId?: string;
+
+ @ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Return leg-2 seat ID' })
+ @IsOptional() @IsString() returnLeg2SeatId?: string;
@ApiProperty({
example: 'Abebe Kebede',
@@ -75,16 +78,16 @@ export class CreateBookingDto {
@ApiProperty({ description: 'Passenger ID' })
@IsString() passengerId: string;
- @ApiProperty({ description: 'Outbound schedule ID' })
+ @ApiProperty({ description: 'Outbound / leg-1 schedule ID' })
@IsString() scheduleId: string;
- @ApiProperty({ description: 'Outbound seat hold ID' })
+ @ApiProperty({ description: 'Outbound / leg-1 seat hold ID' })
@IsString() holdId: string;
- @ApiProperty({ example: 'station-uuid', description: 'Outbound origin station UUID (must match the hold)' })
+ @ApiProperty({ example: 'station-uuid', description: 'Outbound origin station UUID' })
@IsString() originStationId: string;
- @ApiProperty({ example: 'station-uuid', description: 'Outbound destination station UUID (must match the hold)' })
+ @ApiProperty({ example: 'station-uuid', description: 'Outbound destination station UUID' })
@IsString() destinationStationId: string;
@ApiProperty({ example: 'seat-class-uuid', description: 'Outbound seat class UUID (Economy Regular, Economy Bed, VIP Bed)' })
@@ -92,15 +95,33 @@ export class CreateBookingDto {
@ApiProperty({
example: 'ONE_WAY',
- enum: ['ONE_WAY', 'ROUND_TRIP'],
- description: `Booking type:\n\n**ONE_WAY:**\n- Single journey from origin to destination\n- Uses: scheduleId, holdId, originStationId, destinationStationId, seatClassId\n- passengers: PassengerInputDto[] with seatId\n\n**ROUND_TRIP:**\n- Outbound + return journey with single PNR\n- Uses all outbound fields PLUS return fields\n- passengers: RoundTripPassengerDto[] with outboundSeatId and returnSeatId\n- Combined fare calculation with single payment`,
+ enum: ['ONE_WAY', 'ROUND_TRIP', 'TRANSIT', 'ROUND_TRIP_TRANSIT'],
+ description: `Booking type:
+
+**ONE_WAY:** Single direct journey — needs: scheduleId, holdId. Passenger: seatId.
+
+**ROUND_TRIP:** Outbound + return, single PNR — needs above + returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId. Passenger: seatId + returnSeatId.
+
+**TRANSIT:** Single journey via connecting train, single PNR — needs above + leg2ScheduleId, leg2HoldId, transitStationId, leg2DestinationStationId. Passenger: seatId + leg2SeatId.
+
+**ROUND_TRIP_TRANSIT:** Round trip via connecting trains — needs all 4 hold sets + all station fields. Passenger: seatId + leg2SeatId + returnSeatId + returnLeg2SeatId.`,
default: 'ONE_WAY'
})
@IsOptional() @IsString() bookingType?: string;
@ApiProperty({
type: [PassengerInputDto],
- description: `Passenger array - type depends on bookingType:\n\n**For ONE_WAY:** PassengerInputDto[]\n- Each passenger has: seatId, passengerName, dateOfBirth, etc.\n\n**For ROUND_TRIP:** RoundTripPassengerDto[]\n- Each passenger has: outboundSeatId, returnSeatId, passengerName, dateOfBirth, etc.\n\n**Age-based pricing:** First child (<5 years) travels FREE, subsequent children pay full fare`
+ description: `Passenger array — required seat fields vary by bookingType:
+
+**ONE_WAY:** { seatId, passengerName, dateOfBirth, idDocumentType, … }
+
+**ROUND_TRIP:** { seatId (outbound leg-1), returnSeatId (return leg-1), passengerName, … }
+
+**TRANSIT:** { seatId (leg-1), leg2SeatId (leg-2), passengerName, … }
+
+**ROUND_TRIP_TRANSIT:** { seatId, leg2SeatId, returnSeatId, returnLeg2SeatId, passengerName, … }
+
+**Age-based pricing:** First child (<5 years) travels FREE, subsequent children pay full fare.`
})
@IsArray() @ValidateNested({ each: true }) @Type(() => PassengerInputDto)
passengers: PassengerInputDto[];
@@ -114,31 +135,52 @@ export class CreateBookingDto {
@ApiPropertyOptional({ example: 'DJF', enum: Currency, description: 'Display currency for fare breakdown (ETB, DJF, USD). Transaction always in ETB.' })
@IsOptional() @IsEnum(Currency) displayCurrency?: Currency;
- // Round-trip specific fields
- @ApiPropertyOptional({
- description: '**ROUND_TRIP ONLY:** Return schedule ID (required when bookingType=ROUND_TRIP)'
- })
+ // Transit-specific fields
+ @ApiPropertyOptional({ description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Leg-2 schedule ID' })
+ @IsOptional() @IsString() leg2ScheduleId?: string;
+
+ @ApiPropertyOptional({ description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Leg-2 seat hold ID' })
+ @IsOptional() @IsString() leg2HoldId?: string;
+
+ @ApiPropertyOptional({ description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Transit (connecting) station UUID' })
+ @IsOptional() @IsString() transitStationId?: string;
+
+ @ApiPropertyOptional({ description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Leg-2 destination station UUID' })
+ @IsOptional() @IsString() leg2DestinationStationId?: string;
+
+ @ApiPropertyOptional({ description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Leg-2 seat class ID (defaults to outbound seatClassId)' })
+ @IsOptional() @IsString() leg2SeatClassId?: string;
+
+ // Round-trip transit: return direction transit fields
+ @ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Return leg-1 schedule ID' })
@IsOptional() @IsString() returnScheduleId?: string;
-
- @ApiPropertyOptional({
- description: '**ROUND_TRIP ONLY:** Return origin station ID (usually same as outbound destination)'
- })
+
+ @ApiPropertyOptional({ description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return origin station ID' })
@IsOptional() @IsString() returnOriginStationId?: string;
-
- @ApiPropertyOptional({
- description: '**ROUND_TRIP ONLY:** Return destination station ID (usually same as outbound origin)'
- })
+
+ @ApiPropertyOptional({ description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return destination station ID' })
@IsOptional() @IsString() returnDestinationStationId?: string;
-
- @ApiPropertyOptional({
- description: '**ROUND_TRIP ONLY:** Return seat hold ID (required when bookingType=ROUND_TRIP)'
- })
+
+ @ApiPropertyOptional({ description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return seat hold ID' })
@IsOptional() @IsString() returnHoldId?: string;
-
- @ApiPropertyOptional({
- description: '**ROUND_TRIP ONLY:** Return seat class ID (optional, defaults to outbound seatClassId if not provided)'
- })
+
+ @ApiPropertyOptional({ description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return seat class ID' })
@IsOptional() @IsString() returnSeatClassId?: string;
+
+ @ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Return leg-2 schedule ID' })
+ @IsOptional() @IsString() returnLeg2ScheduleId?: string;
+
+ @ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Return leg-2 seat hold ID' })
+ @IsOptional() @IsString() returnLeg2HoldId?: string;
+
+ @ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Return transit (connecting) station UUID' })
+ @IsOptional() @IsString() returnTransitStationId?: string;
+
+ @ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Return leg-2 destination station UUID' })
+ @IsOptional() @IsString() returnLeg2DestinationStationId?: string;
+
+ @ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Return leg-2 seat class ID' })
+ @IsOptional() @IsString() returnLeg2SeatClassId?: string;
}
export class ModifyBookingDto {
diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts
index a588e7330..bce07b915 100644
--- a/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts
@@ -7,9 +7,10 @@ import { GuestBookingService } from './guest-booking.service';
import { SeatsModule } from '../seats/seats.module';
import { VerifaydaModule } from '../verifayda/verifayda.module';
import { CurrencyModule } from '../currency/currency.module';
+import { FareEngineModule } from '../fare-engine/fare-engine.module';
@Module({
- imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, HttpModule],
+ imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule],
controllers: [BookingsController],
providers: [BookingsService, GuestBookingService],
exports: [BookingsService, GuestBookingService]
diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
index 7a52f8422..b40a5c0ee 100644
--- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
@@ -6,6 +6,7 @@ import { CreateBookingDto, ModifyBookingDto } from './bookings.dto';
import { Cron, CronExpression } from '@nestjs/schedule';
import { VerifaydaService } from '../verifayda/verifayda.service';
import { CurrencyService } from '../currency/currency.service';
+import { FareEngineService } from '../fare-engine/fare-engine.service';
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
function generateRef(): string {
@@ -24,6 +25,7 @@ function calculateAge(dateOfBirth: Date): number {
interface BookingFilters {
search?: string;
status?: string;
+ returnLegStatus?: string;
page?: number;
pageSize?: number;
}
@@ -36,6 +38,7 @@ export class BookingsService {
private eventEmitter: EventEmitter2,
private verifaydaService: VerifaydaService,
private currencyService: CurrencyService,
+ private fareEngine: FareEngineService,
) {}
async findByPassengerId(passengerId: string, filters: BookingFilters = {}) {
@@ -82,6 +85,8 @@ export class BookingsService {
displayTotalMinor: booking.displayTotalMinor,
adultCount: booking.adultCount,
childCount: booking.childCount,
+ bookingType: booking.bookingType,
+ returnLegStatus: (booking as any).returnLegStatus ?? null,
createdAt: booking.createdAt,
schedule: {
train: booking.schedule.train,
@@ -159,6 +164,8 @@ export class BookingsService {
displayTotalMinor: booking.displayTotalMinor,
adultCount: booking.adultCount,
childCount: booking.childCount,
+ bookingType: booking.bookingType,
+ returnLegStatus: (booking as any).returnLegStatus ?? null,
createdAt: booking.createdAt,
schedule: {
train: booking.schedule.train,
@@ -180,7 +187,7 @@ export class BookingsService {
}
async findAll(filters: BookingFilters = {}) {
- const { search, status, page = 1, pageSize = 20 } = filters;
+ const { search, status, returnLegStatus, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
const where: any = {};
@@ -194,9 +201,8 @@ export class BookingsService {
];
}
- if (status) {
- where.status = status;
- }
+ if (status) where.status = status;
+ if (returnLegStatus) (where as any).returnLegStatus = returnLegStatus;
const [items, total] = await Promise.all([
this.prisma.booking.findMany({
@@ -225,6 +231,10 @@ export class BookingsService {
displayTotalMinor: booking.displayTotalMinor,
contactEmail: booking.contactEmail,
contactPhone: booking.contactPhone,
+ bookingType: booking.bookingType,
+ returnLegStatus: (booking as any).returnLegStatus ?? null,
+ adultCount: booking.adultCount,
+ childCount: booking.childCount,
createdAt: booking.createdAt,
passenger: booking.passenger?.user,
schedule: {
@@ -246,9 +256,9 @@ export class BookingsService {
}
async create(dto: CreateBookingDto) {
- if (dto.bookingType === 'ROUND_TRIP') {
- return this.createRoundTripBooking(dto);
- }
+ if (dto.bookingType === 'ROUND_TRIP') return this.createRoundTripBooking(dto);
+ if (dto.bookingType === 'TRANSIT') return this.createTransitBooking(dto);
+ if (dto.bookingType === 'ROUND_TRIP_TRANSIT') return this.createRoundTripTransitBooking(dto);
return this.createOneWayBooking(dto);
}
@@ -391,22 +401,42 @@ export class BookingsService {
returnDestinationStationId: dto.returnDestinationStationId,
returnHoldId: dto.returnHoldId,
returnSeatClassId: dto.returnSeatClassId,
+ returnLegStatus: 'NEITHER_USED',
seats: {
- create: passengersData.map(p => ({
- seat: { connect: { id: p.outboundSeatId } },
- passengerName: p.passengerName,
- dateOfBirth: p.dateOfBirth,
- passengerCategory: p.category,
- idDocumentType: p.idDocumentType,
- passportNumber: p.passportNumber,
- passportCountry: p.passportCountry,
- verifaydaVerified: p.verifaydaVerified,
- verifaydaData: p.verifaydaData,
- fareMinor: p.category === PassengerCategory.ADULT ? (outboundFare.baseFareMinor + returnFare.baseFareMinor) : 0,
- displayCurrency
- }))
- }
- },
+ create: [
+ ...passengersData.map(p => ({
+ seat: { connect: { id: p.outboundSeatId } },
+ leg: 1,
+ scheduleId: dto.scheduleId,
+ passengerName: p.passengerName,
+ dateOfBirth: p.dateOfBirth,
+ passengerCategory: p.category,
+ idDocumentType: p.idDocumentType,
+ passportNumber: p.passportNumber,
+ passportCountry: p.passportCountry,
+ verifaydaVerified: p.verifaydaVerified,
+ verifaydaData: p.verifaydaData,
+ fareMinor: p.category === PassengerCategory.ADULT ? outboundFare.baseFareMinor : (outboundFare.paidChildrenCount > 0 ? outboundFare.baseFareMinor : 0),
+ displayCurrency,
+ })),
+ ...passengersData.map(p => ({
+ seat: { connect: { id: p.returnSeatId } },
+ leg: 2,
+ scheduleId: dto.returnScheduleId,
+ passengerName: p.passengerName,
+ dateOfBirth: p.dateOfBirth,
+ passengerCategory: p.category,
+ idDocumentType: p.idDocumentType,
+ passportNumber: p.passportNumber,
+ passportCountry: p.passportCountry,
+ verifaydaVerified: p.verifaydaVerified,
+ verifaydaData: p.verifaydaData,
+ fareMinor: p.category === PassengerCategory.ADULT ? returnFare.baseFareMinor : (returnFare.paidChildrenCount > 0 ? returnFare.baseFareMinor : 0),
+ displayCurrency,
+ })),
+ ],
+ },
+ } as any,
include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } }
});
@@ -436,6 +466,302 @@ export class BookingsService {
};
}
+ private async createTransitBooking(dto: CreateBookingDto) {
+ if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId) {
+ throw new BadRequestException('leg2ScheduleId, leg2HoldId, transitStationId and leg2DestinationStationId are required for TRANSIT bookings');
+ }
+
+ const [leg1Hold, leg2Hold] = await Promise.all([
+ this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }),
+ this.prisma.seatHold.findUnique({ where: { id: dto.leg2HoldId } }),
+ ]);
+ if (!leg1Hold || leg1Hold.expiresAt < new Date()) throw new BadRequestException('Leg-1 seat hold expired');
+ if (!leg2Hold || leg2Hold.expiresAt < new Date()) throw new BadRequestException('Leg-2 seat hold expired');
+
+ const [leg1Schedule, leg2Schedule] = await Promise.all([
+ this.prisma.trainSchedule.findUnique({
+ where: { id: dto.scheduleId },
+ include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
+ }),
+ this.prisma.trainSchedule.findUnique({
+ where: { id: dto.leg2ScheduleId },
+ include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
+ }),
+ ]);
+ if (!leg1Schedule) throw new NotFoundException('Leg-1 schedule not found');
+ if (!leg2Schedule) throw new NotFoundException('Leg-2 schedule not found');
+
+ 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);
+ const leg2DestStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.leg2DestinationStationId);
+ if (!leg1OriginStop || !leg1DestStop) throw new NotFoundException('Leg-1 origin or transit station not found on schedule');
+ if (!leg2OriginStop || !leg2DestStop) throw new NotFoundException('Transit or leg-2 destination station not found on leg-2 schedule');
+
+ const passengersData = await this.processPassengers(dto.passengers as any[]);
+ const { adultCount, childCount } = this.countPassengers(passengersData);
+
+ const leg2SeatClassId = dto.leg2SeatClassId ?? dto.seatClassId;
+ const [leg1Fare, leg2Fare] = await Promise.all([
+ this.calculateFare(dto.scheduleId, dto.seatClassId, leg1OriginStop, leg1DestStop, passengersData[0]?.nationality, adultCount, childCount),
+ this.calculateFare(dto.leg2ScheduleId, leg2SeatClassId, leg2OriginStop, leg2DestStop, passengersData[0]?.nationality, adultCount, childCount),
+ ]);
+
+ const combinedBase = leg1Fare.totalBaseFareMinor + leg2Fare.totalBaseFareMinor;
+ let discountMinor = 0;
+ if (dto.promoCode) {
+ const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
+ if (promo?.active && promo.validUntil > new Date()) {
+ discountMinor = promo.percentOff ? Math.round(combinedBase * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
+ }
+ }
+ const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
+ const taxesMinor = Math.round(combinedBase * 0.05);
+ const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor + taxesMinor);
+ const displayCurrency = dto.displayCurrency || Currency.ETB;
+ const displayTotalMinor = displayCurrency !== Currency.ETB
+ ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
+ : totalMinor;
+
+ // Single booking — leg-1 seats at leg=1, leg-2 seats at leg=2
+ const booking = await this.prisma.booking.create({
+ data: {
+ bookingRef: generateRef(),
+ passengerId: dto.passengerId,
+ scheduleId: dto.scheduleId,
+ status: 'PENDING_PAYMENT',
+ bookingType: 'TRANSIT',
+ totalMinor,
+ adultCount,
+ childCount,
+ displayCurrency,
+ displayTotalMinor,
+ leg2ScheduleId: dto.leg2ScheduleId,
+ leg2OriginStationId: dto.transitStationId,
+ leg2DestinationStationId: dto.leg2DestinationStationId,
+ leg2SeatClassId,
+ seats: {
+ create: [
+ ...passengersData.map(p => ({
+ seat: { connect: { id: p.seatId } },
+ leg: 1,
+ scheduleId: dto.scheduleId,
+ passengerName: p.passengerName,
+ dateOfBirth: p.dateOfBirth,
+ passengerCategory: p.category,
+ idDocumentType: p.idDocumentType,
+ passportNumber: p.passportNumber,
+ passportCountry: p.passportCountry,
+ verifaydaVerified: p.verifaydaVerified,
+ verifaydaData: p.verifaydaData,
+ fareMinor: p.category === PassengerCategory.ADULT ? leg1Fare.baseFareMinor : (leg1Fare.paidChildrenCount > 0 ? leg1Fare.baseFareMinor : 0),
+ displayCurrency,
+ })),
+ ...passengersData.map(p => ({
+ seat: { connect: { id: p.leg2SeatId ?? p.seatId } },
+ leg: 2,
+ scheduleId: dto.leg2ScheduleId,
+ passengerName: p.passengerName,
+ dateOfBirth: p.dateOfBirth,
+ passengerCategory: p.category,
+ idDocumentType: p.idDocumentType,
+ passportNumber: p.passportNumber,
+ passportCountry: p.passportCountry,
+ verifaydaVerified: p.verifaydaVerified,
+ verifaydaData: p.verifaydaData,
+ fareMinor: p.category === PassengerCategory.ADULT ? leg2Fare.baseFareMinor : (leg2Fare.paidChildrenCount > 0 ? leg2Fare.baseFareMinor : 0),
+ displayCurrency,
+ })),
+ ],
+ },
+ } as any,
+ include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } },
+ });
+
+ await Promise.all([
+ this.seatsService.confirmSeats(passengersData.map(p => p.seatId)),
+ this.seatsService.confirmSeats(passengersData.map(p => p.leg2SeatId ?? p.seatId)),
+ ]);
+ this.eventEmitter.emit('booking.created', { booking });
+
+ return {
+ ...booking,
+ fareBreakdown: {
+ leg1BaseFareMinor: leg1Fare.baseFareMinor,
+ leg2BaseFareMinor: leg2Fare.baseFareMinor,
+ adultCount, childCount,
+ freeChildrenCount: Math.min(childCount, 1),
+ paidChildrenCount: leg1Fare.paidChildrenCount,
+ combinedBaseFareMinor: combinedBase,
+ discountMinor, loyaltyRedemptionMinor: loyaltyMinor,
+ taxesFeesMinor: taxesMinor, totalMinor,
+ currency: 'ETB', displayCurrency, displayTotalMinor,
+ },
+ };
+ }
+
+ private async createRoundTripTransitBooking(dto: CreateBookingDto) {
+ if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId ||
+ !dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId ||
+ !dto.returnLeg2ScheduleId || !dto.returnLeg2HoldId || !dto.returnTransitStationId || !dto.returnLeg2DestinationStationId) {
+ throw new BadRequestException(
+ 'ROUND_TRIP_TRANSIT requires outbound transit fields (leg2ScheduleId, leg2HoldId, transitStationId, leg2DestinationStationId) ' +
+ 'AND return transit fields (returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId, ' +
+ 'returnLeg2ScheduleId, returnLeg2HoldId, returnTransitStationId, returnLeg2DestinationStationId)',
+ );
+ }
+
+ // Validate all 4 holds
+ const [obL1Hold, obL2Hold, retL1Hold, retL2Hold] = await Promise.all([
+ this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }),
+ this.prisma.seatHold.findUnique({ where: { id: dto.leg2HoldId } }),
+ this.prisma.seatHold.findUnique({ where: { id: dto.returnHoldId } }),
+ this.prisma.seatHold.findUnique({ where: { id: dto.returnLeg2HoldId } }),
+ ]);
+ const now = new Date();
+ if (!obL1Hold || obL1Hold.expiresAt < now) throw new BadRequestException('Outbound leg-1 seat hold expired');
+ if (!obL2Hold || obL2Hold.expiresAt < now) throw new BadRequestException('Outbound leg-2 seat hold expired');
+ if (!retL1Hold || retL1Hold.expiresAt < now) throw new BadRequestException('Return leg-1 seat hold expired');
+ if (!retL2Hold || retL2Hold.expiresAt < now) throw new BadRequestException('Return leg-2 seat hold expired');
+
+ // Load all 4 schedules
+ const [obL1Sched, obL2Sched, retL1Sched, retL2Sched] = await Promise.all([
+ this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
+ this.prisma.trainSchedule.findUnique({ where: { id: dto.leg2ScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
+ this.prisma.trainSchedule.findUnique({ where: { id: dto.returnScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
+ this.prisma.trainSchedule.findUnique({ where: { id: dto.returnLeg2ScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
+ ]);
+ if (!obL1Sched) throw new NotFoundException('Outbound leg-1 schedule not found');
+ if (!obL2Sched) throw new NotFoundException('Outbound leg-2 schedule not found');
+ if (!retL1Sched) throw new NotFoundException('Return leg-1 schedule not found');
+ if (!retL2Sched) throw new NotFoundException('Return leg-2 schedule not found');
+
+ 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);
+ const obL2Dest = obL2Sched.stopTimes.find(s => s.stationId === dto.leg2DestinationStationId);
+ const retL1Origin = retL1Sched.stopTimes.find(s => s.stationId === dto.returnOriginStationId);
+ const retL1Dest = retL1Sched.stopTimes.find(s => s.stationId === dto.returnTransitStationId);
+ const retL2Origin = retL2Sched.stopTimes.find(s => s.stationId === dto.returnTransitStationId);
+ const retL2Dest = retL2Sched.stopTimes.find(s => s.stationId === dto.returnLeg2DestinationStationId);
+ if (!obL1Origin || !obL1Dest) throw new NotFoundException('Outbound leg-1: origin or transit station not found');
+ if (!obL2Origin || !obL2Dest) throw new NotFoundException('Outbound leg-2: transit or destination not found');
+ if (!retL1Origin || !retL1Dest) throw new NotFoundException('Return leg-1: origin or transit station not found');
+ if (!retL2Origin || !retL2Dest) throw new NotFoundException('Return leg-2: transit or destination not found');
+
+ const passengersData = await this.processRoundTripPassengers(dto.passengers as any[]);
+ const { adultCount, childCount } = this.countPassengers(passengersData);
+ const nat = passengersData[0]?.nationality;
+
+ const obL2SeatClassId = dto.leg2SeatClassId ?? dto.seatClassId;
+ const retL1SeatClassId = dto.returnSeatClassId ?? dto.seatClassId;
+ const retL2SeatClassId = dto.returnLeg2SeatClassId ?? dto.seatClassId;
+
+ const [obL1Fare, obL2Fare, retL1Fare, retL2Fare] = await Promise.all([
+ this.calculateFare(dto.scheduleId, dto.seatClassId, obL1Origin, obL1Dest, nat, adultCount, childCount),
+ this.calculateFare(dto.leg2ScheduleId, obL2SeatClassId, obL2Origin, obL2Dest, nat, adultCount, childCount),
+ this.calculateFare(dto.returnScheduleId, retL1SeatClassId, retL1Origin, retL1Dest, nat, adultCount, childCount),
+ this.calculateFare(dto.returnLeg2ScheduleId, retL2SeatClassId, retL2Origin, retL2Dest, nat, adultCount, childCount),
+ ]);
+
+ const combinedBase = obL1Fare.totalBaseFareMinor + obL2Fare.totalBaseFareMinor +
+ retL1Fare.totalBaseFareMinor + retL2Fare.totalBaseFareMinor;
+ let discountMinor = 0;
+ if (dto.promoCode) {
+ const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
+ if (promo?.active && promo.validUntil > new Date()) {
+ discountMinor = promo.percentOff ? Math.round(combinedBase * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
+ }
+ }
+ const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
+ const taxesMinor = Math.round(combinedBase * 0.05);
+ const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor + taxesMinor);
+ const displayCurrency = dto.displayCurrency || Currency.ETB;
+ const displayTotalMinor = displayCurrency !== Currency.ETB
+ ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
+ : totalMinor;
+
+ const makeSeat = (p: any, seatId: string, leg: number, scheduleId: string, fare: Awaited>) => ({
+ seat: { connect: { id: seatId } },
+ leg,
+ scheduleId,
+ passengerName: p.passengerName,
+ dateOfBirth: p.dateOfBirth,
+ passengerCategory: p.category,
+ idDocumentType: p.idDocumentType,
+ passportNumber: p.passportNumber,
+ passportCountry: p.passportCountry,
+ verifaydaVerified: p.verifaydaVerified,
+ verifaydaData: p.verifaydaData,
+ fareMinor: p.category === PassengerCategory.ADULT ? fare.baseFareMinor : (fare.paidChildrenCount > 0 ? fare.baseFareMinor : 0),
+ displayCurrency,
+ });
+
+ const booking = await this.prisma.booking.create({
+ data: {
+ bookingRef: generateRef(),
+ passengerId: dto.passengerId,
+ scheduleId: dto.scheduleId,
+ status: 'PENDING_PAYMENT',
+ bookingType: 'ROUND_TRIP_TRANSIT',
+ totalMinor, adultCount, childCount, displayCurrency, displayTotalMinor,
+ // Outbound transit leg-2
+ leg2ScheduleId: dto.leg2ScheduleId,
+ leg2OriginStationId: dto.transitStationId,
+ leg2DestinationStationId: dto.leg2DestinationStationId,
+ leg2SeatClassId: obL2SeatClassId,
+ // Return transit
+ returnScheduleId: dto.returnScheduleId,
+ returnOriginStationId: dto.returnOriginStationId,
+ returnDestinationStationId: dto.returnDestinationStationId,
+ returnSeatClassId: retL1SeatClassId,
+ returnLeg2ScheduleId: dto.returnLeg2ScheduleId,
+ returnLeg2OriginStationId: dto.returnTransitStationId,
+ returnLeg2DestStationId: dto.returnLeg2DestinationStationId,
+ returnLeg2SeatClassId: retL2SeatClassId,
+ returnLegStatus: 'NEITHER_USED',
+ seats: {
+ create: [
+ // Outbound leg-1 (sequence 1)
+ ...passengersData.map(p => makeSeat(p, p.outboundSeatId, 1, dto.scheduleId, obL1Fare)),
+ // Outbound leg-2 (sequence 2)
+ ...passengersData.map(p => makeSeat(p, p.outboundLeg2SeatId ?? p.outboundSeatId, 2, dto.leg2ScheduleId!, obL2Fare)),
+ // Return leg-1 (sequence 3)
+ ...passengersData.map(p => makeSeat(p, p.returnSeatId, 3, dto.returnScheduleId!, retL1Fare)),
+ // Return leg-2 (sequence 4)
+ ...passengersData.map(p => makeSeat(p, p.returnLeg2SeatId ?? p.returnSeatId, 4, dto.returnLeg2ScheduleId!, retL2Fare)),
+ ],
+ },
+ } as any,
+ include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } },
+ });
+
+ await Promise.all([
+ this.seatsService.confirmSeats(passengersData.map(p => p.outboundSeatId)),
+ this.seatsService.confirmSeats(passengersData.map(p => p.outboundLeg2SeatId ?? p.outboundSeatId)),
+ this.seatsService.confirmSeats(passengersData.map(p => p.returnSeatId)),
+ this.seatsService.confirmSeats(passengersData.map(p => p.returnLeg2SeatId ?? p.returnSeatId)),
+ ]);
+ this.eventEmitter.emit('booking.created', { booking });
+
+ return {
+ ...booking,
+ fareBreakdown: {
+ outboundLeg1FareMinor: obL1Fare.baseFareMinor,
+ outboundLeg2FareMinor: obL2Fare.baseFareMinor,
+ returnLeg1FareMinor: retL1Fare.baseFareMinor,
+ returnLeg2FareMinor: retL2Fare.baseFareMinor,
+ adultCount, childCount,
+ freeChildrenCount: Math.min(childCount, 1),
+ paidChildrenCount: obL1Fare.paidChildrenCount,
+ combinedBaseFareMinor: combinedBase,
+ discountMinor, loyaltyRedemptionMinor: loyaltyMinor,
+ taxesFeesMinor: taxesMinor, totalMinor,
+ currency: 'ETB', displayCurrency, displayTotalMinor,
+ },
+ };
+ }
+
private async processPassengers(passengers: any[]) {
const processedPassengers = [];
for (const passenger of passengers) {
@@ -560,76 +886,69 @@ export class BookingsService {
destStopSeq?: number,
): Promise {
const now = new Date();
-
- // Get schedule with route info
+
+ // 1. SegmentFareRule — most specific explicit price
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
- include: { route: true },
+ select: { routeId: true, originStationId: true, destinationStationId: true },
});
-
- // Try segment fare rule first (most specific) if route info available
+
if (schedule?.routeId && originStopSeq !== undefined && destStopSeq !== undefined) {
- // Try with nationality first
const segmentFare = await this.prisma.segmentFareRule.findFirst({
where: {
routeId: schedule.routeId,
originStopSequence: originStopSeq,
destinationStopSequence: destStopSeq,
seatClassId,
- nationality: nationality || null,
+ nationality: nationality ?? null,
validFrom: { lte: now },
- OR: [
- { validUntil: null },
- { validUntil: { gte: now } },
- ],
+ OR: [{ validUntil: null }, { validUntil: { gte: now } }],
},
- });
-
- if (segmentFare) {
- return segmentFare.baseFareMinor;
- }
-
- // If no segment fare with nationality, try without nationality filter
- if (nationality) {
- const segmentFareAny = await this.prisma.segmentFareRule.findFirst({
- where: {
- routeId: schedule.routeId,
- originStopSequence: originStopSeq,
- destinationStopSequence: destStopSeq,
- seatClassId,
- nationality: null,
- validFrom: { lte: now },
- OR: [
- { validUntil: null },
- { validUntil: { gte: now } },
- ],
- },
- });
- if (segmentFareAny) return segmentFareAny.baseFareMinor;
- }
+ }) ?? (nationality ? await this.prisma.segmentFareRule.findFirst({
+ where: {
+ routeId: schedule.routeId,
+ originStopSequence: originStopSeq,
+ destinationStopSequence: destStopSeq,
+ seatClassId,
+ nationality: null,
+ validFrom: { lte: now },
+ OR: [{ validUntil: null }, { validUntil: { gte: now } }],
+ },
+ }) : null);
+
+ if (segmentFare) return segmentFare.baseFareMinor;
}
-
- // Fall back to fare rules if no segment fare found
+
+ // 2. FareRule table — explicit override rules
const candidates = await this.prisma.fareRule.findMany({
where: {
seatClassId,
validFrom: { lte: now },
- OR: [
- { validUntil: null },
- { validUntil: { gte: now } },
- ],
+ OR: [{ validUntil: null }, { validUntil: { gte: now } }],
},
});
+ const bestMatch = this.selectBestFareRule(candidates, scheduleId, segmentRoute, fullRoute, nationality);
+ if (bestMatch) return bestMatch.baseFareMinor;
- const bestMatch = this.selectBestFareRule(
- candidates,
- scheduleId,
- segmentRoute,
- fullRoute,
- nationality,
+ // 3. FareEngine — distance × rate-per-km from the schedule's route
+ if (schedule?.routeId) {
+ try {
+ const fare = await this.fareEngine.calculate({
+ routeId: schedule.routeId,
+ originStationId: schedule.originStationId,
+ destinationStationId: schedule.destinationStationId,
+ seatClassId,
+ nationality,
+ });
+ return fare.baseFarePerPassengerMinor;
+ } catch {
+ // FareEngine throws if distanceKm is missing; fall through to error
+ }
+ }
+
+ throw new BadRequestException(
+ `No fare configured for this schedule and seat class. Please set up fare rules or route distances.`,
);
-
- return bestMatch?.baseFareMinor ?? 35000;
}
async getByRef(bookingRef: string) {
@@ -646,7 +965,11 @@ export class BookingsService {
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
totalFare: booking.totalMinor / 100, adultCount: booking.adultCount, childCount: booking.childCount,
displayCurrency: booking.displayCurrency, displayTotalFare: booking.displayTotalMinor ? booking.displayTotalMinor / 100 : undefined,
- bookingType: booking.bookingType, createdAt: booking.createdAt,
+ bookingType: booking.bookingType,
+ returnLegStatus: (booking as any).returnLegStatus ?? null,
+ outboundBoardedAt: (booking as any).outboundBoardedAt ?? null,
+ returnBoardedAt: (booking as any).returnBoardedAt ?? null,
+ createdAt: booking.createdAt,
schedule: {
number: booking.schedule.train.number,
origin: { id: booking.schedule.originStation.id, name: booking.schedule.originStation.name, code: booking.schedule.originStation.code, city: booking.schedule.originStation.city },
@@ -751,6 +1074,37 @@ export class BookingsService {
}
}
+ // Mark round-trip bookings where the return train has departed but the return leg
+ // was never scanned. Runs every minute; only acts on CONFIRMED bookings whose
+ // returnSchedule.departureAt is in the past and returnBoardedAt is still null.
+ @Cron(CronExpression.EVERY_MINUTE)
+ async markReturnLegNoShows() {
+ const now = new Date();
+ const graceCutoff = new Date(now.getTime() - 30 * 60 * 1000);
+
+ const candidates = await this.prisma.booking.findMany({
+ where: {
+ bookingType: 'ROUND_TRIP',
+ status: 'CONFIRMED',
+ returnLegStatus: 'NEITHER_USED' as any,
+ outboundBoardedAt: { not: null },
+ returnBoardedAt: null,
+ returnScheduleId: { not: null },
+ },
+ include: { returnSchedule: { select: { departureAt: true } } },
+ } as any);
+
+ for (const b of candidates) {
+ const returnDep: Date | undefined = (b as any).returnSchedule?.departureAt;
+ if (returnDep && returnDep < graceCutoff) {
+ await this.prisma.booking.update({
+ where: { id: b.id },
+ data: { returnLegStatus: 'OUTBOUND_ONLY' } as any,
+ });
+ }
+ }
+ }
+
private selectBestFareRule(
candidates: any[],
scheduleId: string,
diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts
index 8fca71aea..d3d1e206d 100644
--- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts
@@ -4,9 +4,18 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Currency, IdDocumentType } from '@prisma/client';
export class GuestPassengerDto {
- @ApiProperty({ example: 'seat-id-uuid' })
+ @ApiProperty({ example: 'seat-uuid', description: 'Outbound / leg-1 seat ID (all booking types)' })
@IsString() seatId: string;
+ @ApiPropertyOptional({ example: 'return-seat-uuid', description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return leg-1 seat ID. Required for ROUND_TRIP and ROUND_TRIP_TRANSIT.' })
+ @IsOptional() @IsString() returnSeatId?: string;
+
+ @ApiPropertyOptional({ example: 'leg2-seat-uuid', description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Outbound leg-2 seat ID. Required for TRANSIT and ROUND_TRIP_TRANSIT.' })
+ @IsOptional() @IsString() leg2SeatId?: string;
+
+ @ApiPropertyOptional({ example: 'ret-leg2-seat-uuid', description: '**ROUND_TRIP_TRANSIT:** Return leg-2 seat ID. Required for ROUND_TRIP_TRANSIT.' })
+ @IsOptional() @IsString() returnLeg2SeatId?: string;
+
@ApiProperty({ example: 'Abebe Kebede' })
@IsString() passengerName: string;
@@ -36,24 +45,88 @@ export class GuestPassengerDto {
}
export class CreateGuestBookingDto {
- @ApiProperty({ example: 'schedule-uuid' })
+ @ApiPropertyOptional({
+ example: 'ONE_WAY',
+ enum: ['ONE_WAY', 'ROUND_TRIP', 'TRANSIT', 'ROUND_TRIP_TRANSIT'],
+ default: 'ONE_WAY',
+ description: `Booking type:
+**ONE_WAY:** scheduleId + holdId. Passenger: seatId.
+**ROUND_TRIP:** above + returnScheduleId/returnHoldId/returnOriginStationId/returnDestinationStationId. Passenger: seatId + returnSeatId.
+**TRANSIT:** above + leg2ScheduleId/leg2HoldId/transitStationId/leg2DestinationStationId. Passenger: seatId + leg2SeatId.
+**ROUND_TRIP_TRANSIT:** all 4 hold sets + all station fields. Passenger: seatId + leg2SeatId + returnSeatId + returnLeg2SeatId.`
+ })
+ @IsOptional() @IsString() bookingType?: 'ONE_WAY' | 'ROUND_TRIP' | 'TRANSIT' | 'ROUND_TRIP_TRANSIT';
+
+ @ApiProperty({ example: 'schedule-uuid', description: 'Outbound / leg-1 schedule UUID' })
@IsString() scheduleId: string;
- @ApiProperty({ example: 'hold-uuid' })
+ @ApiProperty({ example: 'hold-uuid', description: 'Outbound seat hold UUID' })
@IsString() holdId: string;
- @ApiProperty({ example: 'station-uuid', description: 'Origin station UUID' })
+ @ApiProperty({ example: 'station-uuid', description: 'Outbound origin station UUID' })
@IsString() originStationId: string;
- @ApiProperty({ example: 'station-uuid', description: 'Destination station UUID' })
+ @ApiProperty({ example: 'station-uuid', description: 'Outbound destination station UUID' })
@IsString() destinationStationId: string;
- @ApiProperty({ type: [GuestPassengerDto], description: 'Array of passengers. First passenger details used for contact.' })
- @IsArray() @ValidateNested({ each: true }) @Type(() => GuestPassengerDto) passengers: GuestPassengerDto[];
-
- @ApiProperty({ example: 'seat-class-uuid', description: 'Seat class UUID' })
+ @ApiProperty({ example: 'seat-class-uuid', description: 'Outbound seat class UUID' })
@IsString() seatClassId: string;
+ @ApiPropertyOptional({ example: 'seat-class-uuid', description: 'ROUND_TRIP / ROUND_TRIP_TRANSIT: return seat class UUID' })
+ @IsOptional() @IsString() returnSeatClassId?: string;
+
+ @ApiPropertyOptional({ example: 'schedule-uuid', description: 'TRANSIT / ROUND_TRIP_TRANSIT: leg-2 schedule UUID' })
+ @IsOptional() @IsString() leg2ScheduleId?: string;
+
+ @ApiPropertyOptional({ example: 'hold-uuid', description: 'TRANSIT / ROUND_TRIP_TRANSIT: leg-2 seat hold UUID' })
+ @IsOptional() @IsString() leg2HoldId?: string;
+
+ @ApiPropertyOptional({ example: 'station-uuid', description: 'TRANSIT / ROUND_TRIP_TRANSIT: connecting station UUID' })
+ @IsOptional() @IsString() transitStationId?: string;
+
+ @ApiPropertyOptional({ example: 'station-uuid', description: 'TRANSIT / ROUND_TRIP_TRANSIT: leg-2 destination station UUID' })
+ @IsOptional() @IsString() leg2DestinationStationId?: string;
+
+ @ApiPropertyOptional({ example: 'seat-class-uuid', description: 'TRANSIT / ROUND_TRIP_TRANSIT: leg-2 seat class UUID' })
+ @IsOptional() @IsString() leg2SeatClassId?: string;
+
+ @ApiPropertyOptional({ example: 'schedule-uuid', description: 'ROUND_TRIP / ROUND_TRIP_TRANSIT: return leg-1 schedule UUID' })
+ @IsOptional() @IsString() returnScheduleId?: string;
+
+ @ApiPropertyOptional({ example: 'hold-uuid', description: 'ROUND_TRIP / ROUND_TRIP_TRANSIT: return seat hold UUID' })
+ @IsOptional() @IsString() returnHoldId?: string;
+
+ @ApiPropertyOptional({ example: 'station-uuid', description: 'ROUND_TRIP / ROUND_TRIP_TRANSIT: return origin station UUID' })
+ @IsOptional() @IsString() returnOriginStationId?: string;
+
+ @ApiPropertyOptional({ example: 'station-uuid', description: 'ROUND_TRIP / ROUND_TRIP_TRANSIT: return destination station UUID' })
+ @IsOptional() @IsString() returnDestinationStationId?: string;
+
+ @ApiPropertyOptional({ example: 'schedule-uuid', description: 'ROUND_TRIP_TRANSIT: return leg-2 schedule UUID' })
+ @IsOptional() @IsString() returnLeg2ScheduleId?: string;
+
+ @ApiPropertyOptional({ example: 'hold-uuid', description: 'ROUND_TRIP_TRANSIT: return leg-2 seat hold UUID' })
+ @IsOptional() @IsString() returnLeg2HoldId?: string;
+
+ @ApiPropertyOptional({ example: 'station-uuid', description: 'ROUND_TRIP_TRANSIT: return transit station UUID' })
+ @IsOptional() @IsString() returnTransitStationId?: string;
+
+ @ApiPropertyOptional({ example: 'station-uuid', description: 'ROUND_TRIP_TRANSIT: return leg-2 destination station UUID' })
+ @IsOptional() @IsString() returnLeg2DestinationStationId?: string;
+
+ @ApiPropertyOptional({ example: 'seat-class-uuid', description: 'ROUND_TRIP_TRANSIT: return leg-2 seat class UUID' })
+ @IsOptional() @IsString() returnLeg2SeatClassId?: string;
+
+ @ApiProperty({
+ type: [GuestPassengerDto],
+ description: `Passenger array. Required seat fields vary by bookingType:
+- ONE_WAY: seatId
+- ROUND_TRIP: seatId + returnSeatId
+- TRANSIT: seatId + leg2SeatId
+- ROUND_TRIP_TRANSIT: seatId + leg2SeatId + returnSeatId + returnLeg2SeatId`
+ })
+ @IsArray() @ValidateNested({ each: true }) @Type(() => GuestPassengerDto) passengers: GuestPassengerDto[];
+
@ApiPropertyOptional({ example: 'WEEKEND15' })
@IsOptional() @IsString() promoCode?: string;
@@ -66,7 +139,7 @@ export class CreateGuestBookingDto {
@ApiPropertyOptional({ example: 'password123', description: 'Password if createAccount is true' })
@IsOptional() @IsString() password?: string;
- @ApiPropertyOptional({ example: true, description: 'Save passenger details for future bookings (requires createAccount)' })
+ @ApiPropertyOptional({ example: true, description: 'Save passenger details for future bookings' })
@IsOptional() @IsBoolean() savePassengerDetails?: boolean;
@ApiPropertyOptional({ example: 'device-uuid-12345', description: 'Device ID for local storage of passenger details' })
diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts
index 93cbda7b0..48b84c4b6 100644
--- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts
@@ -3,6 +3,7 @@ import { PrismaService } from '../../common/prisma.service';
import { SeatsService } from '../seats/seats.service';
import { VerifaydaService } from '../verifayda/verifayda.service';
import { CurrencyService } from '../currency/currency.service';
+import { FareEngineService } from '../fare-engine/fare-engine.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { CreateGuestBookingDto, SavedPassengerProfileDto } from './guest-booking.dto';
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
@@ -28,10 +29,18 @@ export class GuestBookingService {
private seatsService: SeatsService,
private verifaydaService: VerifaydaService,
private currencyService: CurrencyService,
+ private fareEngine: FareEngineService,
private eventEmitter: EventEmitter2,
) {}
async createGuestBooking(dto: CreateGuestBookingDto) {
+ if (dto.bookingType === 'ROUND_TRIP') return this.createGuestRoundTripBooking(dto);
+ if (dto.bookingType === 'TRANSIT') return this.createGuestTransitBooking(dto);
+ if (dto.bookingType === 'ROUND_TRIP_TRANSIT') return this.createGuestRoundTripTransitBooking(dto);
+ return this.createGuestOneWayBooking(dto);
+ }
+
+ private async createGuestOneWayBooking(dto: CreateGuestBookingDto) {
// Validate hold
const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } });
if (!hold || hold.expiresAt < new Date()) {
@@ -157,76 +166,7 @@ export class GuestBookingService {
// Create or get guest passenger
const firstPassenger = passengersData[0];
- let guestPassenger = null;
- let userId = null;
- let createdAccount = false;
-
- // Optional account creation
- if (dto.createAccount && firstPassenger.email && dto.password) {
- const existingUser = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } });
- if (existingUser) {
- throw new BadRequestException('Email already registered. Please login instead.');
- }
-
- let accountPhone = firstPassenger.phone || null;
- if (accountPhone) {
- const existingPhone = await this.prisma.user.findUnique({ where: { phone: accountPhone } });
- if (existingPhone) throw new BadRequestException('Phone number already registered. Please login instead.');
- }
- if (!accountPhone) accountPhone = `+guest-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
-
- const passwordHash = await bcrypt.hash(dto.password, 10);
- const user = await this.prisma.user.create({
- data: {
- fullName: firstPassenger.passengerName,
- email: firstPassenger.email,
- phone: accountPhone,
- passwordHash,
- nationality: firstPassenger.nationality,
- nationalId: firstPassenger.idDocumentType === IdDocumentType.NATIONAL_ID ? firstPassenger.idDocumentNumber : undefined,
- passportNumber: firstPassenger.passportNumber,
- },
- });
-
- guestPassenger = await this.prisma.passenger.create({ data: { userId: user.id } });
- await this.prisma.loyaltyAccount.create({ data: { passengerId: guestPassenger.id, pointsBalance: 0, tier: 'BRONZE' } });
- await this.prisma.walletAccount.create({ data: { passengerId: guestPassenger.id, balanceMinor: 0 } });
-
- userId = user.id;
- createdAccount = true;
- } else {
- // Create anonymous guest passenger with minimal data
- const uniqueId = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
-
- // Check if email exists and use a unique guest email if it does
- let guestEmail = firstPassenger.email || `guest-${uniqueId}@edr-platform.com`;
- if (firstPassenger.email) {
- const existingUser = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } });
- if (existingUser) {
- // Email exists, use guest email instead for anonymous booking
- guestEmail = `guest-${uniqueId}@edr-platform.com`;
- }
- }
-
- // Use a guaranteed-unique guest phone to avoid constraint collisions
- let guestPhone = firstPassenger.phone || null;
- if (guestPhone) {
- const existingPhone = await this.prisma.user.findUnique({ where: { phone: guestPhone } });
- if (existingPhone) guestPhone = null;
- }
- if (!guestPhone) guestPhone = `+guest-${uniqueId}`;
-
- const tempUser = await this.prisma.user.create({
- data: {
- fullName: firstPassenger.passengerName,
- email: guestEmail,
- phone: guestPhone,
- passwordHash: await bcrypt.hash(Math.random().toString(36), 10),
- role: 'PASSENGER',
- },
- });
- guestPassenger = await this.prisma.passenger.create({ data: { userId: tempUser.id } });
- }
+ const { guestPassenger, userId, createdAccount } = await this.resolveGuestPassenger(dto, firstPassenger);
// Save passenger details for future use (if requested)
if (dto.savePassengerDetails && (dto.createAccount || dto.deviceId)) {
@@ -302,6 +242,649 @@ export class GuestBookingService {
};
}
+ private async createGuestRoundTripBooking(dto: CreateGuestBookingDto) {
+ if (!dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId) {
+ throw new BadRequestException('returnScheduleId, returnHoldId, returnOriginStationId and returnDestinationStationId are required for ROUND_TRIP');
+ }
+
+ // Validate both holds
+ const [outboundHold, returnHold] = await Promise.all([
+ this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }),
+ this.prisma.seatHold.findUnique({ where: { id: dto.returnHoldId } }),
+ ]);
+ if (!outboundHold || outboundHold.expiresAt < new Date()) throw new BadRequestException('Outbound seat hold expired or not found');
+ if (!returnHold || returnHold.expiresAt < new Date()) throw new BadRequestException('Return seat hold expired or not found');
+
+ // Validate passengers have returnSeatId
+ for (const p of dto.passengers) {
+ if (!p.returnSeatId) throw new BadRequestException(`returnSeatId is required for each passenger in a ROUND_TRIP booking (missing for ${p.passengerName})`);
+ }
+
+ // Load both schedules
+ const [outboundSchedule, returnSchedule] = await Promise.all([
+ this.prisma.trainSchedule.findUnique({
+ where: { id: dto.scheduleId },
+ include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
+ }),
+ this.prisma.trainSchedule.findUnique({
+ where: { id: dto.returnScheduleId },
+ include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
+ }),
+ ]);
+ if (!outboundSchedule) throw new NotFoundException('Outbound schedule not found');
+ if (!returnSchedule) throw new NotFoundException('Return schedule not found');
+
+ 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);
+ const returnDestStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnDestinationStationId);
+ if (!outboundOriginStop || !outboundDestStop) throw new NotFoundException('Outbound origin or destination not found on schedule');
+ if (!returnOriginStop || !returnDestStop) throw new NotFoundException('Return origin or destination not found on schedule');
+
+ const outboundSegmentRoute = `${outboundOriginStop.station.code}-${outboundDestStop.station.code}`;
+ const outboundFullRoute = `${outboundSchedule.originStation.code}-${outboundSchedule.destinationStation.code}`;
+ const returnSegmentRoute = `${returnOriginStop.station.code}-${returnDestStop.station.code}`;
+ const returnFullRoute = `${returnSchedule.originStation.code}-${returnSchedule.destinationStation.code}`;
+
+ // Process passengers (verify identity once — same person travels both legs)
+ const passengersData: any[] = [];
+ let adultCount = 0, childCount = 0;
+
+ for (const passenger of dto.passengers) {
+ const dateOfBirth = new Date(passenger.dateOfBirth);
+ const age = calculateAge(dateOfBirth);
+ const category: PassengerCategory = age < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT;
+ if (category === PassengerCategory.ADULT) adultCount++; else childCount++;
+
+ let passengerName = passenger.passengerName;
+ let verifaydaVerified = false;
+ let verifaydaData: Record | undefined;
+ let nationality = passenger.nationality;
+
+ const isEthiopian = passenger.nationality === 'Ethiopian' ||
+ passenger.nationality === 'ETHIOPIAN' ||
+ passenger.idDocumentType === IdDocumentType.NATIONAL_ID;
+
+ if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) {
+ if (passenger.idDocumentNumber) {
+ const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber);
+ if (!verification.verified) throw new BadRequestException(`Verifayda verification failed for ${passenger.passengerName}: ${verification.failureReason}`);
+ passengerName = verification.passengerData?.fullName || passengerName;
+ verifaydaVerified = true;
+ verifaydaData = verification.passengerData?.profileData;
+ }
+ nationality = 'Ethiopian';
+ } else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) {
+ if (!passenger.passportNumber || !passenger.passportCountry) throw new BadRequestException(`Passport number and country required for ${passenger.passengerName}`);
+ nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other');
+ } else if (isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) {
+ nationality = 'Ethiopian';
+ } else {
+ nationality = nationality || 'Other';
+ }
+
+ passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
+ }
+
+ // Calculate fares for both legs
+ const returnSeatClassId = dto.returnSeatClassId || dto.seatClassId;
+ 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),
+ ]);
+
+ const paidChildrenCount = Math.max(0, childCount - 1);
+ const outboundTotalBase = outboundBaseFare * adultCount + outboundBaseFare * paidChildrenCount;
+ const returnTotalBase = returnBaseFare * adultCount + returnBaseFare * paidChildrenCount;
+ const combinedBaseFareMinor = outboundTotalBase + returnTotalBase;
+
+ let discountMinor = 0;
+ if (dto.promoCode) {
+ const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
+ if (promo?.active && promo.validUntil > new Date()) {
+ discountMinor = promo.percentOff
+ ? Math.round(combinedBaseFareMinor * promo.percentOff / 100)
+ : (promo.amountOffMinor ?? 0);
+ }
+ }
+
+ const taxesMinor = Math.round(combinedBaseFareMinor * 0.05);
+ const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor + taxesMinor);
+
+ const displayCurrency = dto.displayCurrency || Currency.ETB;
+ const displayTotalMinor = displayCurrency !== Currency.ETB
+ ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
+ : totalMinor;
+
+ // Create or resolve guest passenger (same as one-way)
+ const { guestPassenger, userId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0]);
+
+ // Create booking with outbound seats; return seats confirmed separately
+ const outboundSeatIds = dto.passengers.map(p => p.seatId);
+ const returnSeatIds = dto.passengers.map(p => p.returnSeatId!);
+
+ const booking = await this.prisma.booking.create({
+ data: {
+ bookingRef: generateRef(),
+ passengerId: guestPassenger.id,
+ scheduleId: dto.scheduleId,
+ status: 'PENDING_PAYMENT',
+ bookingType: 'ROUND_TRIP',
+ totalMinor,
+ adultCount,
+ childCount,
+ displayCurrency,
+ displayTotalMinor,
+ returnScheduleId: dto.returnScheduleId,
+ returnOriginStationId: dto.returnOriginStationId,
+ returnDestinationStationId: dto.returnDestinationStationId,
+ returnHoldId: dto.returnHoldId,
+ returnSeatClassId,
+ returnLegStatus: 'NEITHER_USED',
+ userAgent: dto.deviceId,
+ seats: {
+ create: [
+ ...passengersData.map((p) => ({
+ seat: { connect: { id: p.seatId } },
+ leg: 1,
+ scheduleId: dto.scheduleId,
+ passengerName: p.passengerName,
+ dateOfBirth: p.dateOfBirth,
+ passengerCategory: p.category,
+ idDocumentType: p.idDocumentType,
+ passportNumber: p.passportNumber,
+ passportCountry: p.passportCountry,
+ verifaydaVerified: p.verifaydaVerified,
+ verifaydaData: p.verifaydaData || undefined,
+ fareMinor: p.category === PassengerCategory.ADULT ? outboundBaseFare : (paidChildrenCount > 0 ? outboundBaseFare : 0),
+ displayCurrency,
+ })),
+ ...passengersData.map((p) => ({
+ seat: { connect: { id: p.returnSeatId } },
+ leg: 2,
+ scheduleId: dto.returnScheduleId,
+ passengerName: p.passengerName,
+ dateOfBirth: p.dateOfBirth,
+ passengerCategory: p.category,
+ idDocumentType: p.idDocumentType,
+ passportNumber: p.passportNumber,
+ passportCountry: p.passportCountry,
+ verifaydaVerified: p.verifaydaVerified,
+ verifaydaData: p.verifaydaData || undefined,
+ fareMinor: p.category === PassengerCategory.ADULT ? returnBaseFare : (paidChildrenCount > 0 ? returnBaseFare : 0),
+ displayCurrency,
+ })),
+ ],
+ },
+ } as any,
+ include: {
+ seats: { include: { seat: { include: { coach: true } } } },
+ schedule: { include: { originStation: true, destinationStation: true, train: true } },
+ },
+ });
+
+ await Promise.all([
+ this.seatsService.confirmSeats(outboundSeatIds),
+ this.seatsService.confirmSeats(returnSeatIds),
+ ]);
+ this.eventEmitter.emit('booking.created', { booking });
+
+ return {
+ ...booking,
+ createdAccount,
+ userId,
+ fareBreakdown: {
+ outboundBaseFareMinor: outboundBaseFare,
+ returnBaseFareMinor: returnBaseFare,
+ adultCount,
+ childCount,
+ freeChildrenCount: Math.min(childCount, 1),
+ paidChildrenCount,
+ combinedBaseFareMinor,
+ discountMinor,
+ taxesFeesMinor: taxesMinor,
+ totalMinor,
+ currency: 'ETB',
+ displayCurrency,
+ displayTotalMinor,
+ },
+ };
+ }
+
+ private async createGuestTransitBooking(dto: CreateGuestBookingDto) {
+ if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId) {
+ throw new BadRequestException('leg2ScheduleId, leg2HoldId, transitStationId and leg2DestinationStationId are required for TRANSIT bookings');
+ }
+
+ const [leg1Hold, leg2Hold] = await Promise.all([
+ this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }),
+ this.prisma.seatHold.findUnique({ where: { id: dto.leg2HoldId } }),
+ ]);
+ if (!leg1Hold || leg1Hold.expiresAt < new Date()) throw new BadRequestException('Leg-1 seat hold expired or not found');
+ if (!leg2Hold || leg2Hold.expiresAt < new Date()) throw new BadRequestException('Leg-2 seat hold expired or not found');
+
+ for (const p of dto.passengers) {
+ if (!p.leg2SeatId) throw new BadRequestException(`leg2SeatId is required for each passenger in a TRANSIT booking (missing for ${p.passengerName})`);
+ }
+
+ const [leg1Schedule, leg2Schedule] = await Promise.all([
+ this.prisma.trainSchedule.findUnique({
+ where: { id: dto.scheduleId },
+ include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
+ }),
+ this.prisma.trainSchedule.findUnique({
+ where: { id: dto.leg2ScheduleId },
+ include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
+ }),
+ ]);
+ if (!leg1Schedule) throw new NotFoundException('Leg-1 schedule not found');
+ if (!leg2Schedule) throw new NotFoundException('Leg-2 schedule not found');
+
+ 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);
+ const leg2DestStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.leg2DestinationStationId);
+ if (!leg1OriginStop || !leg1DestStop) throw new NotFoundException('Leg-1 origin or transit station not found on schedule');
+ if (!leg2OriginStop || !leg2DestStop) throw new NotFoundException('Transit or leg-2 destination not found on leg-2 schedule');
+
+ // Process passengers (verify identity once)
+ const passengersData: any[] = [];
+ let adultCount = 0, childCount = 0;
+ for (const passenger of dto.passengers) {
+ const dateOfBirth = new Date(passenger.dateOfBirth);
+ const age = calculateAge(dateOfBirth);
+ const category: PassengerCategory = age < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT;
+ if (category === PassengerCategory.ADULT) adultCount++; else childCount++;
+
+ let passengerName = passenger.passengerName;
+ let verifaydaVerified = false;
+ let verifaydaData: Record | undefined;
+ let nationality = passenger.nationality;
+
+ const isEthiopian = passenger.nationality === 'Ethiopian' || passenger.nationality === 'ETHIOPIAN' || passenger.idDocumentType === IdDocumentType.NATIONAL_ID;
+ if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID && passenger.idDocumentNumber) {
+ const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber);
+ if (!verification.verified) throw new BadRequestException(`Verifayda verification failed for ${passenger.passengerName}: ${verification.failureReason}`);
+ passengerName = verification.passengerData?.fullName || passengerName;
+ verifaydaVerified = true;
+ verifaydaData = verification.passengerData?.profileData;
+ nationality = 'Ethiopian';
+ } else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) {
+ if (!passenger.passportNumber || !passenger.passportCountry) throw new BadRequestException(`Passport details required for ${passenger.passengerName}`);
+ nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other');
+ } else {
+ nationality = nationality || 'Other';
+ }
+ passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
+ }
+
+ const leg2SeatClassId = dto.leg2SeatClassId || dto.seatClassId;
+ const primaryNationality = passengersData[0]?.nationality;
+ const paidChildrenCount = Math.max(0, childCount - 1);
+
+ const [leg1BaseFare, leg2BaseFare] = await Promise.all([
+ this.getBaseFare(dto.scheduleId, dto.seatClassId,
+ `${leg1OriginStop.station.code}-${leg1DestStop.station.code}`,
+ `${leg1Schedule.originStation.code}-${leg1Schedule.destinationStation.code}`,
+ primaryNationality),
+ this.getBaseFare(dto.leg2ScheduleId, leg2SeatClassId,
+ `${leg2OriginStop.station.code}-${leg2DestStop.station.code}`,
+ `${leg2Schedule.originStation.code}-${leg2Schedule.destinationStation.code}`,
+ primaryNationality),
+ ]);
+
+ const leg1Total = leg1BaseFare * adultCount + leg1BaseFare * paidChildrenCount;
+ const leg2Total = leg2BaseFare * adultCount + leg2BaseFare * paidChildrenCount;
+ const combinedBase = leg1Total + leg2Total;
+
+ let discountMinor = 0;
+ if (dto.promoCode) {
+ const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
+ if (promo?.active && promo.validUntil > new Date()) {
+ 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 displayCurrency = dto.displayCurrency || Currency.ETB;
+ const displayTotalMinor = displayCurrency !== Currency.ETB
+ ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
+ : totalMinor;
+
+ const { guestPassenger, userId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0]);
+
+ // Single booking — leg-1 seats at leg=1, leg-2 seats at leg=2
+ const booking = await this.prisma.booking.create({
+ data: {
+ bookingRef: generateRef(),
+ passengerId: guestPassenger.id,
+ scheduleId: dto.scheduleId,
+ status: 'PENDING_PAYMENT',
+ bookingType: 'TRANSIT',
+ totalMinor,
+ adultCount,
+ childCount,
+ displayCurrency,
+ displayTotalMinor,
+ leg2ScheduleId: dto.leg2ScheduleId,
+ leg2OriginStationId: dto.transitStationId,
+ leg2DestinationStationId: dto.leg2DestinationStationId,
+ leg2SeatClassId: leg2SeatClassId,
+ userAgent: dto.deviceId,
+ seats: {
+ create: [
+ ...passengersData.map(p => ({
+ seat: { connect: { id: p.seatId } },
+ leg: 1,
+ scheduleId: dto.scheduleId,
+ passengerName: p.passengerName,
+ dateOfBirth: p.dateOfBirth,
+ passengerCategory: p.category,
+ idDocumentType: p.idDocumentType,
+ passportNumber: p.passportNumber,
+ passportCountry: p.passportCountry,
+ verifaydaVerified: p.verifaydaVerified,
+ verifaydaData: p.verifaydaData || undefined,
+ fareMinor: p.category === PassengerCategory.ADULT ? leg1BaseFare : (paidChildrenCount > 0 ? leg1BaseFare : 0),
+ displayCurrency,
+ })),
+ ...passengersData.map(p => ({
+ seat: { connect: { id: p.leg2SeatId! } },
+ leg: 2,
+ scheduleId: dto.leg2ScheduleId,
+ passengerName: p.passengerName,
+ dateOfBirth: p.dateOfBirth,
+ passengerCategory: p.category,
+ idDocumentType: p.idDocumentType,
+ passportNumber: p.passportNumber,
+ passportCountry: p.passportCountry,
+ verifaydaVerified: p.verifaydaVerified,
+ verifaydaData: p.verifaydaData || undefined,
+ fareMinor: p.category === PassengerCategory.ADULT ? leg2BaseFare : (paidChildrenCount > 0 ? leg2BaseFare : 0),
+ displayCurrency,
+ })),
+ ],
+ },
+ } as any,
+ include: {
+ seats: { include: { seat: { include: { coach: true } } } },
+ schedule: { include: { originStation: true, destinationStation: true, train: true } },
+ },
+ });
+
+ await Promise.all([
+ this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId)),
+ this.seatsService.confirmSeats(dto.passengers.map(p => p.leg2SeatId!)),
+ ]);
+ this.eventEmitter.emit('booking.created', { booking });
+
+ return {
+ ...booking,
+ createdAccount,
+ userId,
+ fareBreakdown: {
+ leg1BaseFareMinor: leg1BaseFare,
+ leg2BaseFareMinor: leg2BaseFare,
+ adultCount, childCount,
+ freeChildrenCount: Math.min(childCount, 1),
+ paidChildrenCount,
+ combinedBaseFareMinor: combinedBase,
+ discountMinor, taxesFeesMinor: taxesMinor, totalMinor,
+ currency: 'ETB', displayCurrency, displayTotalMinor,
+ },
+ };
+ }
+
+ private async createGuestRoundTripTransitBooking(dto: CreateGuestBookingDto) {
+ if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId ||
+ !dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId ||
+ !dto.returnLeg2ScheduleId || !dto.returnLeg2HoldId || !dto.returnTransitStationId || !dto.returnLeg2DestinationStationId) {
+ throw new BadRequestException(
+ 'ROUND_TRIP_TRANSIT requires all 4 holds and all transit/return station fields',
+ );
+ }
+ for (const p of dto.passengers) {
+ if (!p.leg2SeatId) throw new BadRequestException(`leg2SeatId required for ${p.passengerName}`);
+ if (!p.returnSeatId) throw new BadRequestException(`returnSeatId required for ${p.passengerName}`);
+ if (!p.returnLeg2SeatId) throw new BadRequestException(`returnLeg2SeatId required for ${p.passengerName}`);
+ }
+
+ const now = new Date();
+ const [obL1Hold, obL2Hold, retL1Hold, retL2Hold] = await Promise.all([
+ this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }),
+ this.prisma.seatHold.findUnique({ where: { id: dto.leg2HoldId } }),
+ this.prisma.seatHold.findUnique({ where: { id: dto.returnHoldId } }),
+ this.prisma.seatHold.findUnique({ where: { id: dto.returnLeg2HoldId } }),
+ ]);
+ if (!obL1Hold || obL1Hold.expiresAt < now) throw new BadRequestException('Outbound leg-1 hold expired');
+ if (!obL2Hold || obL2Hold.expiresAt < now) throw new BadRequestException('Outbound leg-2 hold expired');
+ if (!retL1Hold || retL1Hold.expiresAt < now) throw new BadRequestException('Return leg-1 hold expired');
+ if (!retL2Hold || retL2Hold.expiresAt < now) throw new BadRequestException('Return leg-2 hold expired');
+
+ const [obL1Sched, obL2Sched, retL1Sched, retL2Sched] = await Promise.all([
+ this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
+ this.prisma.trainSchedule.findUnique({ where: { id: dto.leg2ScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
+ this.prisma.trainSchedule.findUnique({ where: { id: dto.returnScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
+ this.prisma.trainSchedule.findUnique({ where: { id: dto.returnLeg2ScheduleId },include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
+ ]);
+ if (!obL1Sched) throw new NotFoundException('Outbound leg-1 schedule not found');
+ if (!obL2Sched) throw new NotFoundException('Outbound leg-2 schedule not found');
+ if (!retL1Sched) throw new NotFoundException('Return leg-1 schedule not found');
+ if (!retL2Sched) throw new NotFoundException('Return leg-2 schedule not found');
+
+ 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);
+ const obL2Dest = obL2Sched.stopTimes.find(s => s.stationId === dto.leg2DestinationStationId);
+ const retL1Origin = retL1Sched.stopTimes.find(s => s.stationId === dto.returnOriginStationId);
+ const retL1Dest = retL1Sched.stopTimes.find(s => s.stationId === dto.returnTransitStationId);
+ const retL2Origin = retL2Sched.stopTimes.find(s => s.stationId === dto.returnTransitStationId);
+ const retL2Dest = retL2Sched.stopTimes.find(s => s.stationId === dto.returnLeg2DestinationStationId);
+ if (!obL1Origin || !obL1Dest) throw new NotFoundException('Outbound leg-1: origin or transit stop not found');
+ if (!obL2Origin || !obL2Dest) throw new NotFoundException('Outbound leg-2: transit or destination stop not found');
+ if (!retL1Origin || !retL1Dest) throw new NotFoundException('Return leg-1: origin or transit stop not found');
+ if (!retL2Origin || !retL2Dest) throw new NotFoundException('Return leg-2: transit or destination stop not found');
+
+ // Process passengers (verify once)
+ const passengersData: any[] = [];
+ let adultCount = 0, childCount = 0;
+ for (const passenger of dto.passengers) {
+ const dateOfBirth = new Date(passenger.dateOfBirth);
+ const category: PassengerCategory = calculateAge(dateOfBirth) < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT;
+ if (category === PassengerCategory.ADULT) adultCount++; else childCount++;
+ let passengerName = passenger.passengerName;
+ let verifaydaVerified = false;
+ let verifaydaData: Record | undefined;
+ let nationality = passenger.nationality;
+ const isEthiopian = nationality === 'Ethiopian' || nationality === 'ETHIOPIAN' || passenger.idDocumentType === IdDocumentType.NATIONAL_ID;
+ if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID && passenger.idDocumentNumber) {
+ const v = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber);
+ if (!v.verified) throw new BadRequestException(`Verifayda failed for ${passenger.passengerName}: ${v.failureReason}`);
+ passengerName = v.passengerData?.fullName || passengerName;
+ verifaydaVerified = true;
+ verifaydaData = v.passengerData?.profileData;
+ nationality = 'Ethiopian';
+ } else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) {
+ if (!passenger.passportNumber || !passenger.passportCountry) throw new BadRequestException(`Passport details required for ${passenger.passengerName}`);
+ nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other');
+ } else {
+ nationality = nationality || 'Other';
+ }
+ passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
+ }
+
+ const nat = passengersData[0]?.nationality;
+ const paidChildren = Math.max(0, childCount - 1);
+ const obL2ClassId = dto.leg2SeatClassId ?? dto.seatClassId;
+ const retL1ClassId = dto.returnSeatClassId ?? dto.seatClassId;
+ 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),
+ ]);
+
+ const combinedBase = (obL1Fare + obL2Fare + retL1Fare + retL2Fare) * adultCount +
+ (obL1Fare + obL2Fare + retL1Fare + retL2Fare) * paidChildren;
+ let discountMinor = 0;
+ if (dto.promoCode) {
+ const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
+ if (promo?.active && promo.validUntil > new Date()) {
+ 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 displayCurrency = dto.displayCurrency || Currency.ETB;
+ const displayTotalMinor = displayCurrency !== Currency.ETB
+ ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
+ : totalMinor;
+
+ const { guestPassenger, userId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0]);
+
+ const makeSeat = (p: any, seatId: string, leg: number, scheduleId: string, fare: number) => ({
+ seat: { connect: { id: seatId } },
+ leg,
+ scheduleId,
+ passengerName: p.passengerName,
+ dateOfBirth: p.dateOfBirth,
+ passengerCategory: p.category,
+ idDocumentType: p.idDocumentType,
+ passportNumber: p.passportNumber,
+ passportCountry: p.passportCountry,
+ verifaydaVerified: p.verifaydaVerified,
+ verifaydaData: p.verifaydaData || undefined,
+ fareMinor: p.category === PassengerCategory.ADULT ? fare : (paidChildren > 0 ? fare : 0),
+ displayCurrency,
+ });
+
+ const booking = await this.prisma.booking.create({
+ data: {
+ bookingRef: generateRef(),
+ passengerId: guestPassenger.id,
+ scheduleId: dto.scheduleId,
+ status: 'PENDING_PAYMENT',
+ bookingType: 'ROUND_TRIP_TRANSIT',
+ totalMinor, adultCount, childCount, displayCurrency, displayTotalMinor,
+ leg2ScheduleId: dto.leg2ScheduleId,
+ leg2OriginStationId: dto.transitStationId,
+ leg2DestinationStationId: dto.leg2DestinationStationId,
+ leg2SeatClassId: obL2ClassId,
+ returnScheduleId: dto.returnScheduleId,
+ returnOriginStationId: dto.returnOriginStationId,
+ returnDestinationStationId: dto.returnDestinationStationId,
+ returnSeatClassId: retL1ClassId,
+ returnLeg2ScheduleId: dto.returnLeg2ScheduleId,
+ returnLeg2OriginStationId: dto.returnTransitStationId,
+ returnLeg2DestStationId: dto.returnLeg2DestinationStationId,
+ returnLeg2SeatClassId: retL2ClassId,
+ returnLegStatus: 'NEITHER_USED',
+ userAgent: dto.deviceId,
+ seats: {
+ create: [
+ ...passengersData.map(p => makeSeat(p, p.seatId, 1, dto.scheduleId, obL1Fare)),
+ ...passengersData.map(p => makeSeat(p, p.leg2SeatId!, 2, dto.leg2ScheduleId!, obL2Fare)),
+ ...passengersData.map(p => makeSeat(p, p.returnSeatId!, 3, dto.returnScheduleId!, retL1Fare)),
+ ...passengersData.map(p => makeSeat(p, p.returnLeg2SeatId!,4, dto.returnLeg2ScheduleId!,retL2Fare)),
+ ],
+ },
+ } as any,
+ include: {
+ seats: { include: { seat: { include: { coach: true } } } },
+ schedule: { include: { originStation: true, destinationStation: true, train: true } },
+ },
+ });
+
+ await Promise.all([
+ this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId)),
+ this.seatsService.confirmSeats(dto.passengers.map(p => p.leg2SeatId!)),
+ this.seatsService.confirmSeats(dto.passengers.map(p => p.returnSeatId!)),
+ this.seatsService.confirmSeats(dto.passengers.map(p => p.returnLeg2SeatId!)),
+ ]);
+ this.eventEmitter.emit('booking.created', { booking });
+
+ return {
+ ...booking,
+ createdAccount,
+ userId,
+ fareBreakdown: {
+ outboundLeg1FareMinor: obL1Fare,
+ outboundLeg2FareMinor: obL2Fare,
+ returnLeg1FareMinor: retL1Fare,
+ returnLeg2FareMinor: retL2Fare,
+ adultCount, childCount,
+ freeChildrenCount: Math.min(childCount, 1),
+ paidChildrenCount: paidChildren,
+ combinedBaseFareMinor: combinedBase,
+ discountMinor, taxesFeesMinor: taxesMinor, totalMinor,
+ currency: 'ETB', displayCurrency, displayTotalMinor,
+ },
+ };
+ }
+
+ private async resolveGuestPassenger(
+ dto: Pick,
+ firstPassenger: any,
+ ): Promise<{ guestPassenger: any; userId: string | null; createdAccount: boolean }> {
+ if (dto.createAccount && firstPassenger.email && dto.password) {
+ const existingUser = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } });
+ if (existingUser) throw new BadRequestException('Email already registered. Please login instead.');
+
+ let accountPhone = firstPassenger.phone || null;
+ if (accountPhone) {
+ const existingPhone = await this.prisma.user.findUnique({ where: { phone: accountPhone } });
+ if (existingPhone) throw new BadRequestException('Phone number already registered. Please login instead.');
+ }
+ if (!accountPhone) accountPhone = `+guest-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
+
+ const user = await this.prisma.user.create({
+ data: {
+ fullName: firstPassenger.passengerName,
+ email: firstPassenger.email,
+ phone: accountPhone,
+ passwordHash: await bcrypt.hash(dto.password, 10),
+ nationality: firstPassenger.nationality,
+ nationalId: firstPassenger.idDocumentType === IdDocumentType.NATIONAL_ID ? firstPassenger.idDocumentNumber : undefined,
+ passportNumber: firstPassenger.passportNumber,
+ },
+ });
+ const guestPassenger = await this.prisma.passenger.create({ data: { userId: user.id } });
+ await this.prisma.loyaltyAccount.create({ data: { passengerId: guestPassenger.id, pointsBalance: 0, tier: 'BRONZE' } });
+ await this.prisma.walletAccount.create({ data: { passengerId: guestPassenger.id, balanceMinor: 0 } });
+ return { guestPassenger, userId: user.id, createdAccount: true };
+ }
+
+ const uniqueId = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
+ let guestEmail = firstPassenger.email || `guest-${uniqueId}@edr-platform.com`;
+ if (firstPassenger.email) {
+ const existing = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } });
+ if (existing) guestEmail = `guest-${uniqueId}@edr-platform.com`;
+ }
+ let guestPhone = firstPassenger.phone || null;
+ if (guestPhone) {
+ const existing = await this.prisma.user.findUnique({ where: { phone: guestPhone } });
+ if (existing) guestPhone = null;
+ }
+ if (!guestPhone) guestPhone = `+guest-${uniqueId}`;
+
+ const tempUser = await this.prisma.user.create({
+ data: {
+ fullName: firstPassenger.passengerName,
+ email: guestEmail,
+ phone: guestPhone,
+ passwordHash: await bcrypt.hash(Math.random().toString(36), 10),
+ role: 'PASSENGER',
+ },
+ });
+ const guestPassenger = await this.prisma.passenger.create({ data: { userId: tempUser.id } });
+ return { guestPassenger, userId: null, createdAccount: false };
+ }
+
async getSavedPassengers(userId?: string, deviceId?: string): Promise {
if (!userId && !deviceId) {
throw new BadRequestException('Either userId or deviceId is required');
@@ -343,6 +926,8 @@ export class GuestBookingService {
nationality?: string,
): Promise {
const now = new Date();
+
+ // 1. FareRule table — explicit override rules
const candidates = await this.prisma.fareRule.findMany({
where: {
seatClassId,
@@ -368,14 +953,34 @@ export class GuestBookingService {
for (const priority of priorities) {
const match = candidates.find(
- (c) =>
- c.tripId === priority.tripId &&
- c.route === priority.route &&
- c.nationality === priority.nationality,
+ (c) => c.tripId === priority.tripId && c.route === priority.route && c.nationality === priority.nationality,
);
if (match) return match.baseFareMinor;
}
- return 35000; // Default fallback
+ // 2. FareEngine — distance × rate-per-km from the schedule's route
+ const schedule = await this.prisma.trainSchedule.findUnique({
+ where: { id: scheduleId },
+ select: { routeId: true, originStationId: true, destinationStationId: true },
+ });
+
+ if (schedule?.routeId) {
+ try {
+ const fare = await this.fareEngine.calculate({
+ routeId: schedule.routeId,
+ originStationId: schedule.originStationId,
+ destinationStationId: schedule.destinationStationId,
+ seatClassId,
+ nationality,
+ });
+ return fare.baseFarePerPassengerMinor;
+ } catch {
+ // FareEngine throws if distanceKm is missing; fall through to error
+ }
+ }
+
+ throw new BadRequestException(
+ `No fare configured for this schedule and seat class. Please set up fare rules or route distances.`,
+ );
}
}
diff --git a/apps/edr-passenger-api/src/modules/notifications/dtos/email.dto.ts b/apps/edr-passenger-api/src/modules/notifications/dtos/email.dto.ts
index 63bbc4a5a..a435fa4ce 100644
--- a/apps/edr-passenger-api/src/modules/notifications/dtos/email.dto.ts
+++ b/apps/edr-passenger-api/src/modules/notifications/dtos/email.dto.ts
@@ -1,8 +1,57 @@
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator';
+
export class SendEmail {
+ @ApiProperty()
+ @IsEmail()
+ @IsNotEmpty()
to: string;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ sourceId?: string;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ sourceName?: string;
+
+ @ApiProperty()
+ @IsNotEmpty()
+ @IsString()
subject: string;
- body: string;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
html?: string;
- templateKey?: string;
- context?: Record;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ text?: string;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ body?: string;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ context?: Record;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ templateName?: string;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsEmail()
+ from?: string;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsEmail()
+ replyTo?: string;
}
diff --git a/apps/edr-passenger-api/src/modules/notifications/dtos/sms.dto.ts b/apps/edr-passenger-api/src/modules/notifications/dtos/sms.dto.ts
index 1897f7794..e50cf6c64 100644
--- a/apps/edr-passenger-api/src/modules/notifications/dtos/sms.dto.ts
+++ b/apps/edr-passenger-api/src/modules/notifications/dtos/sms.dto.ts
@@ -1,9 +1,46 @@
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import { IsArray, IsNotEmpty, IsOptional, IsString, ValidateNested } from 'class-validator';
+import { Type } from 'class-transformer';
+
export class SendMessage {
+ @ApiProperty()
+ @IsNotEmpty()
+ @IsString()
to: string;
+
+ @ApiProperty()
+ @IsNotEmpty()
+ @IsString()
message: string;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
from?: string;
}
+export class SingleMessageDto {
+ @ApiProperty({
+ description: 'Recipient phone number',
+ example: '+1234567890',
+ })
+ @IsString()
+ @IsNotEmpty()
+ to: string;
+
+ @ApiProperty({
+ description: 'Message content',
+ example: 'Test Single SMS from',
+ })
+ @IsString()
+ @IsNotEmpty()
+ sms: string;
+}
+
export class BulkMessagesDto {
+ @ApiProperty({ type: [SendMessage] })
+ @IsArray()
+ @ValidateNested({ each: true })
+ @Type(() => SendMessage)
messages: SendMessage[];
}
diff --git a/apps/edr-passenger-api/src/modules/notifications/email-client.service.ts b/apps/edr-passenger-api/src/modules/notifications/email-client.service.ts
index 8fcb82394..34879ed0a 100644
--- a/apps/edr-passenger-api/src/modules/notifications/email-client.service.ts
+++ b/apps/edr-passenger-api/src/modules/notifications/email-client.service.ts
@@ -1,27 +1,38 @@
-import { Inject, Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
-import { ClientProxy } from '@nestjs/microservices';
-import { SendEmail } from './dtos/email.dto';
+import {
+ Inject,
+ Injectable,
+ Logger,
+ OnApplicationBootstrap,
+} from "@nestjs/common";
+import { ClientProxy } from "@nestjs/microservices";
+import { SendEmail } from "./dtos/email.dto";
@Injectable()
export class EmailClientService implements OnApplicationBootstrap {
private readonly logger = new Logger(EmailClientService.name);
constructor(
- @Inject('EMAIL_SERVICE')
+ @Inject("EMAIL_SERVICE")
private readonly emailServiceClient: ClientProxy,
) {}
+ private readonly enabled = process.env.RABBITMQ_ENABLED !== "false";
+
async onApplicationBootstrap() {
+ if (!this.enabled) return;
this.emailServiceClient
.connect()
- .then(() => this.logger.log('Connected to Email service'))
- .catch((err) => this.logger.error('Error connecting to Email service', err));
+ .then(() => this.logger.log("Connected to Email service"))
+ .catch((err) =>
+ this.logger.error("Error connecting to Email service", err),
+ );
}
async sendEmail(dto: SendEmail) {
- this.emailServiceClient.emit('send-email', {
+ if (!this.enabled) return {};
+ this.emailServiceClient.emit("send-email", {
...dto,
- appKey: 'EDR-PASSENGER-API',
+ appKey: "IFHCRS-LICENSE-MANAGEMENT",
});
return {};
}
diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.controller.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.controller.ts
index 9fd69830c..b622e3b7d 100644
--- a/apps/edr-passenger-api/src/modules/notifications/notifications.controller.ts
+++ b/apps/edr-passenger-api/src/modules/notifications/notifications.controller.ts
@@ -7,7 +7,7 @@ import { TestNotificationDto } from './notifications.dto';
import { EmailClientService } from './email-client.service';
import { SmsClientService } from './sms-client.service';
import { SendEmail } from './dtos/email.dto';
-import { SendMessage } from './dtos/sms.dto';
+import { BulkMessagesDto, SingleMessageDto } from './dtos/sms.dto';
@ApiTags('Notifications')
@Controller('notifications')
@@ -51,11 +51,20 @@ export class NotificationsController {
@UseGuards(IamGuard)
@IamRoles('ADMIN', 'STAFF')
@ApiOperation({ summary: 'Send a direct SMS via the SMS microservice' })
- @ApiBody({ type: SendMessage })
- sendSms(@Body() dto: SendMessage) {
+ @ApiBody({ type: SingleMessageDto })
+ sendSms(@Body() dto: SingleMessageDto) {
return this.smsClient.sendSms(dto);
}
+ @Post('send/sms/bulk')
+ @UseGuards(IamGuard)
+ @IamRoles('ADMIN', 'STAFF')
+ @ApiOperation({ summary: 'Send bulk SMS messages via the SMS microservice' })
+ @ApiBody({ type: BulkMessagesDto })
+ sendBulkSms(@Body() dto: BulkMessagesDto) {
+ return this.smsClient.sendBulkMessages(dto);
+ }
+
@Post('test')
@UseGuards(IamGuard)
@IamRoles('ADMIN', 'STAFF')
diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.module.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.module.ts
index f209ccf17..c72552015 100644
--- a/apps/edr-passenger-api/src/modules/notifications/notifications.module.ts
+++ b/apps/edr-passenger-api/src/modules/notifications/notifications.module.ts
@@ -1,6 +1,5 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
-import { ConfigModule, ConfigService } from '@nestjs/config';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { NotificationsController } from './notifications.controller';
import { NotificationsService } from './notifications.service';
@@ -11,34 +10,24 @@ import { SmsClientService } from './sms-client.service';
@Module({
imports: [
HttpModule.register({ timeout: 10_000 }),
- ClientsModule.registerAsync([
+ ClientsModule.register([
{
name: 'EMAIL_SERVICE',
- imports: [ConfigModule],
- inject: [ConfigService],
- useFactory: (config: ConfigService) => ({
- transport: Transport.RMQ,
- options: {
- urls: [config.get('RABBITMQ_URL') ?? 'amqp://localhost:5672'],
- queue: config.get('EMAIL_QUEUE') ?? 'email_queue',
- queueOptions: { durable: true },
- noAck: true,
- },
- }),
+ transport: Transport.RMQ,
+ options: {
+ urls: [process.env.RABBITMQ_URL as string],
+ queue: process.env.EMAIL_QUEUE ?? 'email_queue',
+ queueOptions: { durable: true },
+ },
},
{
name: 'SMS_SERVICE',
- imports: [ConfigModule],
- inject: [ConfigService],
- useFactory: (config: ConfigService) => ({
- transport: Transport.RMQ,
- options: {
- urls: [config.get('RABBITMQ_URL') ?? 'amqp://localhost:5672'],
- queue: config.get('SMS_QUEUE') ?? 'sms_queue',
- queueOptions: { durable: true },
- noAck: true,
- },
- }),
+ transport: Transport.RMQ,
+ options: {
+ urls: [process.env.RABBITMQ_URL as string],
+ queue: process.env.SMS_QUEUE ?? 'sms_queue',
+ queueOptions: { durable: true },
+ },
},
]),
],
diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts
index 68d76572c..8960f1a87 100644
--- a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts
+++ b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts
@@ -20,8 +20,8 @@ export class NotificationsService {
private pushAdapter: PushAdapter,
) {
this.channels = new Map([
- ['EMAIL', { send: (to, subject, body) => this.emailClient.sendEmail({ to, subject, body }).then(() => true) }],
- ['SMS', { send: (to, _subject, body) => this.smsClient.sendSms({ to, message: body }).then(() => true) }],
+ ['EMAIL', { send: (to, subject, body) => this.emailClient.sendEmail({ to, subject, text: body }).then(() => true) }],
+ ['SMS', { send: (to, _subject, body) => this.smsClient.sendSms({ to, sms: body }).then(() => true) }],
['PUSH', this.pushAdapter as NotificationChannel],
]);
}
@@ -107,7 +107,7 @@ export class NotificationsService {
await this.emailClient.sendEmail({
to: passenger.user.email,
subject: this.sanitize(dto.title),
- body: this.sanitize(dto.body),
+ text: this.sanitize(dto.body),
});
}
diff --git a/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts b/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts
index 0108e0758..ef2758686 100644
--- a/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts
+++ b/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts
@@ -1,35 +1,49 @@
-import { Inject, Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
-import { ClientProxy } from '@nestjs/microservices';
-import { BulkMessagesDto, SendMessage } from './dtos/sms.dto';
+import {
+ Inject,
+ Injectable,
+ Logger,
+ OnApplicationBootstrap,
+} from "@nestjs/common";
+import { ClientProxy } from "@nestjs/microservices";
+import { BulkMessagesDto, SingleMessageDto } from "./dtos/sms.dto";
@Injectable()
export class SmsClientService implements OnApplicationBootstrap {
private readonly logger = new Logger(SmsClientService.name);
constructor(
- @Inject('SMS_SERVICE')
- private readonly smsClient: ClientProxy,
+ @Inject("SMS_SERVICE")
+ private smsClient: ClientProxy,
) {}
+ private readonly enabled = process.env.RABBITMQ_ENABLED !== "false";
+
async onApplicationBootstrap() {
+ if (!this.enabled) return;
this.smsClient
.connect()
- .then(() => this.logger.log('Connected to SMS service'))
- .catch((err) => this.logger.error('Error connecting to SMS service', err));
+ .then(() => {
+ this.logger.log("connected to SMS service");
+ })
+ .catch((err) => {
+ console.error("Error happened at SMS service", err);
+ });
}
- async sendSms(dto: SendMessage) {
- this.smsClient.emit('send-sms', {
+ async sendSms(dto: SingleMessageDto) {
+ if (!this.enabled) return {};
+ this.smsClient.emit("send-sms", {
...dto,
- appKey: 'EDR-PASSENGER-API',
+ appKey: "IFHCRS-LICENSE-MANAGEMENT",
});
return {};
}
async sendBulkMessages(dto: BulkMessagesDto) {
- this.smsClient.emit('ozeking-bulk-sms', {
+ if (!this.enabled) return {};
+ this.smsClient.emit("ozeking-bulk-sms", {
...dto,
- appKey: 'EDR-PASSENGER-API',
+ appKey: "IFHCRS-LICENSE-MANAGEMENT",
});
return {};
}
diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts
index 12d9e5626..7171b56ad 100644
--- a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts
+++ b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts
@@ -21,10 +21,11 @@ export class PassengersService {
const { search, verified, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
- const where: any = {};
+ const where: any = { user: { role: 'PASSENGER' } };
if (search) {
where.user = {
+ ...where.user,
OR: [
{ fullName: { contains: search, mode: 'insensitive' } },
{ email: { contains: search, mode: 'insensitive' } },
@@ -68,7 +69,7 @@ export class PassengersService {
userId: passenger.userId,
fullName: user.fullName,
email: user.email,
- phone: user.phone,
+ phone: user.phone?.startsWith('+guest-') ? null : user.phone,
nationalId: user.nationalId,
nationality: user.nationality,
dateOfBirth: user.dateOfBirth ?? null,
diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts
index 373e10513..a64c4ae9c 100644
--- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts
+++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts
@@ -79,6 +79,28 @@ export class PaymentsController {
return this.service.getIntentByBookingId(bookingId);
}
+ @Get("waafi/return")
+ @ApiOperation({
+ summary:
+ "DEMO ONLY — confirm a Waafi payment from the browser-return params and return JSON for the " +
+ "UI to display. The frontend success page forwards the Waafi query params here. Gated by " +
+ "WAAFI_DEMO_TRUST_RETURN (INSECURE; real confirmation is the webhook/HPP_GETTRANINFO).",
+ })
+ @ApiQuery({ name: "referenceId", required: true })
+ @ApiQuery({ name: "state", required: true })
+ @ApiQuery({ name: "transactionId", required: false })
+ waafiReturn(
+ @Query("referenceId") referenceId: string,
+ @Query("state") state: string,
+ @Query("transactionId") transactionId: string,
+ ) {
+ return this.service.confirmWaafiReturnDemo({
+ referenceId,
+ state,
+ transactionId,
+ });
+ }
+
@Post("refund")
@UseGuards(JwtGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.STAFF, UserRole.AGENT)
diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts
index c017c6332..c8d0580bf 100644
--- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts
+++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts
@@ -44,6 +44,8 @@ export class PaymentsService {
private readonly logger = new Logger(PaymentsService.name);
private readonly walletDemoAutoSucceed = true;
+ private readonly waafiDemoTrustReturn = true;
+
constructor(
private prisma: PrismaService,
private seatsService: SeatsService,
@@ -192,6 +194,41 @@ export class PaymentsService {
return { returnUrl, failureUrl };
}
+ async confirmWaafiReturnDemo(params: {
+ referenceId?: string;
+ state?: string;
+ transactionId?: string;
+ }): Promise<{ confirmed: boolean; bookingId?: string; reason?: string }> {
+ if (!this.waafiDemoTrustReturn) {
+ return { confirmed: false, reason: "demo-disabled" };
+ }
+ if ((params.state ?? "").toUpperCase() !== "APPROVED") {
+ return { confirmed: false, reason: `not-approved (${params.state})` };
+ }
+ if (!params.referenceId) {
+ return { confirmed: false, reason: "missing-referenceId" };
+ }
+
+ const intent = await this.prisma.paymentIntent.findFirst({
+ where: { merchantOrderId: params.referenceId },
+ });
+ if (!intent) {
+ this.logger.warn(
+ `waafi demo return: no local intent for referenceId ${params.referenceId}`,
+ );
+ return { confirmed: false, reason: "intent-not-found" };
+ }
+
+ this.logger.warn(
+ `WAAFI_DEMO_TRUST_RETURN enabled — confirming booking ${intent.bookingId} from browser return (INSECURE, demo only)`,
+ );
+ await this.finalizePaymentSuccess({
+ intentId: intent.id,
+ providerTxnId: params.transactionId,
+ });
+ return { confirmed: true, bookingId: intent.bookingId };
+ }
+
private async syncIntentProjection(
bookingId: string,
snapshot: PaymentIntentSnapshot,
@@ -453,6 +490,30 @@ export class PaymentsService {
});
}
+ /**
+ * 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
+ * whole confirmation. Falls back to "now" for missing/invalid/far-future/ancient values so the
+ * booking still confirms.
+ */
+ private sanitizePaidAt(value?: Date): Date {
+ const now = new Date();
+ if (!value) return now;
+ const t = value.getTime();
+ const oneDayMs = 86_400_000;
+ if (
+ Number.isNaN(t) ||
+ t > now.getTime() + oneDayMs ||
+ t < Date.UTC(2000, 0, 1)
+ ) {
+ this.logger.warn(
+ `finalizePaymentSuccess: implausible paidAt (epoch=${t}); using current time instead`,
+ );
+ return now;
+ }
+ return value;
+ }
+
async finalizePaymentSuccess(input: {
intentId: string;
providerTxnId?: string;
@@ -477,7 +538,7 @@ export class PaymentsService {
});
if (!booking) throw new NotFoundException("Booking not found");
- const paidAt = input.paidAt ?? new Date();
+ const paidAt = this.sanitizePaidAt(input.paidAt);
await this.prisma.$transaction(async (tx) => {
await tx.paymentIntent.update({
where: { id: intent.id },
diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts
index fcad059f4..4e422f2ad 100644
--- a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts
+++ b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts
@@ -98,6 +98,10 @@ export class BulkCreateSchedulesDto {
@ApiPropertyOptional({ type: [PlannedStopTimeDto], description: 'Optional custom planned times per stop. If not provided, will auto-generate.' })
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto)
plannedTimes?: PlannedStopTimeDto[];
+
+ @ApiPropertyOptional({ type: [String], description: 'Optional coach UUIDs to assign to every generated schedule' })
+ @IsOptional() @IsArray() @IsString({ each: true })
+ coachIds?: string[];
}
export class BulkSchedulesResponseDto {
diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts
index f7ef5487f..fd0776141 100644
--- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts
+++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts
@@ -44,6 +44,15 @@ export class SchedulesService {
const schedule = await this.createSchedule(createDto);
scheduleIds.push(schedule.id);
+
+ // Assign coaches if provided
+ if (dto.coachIds && dto.coachIds.length > 0) {
+ await this.assignCoaches(
+ schedule.id,
+ dto.coachIds.map((coachId, idx) => ({ coachId, positionNumber: idx + 1 })),
+ );
+ }
+
scheduleCount++;
} catch (error) {
errors.push(`Failed to create schedule for ${currentDate.toISOString()}: ${error instanceof Error ? error.message : String(error)}`);
diff --git a/apps/edr-passenger-api/src/modules/search/search.dto.ts b/apps/edr-passenger-api/src/modules/search/search.dto.ts
index b1d4c371f..9eb035ef2 100644
--- a/apps/edr-passenger-api/src/modules/search/search.dto.ts
+++ b/apps/edr-passenger-api/src/modules/search/search.dto.ts
@@ -71,27 +71,38 @@ export class FareQuoteDto {
}
export class CoachTypeOptionClass {
- @ApiProperty({ example: 'Economy Regular', description: 'Seat class name' })
- name: string;
-
- @ApiProperty({ example: 35000, description: 'Base fare in ETB minor units per passenger' })
- baseFareMinor: number;
+ @ApiProperty({ example: 'Economy Regular' }) name: string;
+ @ApiProperty({ example: 35000 }) baseFareMinor: number;
}
export class CoachTypeOption {
- @ApiProperty({ example: 'coach-type-uuid', description: 'Coach type unique identifier' })
- coachTypeId: string;
-
- @ApiProperty({ example: 'Economy', description: 'Coach type display name' })
- coachTypeName: string;
-
- @ApiProperty({ example: 'ECO', description: 'Coach type code' })
- coachTypeCode: string;
-
- @ApiProperty({
- type: 'array',
- items: { type: 'object', $ref: '#/components/schemas/CoachTypeOptionClass' },
- description: 'Available seat classes within this coach type with base fares. User selects specific class at seat selection page.',
- })
- classes: CoachTypeOptionClass[];
+ @ApiProperty({ example: 'coach-type-uuid' }) coachTypeId: string;
+ @ApiProperty({ example: 'Economy' }) coachTypeName: string;
+ @ApiProperty({ example: 'ECO' }) coachTypeCode: string;
+ @ApiProperty({ type: 'array', items: { type: 'object', $ref: '#/components/schemas/CoachTypeOptionClass' } }) classes: CoachTypeOptionClass[];
+}
+
+export class TransitLegDto {
+ @ApiProperty({ example: 'schedule-uuid' }) scheduleId: string;
+ @ApiProperty() trainNumber: string;
+ @ApiProperty() trainName: string;
+ @ApiProperty() origin: object;
+ @ApiProperty() destination: object;
+ @ApiProperty() departureAt: Date;
+ @ApiProperty() arrivalAt: Date;
+ @ApiProperty() durationMinutes: number;
+ @ApiProperty() availabilityByClass: object;
+ @ApiProperty() faresByClass: object[];
+ @ApiProperty() coachTypes: CoachTypeOption[];
+}
+
+export class TransitResultDto {
+ @ApiProperty({ example: 'TRANSIT' }) type: string;
+ @ApiProperty({ example: 'station-uuid' }) transitStationId: string;
+ @ApiProperty({ example: 'Dire Dawa' }) transitStationName: string;
+ @ApiProperty({ description: 'Connection wait time in minutes' }) connectionMinutes: number;
+ @ApiProperty({ type: TransitLegDto }) leg1: TransitLegDto;
+ @ApiProperty({ type: TransitLegDto }) leg2: TransitLegDto;
+ @ApiProperty({ description: 'Combined minimum fare across all shared classes', example: 70000 }) combinedMinFareMinor: number;
+ @ApiProperty({ description: 'Total travel time including connection in minutes' }) totalDurationMinutes: number;
}
diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts
index 7d237c18b..9646c20ea 100644
--- a/apps/edr-passenger-api/src/modules/search/search.service.ts
+++ b/apps/edr-passenger-api/src/modules/search/search.service.ts
@@ -18,31 +18,54 @@ export class SearchService {
) {}
async searchTrips(dto: SearchTripsDto) {
- const outbound = await this.searchSchedules(
- dto.originStationId,
- dto.destinationStationId,
- dto.date,
- dto.adultCount,
- dto.childCount,
- dto.nationality,
- );
-
- if (dto.journeyType === 'ROUND_TRIP') {
- const allInbound = await this.searchSchedules(
- dto.destinationStationId,
+ const [direct, transit] = await Promise.all([
+ this.searchSchedules(
dto.originStationId,
- dto.returnDate ?? dto.date,
+ dto.destinationStationId,
+ dto.date,
dto.adultCount,
dto.childCount,
dto.nationality,
- );
+ ),
+ this.searchTransitOptions(
+ dto.originStationId,
+ dto.destinationStationId,
+ dto.date,
+ dto.adultCount,
+ dto.childCount,
+ dto.nationality,
+ ),
+ ]);
+ const outbound = [...direct, ...transit];
+
+ if (dto.journeyType === 'ROUND_TRIP') {
+ const [returnDirect, returnTransit] = await Promise.all([
+ this.searchSchedules(
+ dto.destinationStationId,
+ dto.originStationId,
+ dto.returnDate ?? dto.date,
+ dto.adultCount,
+ dto.childCount,
+ dto.nationality,
+ ),
+ this.searchTransitOptions(
+ dto.destinationStationId,
+ dto.originStationId,
+ dto.returnDate ?? dto.date,
+ dto.adultCount,
+ dto.childCount,
+ dto.nationality,
+ ),
+ ]);
+
+ const allReturn = [...returnDirect, ...returnTransit];
const latestOutboundArrival = outbound.length > 0
- ? Math.max(...outbound.map((s) => new Date(s.arrivalAt).getTime()))
+ ? Math.max(...outbound.map((s: any) => new Date(s.arrivalAt ?? s.leg2?.arrivalAt).getTime()))
: Date.now();
- const inbound = allInbound.filter((schedule) =>
- new Date(schedule.departureAt).getTime() > latestOutboundArrival
+ const inbound = allReturn.filter((s: any) =>
+ new Date(s.departureAt ?? s.leg1?.departureAt).getTime() > latestOutboundArrival
);
return { journeyType: 'ROUND_TRIP', outbound, inbound };
@@ -60,7 +83,7 @@ export class SearchService {
nationality?: string,
) {
const [y, m, d] = dateStr.split('-').map(Number);
- const date = new Date(y, m - 1, d, 0, 0, 0, 0);
+ const date = new Date(y, m - 1, d, 0, 0, 0, 0);
const nextDay = new Date(y, m - 1, d + 1, 0, 0, 0, 0);
const totalPassengers = adultCount + (childCount ?? 0);
@@ -81,126 +104,211 @@ export class SearchService {
},
});
- const results = [];
-
+ const results: any[] = [];
for (const schedule of schedules) {
- const originStop = schedule.stopTimes.find((s: any) => s.stationId === originStationId);
- const destStop = schedule.stopTimes.find((s: any) => s.stationId === destinationStationId);
+ const result = await this.buildScheduleResult(schedule, originStationId, destinationStationId, totalPassengers, nationality);
+ if (result) results.push(result);
+ }
+ return results;
+ }
- if (!originStop || !destStop || originStop.sequence >= destStop.sequence) continue;
+ // ── Transit search ─────────────────────────────────────────────────────────
+ // Finds pairs of schedules (leg1: origin→transit, leg2: transit→destination)
+ // where the passenger has between MIN_CONNECTION and MAX_CONNECTION minutes
+ // to change trains at the transit station.
+ private readonly MIN_CONNECTION_MINUTES = 30;
+ private readonly MAX_CONNECTION_MINUTES = 360;
- const availabilityByClass: Record = {};
+ private async searchTransitOptions(
+ originStationId: string,
+ destinationStationId: string,
+ dateStr: string,
+ adultCount: number,
+ childCount?: number,
+ nationality?: string,
+ ) {
+ // Find all stations that can serve as transit points:
+ // they must be a stop after origin on some schedule AND
+ // a stop before destination on another schedule on the same day.
+ const [y, m, d] = dateStr.split('-').map(Number);
+ const dayStart = new Date(y, m - 1, d, 0, 0, 0, 0);
+ const dayEnd = new Date(y, m - 1, d + 1, 0, 0, 0, 0);
+ const totalPassengers = adultCount + (childCount ?? 0);
- for (const assignment of schedule.coachAssignments) {
- const seatClassNames = assignment.coach.coachType?.seatClasses?.map((sc: any) => sc.name) || ['Standard'];
- const isBedCoach = assignment.coach.seats.some((s: any) => s.bedPosition);
+ // Load all schedules on this date that pass through origin
+ const leg1Schedules = await this.prisma.trainSchedule.findMany({
+ where: {
+ status: { in: ['SCHEDULED', 'BOARDING'] },
+ departureAt: { gte: dayStart, lt: dayEnd },
+ stopTimes: { some: { stationId: originStationId } },
+ },
+ include: {
+ train: true,
+ originStation: true,
+ destinationStation: true,
+ stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
+ coachAssignments: {
+ include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } },
+ },
+ },
+ });
- if (isBedCoach) {
- const bedPositions = ['upper', 'middle', 'lower'];
- for (const bedPosition of bedPositions) {
- let count = 0;
- for (const seat of assignment.coach.seats) {
- if (seat.bedPosition !== bedPosition) continue;
- if (seat.status === 'BLOCKED') continue;
- if (!seat.seatNumber || !seat.seatNumber.trim()) continue;
+ const results: any[] = [];
- const free = await this.segmentsService.isSeatFreeForLeg(
- schedule.id, seat.id,
- originStop.sequence, destStop.sequence,
- );
- if (free) count++;
- }
+ for (const leg1 of leg1Schedules) {
+ const originStop = leg1.stopTimes.find((s: any) => s.stationId === originStationId);
+ if (!originStop) continue;
- if (count > 0) {
- const matchingClass = seatClassNames.find((className: string) => {
- const classNameLower = className.toLowerCase();
- return (
- (bedPosition === 'upper' && classNameLower.includes('upper')) ||
- (bedPosition === 'middle' && classNameLower.includes('middle')) ||
- (bedPosition === 'lower' && classNameLower.includes('lower'))
- );
- });
- if (matchingClass) {
- if (!availabilityByClass[matchingClass]) availabilityByClass[matchingClass] = 0;
- availabilityByClass[matchingClass] += count;
- }
- }
- }
- } else {
- let availableSeatsInCoach = 0;
- for (const seat of assignment.coach.seats) {
- if (seat.status === 'BLOCKED') continue;
- if (!seat.seatNumber || !seat.seatNumber.trim()) continue;
-
- const free = await this.segmentsService.isSeatFreeForLeg(
- schedule.id, seat.id,
- originStop.sequence, destStop.sequence,
- );
- if (free) availableSeatsInCoach++;
- }
-
- for (const seatClassName of seatClassNames) {
- if (!availabilityByClass[seatClassName]) availabilityByClass[seatClassName] = 0;
- availabilityByClass[seatClassName] += availableSeatsInCoach;
- }
- }
- }
-
- const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
- const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt;
-
- const faresByClass = await this.calculateFaresForSegment(
- schedule,
- originStationId,
- destinationStationId,
- nationality,
+ // Every stop after origin on leg1 is a candidate transit station
+ const candidateTransitStops = leg1.stopTimes.filter(
+ (s: any) => s.sequence > originStop.sequence,
);
- const coachTypes = await this.buildCoachTypeDetails(schedule, faresByClass);
+ for (const transitStop of candidateTransitStops) {
+ // leg1 must NOT already contain the final destination
+ const leg1HasDest = leg1.stopTimes.some((s: any) => s.stationId === destinationStationId);
+ if (leg1HasDest) continue; // direct route exists — already returned by searchSchedules
- results.push({
- scheduleId: schedule.id,
- trainNumber: schedule.train.number,
- trainName: schedule.train.name,
- origin: {
- id: originStop.stationId,
- code: originStop.station.code,
- name: originStop.station.name,
- city: originStop.station.city,
- sequence: originStop.sequence,
- },
- destination: {
- id: destStop.stationId,
- code: destStop.station.code,
- name: destStop.station.name,
- city: destStop.station.city,
- sequence: destStop.sequence,
- },
- departureAt: legDepartureAt,
- arrivalAt: legArrivalAt,
- durationMinutes: Math.round(
- (new Date(legArrivalAt).getTime() - new Date(legDepartureAt).getTime()) / 60_000,
- ),
- status: schedule.status,
- stops: schedule.stopTimes
- .filter((st: any) => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence)
- .map((st: any) => ({
- stationId: st.stationId,
- stationName: st.station.name,
- sequence: st.sequence,
- plannedArrivalAt: st.plannedArrivalAt,
- plannedDepartureAt: st.plannedDepartureAt,
- })),
- availabilityByClass,
- hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers),
- faresByClass,
- coachTypes,
- });
+ const transitStationId = transitStop.stationId;
+ const leg1ArrivalAt = transitStop.plannedArrivalAt ?? transitStop.plannedDepartureAt ?? leg1.arrivalAt;
+
+ // Find leg2 schedules departing from the transit station within the connection window,
+ // and reaching the final destination. Search up to the next calendar day to handle
+ // overnight connections.
+ const connWindowStart = new Date(new Date(leg1ArrivalAt).getTime() + this.MIN_CONNECTION_MINUTES * 60_000);
+ const connWindowEnd = new Date(new Date(leg1ArrivalAt).getTime() + this.MAX_CONNECTION_MINUTES * 60_000);
+
+ const leg2Schedules = await this.prisma.trainSchedule.findMany({
+ where: {
+ status: { in: ['SCHEDULED', 'BOARDING'] },
+ departureAt: { gte: connWindowStart, lte: connWindowEnd },
+ stopTimes: { some: { stationId: transitStationId } },
+ },
+ include: {
+ train: true,
+ originStation: true,
+ destinationStation: true,
+ stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
+ coachAssignments: {
+ include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } },
+ },
+ },
+ });
+
+ for (const leg2 of leg2Schedules) {
+ const leg2TransitStop = leg2.stopTimes.find((s: any) => s.stationId === transitStationId);
+ const leg2DestStop = leg2.stopTimes.find((s: any) => s.stationId === destinationStationId);
+
+ if (!leg2TransitStop || !leg2DestStop) continue;
+ if (leg2TransitStop.sequence >= leg2DestStop.sequence) continue;
+
+ // Build individual leg result objects (reuse existing per-schedule logic)
+ const [leg1Result, leg2Result] = await Promise.all([
+ this.buildScheduleResult(leg1, originStationId, transitStationId, totalPassengers, nationality),
+ this.buildScheduleResult(leg2, transitStationId, destinationStationId, totalPassengers, nationality),
+ ]);
+
+ if (!leg1Result || !leg2Result) continue;
+ if (!leg1Result.hasAvailability || !leg2Result.hasAvailability) continue;
+
+ const leg2DepartureAt = leg2TransitStop.plannedDepartureAt ?? leg2.departureAt;
+ const connectionMinutes = Math.round(
+ (new Date(leg2DepartureAt).getTime() - new Date(leg1ArrivalAt).getTime()) / 60_000,
+ );
+
+ const leg1MinFare = Math.min(...(leg1Result.faresByClass as any[]).map((f: any) => f.baseFareMinor).filter((n: number) => n > 0), Infinity);
+ const leg2MinFare = Math.min(...(leg2Result.faresByClass as any[]).map((f: any) => f.baseFareMinor).filter((n: number) => n > 0), Infinity);
+ const combinedMinFareMinor = (isFinite(leg1MinFare) ? leg1MinFare : 0) + (isFinite(leg2MinFare) ? leg2MinFare : 0);
+
+ results.push({
+ type: 'TRANSIT',
+ transitStationId,
+ transitStationName: transitStop.station.name,
+ connectionMinutes,
+ leg1: leg1Result,
+ leg2: leg2Result,
+ combinedMinFareMinor,
+ // Convenience top-level fields so round-trip filter can read them uniformly
+ departureAt: leg1Result.departureAt,
+ arrivalAt: leg2Result.arrivalAt,
+ totalDurationMinutes:
+ leg1Result.durationMinutes + connectionMinutes + leg2Result.durationMinutes,
+ });
+ }
+ }
}
return results;
}
+ // Builds the same result shape as searchSchedules for a single schedule+leg,
+ // extracted so both direct and transit paths share identical output.
+ private async buildScheduleResult(
+ schedule: any,
+ originStationId: string,
+ destinationStationId: string,
+ totalPassengers: number,
+ nationality?: string,
+ ) {
+ const originStop = schedule.stopTimes.find((s: any) => s.stationId === originStationId);
+ const destStop = schedule.stopTimes.find((s: any) => s.stationId === destinationStationId);
+ if (!originStop || !destStop || originStop.sequence >= destStop.sequence) return null;
+
+ const availabilityByClass: Record = {};
+ for (const assignment of schedule.coachAssignments) {
+ const seatClassNames = assignment.coach.coachType?.seatClasses?.map((sc: any) => sc.name) || ['Standard'];
+ const isBedCoach = assignment.coach.seats.some((s: any) => s.bedPosition);
+
+ if (isBedCoach) {
+ for (const bedPosition of ['upper', 'middle', 'lower']) {
+ let count = 0;
+ for (const seat of assignment.coach.seats) {
+ if (seat.bedPosition !== bedPosition || seat.status === 'BLOCKED' || !seat.seatNumber?.trim()) continue;
+ const free = await this.segmentsService.isSeatFreeForLeg(schedule.id, seat.id, originStop.sequence, destStop.sequence);
+ if (free) count++;
+ }
+ if (count > 0) {
+ const matchingClass = seatClassNames.find((n: string) => n.toLowerCase().includes(bedPosition));
+ if (matchingClass) availabilityByClass[matchingClass] = (availabilityByClass[matchingClass] ?? 0) + count;
+ }
+ }
+ } else {
+ let available = 0;
+ for (const seat of assignment.coach.seats) {
+ if (seat.status === 'BLOCKED' || !seat.seatNumber?.trim()) continue;
+ const free = await this.segmentsService.isSeatFreeForLeg(schedule.id, seat.id, originStop.sequence, destStop.sequence);
+ if (free) available++;
+ }
+ for (const name of seatClassNames) availabilityByClass[name] = (availabilityByClass[name] ?? 0) + available;
+ }
+ }
+
+ const faresByClass = await this.calculateFaresForSegment(schedule, originStationId, destinationStationId, nationality);
+ const coachTypes = await this.buildCoachTypeDetails(schedule, faresByClass);
+ const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
+ const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt;
+
+ return {
+ type: 'DIRECT',
+ scheduleId: schedule.id,
+ trainNumber: schedule.train.number,
+ trainName: schedule.train.name,
+ origin: { id: originStop.stationId, code: originStop.station.code, name: originStop.station.name, city: originStop.station.city, sequence: originStop.sequence },
+ destination: { id: destStop.stationId, code: destStop.station.code, name: destStop.station.name, city: destStop.station.city, sequence: destStop.sequence },
+ departureAt: legDepartureAt,
+ arrivalAt: legArrivalAt,
+ durationMinutes: Math.round((new Date(legArrivalAt).getTime() - new Date(legDepartureAt).getTime()) / 60_000),
+ status: schedule.status,
+ stops: schedule.stopTimes
+ .filter((st: any) => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence)
+ .map((st: any) => ({ stationId: st.stationId, stationName: st.station.name, sequence: st.sequence, plannedArrivalAt: st.plannedArrivalAt, plannedDepartureAt: st.plannedDepartureAt })),
+ availabilityByClass,
+ hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers),
+ faresByClass,
+ coachTypes,
+ };
+ }
+
async getFareQuote(dto: FareQuoteDto) {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: dto.scheduleId },
@@ -244,7 +352,8 @@ export class SearchService {
nationality,
);
- const baseFareMinor = bestMatch?.baseFareMinor ?? this.defaultFare(dto.seatClassName);
+ const baseFareMinor = bestMatch?.baseFareMinor
+ ?? await this.resolveScheduleFare(dto.scheduleId, seatClass?.id, dto.seatClassName);
const adultCount = dto.adultCount;
const childCount = dto.childCount ?? 0;
@@ -379,11 +488,8 @@ export class SearchService {
}
}
- console.log(`No fares found, using defaults for ${originStationId} to ${destinationStationId}`);
- return seatClasses.map(sc => ({
- seatClassName: sc.name,
- baseFareMinor: this.getDefaultFareForClass(sc.name),
- }));
+ console.log(`No fares found via engine or rules for ${originStationId} to ${destinationStationId}`);
+ return [];
}
private async buildCoachTypeDetails(
@@ -420,11 +526,10 @@ export class SearchService {
const classes = Array.from(classNames)
.map((className) => {
const fareInfo = faresByClass.find((f) => f.seatClassName === className);
- return {
- name: className,
- baseFareMinor: fareInfo?.baseFareMinor ?? this.getDefaultFareForClass(className),
- };
+ if (!fareInfo) return null;
+ return { name: className, baseFareMinor: fareInfo.baseFareMinor };
})
+ .filter((c): c is { name: string; baseFareMinor: number } => c !== null)
.sort((a, b) => a.baseFareMinor - b.baseFareMinor);
result.push({
@@ -442,22 +547,28 @@ export class SearchService {
});
}
- private getDefaultFareForClass(className: string): number {
- const defaults: Record = {
- 'Economy Regular': 35000,
- 'Economy Bed': 49000,
- 'VIP Bed': 63000,
- };
- return defaults[className] ?? 35000;
+ private async resolveScheduleFare(scheduleId: string, seatClassId?: string, seatClassName?: string): Promise {
+ if (!seatClassId) throw new NotFoundException(`Seat class '${seatClassName}' not found`);
+ const schedule = await this.prisma.trainSchedule.findUnique({
+ where: { id: scheduleId },
+ select: { routeId: true, originStationId: true, destinationStationId: true },
+ });
+ if (!schedule?.routeId) throw new NotFoundException('Schedule has no route configured for fare calculation');
+ const fare = await this.fareEngine.calculate({
+ routeId: schedule.routeId,
+ originStationId: schedule.originStationId,
+ destinationStationId: schedule.destinationStationId,
+ seatClassId,
+ });
+ return fare.baseFarePerPassengerMinor;
}
- private defaultFare(seatClassName: string): number {
- const fares: Record = {
- 'Economy Regular': 45000,
- 'Economy Bed': 65000,
- 'VIP Bed': 95000,
- };
- return fares[seatClassName] ?? 45000;
+ private getDefaultFareForClass(_className: string): never {
+ throw new Error('getDefaultFareForClass should not be called — use resolveScheduleFare instead');
+ }
+
+ private defaultFare(_seatClassName: string): never {
+ throw new Error('defaultFare should not be called — use resolveScheduleFare instead');
}
private selectBestFareRule(
diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.spec.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.spec.ts
index 2a3b84539..2c4215dc8 100644
--- a/apps/edr-passenger-api/src/modules/seats/seats.service.spec.ts
+++ b/apps/edr-passenger-api/src/modules/seats/seats.service.spec.ts
@@ -62,7 +62,7 @@ describe('SeatsService - Auto Assign', () => {
mockPrisma.seat.findMany.mockResolvedValue(mockSeats);
- const result = await service.autoAssignSeats('trip-1', 2, 'ECONOMY_REGULAR', 'ACCESSIBLE');
+ const result = await service.autoAssignSeats('trip-1', 2, 'ECONOMY_REGULAR');
expect(result).toHaveLength(2);
});
diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts
index 88f9666bd..21060b595 100644
--- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts
+++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts
@@ -547,16 +547,14 @@ export class SeatsService {
@Cron(CronExpression.EVERY_MINUTE)
async expireHolds() {
- const expired = await this.prisma.seatHold.findMany({ where: { expiresAt: { lt: new Date() } } });
- for (const hold of expired) {
+ const now = new Date();
+ const expired = await this.prisma.seatHold.findMany({ where: { expiresAt: { lt: now } } });
+ if (expired.length === 0) return;
+
+ const expiredIds = expired.map(h => h.id);
+ for (const hold of expired) {
await this.releaseSeats(hold.seatIds);
- try {
- await this.prisma.seatHold.delete({ where: { id: hold.id } });
- } catch (err) {
- if (err instanceof Error && !err.message.includes('P2025')) {
- throw err;
- }
- }
}
+ await this.prisma.seatHold.deleteMany({ where: { id: { in: expiredIds } } });
}
}
diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts
index e7760b565..d4d68ad1f 100644
--- a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts
+++ b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts
@@ -1,5 +1,5 @@
import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch } from '@nestjs/common';
-import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
+import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger';
import { TicketsService } from './tickets.service';
import { JwtGuard } from '../../common/jwt.guard';
@@ -30,15 +30,28 @@ export class TicketsController {
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'List all tickets with optional filters' })
+ @ApiQuery({ name: 'search', required: false })
+ @ApiQuery({ name: 'status', required: false })
+ @ApiQuery({ name: 'originStationId', required: false })
+ @ApiQuery({ name: 'destinationStationId', required: false })
+ @ApiQuery({ name: 'arrivalDate', required: false })
+ @ApiQuery({ name: 'skip', required: false })
+ @ApiQuery({ name: 'take', required: false })
listTickets(
@Query('search') search?: string,
@Query('status') status?: string,
+ @Query('originStationId') originStationId?: string,
+ @Query('destinationStationId') destinationStationId?: string,
+ @Query('arrivalDate') arrivalDate?: string,
@Query('skip') skip?: string,
@Query('take') take?: string,
) {
return this.service.listTickets({
search,
status,
+ originStationId,
+ destinationStationId,
+ arrivalDate,
skip: skip ? parseInt(skip) : 0,
take: take ? parseInt(take) : 50,
});
@@ -56,17 +69,8 @@ export class TicketsController {
}
@Get(':bookingRef')
- @UseGuards(JwtGuard)
- @ApiBearerAuth('JWT-auth')
@ApiOperation({
- summary: 'Get ticket with QR code and passenger details',
- description: `Returns ticket information including:
-- QR code for gate scanning
-- Barcode for offline validation
-- Passenger details (name, age category, nationality)
-- Journey details (origin, destination, seat, coach)
-- Fare breakdown with currency
-- PDF download link`
+ summary: 'Get ticket with QR code and passenger details (public)',
})
getByRef(@Param('bookingRef') ref: string) {
return this.service.getByRef(ref);
@@ -77,14 +81,30 @@ export class TicketsController {
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Validate ticket at gate with audit logging',
- description: 'Validates ticket QR/barcode at station gate. Records validation in audit log with timestamp, gate, and validator.'
+ description: 'Validates ticket QR/barcode at station gate. For round-trip bookings, supply `leg` (OUTBOUND or RETURN) to record which leg is being used. Defaults to OUTBOUND if omitted. Records validation in audit log with timestamp, gate, and validator.'
+ })
+ @ApiBody({
+ schema: {
+ type: 'object',
+ required: ['validatorId'],
+ properties: {
+ validatorId: { type: 'string', example: 'agent-uuid' },
+ gateId: { type: 'string', example: 'gate-01' },
+ leg: {
+ type: 'string',
+ enum: ['OUTBOUND', 'RETURN', 'LEG1', 'LEG2', 'OUTBOUND_LEG1', 'OUTBOUND_LEG2', 'RETURN_LEG1', 'RETURN_LEG2'],
+ description: 'ONE_WAY: omit | TRANSIT: LEG1/LEG2 | ROUND_TRIP: OUTBOUND/RETURN | ROUND_TRIP_TRANSIT: OUTBOUND_LEG1/OUTBOUND_LEG2/RETURN_LEG1/RETURN_LEG2',
+ },
+ },
+ },
})
validate(
- @Param('bookingRef') ref: string,
+ @Param('bookingRef') ref: string,
@Body('validatorId') validatorId: string,
- @Body('gateId') gateId?: string
- ) {
- return this.service.validate(ref, validatorId, gateId);
+ @Body('gateId') gateId?: string,
+ @Body('leg') leg?: string,
+ ) {
+ return this.service.validate(ref, validatorId, gateId, leg);
}
@Get(':ticketId/validation-logs')
@@ -106,7 +126,31 @@ export class TicketsController {
@Post('validate/offline')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
- @ApiOperation({ summary: 'Batch import offline validations' })
+ @ApiOperation({
+ summary: 'Batch import offline validations',
+ description: 'Processes validations collected offline. Each entry may include an optional `leg` field (OUTBOUND | RETURN) for round-trip tickets. Deduplication is per bookingRef+leg combination so both legs of the same booking can be submitted in one batch.'
+ })
+ @ApiBody({
+ schema: {
+ type: 'object',
+ properties: {
+ validations: {
+ type: 'array',
+ items: {
+ type: 'object',
+ required: ['bookingRef', 'validatorId', 'validatedAt'],
+ properties: {
+ bookingRef: { type: 'string' },
+ validatorId: { type: 'string' },
+ gateId: { type: 'string' },
+ validatedAt: { type: 'string', format: 'date-time' },
+ leg: { type: 'string', enum: ['OUTBOUND', 'RETURN', 'LEG1', 'LEG2', 'OUTBOUND_LEG1', 'OUTBOUND_LEG2', 'RETURN_LEG1', 'RETURN_LEG2'] },
+ },
+ },
+ },
+ },
+ },
+ })
validateOfflineBatch(@Body() body: { validations: any[] }) {
return this.service.validateOfflineBatch(body.validations);
}
diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts
index de9bdf4b5..2c57a59e3 100644
--- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts
+++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts
@@ -7,13 +7,14 @@ interface OfflineValidation {
validatorId: string;
gateId?: string;
validatedAt: string;
+ leg?: string;
}
@Injectable()
export class TicketsService {
constructor(private prisma: PrismaService) {}
- async listTickets(filters: { search?: string; status?: string; skip: number; take: number }) {
+ async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; arrivalDate?: string; skip: number; take: number }) {
const where: any = {};
if (filters.search) {
where.OR = [
@@ -23,7 +24,19 @@ export class TicketsService {
];
}
if (filters.status) {
- where.booking = { status: filters.status };
+ where.booking = { ...where.booking, status: filters.status };
+ }
+ if (filters.originStationId) {
+ where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, originStationId: filters.originStationId } };
+ }
+ if (filters.destinationStationId) {
+ where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, destinationStationId: filters.destinationStationId } };
+ }
+ if (filters.arrivalDate) {
+ const start = new Date(filters.arrivalDate);
+ const end = new Date(filters.arrivalDate);
+ end.setDate(end.getDate() + 1);
+ where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, arrivalAt: { gte: start, lt: end } } };
}
const tickets = await this.prisma.ticket.findMany({
where,
@@ -31,7 +44,7 @@ export class TicketsService {
booking: {
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
- seats: { include: { seat: { include: { coach: true } } } },
+ seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
passenger: { include: { user: true } },
},
},
@@ -49,6 +62,10 @@ export class TicketsService {
booking: {
bookingRef: t.booking.bookingRef,
status: t.booking.status,
+ bookingType: t.booking.bookingType,
+ returnLegStatus: (t.booking as any).returnLegStatus ?? null,
+ outboundBoardedAt: (t.booking as any).outboundBoardedAt ?? null,
+ returnBoardedAt: (t.booking as any).returnBoardedAt ?? null,
totalMinor: t.booking.totalMinor,
currency: t.booking.currency,
displayCurrency: t.booking.displayCurrency,
@@ -69,48 +86,65 @@ export class TicketsService {
}
async generate(bookingId: string) {
- if (!bookingId) {
- throw new BadRequestException('Booking ID is required');
- }
-
+ if (!bookingId) throw new BadRequestException('Booking ID is required');
+
const booking = await this.prisma.booking.findUnique({
where: { id: bookingId },
- include: {
- schedule: { include: { originStation: true, destinationStation: true, train: true } },
- seats: { include: { seat: { include: { coach: true } } } }
+ include: {
+ schedule: { include: { originStation: true, destinationStation: true, train: true } },
+ seats: { include: { seat: { include: { coach: true } } } },
},
});
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
-
- const qrPayload = await QRCode.toDataURL(`edr:tkt:${booking.id}:${booking.bookingRef}`);
+
+ // Build a compact multi-leg payload for the QR so gate scanners see all legs
+ const legSummary = this.buildLegSummary(booking);
+ const qrData = JSON.stringify({
+ ref: booking.bookingRef,
+ type: booking.bookingType,
+ legs: legSummary,
+ });
+ const qrPayload = await QRCode.toDataURL(qrData);
const barcodePayload = `EDR${booking.bookingRef}${booking.id.substring(0, 8).toUpperCase()}`;
-
+
const ticket = await this.prisma.ticket.upsert({
- where: { bookingId },
+ where: { bookingId },
update: { qrPayload, barcodePayload },
create: { bookingId, bookingRef: booking.bookingRef, qrPayload, barcodePayload },
});
- // Update all booked seats from HELD to BOOKED and create permanent seat blocks
+ // Block all seats across all legs
const seatIds = booking.seats.map(bs => bs.seatId);
for (const seatId of seatIds) {
- // Update seat status to BOOKED
- await this.prisma.seat.update({
- where: { id: seatId },
- data: { status: 'BOOKED' },
- });
- // Create permanent seat blocks for all booked seats
+ await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'BOOKED' } });
await this.prisma.seatBlock.create({
- data: {
- seatId,
- reason: `Permanently booked in ticket ${ticket.id}`,
- blockedBy: 'SYSTEM',
- approvedBy: 'SYSTEM',
- }
- }).catch(() => null); // Ignore if already exists
+ data: { seatId, reason: `Booked in ticket ${ticket.id}`, blockedBy: 'SYSTEM', approvedBy: 'SYSTEM' },
+ }).catch(() => null);
}
-
- return ticket;
+
+ return { ...ticket, legs: legSummary };
+ }
+
+ private buildLegSummary(booking: any) {
+ const seatsByLeg = new Map();
+ for (const bs of booking.seats) {
+ const leg = bs.leg ?? 1;
+ if (!seatsByLeg.has(leg)) seatsByLeg.set(leg, []);
+ seatsByLeg.get(leg)!.push(bs);
+ }
+ return Array.from(seatsByLeg.entries())
+ .sort(([a], [b]) => a - b)
+ .map(([leg, seats]) => ({
+ leg,
+ scheduleId: (seats[0] as any).scheduleId ?? booking.scheduleId,
+ passengers: seats.map(bs => ({
+ name: bs.passengerName,
+ category: bs.passengerCategory,
+ coach: bs.seat?.coach?.number,
+ seat: bs.seat?.seatNumber,
+ fareMinor: bs.fareMinor,
+ })),
+ }));
}
async updateSeats(bookingId: string, newSeatIds: string[]) {
@@ -213,22 +247,122 @@ export class TicketsService {
};
}
- async validate(bookingRef: string, validatorId: string, gateId?: string) {
+ async validate(ticketIdOrRef: string, validatorId: string, gateId?: string, leg?: string) {
+ // Accept either a ticket UUID or a bookingRef
+ let bookingRef = ticketIdOrRef;
+ const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(ticketIdOrRef);
+ if (isUuid) {
+ const ticket = await this.prisma.ticket.findUnique({ where: { id: ticketIdOrRef }, select: { bookingRef: true } });
+ if (!ticket) throw new NotFoundException('Ticket not found');
+ bookingRef = ticket.bookingRef;
+ }
+ const resolvedValidatorId = validatorId || 'BACKOFFICE';
const booking = await this.prisma.booking.findUnique({ where: { bookingRef } });
if (!booking) throw new NotFoundException('Booking not found');
const ticket = await this.prisma.ticket.findUnique({ where: { bookingId: booking.id } });
if (!ticket) throw new NotFoundException('Ticket not found');
- if (ticket.validatedAt) {
- await this.prisma.gateValidationLog.create({
- data: { ticketId: ticket.id, validatorId, gateId, status: 'REJECTED', reason: 'ALREADY_VALIDATED' }
- });
- throw new BadRequestException('Ticket already validated');
+
+ const type = booking.bookingType;
+ const now = new Date();
+
+ // ── ONE_WAY / TRANSIT (single scan) ───────────────────────────────────
+ if (type === 'ONE_WAY') {
+ if (ticket.validatedAt) {
+ return { validated: true, ticketId: ticket.id, validatedAt: ticket.validatedAt, alreadyValidated: true };
+ }
+ await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
+ await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } });
+ return { validated: true, ticketId: ticket.id, validatedAt: now };
}
- await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: new Date(), validatorId } });
- await this.prisma.gateValidationLog.create({
- data: { ticketId: ticket.id, validatorId, gateId, status: 'APPROVED' }
- });
- return { validated: true, ticketId: ticket.id, validatedAt: new Date() };
+
+ // ── TRANSIT — leg=LEG1 or leg=LEG2 ──────────────────────────────────
+ if (type === 'TRANSIT') {
+ const resolvedLeg = (leg ?? 'LEG1').toUpperCase();
+ if (resolvedLeg !== 'LEG1' && resolvedLeg !== 'LEG2') {
+ throw new BadRequestException('For TRANSIT bookings supply leg=LEG1 or leg=LEG2');
+ }
+ const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } });
+ const alreadyValidated = logs.some(l => l.leg === resolvedLeg);
+ if (alreadyValidated) {
+ await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any });
+ throw new BadRequestException(`${resolvedLeg} already validated`);
+ }
+ if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
+ await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
+ return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
+ }
+
+ // ── ROUND_TRIP — leg=OUTBOUND or leg=RETURN ────────────────────────
+ if (type === 'ROUND_TRIP') {
+ let resolvedLeg = (leg ?? '').toUpperCase();
+ // Auto-detect next unused leg when called from backoffice without a leg param
+ if (!resolvedLeg) {
+ resolvedLeg = !(booking as any).outboundBoardedAt ? 'OUTBOUND' : 'RETURN';
+ }
+ const bookingData: Record = {};
+ if (resolvedLeg === 'OUTBOUND') {
+ if ((booking as any).outboundBoardedAt) {
+ await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'OUTBOUND_ALREADY_USED' } as any });
+ throw new BadRequestException('Outbound leg already used');
+ }
+ bookingData.outboundBoardedAt = now;
+ if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
+ } else if (resolvedLeg === 'RETURN') {
+ if ((booking as any).returnBoardedAt) {
+ await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'RETURN_ALREADY_USED' } as any });
+ throw new BadRequestException('Return leg already used');
+ }
+ bookingData.returnBoardedAt = now;
+ } else {
+ throw new BadRequestException('For ROUND_TRIP bookings supply leg=OUTBOUND or leg=RETURN');
+ }
+ const outboundUsed = resolvedLeg === 'OUTBOUND' ? true : !!(booking as any).outboundBoardedAt;
+ const returnUsed = resolvedLeg === 'RETURN' ? true : !!(booking as any).returnBoardedAt;
+ if (outboundUsed && returnUsed) bookingData.returnLegStatus = 'BOTH_USED';
+ else if (outboundUsed && !returnUsed) bookingData.returnLegStatus = 'OUTBOUND_ONLY';
+ else if (!outboundUsed && returnUsed) bookingData.returnLegStatus = 'INBOUND_ONLY';
+ await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData });
+ await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
+ return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
+ }
+
+ // ── ROUND_TRIP_TRANSIT — leg=OUTBOUND_LEG1|OUTBOUND_LEG2|RETURN_LEG1|RETURN_LEG2
+ if (type === 'ROUND_TRIP_TRANSIT') {
+ const validLegs = ['OUTBOUND_LEG1', 'OUTBOUND_LEG2', 'RETURN_LEG1', 'RETURN_LEG2'];
+ const resolvedLeg = (leg ?? '').toUpperCase();
+ if (!validLegs.includes(resolvedLeg)) {
+ throw new BadRequestException(`For ROUND_TRIP_TRANSIT supply leg=${validLegs.join('|')}`);
+ }
+ const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } });
+ if (logs.some(l => l.leg === resolvedLeg)) {
+ await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any });
+ throw new BadRequestException(`${resolvedLeg} already validated`);
+ }
+ const bookingData: Record = {};
+ if (resolvedLeg.startsWith('OUTBOUND') && !logs.some(l => l.leg?.startsWith('OUTBOUND') && l.status === 'APPROVED')) {
+ bookingData.outboundBoardedAt = now;
+ }
+ if (resolvedLeg.startsWith('RETURN') && !logs.some(l => l.leg?.startsWith('RETURN') && l.status === 'APPROVED')) {
+ bookingData.returnBoardedAt = now;
+ }
+ const allOutboundDone = ['OUTBOUND_LEG1','OUTBOUND_LEG2'].every(l => l === resolvedLeg || logs.some(x => x.leg === l && x.status === 'APPROVED'));
+ const allReturnDone = ['RETURN_LEG1','RETURN_LEG2'].every(l => l === resolvedLeg || logs.some(x => x.leg === l && x.status === 'APPROVED'));
+ if (allOutboundDone && allReturnDone) bookingData.returnLegStatus = 'BOTH_USED';
+ else if (allOutboundDone && !allReturnDone) bookingData.returnLegStatus = 'OUTBOUND_ONLY';
+ else if (!allOutboundDone && allReturnDone) bookingData.returnLegStatus = 'INBOUND_ONLY';
+ if (Object.keys(bookingData).length) await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData });
+ if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
+ await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
+ return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
+ }
+
+ // Fallback for unknown booking types — single scan
+ if (ticket.validatedAt) {
+ return { validated: true, ticketId: ticket.id, validatedAt: ticket.validatedAt, alreadyValidated: true };
+ }
+ await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
+ await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } });
+ return { validated: true, ticketId: ticket.id, validatedAt: now };
}
async getValidationLogs(ticketId: string) {
@@ -243,7 +377,7 @@ export class TicketsService {
where: { scheduleId: tripId, status: 'CONFIRMED' },
include: {
ticket: true,
- seats: { include: { seat: { include: { coach: true } } } },
+ seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
passenger: { include: { user: true } },
},
});
@@ -256,6 +390,8 @@ export class TicketsService {
coachLabel: b.seats[0]?.seat.coach.number,
qrPayload: b.ticket?.qrPayload,
status: b.status,
+ bookingType: b.bookingType,
+ returnLegStatus: (b as any).returnLegStatus ?? null,
validatedAt: b.ticket?.validatedAt,
}));
}
@@ -265,11 +401,13 @@ export class TicketsService {
const processedRefs = new Set();
for (const v of validations) {
- if (processedRefs.has(v.bookingRef)) {
+ const offlineLeg = v.leg;
+ const dedupKey = offlineLeg ? `${v.bookingRef}:${offlineLeg}` : v.bookingRef;
+ if (processedRefs.has(dedupKey)) {
results.duplicate++;
continue;
}
- processedRefs.add(v.bookingRef);
+ processedRefs.add(dedupKey);
try {
const booking = await this.prisma.booking.findUnique({ where: { bookingRef: v.bookingRef } });
@@ -286,11 +424,24 @@ export class TicketsService {
continue;
}
- if (ticket.validatedAt) {
+ if (ticket.validatedAt && booking.bookingType !== 'ROUND_TRIP' &&
+ booking.bookingType !== 'TRANSIT' && booking.bookingType !== 'ROUND_TRIP_TRANSIT') {
results.duplicate++;
continue;
}
+ // For multi-leg bookings, check per-leg duplication
+ const isMultiLeg = booking.bookingType === 'ROUND_TRIP' ||
+ booking.bookingType === 'TRANSIT' ||
+ booking.bookingType === 'ROUND_TRIP_TRANSIT';
+ if (isMultiLeg && offlineLeg) {
+ const existingLogs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } });
+ if (existingLogs.some(l => l.leg === offlineLeg)) {
+ results.duplicate++;
+ continue;
+ }
+ }
+
await this.prisma.ticket.update({
where: { id: ticket.id },
data: { validatedAt: new Date(v.validatedAt), validatorId: v.validatorId },
@@ -301,11 +452,27 @@ export class TicketsService {
ticketId: ticket.id,
validatorId: v.validatorId,
gateId: v.gateId,
+ leg: v.leg ?? null,
status: 'APPROVED',
validatedAt: new Date(v.validatedAt),
- },
+ } as any,
});
+ // update boarding timestamps for multi-leg bookings
+ const isMultiLegBooking = booking.bookingType === 'ROUND_TRIP' ||
+ booking.bookingType === 'TRANSIT' ||
+ booking.bookingType === 'ROUND_TRIP_TRANSIT';
+ if (isMultiLegBooking && offlineLeg) {
+ const bookingData: Record = {};
+ const isOutbound = (offlineLeg as string) === 'OUTBOUND' || (offlineLeg as string) === 'OUTBOUND_LEG1' || (offlineLeg as string) === 'LEG1';
+ const isReturn = (offlineLeg as string) === 'RETURN' || (offlineLeg as string) === 'RETURN_LEG1' || (offlineLeg as string) === 'RETURN_LEG2';
+ if (isOutbound && !(booking as any).outboundBoardedAt) bookingData.outboundBoardedAt = new Date(v.validatedAt);
+ if (isReturn && !(booking as any).returnBoardedAt) bookingData.returnBoardedAt = new Date(v.validatedAt);
+ if (Object.keys(bookingData).length) {
+ await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData });
+ }
+ }
+
results.success++;
} catch (err) {
results.failed++;
diff --git a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx
index ce8541f29..7b43fb677 100644
--- a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx
@@ -24,6 +24,19 @@ export default function BookingsPage() {
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [bookingToDelete, setBookingToDelete] = useState(null);
const [successMessage, setSuccessMessage] = useState('');
+ const [exportModalOpen, setExportModalOpen] = useState(false);
+ const [exportDateFrom, setExportDateFrom] = useState('');
+ const [exportDateTo, setExportDateTo] = useState('');
+ const [exportColumns, setExportColumns] = useState>({
+ bookingRef: true,
+ passenger: true,
+ status: true,
+ bookingType: false,
+ passengerCount: false,
+ totalMinor: true,
+ paymentStatus: true,
+ createdAt: true,
+ });
const queryClient = useQueryClient();
@@ -80,22 +93,23 @@ export default function BookingsPage() {
}
};
- const handleExportBookings = async () => {
- const selectedColumns = prompt(
- 'Select columns to export (comma-separated):\n\n' +
- 'Available: bookingRef, passenger, status, bookingType, passengerCount, totalMinor, paymentStatus, createdAt\n\n' +
- 'Default: bookingRef, passenger, status, totalMinor, paymentStatus, createdAt',
- 'bookingRef, passenger, status, totalMinor, paymentStatus, createdAt'
- );
-
- if (!selectedColumns) return;
-
- const cols = selectedColumns.split(',').map(c => c.trim());
+ const confirmExport = () => {
+ const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k);
+ if (cols.length === 0) { alert('Please select at least one column'); return; }
+
+ const exportItems = (data?.items || []).filter((b: any) => {
+ if (!exportDateFrom && !exportDateTo) return true;
+ const d = b.createdAt ? new Date(b.createdAt).toISOString().split('T')[0] : null;
+ if (exportDateFrom && (!d || d < exportDateFrom)) return false;
+ if (exportDateTo && (!d || d > exportDateTo)) return false;
+ return true;
+ });
+
const csv = [
cols.join(','),
- ...data?.items?.map((booking: any) => {
+ ...exportItems.map((booking: any) => {
const values = cols.map(col => {
- switch(col) {
+ switch (col) {
case 'bookingRef': return booking.bookingRef;
case 'passenger': return booking.passenger?.fullName || booking.contactEmail || 'Guest';
case 'status': return booking.status;
@@ -108,28 +122,29 @@ export default function BookingsPage() {
}
});
return values.map(v => `"${v}"`).join(',');
- }) || []
+ }),
].join('\n');
-
+
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `bookings-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
+ setExportModalOpen(false);
};
const columns = [
- {
- key: 'bookingRef',
+ {
+ key: 'bookingRef',
label: 'Reference',
sortable: true,
render: (booking: any) => (
{booking.bookingRef}
),
},
- {
- key: 'passenger',
+ {
+ key: 'passenger',
label: 'Passenger',
render: (booking: any) => (
@@ -138,32 +153,39 @@ export default function BookingsPage() {