From 8678258157ed9b42a69f2f2b799119a78698419b Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Fri, 26 Jun 2026 15:49:14 +0300 Subject: [PATCH 1/2] feat: ( permission ) add permission guard --- .../backoffice/src/app/bookings/page.tsx | 15 +++- .../backoffice/src/app/dashboard/page.tsx | 12 ++- .../backoffice/src/app/login/page.tsx | 7 +- .../src/components/layout/PermissionGuard.tsx | 36 ++++++++ .../src/components/layout/Sidebar.tsx | 86 ++++++++++++------- .../backoffice/src/lib/auth-store.ts | 47 +++++++++- .../backoffice/src/lib/permissions.ts | 42 +++++++++ .../backoffice/src/lib/use-permission.ts | 15 ++++ .../backoffice/src/middleware.ts | 24 ++++++ .../backoffice/src/types/index.ts | 3 + 10 files changed, 249 insertions(+), 38 deletions(-) create mode 100644 apps/edr-passenger-web/backoffice/src/components/layout/PermissionGuard.tsx create mode 100644 apps/edr-passenger-web/backoffice/src/lib/permissions.ts create mode 100644 apps/edr-passenger-web/backoffice/src/lib/use-permission.ts create mode 100644 apps/edr-passenger-web/backoffice/src/middleware.ts 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 d1b7cdf87..6caa5733b 100644 --- a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx @@ -8,6 +8,9 @@ import Badge from '@/components/ui/Badge'; import Pagination from '@/components/ui/Pagination'; import ActionButton from '@/components/ui/ActionButton'; import Modal from '@/components/ui/Modal'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { usePermission } from '@/lib/use-permission'; +import { PERMS } from '@/lib/permissions'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { bookingsApi, apiClient } from '@/lib/api'; import { formatCurrency, formatDateTime } from '@/lib/utils'; @@ -26,7 +29,9 @@ const SectionHeader = ({ title }: { title: string }) => ( ); -export default function BookingsPage() { +function BookingsPageContent() { + const canManage = usePermission(PERMS.bookings.manage); + const canCancel = usePermission(PERMS.bookings.cancel); const [filters, setFilters] = useState({ page: 1, pageSize: 20, search: '', status: '' }); const [extraFilters, setExtraFilters] = useState({ bookingType: '', dateFrom: '', dateTo: '', paymentStatus: '' }); const [showExtraFilters, setShowExtraFilters] = useState(false); @@ -481,3 +486,11 @@ export default function BookingsPage() { ); } + +export default function BookingsPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx index d61acff41..66d5ebeb6 100644 --- a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx @@ -1,6 +1,8 @@ 'use client'; import { useQuery } from '@tanstack/react-query'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; import { Ticket, Users, DollarSign, Percent } from 'lucide-react'; import StatCard from '@/components/dashboard/StatCard'; import DataTable from '@/components/ui/DataTable'; @@ -11,7 +13,7 @@ import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, R const COLORS = ['#2563eb', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6']; -export default function DashboardPage() { +function DashboardPageContent() { const { data: stats, isLoading: statsLoading } = useQuery({ queryKey: ['dashboard-stats'], queryFn: dashboardApi.getStats, @@ -237,3 +239,11 @@ export default function DashboardPage() { ); } + +export default function DashboardPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/login/page.tsx b/apps/edr-passenger-web/backoffice/src/app/login/page.tsx index 0e821873e..d9c9b6220 100644 --- a/apps/edr-passenger-web/backoffice/src/app/login/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/login/page.tsx @@ -42,7 +42,12 @@ export default function LoginPage() { await login(email, password); router.push('/dashboard'); } catch (err: any) { - setError(err.response?.data?.message || err.message || 'Invalid credentials. Please try again.'); + const msg = err.message || err.response?.data?.message || ''; + if (msg === 'ACCESS_DENIED') { + setError('This account does not have back-office access. Contact your administrator.'); + } else { + setError(err.response?.data?.message || msg || 'Invalid credentials. Please try again.'); + } } finally { setLoading(false); } diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/PermissionGuard.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/PermissionGuard.tsx new file mode 100644 index 000000000..2da55e37f --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/components/layout/PermissionGuard.tsx @@ -0,0 +1,36 @@ +'use client'; + +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { useAuthStore } from '@/lib/auth-store'; + +interface Props { + permission?: string; + children: React.ReactNode; +} + +/** + * Wraps a page to enforce auth + optional permission check. + * - Not logged in → redirect to /login + * - Missing permission → redirect to /dashboard + */ +export function PermissionGuard({ permission, children }: Props) { + const router = useRouter(); + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const hasPermission = useAuthStore((s) => s.hasPermission); + + useEffect(() => { + if (!isAuthenticated) { + router.replace('/login'); + return; + } + if (permission && !hasPermission(permission)) { + router.replace('/dashboard'); + } + }, [isAuthenticated, permission, hasPermission, router]); + + if (!isAuthenticated) return null; + if (permission && !hasPermission(permission)) return null; + + return <>{children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx index 96798936b..a2a9f68b0 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -39,49 +39,65 @@ import { import { useAuthStore } from '@/lib/auth-store'; import { cn } from '@/lib/utils'; import { useTheme } from '@/lib/theme-store'; +import { PERMS } from '@/lib/permissions'; -const navigationSections = [ +interface NavItem { + name: string; + href: string; + icon: React.ComponentType<{ className?: string }>; + permission?: string; +} + +const navigationSections: { title: string; items: NavItem[] }[] = [ { title: 'Overview', items: [ - { name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard }, + { name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard, permission: PERMS.dashboard }, ] }, { title: 'Operations', items: [ - { name: 'Bookings', href: '/bookings', icon: Ticket }, - { name: 'Passengers', href: '/passengers', icon: Users }, - { name: 'Tickets', href: '/tickets', icon: FileText }, - { name: 'Lugagges', href: '/excess-baggage', icon: Banknote }, + { name: 'Bookings', href: '/bookings', icon: Ticket, permission: PERMS.bookings.view }, + { name: 'Passengers', href: '/passengers', icon: Users, permission: PERMS.passengers.view }, + { name: 'Tickets', href: '/tickets', icon: FileText, permission: PERMS.tickets.view }, + { name: 'Luggage', href: '/excess-baggage', icon: Banknote, permission: PERMS.bookings.view }, ] }, { title: 'Tourism', items: [ - { name: 'Packages', href: '/packages', icon: Package }, - { name: 'Inquiries', href: '/package-inquiries', icon: MessageSquare }, + { name: 'Packages', href: '/packages', icon: Package, permission: PERMS.admin }, + { name: 'Inquiries', href: '/package-inquiries', icon: MessageSquare, permission: PERMS.admin }, ] }, { title: 'Master Data', items: [ - { name: 'Stations', href: '/stations', icon: MapPin }, - { name: 'Trains', href: '/trains', icon: Train }, - { name: 'Coaches', href: '/coaches', icon: Grid3x3 }, - { name: 'Seats', href: '/seats', icon: Armchair }, - { name: 'Classes', href: '/classes', icon: Settings }, - { name: 'Routes', href: '/routes', icon: Route }, - { name: 'Schedules', href: '/schedules', icon: Calendar }, + { name: 'Stations', href: '/stations', icon: MapPin, permission: PERMS.admin }, + { name: 'Trains', href: '/trains', icon: Train, permission: PERMS.admin }, + { name: 'Coaches', href: '/coaches', icon: Grid3x3, permission: PERMS.admin }, + { name: 'Seats', href: '/seats', icon: Armchair, permission: PERMS.admin }, + { name: 'Classes', href: '/classes', icon: Settings, permission: PERMS.admin }, + { name: 'Routes', href: '/routes', icon: Route, permission: PERMS.admin }, + { name: 'Schedules', href: '/schedules', icon: Calendar, permission: PERMS.admin }, ] }, { title: 'Financial', items: [ - { name: 'Fares', href: '/pricing', icon: DollarSign }, - { name: 'Currencies', href: '/currencies', icon: Banknote }, - { name: 'Payments', href: '/payments', icon: CreditCard }, - { name: 'Promos', href: '/promos', icon: Gift }, + { name: 'Pricing & Fares', href: '/pricing', icon: DollarSign, permission: PERMS.admin }, + { name: 'Currencies', href: '/currencies', icon: Banknote, permission: PERMS.currencies.manage }, + { name: 'Payments', href: '/payments', icon: CreditCard, permission: PERMS.payments.view }, + { name: 'Promo Codes', href: '/promos', icon: Gift, permission: PERMS.admin }, + ] + }, + { + title: 'Customer Services', + items: [ + { name: 'Loyalty Program', href: '/loyalty', icon: Gift, permission: PERMS.passengers.view }, + { name: 'Support Center', href: '/support', icon: MessageSquare, permission: PERMS.bookings.view }, + { name: 'Notifications', href: '/notifications', icon: Bell, permission: PERMS.notifications.send }, ] }, // { @@ -95,32 +111,32 @@ const navigationSections = [ { title: 'Security & Compliance', items: [ - { name: 'Logs', href: '/audit', icon: AlertTriangle }, - { name: 'Fraud', href: '/fraud', icon: Shield }, - { name: 'Verifayda', href: '/verifayda', icon: UserCheck }, + { name: 'Audit Logs', href: '/audit', icon: AlertTriangle, permission: PERMS.audit.view }, + { name: 'Fraud Detection', href: '/fraud', icon: Shield, permission: PERMS.fraud.view }, + { name: 'Verifayda Integration', href: '/verifayda', icon: UserCheck, permission: PERMS.admin }, ] }, { title: 'Analytics & Reports', items: [ - { name: 'Reports', href: '/reports', icon: BarChart3 }, - { name: 'Operational', href: '/operational-reports', icon: FileText }, + { name: 'Reports', href: '/reports', icon: BarChart3, permission: PERMS.reports.view }, + { name: 'Operational Reports', href: '/operational-reports', icon: FileText, permission: PERMS.reports.view }, ] }, { title: 'System', items: [ - { name: 'Agents', href: '/agents', icon: Briefcase }, - { name: 'Users', href: '/settings/users', icon: Users }, - { name: 'Settings', href: '/settings', icon: Settings }, - { name: 'Health', href: '/health', icon: Activity }, + { name: 'Agent Operations', href: '/agents', icon: Briefcase, permission: PERMS.agents.view }, + { name: 'User Management', href: '/settings/users', icon: Users, permission: PERMS.admin }, + { name: 'Settings', href: '/settings', icon: Settings, permission: PERMS.admin }, + { name: 'Health', href: '/health', icon: Activity, permission: PERMS.admin }, ] } ]; export default function Sidebar() { const pathname = usePathname(); - const { user, logout } = useAuthStore(); + const { user, logout, hasPermission } = useAuthStore(); const { isDark, toggleTheme } = useTheme(); const [isCollapsed, setIsCollapsed] = useState(false); @@ -154,7 +170,12 @@ export default function Sidebar() { {/* Navigation */} diff --git a/apps/edr-passenger-web/backoffice/src/lib/auth-store.ts b/apps/edr-passenger-web/backoffice/src/lib/auth-store.ts index fcba670b6..4048e6d0e 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/auth-store.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/auth-store.ts @@ -1,3 +1,5 @@ +'use client'; + import { create } from 'zustand'; import { AdminUser } from '@/types'; import axios from 'axios'; @@ -20,9 +22,10 @@ interface AuthState { logout: () => void; setUser: (user: AdminUser, token: string) => void; initialize: () => void; + hasPermission: (key: string) => boolean; } -export const useAuthStore = create((set) => ({ +export const useAuthStore = create((set, get) => ({ user: null, token: null, refreshToken: null, @@ -34,7 +37,11 @@ export const useAuthStore = create((set) => ({ const userStr = localStorage.getItem('auth_user'); if (token && userStr) { try { - const user = JSON.parse(userStr); + const user = JSON.parse(userStr) as AdminUser; + // backfill for sessions stored before permissions were added + if (!user.permissions) user.permissions = []; + if (user.isSuperAdmin === undefined) user.isSuperAdmin = false; + if (user.isOrgAdmin === undefined) user.isOrgAdmin = false; set({ user, token, isAuthenticated: true }); } catch { localStorage.removeItem('auth_token'); @@ -51,23 +58,49 @@ export const useAuthStore = create((set) => ({ const { token, refreshToken } = loginData; if (!token) throw new Error('No token received from server'); - // Step 2: fetch full user info with the token + // Step 2: fetch full user from IAM /v1/auth/me — returns session.userInfo + // employee is an array here (unlike /auth/me which transforms it to a single object via parseToken) const meRes = await axios.get(`${API_URL}/v1/auth/me`, { headers: { Authorization: `Bearer ${token}` }, }); const iamUser = meRes.data?.data ?? meRes.data; + // Role permissions — flat array in data.permissions + const rolePerms = (iamUser.permissions ?? []).map((p: any) => String(p.key)); + // Position permissions — employee[] is an array here; positions[].permissions[] merged by IAM + const employeeArr: any[] = Array.isArray(iamUser.employee) ? iamUser.employee : []; + const positionPerms = employeeArr.flatMap((emp: any) => + (emp.positions ?? []).flatMap((pos: any) => + (pos.permissions ?? []).map((p: any) => String(p.key)) + ) + ); + const permissions = Array.from(new Set([...rolePerms, ...positionPerms])); + + const isSuperAdmin = iamUser.isSuperAdmin ?? false; + const isOrgAdmin = iamUser.isOrganizationAdmin ?? false; + + // Block individual (passenger) accounts — backoffice requires at least one of: + // super admin, org admin, an employee position, or an explicit permission. + if (!isSuperAdmin && !isOrgAdmin && employeeArr.length === 0 && permissions.length === 0) { + throw new Error('ACCESS_DENIED'); + } + const user: AdminUser = { id: iamUser.id, email: iamUser.email, fullName: iamUser.name?.en ?? iamUser.name?.am ?? iamUser.email, role: mapIamRole(iamUser.roles ?? []), active: true, + permissions, + isSuperAdmin, + isOrgAdmin, }; localStorage.setItem('auth_token', token); localStorage.setItem('auth_user', JSON.stringify(user)); if (refreshToken) localStorage.setItem('auth_refresh_token', refreshToken); + // cookie lets middleware detect auth without reading localStorage + document.cookie = `auth_token=${token}; path=/; SameSite=Lax`; set({ user, token, refreshToken: refreshToken ?? null, isAuthenticated: true }); }, @@ -76,10 +109,18 @@ export const useAuthStore = create((set) => ({ localStorage.removeItem('auth_token'); localStorage.removeItem('auth_refresh_token'); localStorage.removeItem('auth_user'); + document.cookie = 'auth_token=; path=/; max-age=0'; set({ user: null, token: null, refreshToken: null, isAuthenticated: false }); }, setUser: (user: AdminUser, token: string) => { set({ user, token, isAuthenticated: true }); }, + + hasPermission: (key: string) => { + const { user } = get(); + if (!user) return false; + if (user.isSuperAdmin || user.isOrgAdmin) return true; + return user.permissions.includes(key); + }, })); diff --git a/apps/edr-passenger-web/backoffice/src/lib/permissions.ts b/apps/edr-passenger-web/backoffice/src/lib/permissions.ts new file mode 100644 index 000000000..7731d784e --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/lib/permissions.ts @@ -0,0 +1,42 @@ +export const PERMS = { + dashboard: 'edr_passenger_app:dashboard:view', + bookings: { + view: 'edr_passenger_app:bookings:view', + manage: 'edr_passenger_app:bookings:manage', + cancel: 'edr_passenger_app:bookings:cancel', + }, + passengers: { + view: 'edr_passenger_app:passengers:view', + manage: 'edr_passenger_app:passengers:manage', + }, + tickets: { + view: 'edr_passenger_app:tickets:view', + manage: 'edr_passenger_app:tickets:manage', + }, + payments: { + view: 'edr_passenger_app:payments:view_all', + refund: 'edr_passenger_app:payments:refund', + manage: 'edr_passenger_app:payments:manage_methods', + }, + reports: { + view: 'edr_passenger_app:reports:view', + }, + fraud: { + view: 'edr_passenger_app:fraud:view', + manage: 'edr_passenger_app:fraud:manage', + }, + audit: { + view: 'edr_passenger_app:audit:view', + }, + agents: { + view: 'edr_passenger_app:agents:view', + manage: 'edr_passenger_app:agents:manage', + }, + currencies: { + manage: 'edr_passenger_app:currencies:manage', + }, + notifications: { + send: 'edr_passenger_app:notifications:send', + }, + admin: 'edr_passenger_app:admin', +} as const; diff --git a/apps/edr-passenger-web/backoffice/src/lib/use-permission.ts b/apps/edr-passenger-web/backoffice/src/lib/use-permission.ts new file mode 100644 index 000000000..0991a360e --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/lib/use-permission.ts @@ -0,0 +1,15 @@ +'use client'; + +import { useAuthStore } from './auth-store'; + +/** + * Returns whether the current user has a given permission key. + * Super admins and org admins always return true. + * + * Usage: + * const canCancel = usePermission(PERMS.bookings.cancel); + * {canCancel && } + */ +export function usePermission(key: string): boolean { + return useAuthStore((s) => s.hasPermission(key)); +} diff --git a/apps/edr-passenger-web/backoffice/src/middleware.ts b/apps/edr-passenger-web/backoffice/src/middleware.ts new file mode 100644 index 000000000..0b437d112 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/middleware.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const PUBLIC_PATHS = ['/login']; + +export function middleware(request: NextRequest) { + const { pathname } = request.nextUrl; + + if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) { + return NextResponse.next(); + } + + // Token is stored in localStorage (client-side only), so middleware can't + // read it directly. We use a cookie set on login as the server-side signal. + const token = request.cookies.get('auth_token')?.value; + if (!token) { + return NextResponse.redirect(new URL('/login', request.url)); + } + + return NextResponse.next(); +} + +export const config = { + matcher: ['/((?!_next/static|_next/image|favicon.ico|api).*)'], +}; diff --git a/apps/edr-passenger-web/backoffice/src/types/index.ts b/apps/edr-passenger-web/backoffice/src/types/index.ts index 5ec6f4e1d..f853ec95e 100644 --- a/apps/edr-passenger-web/backoffice/src/types/index.ts +++ b/apps/edr-passenger-web/backoffice/src/types/index.ts @@ -96,6 +96,9 @@ export interface AdminUser { fullName: string; role: 'ADMIN' | 'AGENT' | 'SUPERVISOR'; active: boolean; + permissions: string[]; + isSuperAdmin: boolean; + isOrgAdmin: boolean; } // Re-export EDR types From c50abbffaac2e08b9c169d4b3905b672ad567326 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Sat, 27 Jun 2026 07:16:08 +0300 Subject: [PATCH 2/2] Update fare display based on currency and fix seat allocation --- .../modules/fare-engine/fare-engine.dto.ts | 10 +- .../notifications/notifications.service.ts | 24 +- .../modules/payments/payments.controller.ts | 10 +- .../src/modules/payments/payments.service.ts | 3 +- .../src/modules/search/search.service.ts | 52 +- .../src/modules/seats/seats.service.ts | 77 ++- .../src/modules/tickets/tickets.service.ts | 36 +- .../src/app/booking/auth-check/page.tsx | 193 +++--- .../src/app/booking/passengers/page.tsx | 5 +- .../portal/src/app/booking/payment/page.tsx | 631 +++++++----------- .../booking/payment/telebirr/failure/page.tsx | 5 +- .../booking/payment/waafi/failure/page.tsx | 5 +- .../portal/src/app/booking/results/page.tsx | 38 +- .../portal/src/app/booking/review/page.tsx | 231 ++++--- .../portal/src/app/booking/seats/page.tsx | 27 +- .../portal/src/lib/booking-store.ts | 1 + .../portal/src/types/index.ts | 7 +- 17 files changed, 662 insertions(+), 693 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.dto.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.dto.ts index 5d3a288cd..5652ae91d 100644 --- a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.dto.ts +++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.dto.ts @@ -3,15 +3,15 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { Currency } from '@prisma/client'; -// Nationality → home currency mapping +// Nationality → home currency mapping (keys are uppercase for case-insensitive lookup) export const NATIONALITY_CURRENCY_MAP: Record = { - Ethiopian: Currency.ETB, - Djiboutian: Currency.DJF, + ETHIOPIAN: Currency.ETB, + DJIBOUTIAN: Currency.DJF, }; export function resolveCurrencyFromNationality(nationality?: string): Currency { if (!nationality) return Currency.ETB; - return NATIONALITY_CURRENCY_MAP[nationality] ?? Currency.USD; + return NATIONALITY_CURRENCY_MAP[nationality.toUpperCase()] ?? Currency.USD; } export class FareCalculateDto { @@ -29,7 +29,7 @@ export class FareCalculateDto { @ApiPropertyOptional({ example: 'Ethiopian', - description: 'Passenger nationality. Determines the billing currency: Ethiopian → ETB, Djiboutian → DJF, other → USD. Defaults to ETB.', + description: 'Passenger nationality. Determines the billing currency: ETHIOPIAN → ETB, DJIBOUTIAN → DJF, other → USD. Case-insensitive. Defaults to ETB.', }) @IsOptional() @IsString() nationality?: string; 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 c7ed031cd..2660fe616 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts @@ -295,20 +295,34 @@ export class NotificationsService { { category: 'PAYMENT', deepLink: `edr://tickets/${ref}` }, ); + // Resolve SMS phone: prefer the IAM user's stored number, fall back to the phone + // the passenger entered on the booking form (contactPhone). + const contactPhone: string | null = (booking as any)?.contactPhone ?? (payload.booking as any)?.contactPhone ?? null; + const iamPhone = passengerId ? await this.getRecipientAddress(passengerId, 'SMS').catch(() => null) : null; + const smsPhone = iamPhone ?? contactPhone; + // Ticket not ready (generation failed/raced) — fall back to a payment-only confirmation. if (!ticket || !booking) { this.logger.warn(`payment.succeeded: ticket not ready for booking ${ref}; sending payment-only confirmation`); const text = `EDR: Payment of ${amount} ${currency} received for booking ${ref}. Your ticket is being prepared.`; await this.deliverEmail(passengerId, `Payment received — ${ref}`, text); - await this.deliverSms(passengerId, text); + if (smsPhone) { + await this.smsClient.sendSms({ to: smsPhone, message: text }).catch(() => null); + } else { + this.logger.warn(`No SMS phone for booking ${ref}`); + } return; } // SMS — short pointer (no HTML/QR over SMS). - await this.deliverSms( - passengerId, - `EDR: Booking ${ref} confirmed, ${amount} ${currency} paid. Show ref ${ref} at the gate or view your ticket: ${ticketUrl}`, - ); + if (smsPhone) { + await this.smsClient.sendSms({ + to: smsPhone, + message: `EDR: Booking ${ref} confirmed, ${amount} ${currency} paid. Show ref ${ref} at the gate or view your ticket: ${ticketUrl}`, + }).catch(() => null); + } else { + this.logger.warn(`No SMS phone for booking ${ref}`); + } // EMAIL — rich HTML ticket with plain-text fallback. await this.deliverEmail( 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 6a1df8cb1..aea08150e 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -128,12 +128,16 @@ export class PaymentsController { @ApiOperation({ summary: "List payment systems supported by the platform", description: - "Returns the global catalog of accepted payment systems. Not user-specific. Optionally filter by region to match a passenger's nationality.", + "Returns the global catalog of accepted payment systems. Filter by `currency` (e.g. ETB, DJF, USD) to get methods that settle in that currency, and/or by `region` to match a passenger's nationality. Both filters can be combined.", }) + @ApiQuery({ name: "currency", required: false, example: "DJF", description: "Settlement currency — ETB, DJF, USD, etc." }) @ApiQuery({ name: "region", enum: PaymentRegionEnum, required: false }) @ApiOkResponse({ type: [SupportedPaymentMethodDto] }) - getMethods(@Query("region") region?: PaymentRegionEnum) { - return this.service.getSupportedPaymentMethods(region); + getMethods( + @Query("currency") currency?: string, + @Query("region") region?: PaymentRegionEnum, + ) { + return this.service.getSupportedPaymentMethods(region, currency); } @Get("checkout") 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 7453e1c06..98d7520cf 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -473,7 +473,7 @@ export class PaymentsService { }); } - getSupportedPaymentMethods(region?: PaymentRegionEnum) { + getSupportedPaymentMethods(region?: PaymentRegionEnum, currency?: string) { return this.prisma.paymentMethod.findMany({ where: { enabled: true, @@ -487,6 +487,7 @@ export class PaymentsService { }, } : {}), + ...(currency ? { currency: currency.toUpperCase() } : {}), }, orderBy: [{ sortOrder: "asc" }, { displayName: "asc" }], }); 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 4797c6c19..3a7b02682 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -4,6 +4,7 @@ import { SearchTripsDto, FareQuoteDto } from './search.dto'; import { CurrencyService } from '../currency/currency.service'; import { FareEngineService } from '../fare-engine/fare-engine.service'; import { SegmentsService } from '../segments/segments.service'; +import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto'; import { Currency } from '@prisma/client'; const POINTS_TO_MINOR = 10; @@ -307,9 +308,13 @@ export class SearchService { (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); + 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 leg1MinDisplay = Math.min(...(leg1Result.faresByClass as any[]).map((f: any) => f.displayAmountMinor).filter((n: number) => n > 0), Infinity); + const leg2MinDisplay = Math.min(...(leg2Result.faresByClass as any[]).map((f: any) => f.displayAmountMinor).filter((n: number) => n > 0), Infinity); + const combinedMinFareMinor = (isFinite(leg1MinFare) ? leg1MinFare : 0) + (isFinite(leg2MinFare) ? leg2MinFare : 0); + const combinedMinFareDisplay = (isFinite(leg1MinDisplay) ? leg1MinDisplay : 0) + (isFinite(leg2MinDisplay) ? leg2MinDisplay : 0); + const displayCurrency = leg1Result.displayCurrency ?? leg2Result.displayCurrency ?? Currency.ETB; results.push({ type: 'TRANSIT', @@ -318,7 +323,9 @@ export class SearchService { connectionMinutes, leg1: leg1Result, leg2: leg2Result, + displayCurrency, combinedMinFareMinor, + combinedMinFareDisplay, // Convenience top-level fields so round-trip filter can read them uniformly departureAt: leg1Result.departureAt, arrivalAt: leg2Result.arrivalAt, @@ -379,6 +386,8 @@ export class SearchService { const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt; const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt; + const displayCurrency = faresByClass[0]?.displayCurrency ?? resolveCurrencyFromNationality(nationality); + return { type: 'DIRECT', scheduleId: schedule.id, @@ -395,6 +404,7 @@ export class SearchService { .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), + displayCurrency, faresByClass, coachTypes, }; @@ -467,7 +477,7 @@ export class SearchService { const taxesMinor = Math.round(totalBaseFareMinor * 0.05); const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor); - const displayCurrency = dto.displayCurrency ?? Currency.ETB; + const displayCurrency = dto.displayCurrency ?? resolveCurrencyFromNationality(dto.nationality); const displayTotalMinor = displayCurrency !== Currency.ETB ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) : totalMinor; @@ -494,7 +504,9 @@ export class SearchService { originStationId: string, destinationStationId: string, nationality?: string, - ): Promise> { + ): Promise> { + const displayCurrency = resolveCurrencyFromNationality(nationality); + const seatClassIds: string[] = Array.from( new Set( schedule.coachAssignments @@ -535,8 +547,10 @@ export class SearchService { scheduleId: schedule.id, }); return { - seatClassName: fare.seatClassName, - baseFareMinor: fare.baseFarePerPassengerMinor, + seatClassName: fare.seatClassName, + baseFareMinor: fare.baseFarePerPassengerMinor, + displayCurrency: fare.billingCurrency as Currency, + displayAmountMinor: Math.round(fare.baseFarePerPassengerMinor * fare.exchangeRate), }; } catch (error) { console.error(`Failed to calculate fare for ${sc.name}:`, (error as Error).message); @@ -545,7 +559,9 @@ export class SearchService { }), ); - const validResults = results.filter((r): r is { seatClassName: string; baseFareMinor: number } => r !== null); + const validResults = results.filter( + (r): r is { seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number } => r !== null, + ); if (validResults.length > 0) { return validResults; } @@ -573,9 +589,12 @@ export class SearchService { if (fareRules.length > 0) { console.log(`Found ${fareRules.length} fare rules for segment ${segmentRoute}`); const seatClassMap = Object.fromEntries(seatClasses.map(sc => [sc.id, sc.name])); + const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, displayCurrency); return fareRules.map(rule => ({ - seatClassName: seatClassMap[rule.seatClassId] || 'Unknown', - baseFareMinor: rule.baseFareMinor, + seatClassName: seatClassMap[rule.seatClassId] || 'Unknown', + baseFareMinor: rule.baseFareMinor, + displayCurrency, + displayAmountMinor: Math.round(rule.baseFareMinor * exchangeRate), })); } } @@ -586,13 +605,13 @@ export class SearchService { private async buildCoachTypeDetails( schedule: any, - faresByClass: Array<{ seatClassName: string; baseFareMinor: number }>, + faresByClass: Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>, ): Promise; + classes: Array<{ name: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>; }>> { const coachTypeMap = new Map< string, @@ -621,9 +640,14 @@ export class SearchService { .map((className) => { const fareInfo = faresByClass.find((f) => f.seatClassName === className); if (!fareInfo) return null; - return { name: className, baseFareMinor: fareInfo.baseFareMinor }; + return { + name: className, + baseFareMinor: fareInfo.baseFareMinor, + displayCurrency: fareInfo.displayCurrency, + displayAmountMinor: fareInfo.displayAmountMinor, + }; }) - .filter((c): c is { name: string; baseFareMinor: number } => c !== null) + .filter((c): c is { name: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number } => c !== null) .sort((a, b) => a.baseFareMinor - b.baseFareMinor); result.push({ 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 f67e9b3f3..4a0931c6e 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -307,9 +307,9 @@ export class SeatsService { throw new NotFoundException(`Seat(s) not found: ${missing.join(', ')}`); } - const blocked = seats.filter(s => s.status === 'BLOCKED'); + const blocked = seats.filter(s => s.status === 'BLOCKED' || s.status === 'BOOKED' || s.status === 'HELD'); if (blocked.length > 0) - throw new ConflictException(`Seat(s) ${blocked.map(s => s.seatNumber).join(', ')} are blocked`); + throw new ConflictException(`Seat(s) ${blocked.map(s => s.seatNumber).join(', ')} are already taken`); const seatLabelById = Object.fromEntries(seats.map(s => [s.id, s.seatNumber])); @@ -334,28 +334,34 @@ export class SeatsService { select: { seatIds: true, createdBy: true }, }); - const parsedHolds: { seatIds: string[]; from: number; to: number; passengerIds: string[] }[] = []; + const parsedHolds: { seatIds: string[]; from: number; to: number; passengerIds: string[]; legUnknown: boolean }[] = []; for (const h of activeHolds) { + const rawSeatIds = h.seatIds as string[]; try { if (h.createdBy?.trimStart().startsWith('{')) { const meta = JSON.parse(h.createdBy); const holdFrom = seqOf(meta.originStationId); const holdTo = seqOf(meta.destinationStationId); - if (holdFrom !== undefined && holdTo !== undefined) { - parsedHolds.push({ - seatIds: h.seatIds, - from: holdFrom, - to: holdTo, - passengerIds: (meta.passengers ?? []).map((p: any) => p.passengerId), - }); - } + parsedHolds.push({ + seatIds: rawSeatIds, + from: holdFrom ?? 0, + to: holdTo ?? Number.MAX_SAFE_INTEGER, + passengerIds: (meta.passengers ?? []).map((p: any) => p.passengerId), + legUnknown: holdFrom === undefined || holdTo === undefined, + }); + } else { + // Legacy plain-string createdBy — can't determine leg; block conservatively. + parsedHolds.push({ seatIds: rawSeatIds, from: 0, to: Number.MAX_SAFE_INTEGER, passengerIds: [], legUnknown: true }); } - } catch { /* ignore */ } + } catch { + // Malformed JSON — block conservatively. + parsedHolds.push({ seatIds: rawSeatIds, from: 0, to: Number.MAX_SAFE_INTEGER, passengerIds: [], legUnknown: true }); + } } for (const { passengerId, seatId } of dto.passengers) { for (const hold of parsedHolds) { - const legsOverlap = hold.from < reqTo && reqFrom < hold.to; + const legsOverlap = hold.legUnknown || (hold.from < reqTo && reqFrom < hold.to); if (!legsOverlap) continue; if (hold.seatIds.includes(seatId)) { @@ -364,7 +370,7 @@ export class SeatsService { ); } - if (hold.passengerIds.includes(passengerId)) { + if (!hold.legUnknown && hold.passengerIds.includes(passengerId)) { throw new ConflictException( `Passenger already holds a seat on this journey leg`, ); @@ -386,12 +392,14 @@ export class SeatsService { if (!seg.seatId) continue; const segFrom = seqOf(seg.departureStationId); const segTo = seqOf(seg.arrivalStationId); - if (segFrom !== undefined && segTo !== undefined) { - if (segFrom < reqTo && reqFrom < segTo) { - throw new ConflictException( - `Seat ${seatLabelById[seg.seatId]} is already booked for this leg`, - ); - } + // If stations can't be resolved, assume overlap (conservative) to prevent double-booking. + const overlaps = (segFrom === undefined || segTo === undefined) + ? true + : segFrom < reqTo && reqFrom < segTo; + if (overlaps) { + throw new ConflictException( + `Seat ${seatLabelById[seg.seatId]} is already booked for this leg`, + ); } } @@ -401,6 +409,13 @@ export class SeatsService { passengers: dto.passengers.map(p => ({ passengerId: p.passengerId, seatId: p.seatId })), }; + // Mark seats as HELD so the status check catches them immediately on any + // subsequent hold attempt (avoids relying solely on the SeatHold table scan). + await tx.seat.updateMany({ + where: { id: { in: seatIds } }, + data: { status: 'HELD' }, + }); + return tx.seatHold.create({ data: { scheduleId: dto.scheduleId, @@ -541,11 +556,16 @@ export class SeatsService { async releaseHold(holdId: string) { const hold = await this.prisma.seatHold.findUnique({ where: { id: holdId } }); if (!hold) throw new NotFoundException('Hold not found'); - await this.prisma.seatHold.delete({ where: { id: holdId } }); + await this.prisma.$transaction([ + this.prisma.seat.updateMany({ + where: { id: { in: hold.seatIds as string[] }, status: 'HELD' }, + data: { status: 'AVAILABLE' }, + }), + this.prisma.seatHold.delete({ where: { id: holdId } }), + ]); return { released: true, holdId }; } - // Physical seat.status stays AVAILABLE — segment rows are the source of truth for occupancy. async confirmSeats(_seatIds: string[]) {} // Delete the Journey (and its JourneySegments) scoped to this booking. @@ -719,7 +739,18 @@ export class SeatsService { @Cron(CronExpression.EVERY_MINUTE) async expireHolds() { - // Holds are temporary and don't create Journey rows — just delete expired ones. + const expired = await this.prisma.seatHold.findMany({ + where: { expiresAt: { lt: new Date() } }, + select: { id: true, seatIds: true }, + }); + if (expired.length === 0) return; + + const expiredSeatIds = expired.flatMap(h => h.seatIds as string[]); + // Only reset seats that are still HELD — BOOKED seats have been confirmed and must not be touched. + await this.prisma.seat.updateMany({ + where: { id: { in: expiredSeatIds }, status: 'HELD' }, + data: { status: 'AVAILABLE' }, + }); await this.prisma.seatHold.deleteMany({ where: { expiresAt: { lt: new Date() } } }); } } 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 ecc17dc53..f39c268e2 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException, BadRequestException, HttpException, HttpStatus } from '@nestjs/common'; +import { Injectable, NotFoundException, BadRequestException, HttpException, HttpStatus, Logger } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { PrismaService } from '../../common/prisma.service'; @@ -15,6 +15,8 @@ interface OfflineValidation { @Injectable() export class TicketsService { + private readonly logger = new Logger(TicketsService.name); + constructor( private readonly prisma: PrismaService, private readonly notifications: NotificationsService, @@ -154,17 +156,29 @@ export class TicketsService { ); } - // Booking not in CONFIRMED state (safety net — should align with SUCCEEDED) + // Booking not in CONFIRMED state — could be a webhook delivery failure. + // If the intent already SUCCEEDED but the booking is still PENDING_PAYMENT, + // self-heal here rather than rejecting a legitimately paid booking. if (booking.status !== 'CONFIRMED') { - throw new HttpException( - { - status: 'error', - message: 'Payment not completed', - code: 400, - detail: `Booking status: ${booking.status}`, - }, - HttpStatus.BAD_REQUEST, - ); + if (booking.status === 'PENDING_PAYMENT') { + this.logger.warn( + `Booking ${bookingId} is PENDING_PAYMENT but payment intent SUCCEEDED — webhook likely missed. Auto-confirming before ticket generation.`, + ); + await this.prisma.booking.update({ + where: { id: bookingId }, + data: { status: 'CONFIRMED' }, + }); + } else { + throw new HttpException( + { + status: 'error', + message: 'Payment not completed', + code: 400, + detail: `Booking status: ${booking.status}`, + }, + HttpStatus.BAD_REQUEST, + ); + } } // Build a compact multi-leg payload for the QR so gate scanners see all legs diff --git a/apps/edr-passenger-web/portal/src/app/booking/auth-check/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/auth-check/page.tsx index c1787010c..0ef5c74ec 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/auth-check/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/auth-check/page.tsx @@ -1,9 +1,42 @@ 'use client'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; import { useAuthStore } from '@/lib/auth-store'; -import { LogIn, UserPlus, Shield, Clock } from 'lucide-react'; +import { LogIn, UserPlus, ChevronLeft } from 'lucide-react'; + +function Tooltip({ children, content }: { children: React.ReactNode; content: string[] }) { + const [visible, setVisible] = useState(false); + + return ( +
setVisible(true)} + onMouseLeave={() => setVisible(false)} + onFocus={() => setVisible(true)} + onBlur={() => setVisible(false)} + > + {children} +
+
    + {content.map((item, i) => ( +
  • + + {item} +
  • + ))} +
+ {/* Arrow */} +
+
+
+ ); +} export default function AuthCheckPage() { const router = useRouter(); @@ -19,122 +52,54 @@ export default function AuthCheckPage() { } }, [isAuthenticated, router]); - const handleSignIn = () => { - router.push('/login?redirect=/booking/passengers'); - }; - - const handleGuest = () => { - router.push('/booking/passengers'); - }; - return ( -
-
-
- {/* Header */} -
-

Continue your booking

-

- Sign in to access saved profiles or continue as a guest -

-
+
+
+

+ Continue your booking +

+

+ Choose how you'd like to proceed +

- {/* Options Grid */} -
- {/* Sign In Option */} -
+ + -
-
- - {/* Guest Option */} -
-
-
- -
-

Continue as guest

-

- Book without an account. You can create one after completing your booking -

- - {/* Benefits */} -
-
-
- -
- Quick checkout process -
-
-
- -
- No account required -
-
-
- -
- Create account later (optional) -
-
- - -
-
-
- - {/* Back Link */} -
- -
+ + + + + +
+ +
+
diff --git a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx index 272992b73..92b82d2cb 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx @@ -8,7 +8,7 @@ import { useBookingStore } from '@/lib/booking-store'; import { useAuthStore } from '@/lib/auth-store'; import { apiClient } from '@/lib/api-client'; import { useState, useEffect, useRef } from 'react'; -import { CheckCircle, ExternalLink, Loader2, CalendarDays, X, Globe } from 'lucide-react'; +import { CheckCircle, ExternalLink, Loader2, CalendarDays, X, Globe, ChevronLeft } from 'lucide-react'; import { gregorianToEthiopian, ethiopianToGregorian, ETHIOPIAN_MONTHS, getDaysInEthiopianMonth } from '@/lib/ethiopian-calendar'; const GC_MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December']; @@ -951,7 +951,8 @@ export default function PassengersPage() { ...(searchCriteria.promoCode && { promoCode: searchCriteria.promoCode }), }); router.push(`/booking/results?${params}`); - }} className="btn-secondary flex-1" disabled={saving}> + }} className="btn-secondary flex-1 flex items-center justify-center gap-2" disabled={saving}> + Back + +

+ 🔒 Secure & encrypted payment +

+
+
+ ); + return ( -
+
-

- Complete payment -

-

- Booking reference:{" "} - {pnr} -

+

Complete payment

{/* Payment Processing Overlay */} {isProcessing && ( -
-
+
+
{paymentMutation.isSuccess ? ( <> - -

- Payment successful! -

-

- Redirecting to confirmation... -

- + +

Payment successful!

+

Redirecting to confirmation...

+ ) : ( <> - -

- Processing payment -

-

- Please wait while we process your payment... -

+ +

Processing payment

+

Please wait...

)}
)} - {/* Order Summary */} -
-

- Order summary -

-
- {isRoundTrip ? ( - <> - {/* Outbound Journey */} -
-
-
- Outbound Journey - - {outboundSchedule?.selectedSeatClassName?.replace(/_/g, ' ') || 'Standard'} - -
- - {/* Flight-style timeline */} -
- {/* Left column: Timeline with dots and line */} -
- {/* Origin dot */} -
- {/* Vertical line */} -
- {/* Destination dot */} -
-
- - {/* Right column: Content */} -
- {/* Origin */} -
-
- {outboundSchedule?.departureTime ? format(new Date(outboundSchedule.departureTime), 'HH:mm') : '--:--'} -
-
- {outboundSchedule?.departureTime ? format(new Date(outboundSchedule.departureTime), 'EEE, MMM d') : 'N/A'} -
-
- {outboundSchedule?.origin} -
-
+ {/* Two-column grid */} +
- {/* Journey Info */} -
-
-
- - - - {outboundSchedule?.duration} -
-
- - - - Train {outboundSchedule?.trainNumber} -
-
-
- - {/* Destination */} -
-
- {outboundSchedule?.arrivalTime ? format(new Date(outboundSchedule.arrivalTime), 'HH:mm') : '--:--'} -
-
- {outboundSchedule?.arrivalTime ? format(new Date(outboundSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'} -
-
- {outboundSchedule?.destination} -
-
-
-
- -
-
- Outbound fare - ETB {(outboundBaseFare / 100).toFixed(2)} -
-
+ {/* Left column — payment methods (2/3 width) */} +
+
+

Select payment method

+ {loadingMethods ? ( +
+ + Loading payment methods...
- - {/* Return Journey */} -
-
-
- Return Journey - - {inboundSchedule?.selectedSeatClassName?.replace(/_/g, ' ') || 'Standard'} - -
- - {/* Flight-style timeline */} -
- {/* Left column: Timeline with dots and line */} -
- {/* Origin dot */} -
- {/* Vertical line */} -
- {/* Destination dot */} -
-
- - {/* Right column: Content */} -
- {/* Origin */} -
-
- {inboundSchedule?.departureTime ? format(new Date(inboundSchedule.departureTime), 'HH:mm') : '--:--'} -
-
- {inboundSchedule?.departureTime ? format(new Date(inboundSchedule.departureTime), 'EEE, MMM d') : 'N/A'} -
-
- {inboundSchedule?.origin} -
-
- - {/* Journey Info */} -
-
-
- - - - {inboundSchedule?.duration} -
-
- - - - Train {inboundSchedule?.trainNumber} -
-
-
- - {/* Destination */} -
-
- {inboundSchedule?.arrivalTime ? format(new Date(inboundSchedule.arrivalTime), 'HH:mm') : '--:--'} -
-
- {inboundSchedule?.arrivalTime ? format(new Date(inboundSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'} -
-
- {inboundSchedule?.destination} -
-
-
-
- -
-
- Return fare - ETB {(inboundBaseFare / 100).toFixed(2)} -
-
+ ) : error ? ( +
+

Failed to load payment methods. Please refresh.

- - ) : ( - <> - {/* One-Way Journey */} -
-
-
- Your Journey - - {selectedSchedule?.selectedSeatClassName?.replace(/_/g, ' ') || 'Standard'} - -
- - {/* Flight-style timeline */} -
- {/* Left column: Timeline with dots and line */} -
- {/* Origin dot */} -
- {/* Vertical line */} -
- {/* Destination dot */} -
-
- - {/* Right column: Content */} -
- {/* Origin */} -
-
- {selectedSchedule?.departureTime ? format(new Date(selectedSchedule.departureTime), 'HH:mm') : '--:--'} -
-
- {selectedSchedule?.departureTime ? format(new Date(selectedSchedule.departureTime), 'EEE, MMM d') : 'N/A'} -
-
- {selectedSchedule?.origin} -
-
- - {/* Journey Info */} -
-
-
- - - - {selectedSchedule?.duration} -
-
- - - - Train {selectedSchedule?.trainNumber} -
-
-
- - {/* Destination */} -
-
- {selectedSchedule?.arrivalTime ? format(new Date(selectedSchedule.arrivalTime), 'HH:mm') : '--:--'} -
-
- {selectedSchedule?.arrivalTime ? format(new Date(selectedSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'} -
-
- {selectedSchedule?.destination} -
-
-
-
+ ) : paymentMethods.length === 0 ? ( +
+

No payment methods available at the moment.

- - )} - - {/* Passengers and Total */} -
-
- - Passengers - - - {passengers.length} passenger{passengers.length !== 1 ? "s" : ""} - -
-
- - Total amount - - - ETB {(totalAmount / 100).toFixed(2)} - -
-
-
-
- - {/* Payment Methods */} -
-

- Select payment method -

- {loadingMethods ? ( -
- -

Loading payment methods...

-
- ) : error ? ( -
-

- Failed to load payment methods. Please refresh the page. -

-
- ) : paymentMethods.length === 0 ? ( -
-

- No payment methods available at the moment. -

-
- ) : ( -
- {paymentMethods.map((method) => { - const Icon = getIconForMethod(method.type); - const isSelected = selectedMethod === method.type; - return ( -
-
-

- {method.displayName} -

-

- {method.region} · {method.currency} -

-
- {isSelected && ( -
- +
+
+ +
+
+

{method.displayName}

+

{method.region} · {method.currency}

+
+ {isSelected && ( + + )}
- )} -
- - ); - })} + + ); + })} +
+ )}
- )} -
- {/* Action Buttons */} -
- - - -
- - {/* Error Message */} - {paymentError && ( -
-

- ⚠️ {paymentError} -

+ {/* Order summary inline — mobile only */} +
+ +
- )} - {/* Security Notice */} -
-

- 🔒 Your payment is secure and encrypted. We do not store your - payment information. -

-
+ {/* Right column — sticky order summary (desktop only) */} +
+
+ +
+
+ +
{/* end grid */}
+ + {/* Mobile sticky bottom bar */} +
+
+ Total + {displayCurrency} {(totalAmount / 100).toFixed(2)} +
+ {paymentError && ( +

⚠️ {paymentError}

+ )} +
+ + +
+
+
); } diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/failure/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/failure/page.tsx index 53da93781..04c2d8251 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/failure/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/failure/page.tsx @@ -3,7 +3,7 @@ import { useSearchParams, useRouter } from 'next/navigation'; import { usePaymentStore } from '@/lib/payment-store'; import { useEffect, Suspense } from 'react'; -import { XCircle, Loader2, RefreshCw } from 'lucide-react'; +import { XCircle, Loader2, RefreshCw, ChevronLeft } from 'lucide-react'; function TelebirrFailureContent() { const router = useRouter(); @@ -36,7 +36,8 @@ function TelebirrFailureContent() { Try Again
diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/failure/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/failure/page.tsx index 19e3832fe..1e49d5889 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/failure/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/failure/page.tsx @@ -3,7 +3,7 @@ import { useSearchParams, useRouter } from 'next/navigation'; import { usePaymentStore } from '@/lib/payment-store'; import { useEffect, Suspense } from 'react'; -import { XCircle, Loader2, RefreshCw } from 'lucide-react'; +import { XCircle, Loader2, RefreshCw, ChevronLeft } from 'lucide-react'; function WaafiFailureContent() { const router = useRouter(); @@ -39,7 +39,8 @@ function WaafiFailureContent() { Try Again
diff --git a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx index 38693ede6..10eab03e0 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx @@ -159,12 +159,17 @@ export default function ResultsPage() { // Find the coach type to get pricing info const coachType = schedule.coachTypes?.find(ct => ct.coachTypeCode === selectedCoachType.code); - const minFare = coachType?.classes.length ? Math.min(...coachType.classes.map(c => c.baseFareMinor)) : 0; - + // Use displayAmountMinor (passenger's currency) so stored fare matches what the card showed. + const minFare = coachType?.classes.length + ? Math.min(...coachType.classes.map(c => c.displayAmountMinor ?? c.baseFareMinor)) + : 0; + const fareCurrency: string = + coachType?.classes[0]?.displayCurrency ?? schedule.displayCurrency ?? 'ETB'; + const hours = Math.floor((schedule.durationMinutes || 0) / 60); const minutes = (schedule.durationMinutes || 0) % 60; const durationStr = `${hours}h ${minutes}m`; - + const scheduleData = { id: scheduleId, trainNumber: schedule.trainNumber, @@ -175,6 +180,7 @@ export default function ResultsPage() { duration: durationStr, baseFareAdult: minFare, baseFareChild: minFare, + displayCurrency: fareCurrency, selectedSeatClass: selectedCoachType.name, selectedSeatClassName: selectedCoachType.name, selectedCoachTypeId: selectedCoachType.id, @@ -213,13 +219,22 @@ export default function ResultsPage() { const scheduleId = schedule.scheduleId || schedule.id || ''; const selectedCoachType = selectedCoachTypes[scheduleId]; - // Calculate lowest fare from coach types + // Calculate lowest fare and display currency from coach types / faresByClass. + // Prefer displayAmountMinor (passenger's own currency) over baseFareMinor (ETB internal). let lowestFare = null; + let displayCurrency = schedule.displayCurrency || 'ETB'; if (schedule.coachTypes?.length) { - const allFares = schedule.coachTypes.flatMap(ct => ct.classes.map(c => c.baseFareMinor)).filter(f => f > 0); + const allClasses = schedule.coachTypes.flatMap(ct => ct.classes); + const allFares = allClasses.map(c => c.displayAmountMinor ?? c.baseFareMinor).filter(f => f > 0); lowestFare = allFares.length ? Math.min(...allFares) : null; + const firstWithCurrency = allClasses.find(c => c.displayCurrency); + if (firstWithCurrency?.displayCurrency) displayCurrency = firstWithCurrency.displayCurrency; } else if (schedule.faresByClass?.length) { - lowestFare = Math.min(...schedule.faresByClass.map((f: any) => f.baseFareMinor).filter((fare: number) => fare > 0)); + lowestFare = Math.min(...schedule.faresByClass.map(f => f.displayAmountMinor ?? f.baseFareMinor).filter(f => f > 0)); + const firstWithCurrency = schedule.faresByClass.find(f => f.displayCurrency); + if (firstWithCurrency?.displayCurrency) displayCurrency = firstWithCurrency.displayCurrency; + } else if (schedule.combinedMinFareDisplay) { + lowestFare = schedule.combinedMinFareDisplay; } const hours = Math.floor((schedule.durationMinutes || 0) / 60); const minutes = (schedule.durationMinutes || 0) % 60; @@ -287,7 +302,7 @@ export default function ResultsPage() {
Starting from
- {lowestFare ? `ETB ${(lowestFare / 100).toFixed(2)}` : 'N/A'} + {lowestFare ? `${displayCurrency} ${(lowestFare / 100).toFixed(2)}` : 'N/A'}
per adult
{selectedCoachType && ( @@ -536,7 +551,8 @@ export default function ResultsPage() {
{coachTypes.map((coachType: any, index: number) => { const isSelected = selectedCoachType?.id === coachType.coachTypeId; - const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.baseFareMinor)) : 0; + const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.displayAmountMinor ?? c.baseFareMinor)) : 0; + const coachCurrency: string = (coachType.classes[0] as any)?.displayCurrency ?? (classModal as any).displayCurrency ?? 'ETB'; const CoachIcon = getCoachIcon(coachType.coachTypeName); return ( @@ -585,7 +601,7 @@ export default function ResultsPage() { }`}> {(minPrice / 100).toFixed(2)} - ETB + {coachCurrency}
@@ -615,10 +631,10 @@ export default function ResultsPage() {
- {(cls.baseFareMinor / 100).toFixed(2)} + {((cls.displayAmountMinor ?? cls.baseFareMinor) / 100).toFixed(2)} - ETB + {cls.displayCurrency ?? coachCurrency}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx index 7b94503a0..c74574d65 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx @@ -7,6 +7,7 @@ import { useMutation } from '@tanstack/react-query'; import { apiClient } from '@/lib/api-client'; import { format } from 'date-fns'; import { useState, useEffect } from 'react'; +import { ChevronLeft } from 'lucide-react'; // Helper function to decode JWT token and extract passengerId function getPassengerIdFromToken(token: string): string | null { @@ -58,6 +59,14 @@ export default function ReviewPage() { const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP'; + // Prefer the currency already stored on the selected schedule (set from search results). + // Fall back to deriving from nationality so the review page is never left with a stale value. + const NATIONALITY_TO_CURRENCY: Record = { ETHIOPIAN: 'ETB', DJIBOUTIAN: 'DJF' }; + const displayCurrency: string = + (isRoundTrip ? outboundSchedule?.displayCurrency : selectedSchedule?.displayCurrency) ?? + NATIONALITY_TO_CURRENCY[searchCriteria?.nationality?.toUpperCase() ?? ''] ?? + 'USD'; + useEffect(() => { if (!seatHold?.expiresAt) return; @@ -79,55 +88,54 @@ export default function ReviewPage() { return () => clearInterval(interval); }, [seatHold]); + const buildSeatLabel = (seat: any): string => { + const base: string = seat.number || seat.label || seat.seatNumber || ''; + if (!base) return 'N/A'; + const posMap: Record = { lower: 'L', middle: 'M', upper: 'U' }; + const suffix = seat.bedPosition ? (posMap[seat.bedPosition] ?? '') : ''; + return suffix ? `${base}${suffix}` : base; + }; + useEffect(() => { const fetchSeatDetails = async () => { try { const details: Record = {}; - + // Fetch outbound seat details if (isRoundTrip && outboundSchedule?.id) { const outboundSeatMap: any = await apiClient.get(`/seats/seatmap/${outboundSchedule.id}`); - const outboundCoaches = outboundSeatMap?.coaches || []; - const outboundSeats = outboundCoaches.flatMap((coach: any) => coach.seats || []); - + const outboundSeats = (outboundSeatMap?.coaches || []).flatMap((coach: any) => coach.seats || []); + passengers.forEach(p => { if ((p as any).outboundSeatId) { const seat = outboundSeats.find((s: any) => s.id === (p as any).outboundSeatId); - if (seat) { - details[`outbound-${(p as any).outboundSeatId}`] = seat.number || seat.label || seat.seatNumber || 'N/A'; - } + if (seat) details[`outbound-${(p as any).outboundSeatId}`] = buildSeatLabel(seat); } }); } - + // Fetch inbound seat details if (isRoundTrip && inboundSchedule?.id) { const inboundSeatMap: any = await apiClient.get(`/seats/seatmap/${inboundSchedule.id}`); - const inboundCoaches = inboundSeatMap?.coaches || []; - const inboundSeats = inboundCoaches.flatMap((coach: any) => coach.seats || []); - + const inboundSeats = (inboundSeatMap?.coaches || []).flatMap((coach: any) => coach.seats || []); + passengers.forEach(p => { if ((p as any).inboundSeatId) { const seat = inboundSeats.find((s: any) => s.id === (p as any).inboundSeatId); - if (seat) { - details[`inbound-${(p as any).inboundSeatId}`] = seat.number || seat.label || seat.seatNumber || 'N/A'; - } + if (seat) details[`inbound-${(p as any).inboundSeatId}`] = buildSeatLabel(seat); } }); } - + // Fetch one-way seat details if (!isRoundTrip && selectedSchedule?.id) { const seatMapData: any = await apiClient.get(`/seats/seatmap/${selectedSchedule.id}`); - const coaches = seatMapData?.coaches || []; - const allSeats = coaches.flatMap((coach: any) => coach.seats || []); - + const allSeats = (seatMapData?.coaches || []).flatMap((coach: any) => coach.seats || []); + passengers.forEach(p => { if (p.seatId) { const seat = allSeats.find((s: any) => s.id === p.seatId); - if (seat) { - details[p.seatId] = seat.number || seat.label || seat.seatNumber || 'N/A'; - } + if (seat) details[p.seatId] = buildSeatLabel(seat); } }); } @@ -249,7 +257,7 @@ export default function ReviewPage() { destinationStationId: searchCriteria.destinationStationId, seatClassId: seatClassId, bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY', - displayCurrency: 'ETB', + displayCurrency: displayCurrency, passengers: passengers.map((p) => { const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian'; return { @@ -288,7 +296,7 @@ export default function ReviewPage() { destinationStationId: searchCriteria.destinationStationId, seatClassId: seatClassId, bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY', - displayCurrency: 'ETB', + displayCurrency: displayCurrency, passengers: passengers.map(p => { const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian'; return { @@ -373,21 +381,88 @@ export default function ReviewPage() { }, 0); const total = baseFare; + // Shared fare sidebar — rendered in right column (desktop) and inline (mobile) + const FareSidebar = () => ( +
+

+ Fare breakdown +

+ {passengers.map((p, i) => { + const outFare = outboundSchedule?.baseFareAdult || 0; + const inFare = inboundSchedule?.baseFareAdult || 0; + const onewayFare = selectedSchedule?.baseFareAdult || 0; + const passengerTotal = isRoundTrip ? outFare + inFare : onewayFare; + return ( +
+
+ + {p.name || `Passenger ${i + 1}`} + + + {displayCurrency} {(passengerTotal / 100).toFixed(2)} + +
+ {isRoundTrip && ( +
+
+ Outbound + {displayCurrency} {(outFare / 100).toFixed(2)} +
+
+ Return + {displayCurrency} {(inFare / 100).toFixed(2)} +
+
+ )} +
+ ); + })} +
+ Total + {displayCurrency} {(total / 100).toFixed(2)} +
+ + {/* Action buttons — visible only in desktop sidebar */} +
+ {createBookingMutation.isError && ( +

+ ⚠️ {createBookingMutation.error instanceof Error ? createBookingMutation.error.message : 'An error occurred. Please try again.'} +

+ )} + + +
+
+ ); + return ( -
+
-

Review your booking

+

Review your booking

{seatHold && ( -
-

- ⏱️ Your seats will be released in: {timeLeft} -

+
+ + ⏱️ Seats held for: {timeLeft} +
)} -
+ {/* Two-column layout on desktop */} +
+ + {/* Left column — trip details + passengers */} +
{/* Outbound Trip Details */} {isRoundTrip && outboundSchedule && (
@@ -646,67 +721,47 @@ export default function ReviewPage() {
-
-

Fare breakdown

-
- {passengers.map((p, i) => { - const outFare = outboundSchedule?.baseFareAdult || 0; - const inFare = inboundSchedule?.baseFareAdult || 0; - const onewayFare = selectedSchedule?.baseFareAdult || 0; - const passengerTotal = isRoundTrip ? outFare + inFare : onewayFare; - return ( -
-
- - {p.name || `Passenger ${i + 1}`} - - - ETB {(passengerTotal / 100).toFixed(2)} - -
- {isRoundTrip && ( -
-
- Outbound - ETB {(outFare / 100).toFixed(2)} -
-
- Return - ETB {(inFare / 100).toFixed(2)} -
-
- )} -
- ); - })} -
- Total - ETB {(total / 100).toFixed(2)} -
+ {/* Fare breakdown — visible only on mobile (desktop shows it in right column) */} +
+ +
+ +
{/* end left column */} + + {/* Right column — sticky fare card (desktop only) */} +
+
+
-
- - -
- - {createBookingMutation.isError && ( -
-

- ⚠️ {createBookingMutation.error instanceof Error ? createBookingMutation.error.message : 'An error occurred while creating your booking. Please try again.'} -

-
- )} -
+
{/* end grid */} +
+
+ + {/* Mobile sticky bottom bar */} +
+
+ Total + {displayCurrency} {(total / 100).toFixed(2)} +
+ {createBookingMutation.isError && ( +

+ ⚠️ {createBookingMutation.error instanceof Error ? createBookingMutation.error.message : 'An error occurred. Please try again.'} +

+ )} +
+ +
diff --git a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx index ba3fe0159..696dcb45b 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx @@ -12,6 +12,15 @@ import Image from "next/image"; import CustomModal from "@/components/CustomModal"; +const BED_POSITION_SUFFIX: Record = { lower: 'L', middle: 'M', upper: 'U' }; + +const buildSeatLabel = (seat: any): string => { + const base: string = seat.number || seat.label || seat.seatNumber || ''; + if (!base) return ''; + const suffix = seat.bedPosition ? (BED_POSITION_SUFFIX[seat.bedPosition] ?? '') : ''; + return suffix ? `${base}${suffix}` : base; +}; + const BedCard = memo(({ bed, isSelected, onToggle }: any) => { const seatLabel = bed.label || bed.seatNumber || bed.number || "?"; const bedPosition = bed.bedPosition || ""; @@ -364,11 +373,7 @@ export default function SeatsPage() { return { ...p, outboundSeatId: selectedSeats[i], - outboundSeatNumber: - seatData?.number || - seatData?.label || - seatData?.seatNumber || - "", + outboundSeatNumber: seatData ? buildSeatLabel(seatData) : '', }; }); setPassengers(updatedPassengers); @@ -401,18 +406,13 @@ export default function SeatsPage() { return { ...p, inboundSeatId: selectedSeats[i], - inboundSeatNumber: - seatData?.number || - seatData?.label || - seatData?.seatNumber || - "", + inboundSeatNumber: seatData ? buildSeatLabel(seatData) : '', }; } return { ...p, seatId: selectedSeats[i], - seatNumber: - seatData?.number || seatData?.label || seatData?.seatNumber || "", + seatNumber: seatData ? buildSeatLabel(seatData) : '', }; }); setPassengers(updatedPassengers); @@ -456,8 +456,7 @@ export default function SeatsPage() { return { ...p, seatId: autoSelectedSeats[i], - seatNumber: - seatData?.number || seatData?.label || seatData?.seatNumber || "", + seatNumber: seatData ? buildSeatLabel(seatData) : '', }; }); setPassengers(updatedPassengers); diff --git a/apps/edr-passenger-web/portal/src/lib/booking-store.ts b/apps/edr-passenger-web/portal/src/lib/booking-store.ts index 012fbef44..864dd8086 100644 --- a/apps/edr-passenger-web/portal/src/lib/booking-store.ts +++ b/apps/edr-passenger-web/portal/src/lib/booking-store.ts @@ -44,6 +44,7 @@ export interface SelectedSchedule { duration: string; baseFareAdult: number; baseFareChild: number; + displayCurrency: string; selectedSeatClass?: string; selectedSeatClassName?: string; } diff --git a/apps/edr-passenger-web/portal/src/types/index.ts b/apps/edr-passenger-web/portal/src/types/index.ts index 08203494a..1baabfd3b 100644 --- a/apps/edr-passenger-web/portal/src/types/index.ts +++ b/apps/edr-passenger-web/portal/src/types/index.ts @@ -38,7 +38,7 @@ export interface Schedule { baseFareChild?: number; availableSeats?: number; availabilityByClass?: Record; // API returns this - faresByClass?: Array<{ seatClassName: string; baseFareMinor: number }>; // API returns this + faresByClass?: Array<{ seatClassName: string; baseFareMinor: number; displayCurrency?: string; displayAmountMinor?: number }>; // API returns this coachTypes?: Array<{ coachId: string; coachTypeName: string; @@ -46,8 +46,13 @@ export interface Schedule { classes: Array<{ name: string; baseFareMinor: number; + displayCurrency?: string; + displayAmountMinor?: number; }>; }>; + displayCurrency?: string; + combinedMinFareMinor?: number; + combinedMinFareDisplay?: number; serviceClass?: string; status?: string; hasAvailability?: boolean;