Files
edr-platform/apps/edr-passenger-web/backoffice/src/lib/api/group-booking.ts
2026-08-27 22:54:59 +03:00

259 lines
8.0 KiB
TypeScript

import { apiClient } from '@/lib/api-client';
// ── Search (POST /search) ──────────────────────────────────────────────────
export interface SearchTripsRequest {
originStationId: string;
destinationStationId: string;
date: string;
adultCount: number;
childCount?: number;
journeyType: 'ONE_WAY' | 'ROUND_TRIP';
/** Required when journeyType is ROUND_TRIP. */
returnDate?: string;
/** Drives which fare tier (Local vs International) gets quoted — see fareTier in the page component. */
nationality?: string;
/** Always 'GROUP_BOOKING' for this app — search returns ONLY schedules marked
* isGroupBookingOnly (an exclusive partition, not additive): normal passenger-facing
* schedules never show up here, and group-only schedules never show up in the portal. */
channel?: 'PORTAL' | 'GROUP_BOOKING';
}
export interface ScheduleClassOption {
name: string;
baseFareMinor: number;
displayCurrency: string;
displayAmountMinor: number;
available: number;
}
export interface ScheduleCoachType {
coachTypeId: string;
coachTypeName: string;
coachTypeCode: string;
coachId: string;
classes: ScheduleClassOption[];
}
export interface ScheduleResult {
type: 'DIRECT';
scheduleId: string;
trainNumber: string;
trainName: string;
origin: { id: string; code: string; name: string; city: string; sequence: number };
destination: { id: string; code: string; name: string; city: string; sequence: number };
departureAt: string;
arrivalAt: string;
durationMinutes: number;
status: string;
hasAvailability: boolean;
displayCurrency: string;
coachTypes: ScheduleCoachType[];
}
export type SearchEmptyReasonCode =
| 'NO_ROUTE'
| 'NO_SCHEDULE_ON_DATE'
| 'CANCELLED'
| 'PACKAGE_ONLY'
| 'GROUP_BOOKING_ONLY'
| 'CHECKIN_CLOSED'
| 'FULLY_BOOKED';
/** Structured, not a string — always render via a code→message lookup, never directly. */
export interface SearchEmptyReason {
code: SearchEmptyReasonCode;
originStationName: string;
destinationStationName: string;
}
export interface SearchTripsResponse {
journeyType: string;
outbound: ScheduleResult[];
requestedDate: string;
outboundReason?: SearchEmptyReason;
/** Nearby schedules for the same station pair on a different date, offered when `outbound` is empty. */
alternativeOutbound?: ScheduleResult[];
/** Return-leg schedules — present when the request's journeyType was ROUND_TRIP. */
inbound?: ScheduleResult[];
requestedReturnDate?: string;
inboundReason?: SearchEmptyReason;
alternativeInbound?: ScheduleResult[];
}
// ── Seat classes (GET /seat-classes) ───────────────────────────────────────
export interface SeatClassOption {
id: string;
name: string;
}
// ── Auto-assign + hold (POST /seats/auto-assign-hold) ─────────────────────
export interface AutoAssignHoldRequest {
scheduleId: string;
originStationId: string;
destinationStationId: string;
seatClassName: string;
adultCount: number;
childCount?: number;
/** Round-trip leg tag — omit for a one-way booking. */
journeyDirection?: 'OUTBOUND' | 'RETURN';
}
export interface HeldPassengerSeat {
passengerId: string;
seat: {
id: string;
label?: string;
seatNumber?: string;
coach?: string;
row?: number;
col?: string;
};
}
export interface AutoAssignHoldResponse {
holdId: string;
expiresAt: string;
ttlSeconds: number;
schedule: { id: string; trainNumber: string; trainName: string; departureAt: string; arrivalAt: string } | null;
passengers: HeldPassengerSeat[];
}
// ── Group booking creation (POST /bookings/group) ──────────────────────────
export interface GroupBookingPassengerInput {
/** Omit for a free child (ONE_WAY only) — matches guest-booking.dto.ts's own optional seatId. */
seatId?: string;
passengerName: string;
dateOfBirth: string;
idDocumentType: 'NATIONAL_ID' | 'PASSPORT' | 'DRIVING_LICENSE' | 'OTHER';
idDocumentNumber?: string;
passportNumber?: string;
passportCountry?: string;
nationality?: string;
phone?: string;
email?: string;
/** Return-leg seat ID — required when the booking is ROUND_TRIP. */
returnSeatId?: string;
}
export interface CreateGroupBookingRequest {
scheduleId: string;
holdId: string;
originStationId: string;
destinationStationId: string;
seatClassId: string;
bookingType: 'ONE_WAY' | 'ROUND_TRIP';
passengers: GroupBookingPassengerInput[];
/** ROUND_TRIP only. */
returnScheduleId?: string;
returnHoldId?: string;
returnOriginStationId?: string;
returnDestinationStationId?: string;
/** Falls back to seatClassId on the backend if omitted. */
returnSeatClassId?: string;
}
export interface GroupBookingSeat {
seatId: string;
passengerName: string;
passengerCategory: 'ADULT' | 'CHILD';
/** 1 = outbound leg, 2 = return leg. Absent on a plain ONE_WAY booking. */
leg?: number;
seat: { seatNumber: string; bedPosition?: string | null; coach: { number: string } };
}
export interface CreateGroupBookingResponse {
id: string;
bookingRef: string;
status: string;
totalMinor: number;
currency: string;
adultCount: number;
childCount: number;
seats: GroupBookingSeat[];
schedule: {
departureAt: string;
arrivalAt: string;
train: { number: string; name: string };
originStation: { name: string };
destinationStation: { name: string };
};
}
// ── Payment (GET /payments/methods, POST /payments/initiate) ───────────────
export type PaymentMethodType =
| 'TELEBIRR' | 'CBE_BIRR' | 'EBIRR' | 'WAAFI' | 'DMONEY' | 'CAC_BANK' | 'CARD' | 'WALLET' | 'CBE_BILL';
export interface SupportedPaymentMethod {
id: string;
type: PaymentMethodType;
displayName: string;
region: string;
currency: string;
enabled: boolean;
}
export interface InitiatePaymentRequest {
bookingId: string;
method: PaymentMethodType;
paymentMethodId?: string;
platform?: 'web' | 'mobile' | 'inapp';
}
export interface PaymentClientAction {
type: 'REDIRECT' | 'LAUNCH_APP' | 'INVOKE_BRIDGE' | 'COLLECT_OTP' | 'AWAIT_PUSH' | 'SHOW_BILL_REFERENCE';
url?: string;
/** Set when type=SHOW_BILL_REFERENCE (CBE bill payment) — the number the payer enters at any CBE channel. */
billReference?: string;
instructions?: string;
expiresAt?: string;
message?: string;
payerAccountMasked?: string;
}
export interface InitiatePaymentResponse {
intentId: string;
status: string;
clientAction?: PaymentClientAction;
merchantOrderId?: string;
failureCode?: string;
failureMessage?: string;
sessionExpiresAt?: string;
paymentDeadline?: string;
}
export const groupBookingApi = {
searchTrips: (dto: SearchTripsRequest) =>
apiClient.post<SearchTripsResponse>('/search', dto),
getSeatClasses: () => apiClient.get<SeatClassOption[]>('/seat-classes'),
autoAssignHold: (dto: AutoAssignHoldRequest) =>
apiClient.post<AutoAssignHoldResponse>('/seats/auto-assign-hold', dto),
/** Best-effort early release — e.g. freeing an outbound hold when the return leg's auto-assign fails. */
releaseHold: (holdId: string) => apiClient.delete(`/seats/hold/${holdId}`),
createGroupBooking: (dto: CreateGroupBookingRequest) =>
apiClient.post<CreateGroupBookingResponse>('/bookings/group', dto),
// Guards against a non-array response the same way dashboardApi.getPaymentMethods /
// paymentsApi.getMethods already do elsewhere in this app — never lets a bad/unexpected
// response shape reach a caller expecting a plain array.
getPaymentMethods: async (): Promise<SupportedPaymentMethod[]> => {
try {
const response = await apiClient.get<SupportedPaymentMethod[]>('/payments/methods');
return Array.isArray(response) ? response : [];
} catch {
return [];
}
},
initiatePayment: (dto: InitiatePaymentRequest) =>
apiClient.post<InitiatePaymentResponse>('/payments/initiate', dto),
};