From 376c23a0adaa21ba17169873bf5b9bf1380811c8 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Thu, 13 Aug 2026 22:47:22 +0300 Subject: [PATCH] Updated financial report and sms outside of Ethiopia --- .../modules/payments/payments.controller.ts | 9 +- .../src/modules/payments/payments.service.ts | 13 +- .../src/modules/reports/reports.controller.ts | 12 +- .../src/modules/reports/reports.service.ts | 104 +++--- .../backoffice/src/app/dashboard/page.tsx | 2 +- .../backoffice/src/app/payments/page.tsx | 66 +++- .../src/app/reports/finance/page.tsx | 327 ++++++++++-------- .../backoffice/src/lib/api/finance.ts | 16 +- .../src/lib/export/finance-workbook.ts | 135 +++++--- .../src/app/booking/passengers/page.tsx | 110 +++--- .../portal/src/app/booking/search/page.tsx | 11 +- .../portal/src/components/PackagesSection.tsx | 12 +- 12 files changed, 509 insertions(+), 308 deletions(-) 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 9c1bfa65d..158eacfc7 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -85,14 +85,20 @@ export class PaymentsController { @ApiBearerAuth("IAM-auth") @ApiOperation({ summary: "Get all payments with filters (staff/admin only)" }) @ApiQuery({ name: "search", required: false }) - @ApiQuery({ name: "status", required: false }) + @ApiQuery({ name: "status", required: false, description: "PaymentIntentStatus value, e.g. SUCCEEDED" }) @ApiQuery({ name: "method", required: false }) + @ApiQuery({ + name: "bookingStatus", + required: false, + description: "Comma-separated Booking.status values, e.g. CONFIRMED,BOARDED — restricts to payments backing bookings in those states.", + }) @ApiQuery({ name: "page", required: false }) @ApiQuery({ name: "pageSize", required: false }) async getAll( @Query("search") search?: string, @Query("status") status?: string, @Query("method") method?: string, + @Query("bookingStatus") bookingStatus?: string, @Query("page") page?: string, @Query("pageSize") pageSize?: string, ) { @@ -100,6 +106,7 @@ export class PaymentsController { search, status, method, + bookingStatus, page: page ? parseInt(page) : 1, pageSize: pageSize ? parseInt(pageSize) : 10, }); 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 5cd483830..f7c27a2e9 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -99,10 +99,13 @@ export class PaymentsService { search?: string; status?: string; method?: string; + /** Comma-separated Booking.status values, e.g. "CONFIRMED,BOARDED" — lets a caller ask + * for exactly the payments that back confirmed revenue, not every payment attempt. */ + bookingStatus?: string; page?: number; pageSize?: number; }) { - const { search, status, method, page = 1, pageSize = 10 } = filters; + const { search, status, method, bookingStatus, page = 1, pageSize = 10 } = filters; const skip = (page - 1) * pageSize; const where: any = {}; @@ -118,6 +121,12 @@ export class PaymentsService { if (method) { where.method = method; } + if (bookingStatus) { + const statuses = bookingStatus.split(",").map((s) => s.trim()).filter(Boolean); + if (statuses.length > 0) { + where.booking = { status: { in: statuses } }; + } + } const [items, total] = await Promise.all([ this.prisma.paymentIntent.findMany({ @@ -133,6 +142,7 @@ export class PaymentsService { childCount: true, totalMinor: true, currency: true, + status: true, priceTier: { select: { priceMinor: true } }, }, }, @@ -172,6 +182,7 @@ export class PaymentsService { bookingRef: b?.bookingRef, totalMinor: b?.totalMinor, currency: b?.currency, + status: b?.status, }, amountMinor, currency: item.currency, diff --git a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts index 2876b2294..6cf12a9f6 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts @@ -122,12 +122,16 @@ export class ReportsController { @Get("finance") @ApiOperation({ - summary: "Finance summary — revenue by period, origin/destination segment, and payment method", + summary: "Finance summary — revenue by period, origin/destination segment, payment method, and currency", description: "Revenue collected in the window (PaymentIntent.paidAt), grouped by day/week/month, origin → " + - "destination station pair, and payment method. Filter by originStationId and/or destinationStationId " + - "independently to query any station-pair segment (A→B, A→D, B→C), not just a whole predefined route. " + - "Returns per-bucket rows plus roll-ups by period, segment, and method for charting.", + "destination station pair, payment method, and currency. Amounts are never converted to ETB — a " + + "Waafi payment is reported in whatever currency Waafi actually charged, and with no method filter " + + "every currency present is listed separately rather than summed. Filter by originStationId and/or " + + "destinationStationId independently to query any station-pair segment (A→B, A→D, B→C), not just a " + + "whole predefined route. Only counts CONFIRMED/BOARDED bookings with a SUCCEEDED payment — the same " + + "revenue definition as the dashboard and /payments confirmed-revenue filter. Returns per-bucket rows " + + "plus roll-ups by period, segment, and method for charting.", }) getFinanceSummary(@Query() query: FinanceSummaryQueryDto) { return this.service.getFinanceSummary(query); diff --git a/apps/edr-passenger-api/src/modules/reports/reports.service.ts b/apps/edr-passenger-api/src/modules/reports/reports.service.ts index bfafe1921..d727014ee 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -152,8 +152,17 @@ export interface FinanceBucket { destinationStationId: string; segmentLabel: string; method: string; + currency: string; + bookingCount: number; + revenueMinor: number; +} + +export interface FinanceRollupRow { + key: string; + label: string; + currency: string; + revenueMinor: number; bookingCount: number; - revenueEtbMinor: number; } @Injectable() @@ -1724,8 +1733,20 @@ export class ReportsService { /** * Revenue collected in the window, grouped by reporting period (day/week/month), origin → - * destination station pair, and payment method — the shape finance reconciles against - * provider settlement statements. + * destination station pair, payment method, and currency — the shape finance reconciles + * against provider settlement statements. + * + * Amounts are never converted to ETB. A Waafi payment settles in whatever currency Waafi + * actually charged (DJF/USD), not an exchange-rate estimate of its ETB equivalent — so + * filtering to one method shows exactly what that method collected, in its own currency, + * and leaving every method selected lists each currency's total separately rather than + * summing unlike currencies into one converted figure. + * + * The "actual" amount/currency is `displayTotalMinor`/`displayCurrency` when set, falling + * back to `totalMinor`/`currency` — the same resolution `getPaymentDiscrepancyReport` and + * `getPaymentsReport` use, because `Booking.currency` is often just the internal ETB + * charge basis (many booking-creation paths hardcode it to ETB); the currency the + * passenger was actually shown and charged in lives in the display fields. * * Grouped by the booking's own origin/destination, not the parent Route — a route like * "Sebeta - Dire Dawa" has intermediate stops, and a passenger may have booked any @@ -1734,29 +1755,26 @@ export class ReportsService { * * Bucketed on `PaymentIntent.paidAt` (cash actually received), not `Booking.createdAt`, * so a booking made in one period but paid in another lands in the period it was paid. + * + * Same revenue definition as `getBackofficeStats` and the `/payments` "confirmed revenue" + * filter: `Booking.status` must still be CONFIRMED/BOARDED (a booking that was paid and + * later cancelled is not revenue) and `PaymentIntent.status` must be SUCCEEDED, not just + * carry a stale `paidAt` from before a cancellation. */ async getFinanceSummary(query: FinanceSummaryQueryDto) { const dateFrom = new Date(query.dateFrom + "T00:00:00.000Z"); const dateTo = new Date(query.dateTo + "T23:59:59.999Z"); const granularity = query.granularity ?? FinanceGranularity.DAILY; - const rateRows = await this.prisma.currencyExchangeRate.findMany({ - where: { toCurrency: "ETB" as any }, - orderBy: { effectiveDate: "desc" }, - }); - const rateToEtb = new Map(); - for (const r of rateRows) { - if (!rateToEtb.has(r.fromCurrency)) rateToEtb.set(r.fromCurrency, Number(r.rate)); - } - const toEtbMinor = (minor: number, currency: string): number => { - if (currency === "ETB") return minor; - const rate = rateToEtb.get(currency); - return rate ? Math.round(minor * rate) : minor; - }; - const bookings = await this.prisma.booking.findMany({ where: { + // Same revenue definition as the dashboard's backoffice-stats and the /payments + // "confirmed revenue" filter: the booking must still be CONFIRMED/BOARDED (a booking + // that was paid and later cancelled is not revenue) and the payment itself must have + // actually succeeded, not just carry a stale paidAt. + status: { in: ["CONFIRMED", "BOARDED"] }, paymentIntent: { + status: "SUCCEEDED", paidAt: { gte: dateFrom, lte: dateTo }, ...(query.method ? { method: query.method } : {}), }, @@ -1766,6 +1784,8 @@ export class ReportsService { select: { totalMinor: true, currency: true, + displayTotalMinor: true, + displayCurrency: true, originStationId: true, destinationStationId: true, schedule: { select: { originStationId: true, destinationStationId: true } }, @@ -1795,11 +1815,12 @@ export class ReportsService { destinationStationId: string, segmentLabel: string, method: string, + currency: string, ): FinanceBucket => { - const key = `${period}|${originStationId}|${destinationStationId}|${method}`; + const key = `${period}|${originStationId}|${destinationStationId}|${method}|${currency}`; let bucket = buckets.get(key); if (!bucket) { - bucket = { period, originStationId, destinationStationId, segmentLabel, method, bookingCount: 0, revenueEtbMinor: 0 }; + bucket = { period, originStationId, destinationStationId, segmentLabel, method, currency, bookingCount: 0, revenueMinor: 0 }; buckets.set(key, bucket); } return bucket; @@ -1811,65 +1832,62 @@ export class ReportsService { const originStationId = b.originStationId ?? b.schedule.originStationId ?? "UNKNOWN"; const destinationStationId = b.destinationStationId ?? b.schedule.destinationStationId ?? "UNKNOWN"; const segmentLabel = `${stationName.get(originStationId) ?? "Unknown"} → ${stationName.get(destinationStationId) ?? "Unknown"}`; - const bucket = bucketFor(period, originStationId, destinationStationId, segmentLabel, pi.method); + const currency = (b.displayCurrency as string | null) ?? b.currency; + const amountMinor = b.displayTotalMinor ?? b.totalMinor; + const bucket = bucketFor(period, originStationId, destinationStationId, segmentLabel, pi.method, currency); bucket.bookingCount += 1; - bucket.revenueEtbMinor += toEtbMinor(b.totalMinor, b.currency); + bucket.revenueMinor += amountMinor; } const rows = [...buckets.values()].sort((a, b) => a.period === b.period - ? a.segmentLabel.localeCompare(b.segmentLabel) || a.method.localeCompare(b.method) + ? a.segmentLabel.localeCompare(b.segmentLabel) || a.method.localeCompare(b.method) || a.currency.localeCompare(b.currency) : a.period.localeCompare(b.period), ); - const totals = rows.reduce( - (acc, r) => { - acc.bookingCount += r.bookingCount; - acc.revenueEtbMinor += r.revenueEtbMinor; - return acc; - }, - { bookingCount: 0, revenueEtbMinor: 0 }, - ); - - const rollUp = (keyOf: (r: FinanceBucket) => string, labelOf: (r: FinanceBucket) => string) => { - const map = new Map(); + const rollUp = (keyOf: (r: FinanceBucket) => string, labelOf: (r: FinanceBucket) => string): FinanceRollupRow[] => { + const map = new Map(); for (const r of rows) { const key = keyOf(r); let entry = map.get(key); if (!entry) { - entry = { key, label: labelOf(r), revenueEtbMinor: 0, bookingCount: 0 }; + entry = { key, label: labelOf(r), currency: r.currency, revenueMinor: 0, bookingCount: 0 }; map.set(key, entry); } - entry.revenueEtbMinor += r.revenueEtbMinor; + entry.revenueMinor += r.revenueMinor; entry.bookingCount += r.bookingCount; } - return [...map.values()].sort((a, b) => b.revenueEtbMinor - a.revenueEtbMinor); + return [...map.values()].sort((a, b) => b.revenueMinor - a.revenueMinor); }; + // Currency is folded into every rollup key so amounts in different currencies are never + // summed together — see class-level note on why this endpoint doesn't convert to ETB. + const totals = rollUp((r) => r.currency, (r) => r.currency); + return { granularity, dateFrom: query.dateFrom, dateTo: query.dateTo, - currency: "ETB", totals, - byPeriod: rollUp((r) => r.period, (r) => r.period), - bySegment: rollUp((r) => `${r.originStationId}|${r.destinationStationId}`, (r) => r.segmentLabel), - byMethod: rollUp((r) => r.method, (r) => r.method), + byPeriod: rollUp((r) => `${r.period}|${r.currency}`, (r) => r.period), + bySegment: rollUp((r) => `${r.originStationId}|${r.destinationStationId}|${r.currency}`, (r) => r.segmentLabel), + byMethod: rollUp((r) => `${r.method}|${r.currency}`, (r) => r.method), rows, }; } - /** CSV of the finance summary, one row per period + origin/destination segment + payment method. */ + /** CSV of the finance summary, one row per period + origin/destination segment + payment method + currency. */ async exportFinanceSummaryCsv(query: FinanceSummaryQueryDto): Promise { const report = await this.getFinanceSummary(query); - const headers = ["Period", "Origin → Destination", "Payment Method", "Bookings", "Revenue (ETB)"]; + const headers = ["Period", "Origin → Destination", "Payment Method", "Currency", "Bookings", "Revenue"]; const rows = report.rows.map((r) => [ r.period, r.segmentLabel, r.method, + r.currency, r.bookingCount, - (r.revenueEtbMinor / 100).toFixed(2), + (r.revenueMinor / 100).toFixed(2), ]); return [headers, ...rows].map((row) => row.map(toCsvCell).join(",")).join("\n"); 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 7eede0089..571d63c9d 100644 --- a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx @@ -312,7 +312,7 @@ function DashboardPageContent() { View payments diff --git a/apps/edr-passenger-web/backoffice/src/app/payments/page.tsx b/apps/edr-passenger-web/backoffice/src/app/payments/page.tsx index 9cbfdd4e3..e59a867ed 100644 --- a/apps/edr-passenger-web/backoffice/src/app/payments/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/payments/page.tsx @@ -1,8 +1,9 @@ 'use client'; -import { useState } from 'react'; +import { Suspense, useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Download, Eye, Trash2, AlertCircle, Send, CheckCircle, XCircle, RotateCcw } from 'lucide-react'; +import { useSearchParams } from 'next/navigation'; +import { Download, Eye, Trash2, AlertCircle, Send, CheckCircle, XCircle, RotateCcw, X } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import Badge from '@/components/ui/Badge'; import ActionButton from '@/components/ui/ActionButton'; @@ -22,6 +23,18 @@ import { type PageTab = 'payments' | 'supplementary'; +// PaymentIntentStatus values, as actually defined on the backend — the dropdown used to +// offer PENDING/COMPLETED/FAILED, none of which are real values, so selecting them just +// returned nothing. +const PAYMENT_STATUS_OPTIONS = [ + { value: 'SUCCEEDED', label: 'Succeeded' }, + { value: 'PROCESSING', label: 'Processing' }, + { value: 'REQUIRES_ACTION', label: 'Requires Action' }, + { value: 'FAILED', label: 'Failed' }, + { value: 'CANCELLED', label: 'Cancelled' }, + { value: 'REFUNDED', label: 'Refunded' }, +]; + const STATUS_COLORS: Record = { PENDING: 'warning', PAID: 'success', @@ -42,9 +55,19 @@ const SectionHeader = ({ title }: { title: string }) => ( ); -export default function PaymentsPage() { +function PaymentsPageContent() { + const searchParams = useSearchParams(); + // A link can pre-filter this page — the dashboard's Revenue card links here with + // status=SUCCEEDED&bookingStatus=CONFIRMED,BOARDED so "view payments" shows exactly the + // payments that make up that revenue figure, not every payment attempt. + const initialBookingStatus = searchParams.get('bookingStatus') ?? ''; const [pageTab, setPageTab] = useState('payments'); - const [filters, setFilters] = useState({ search: '', status: '', method: '' }); + const [filters, setFilters] = useState({ + search: '', + status: searchParams.get('status') ?? '', + method: '', + bookingStatus: initialBookingStatus, + }); const [selectedPayment, setSelectedPayment] = useState(null); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [paymentToDelete, setPaymentToDelete] = useState(null); @@ -108,6 +131,7 @@ export default function PaymentsPage() { search: filters.search || undefined, status: filters.status || undefined, method: filters.method || undefined, + bookingStatus: filters.bookingStatus || undefined, }), }); @@ -168,7 +192,8 @@ export default function PaymentsPage() { { key: 'booking', label: 'Booking', render: (payment: any) => payment.booking?.bookingRef || 'N/A' }, { key: 'amount', label: 'Amount', render: (payment: any) => formatCurrency(payment.booking?.totalMinor ?? payment.amountMinor, 'ETB') }, { key: 'method', label: 'Method', render: (payment: any) => {payment.method} }, - { key: 'status', label: 'Status', render: (payment: any) => {payment.status} }, + { key: 'status', label: 'Payment Status', render: (payment: any) => {payment.status} }, + { key: 'bookingStatus', label: 'Booking Status', render: (payment: any) => payment.booking?.status ? {payment.booking.status} : '—' }, { key: 'createdAt', label: 'Created', render: (payment: any) => formatDateTime(payment.createdAt) }, ]; @@ -286,18 +311,33 @@ export default function PaymentsPage() { {successMessage && (
✓ {successMessage}
)} + {filters.bookingStatus && ( +
+ + Showing payments backing {filters.bookingStatus.split(',').join(' / ')} bookings only — + the same confirmed revenue the dashboard total is built from. + + +
+ )}
setFilters({ ...filters, search: e.target.value })} />
- +
@@ -478,3 +518,11 @@ export default function PaymentsPage() {
); } + +export default function PaymentsPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/finance/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/finance/page.tsx index 8e3e2a23b..b83e96ca7 100644 --- a/apps/edr-passenger-web/backoffice/src/app/reports/finance/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/reports/finance/page.tsx @@ -2,7 +2,7 @@ import { useMemo, useRef, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Banknote, BookOpen, FileSpreadsheet, Receipt } from 'lucide-react'; +import { Banknote, BookOpen, FileSpreadsheet } from 'lucide-react'; import { Bar, BarChart, CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip as RechartsTooltip, XAxis, YAxis, @@ -114,9 +114,12 @@ export default function FinanceReportPage() { const [method, setMethod] = useState(''); const [exporting, setExporting] = useState(false); - const trendCardRef = useRef(null); - const segmentCardRef = useRef(null); - const methodCardRef = useRef(null); + // Chart cards are captured for the Excel export. There's one Trend/Segment/Method set per + // currency (see currencySections below), so refs are keyed by `${currency}-${chart}`. + const chartRefs = useRef>({}); + const setChartRef = (key: string) => (el: HTMLDivElement | null) => { + chartRefs.current[key] = el; + }; const { dateFrom, dateTo } = useMemo(() => { const end = new Date(); @@ -192,11 +195,17 @@ export default function FinanceReportPage() { if (!data) return; setExporting(true); try { - const [trend, segment, method_] = await Promise.all([ - captureCard(trendCardRef.current), - captureCard(segmentCardRef.current), - captureCard(methodCardRef.current), - ]); + const imagesByCurrency: Record = {}; + await Promise.all( + currencySections.map(async (section) => { + const [trend, segment, methodImg] = await Promise.all([ + captureCard(chartRefs.current[`${section.currency}-trend`]), + captureCard(chartRefs.current[`${section.currency}-segment`]), + captureCard(chartRefs.current[`${section.currency}-method`]), + ]); + imagesByCurrency[section.currency] = { trend, segment, method: methodImg }; + }), + ); const stationLabel = (id: string) => stations.find((s) => s.id === id)?.name ?? 'Any'; @@ -212,7 +221,7 @@ export default function FinanceReportPage() { }, methodLabel, periodLabel, - images: { trend, segment, method: method_ }, + imagesByCurrency, }); const url = URL.createObjectURL(blob); @@ -226,43 +235,53 @@ export default function FinanceReportPage() { } }; - const trendData = useMemo( - () => - (data?.byPeriod ?? []) + const totals = useMemo( + () => (data?.totals ?? []).slice().sort((a, b) => b.revenueMinor - a.revenueMinor), + [data], + ); + const totalBookings = totals.reduce((sum, t) => sum + t.bookingCount, 0); + const hasData = totalBookings > 0; + + // Money is never comparable across currencies, so rather than scoping every chart to + // whichever currency happens to be biggest overall (which would silently drop a + // currency-specific method like Waafi/DJF from the payment-method breakdown whenever ETB + // dominates the total), each currency present gets its own full Trend/Segment/Method set. + const currencySections = useMemo(() => { + if (!data) return []; + return totals.map((t) => { + const trendData = data.byPeriod + .filter((p) => p.currency === t.currency) .slice() .sort((a, b) => a.key.localeCompare(b.key)) - .map((p) => ({ - label: periodLabel(p.key, data!.granularity), - revenue: p.revenueEtbMinor / 100, - })), - [data], - ); + .map((p) => ({ label: periodLabel(p.label, data.granularity), revenue: p.revenueMinor / 100 })); - const segmentData = useMemo( - () => - (data?.bySegment ?? []) + const segmentData = data.bySegment + .filter((r) => r.currency === t.currency) .slice() - .sort((a, b) => b.revenueEtbMinor - a.revenueEtbMinor) - .map((r) => ({ label: r.label, revenue: r.revenueEtbMinor })), - [data], - ); + .sort((a, b) => b.revenueMinor - a.revenueMinor) + .map((r) => ({ label: r.label, revenue: r.revenueMinor })); - const methodBreakdown = useMemo(() => { - const rowsByMethod = data?.byMethod ?? []; - const total = rowsByMethod.reduce((sum, r) => sum + r.revenueEtbMinor, 0); - return rowsByMethod - .slice() - .sort((a, b) => PAYMENT_METHOD_ORDER.indexOf(a.key as any) - PAYMENT_METHOD_ORDER.indexOf(b.key as any)) - .map((r) => ({ - ...r, - color: categoricalColor(palette, PAYMENT_METHOD_ORDER.indexOf(r.key as any)), - sharePercent: total > 0 ? (r.revenueEtbMinor / total) * 100 : 0, - })); - }, [data, palette]); + const methodRows = data.byMethod.filter((r) => r.currency === t.currency); + const methodTotal = methodRows.reduce((sum, r) => sum + r.revenueMinor, 0); + const methodBreakdown = methodRows + .slice() + .sort((a, b) => PAYMENT_METHOD_ORDER.indexOf(a.label as any) - PAYMENT_METHOD_ORDER.indexOf(b.label as any)) + .map((r) => ({ + ...r, + color: categoricalColor(palette, PAYMENT_METHOD_ORDER.indexOf(r.label as any)), + sharePercent: methodTotal > 0 ? (r.revenueMinor / methodTotal) * 100 : 0, + })); - const totals = data?.totals; - const hasData = (totals?.bookingCount ?? 0) > 0; - const avgPerBookingMinor = hasData ? Math.round(totals!.revenueEtbMinor / totals!.bookingCount) : 0; + return { + currency: t.currency, + revenueMinor: t.revenueMinor, + bookingCount: t.bookingCount, + trendData, + segmentData, + methodBreakdown, + }; + }); + }, [data, totals, palette]); return (
@@ -357,18 +376,25 @@ export default function FinanceReportPage() {
) : (
- {/* KPI tiles */} -
-
-
-

Revenue

+ {/* Revenue by currency — never summed across currencies, so a Waafi/DJF total and an + ETB total each get their own row instead of one converted figure. */} +
+
+
+

Revenue by Currency

-

- {formatCurrency(totals!.revenueEtbMinor, 'ETB')} -

+ {totals.map((t) => ( +
+ {t.currency} + + {formatCurrency(t.revenueMinor, t.currency)} + {t.bookingCount.toLocaleString()} bookings + +
+ ))}
@@ -377,104 +403,116 @@ export default function FinanceReportPage() {
-

{totals!.bookingCount.toLocaleString()}

+

{totalBookings.toLocaleString()}

+

+ Across {totals.length} currenc{totals.length === 1 ? 'y' : 'ies'} +

-
-
-

Avg. per Booking

-
- +
+ + {/* One full Trend + Segment + Method set per currency — never scoped to a single + "dominant" currency, so a currency-specific method like Waafi/DJF always shows + its own numbers instead of being dropped in favor of whichever currency is + biggest overall. */} + {currencySections.map((section) => ( +
+
+

{section.currency}

+ + {formatCurrency(section.revenueMinor, section.currency)} · {section.bookingCount.toLocaleString()} bookings + +
+ +
+
+

+ Revenue Trend ({section.currency}, {granularity}) +

+ {section.trendData.length > 0 ? ( + + + + + + `${section.currency} ${Math.round(value).toLocaleString()}`} /> + + + + ) : ( +
No data for selected range
+ )} +
+ +
+

+ Revenue by Segment ({section.currency}) +

+ {section.segmentData.length > 0 ? ( + + + + + + `${section.currency} ${Math.round(value).toLocaleString()}`} /> + + + + ) : ( +
No data for selected range
+ )}
-

{formatCurrency(avgPerBookingMinor, 'ETB')}

-
-
- {/* Trend + route charts */} -
-
-

- Revenue Trend (ETB, {granularity}) -

- {trendData.length > 0 ? ( - - - - - - `ETB ${Math.round(value).toLocaleString()}`} /> - - - - ) : ( -
No data for selected range
- )} -
- -
-

Revenue by Segment

- {segmentData.length > 0 ? ( - - - - - - `ETB ${Math.round(value).toLocaleString()}`} /> - - - - ) : ( -
No data for selected range
- )} -
-
- - {/* Payment method breakdown — part-to-whole stacked bar + legend table */} -
-

Revenue by Payment Method

-

Share of revenue, in ETB

-
`${methodLabel(m.key)} ${m.sharePercent.toFixed(0)}%`) - .join(', ')}`} - > - {methodBreakdown.map((m, i) => ( + {/* Payment method breakdown — part-to-whole stacked bar + legend table */} +
+

+ Revenue by Payment Method ({section.currency}) +

+

Share of {section.currency} revenue by method

- ))} + className="flex w-full h-7 rounded-md overflow-hidden" + role="img" + aria-label={`${section.currency} revenue by payment method: ${section.methodBreakdown + .map((m) => `${methodLabel(m.label)} ${m.sharePercent.toFixed(0)}%`) + .join(', ')}`} + > + {section.methodBreakdown.map((m, i) => ( +
+ ))} +
+ + + + + + + + + + + {section.methodBreakdown.map((m) => ( + + + + + + + ))} + +
MethodBookingsShareRevenue
+ + + {m.bookingCount.toLocaleString()}{m.sharePercent.toFixed(1)}%{formatCurrency(m.revenueMinor, m.currency)}
+
- - - - - - - - - - - {methodBreakdown.map((m) => ( - - - - - - - ))} - -
MethodBookingsShareRevenue
- - - {m.bookingCount.toLocaleString()}{m.sharePercent.toFixed(1)}%{formatCurrency(m.revenueEtbMinor, 'ETB')}
-
+ ))} {/* Detail table */}
@@ -487,7 +525,7 @@ export default function FinanceReportPage() { - {['Period', 'Segment', 'Method', 'Bookings', 'Revenue'].map((h) => ( + {['Period', 'Segment', 'Method', 'Currency', 'Bookings', 'Revenue'].map((h) => ( @@ -500,13 +538,14 @@ export default function FinanceReportPage() { + - + ))} {pg.paged.length === 0 && ( - + )} diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/finance.ts b/apps/edr-passenger-web/backoffice/src/lib/api/finance.ts index d99aaeae2..fa0bc943d 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/finance.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/finance.ts @@ -17,28 +17,26 @@ export interface FinanceBucketRow { destinationStationId: string; segmentLabel: string; method: string; + currency: string; bookingCount: number; - revenueEtbMinor: number; + revenueMinor: number; } +/** One roll-up entry. Amounts are never mixed across currencies — `currency` names which one this row is in. */ export interface FinanceRollupRow { key: string; label: string; - revenueEtbMinor: number; + currency: string; + revenueMinor: number; bookingCount: number; } -export interface FinanceTotals { - bookingCount: number; - revenueEtbMinor: number; -} - export interface FinanceSummaryReport { granularity: FinanceGranularity; dateFrom: string; dateTo: string; - currency: string; - totals: FinanceTotals; + /** Grand totals, one entry per currency present — never summed across currencies. */ + totals: FinanceRollupRow[]; byPeriod: FinanceRollupRow[]; bySegment: FinanceRollupRow[]; byMethod: FinanceRollupRow[]; diff --git a/apps/edr-passenger-web/backoffice/src/lib/export/finance-workbook.ts b/apps/edr-passenger-web/backoffice/src/lib/export/finance-workbook.ts index da8f172be..066d6b355 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/export/finance-workbook.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/export/finance-workbook.ts @@ -12,7 +12,6 @@ const ROW_ALT = 'FFF7F8F7'; const BORDER = 'FFE2E5E1'; const WHITE = 'FFFFFFFF'; -const CURRENCY_FMT = '"ETB" #,##0.00'; const THIN_BORDER: Partial = { top: { style: 'thin', color: { argb: BORDER } }, left: { style: 'thin', color: { argb: BORDER } }, @@ -20,6 +19,11 @@ const THIN_BORDER: Partial = { right: { style: 'thin', color: { argb: BORDER } }, }; +/** Amounts are never converted between currencies, so every number format names its own currency. */ +function currencyFmt(currency: string): string { + return `"${currency}" #,##0.00`; +} + export interface ChartImage { dataUrl: string; width: number; @@ -31,7 +35,8 @@ export interface FinanceWorkbookInput { filters: { dateFrom: string; dateTo: string; granularity: FinanceGranularity; originLabel: string; destinationLabel: string; methodLabel: string }; methodLabel: (method: string) => string; periodLabel: (period: string, granularity: FinanceGranularity) => string; - images: { trend?: ChartImage; segment?: ChartImage; method?: ChartImage }; + /** One Trend/Segment/Method image set per currency present — mirrors the on-screen per-currency sections. */ + imagesByCurrency: Record; } function styleHeaderCell(cell: ExcelJS.Cell) { @@ -110,6 +115,18 @@ function kpiCard(ws: ExcelJS.Worksheet, startRow: number, startCol: number, span ws.getRow(startRow + 1).height = 26; } +/** Lays out KPI cards three to a row (each spanning 2 of 6 columns). Returns the next free row. */ +function kpiRow(ws: ExcelJS.Worksheet, startRow: number, cards: { label: string; value: string; accent: string }[]): number { + const perRow = 3; + let row = startRow; + for (let i = 0; i < cards.length; i += perRow) { + const rowCards = cards.slice(i, i + perRow); + rowCards.forEach((c, idx) => kpiCard(ws, row, 1 + idx * 2, 2, c.label, c.value, c.accent)); + row += 3; + } + return row; +} + function addImage(wb: ExcelJS.Workbook, ws: ExcelJS.Worksheet, image: ChartImage | undefined, anchorRow: number, heading: string) { const headingCell = ws.getCell(anchorRow, 1); headingCell.value = heading; @@ -140,10 +157,13 @@ function addImage(wb: ExcelJS.Workbook, ws: ExcelJS.Worksheet, image: ChartImage } export async function buildFinanceWorkbook(input: FinanceWorkbookInput): Promise { - const { report, filters, images } = input; + const { report, filters, imagesByCurrency } = input; const methodLabel = input.methodLabel; const periodLabel = input.periodLabel; + const totals = [...report.totals].sort((a, b) => b.revenueMinor - a.revenueMinor); + const totalBookings = totals.reduce((sum, t) => sum + t.bookingCount, 0); + const wb = new ExcelJS.Workbook(); wb.creator = 'EDR Passenger Backoffice'; wb.created = new Date(); @@ -159,86 +179,105 @@ export async function buildFinanceWorkbook(input: FinanceWorkbookInput): Promise 6, ); - const avgPerBooking = report.totals.bookingCount > 0 ? report.totals.revenueEtbMinor / report.totals.bookingCount : 0; - kpiCard(summary, 4, 1, 2, 'Total Revenue', `ETB ${(report.totals.revenueEtbMinor / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}`, BRAND_DARK); - kpiCard(summary, 4, 3, 2, 'Bookings', report.totals.bookingCount.toLocaleString('en-US'), INK); - kpiCard(summary, 4, 5, 2, 'Avg. per Booking', `ETB ${(avgPerBooking / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}`, INK); + // Amounts are never converted between currencies — each currency present gets its own + // card, exactly like the on-screen "Revenue by Currency" breakdown. + const revenueCards = totals.map((t) => ({ + label: `Revenue (${t.currency})`, + value: `${t.currency} ${(t.revenueMinor / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}`, + accent: BRAND_DARK, + })); + const cursorAfterKpis = kpiRow(summary, 4, [ + { label: 'Bookings', value: totalBookings.toLocaleString('en-US'), accent: INK }, + ...revenueCards, + ]); - let cursor = 7; - cursor = addImage(wb, summary, images.trend, cursor, 'Revenue Trend') + 1; - cursor = addImage(wb, summary, images.segment, cursor, 'Revenue by Segment') + 1; - addImage(wb, summary, images.method, cursor, 'Revenue by Payment Method'); + // One Trend/Segment/Method chart set per currency, largest currency first — mirrors the + // on-screen layout so no currency's payment-method breakdown gets dropped from the file. + let cursor = cursorAfterKpis + 1; + for (const t of totals) { + const images = imagesByCurrency[t.currency] ?? {}; + cursor = addImage(wb, summary, images.trend, cursor, `Revenue Trend (${t.currency})`) + 1; + cursor = addImage(wb, summary, images.segment, cursor, `Revenue by Segment (${t.currency})`) + 1; + cursor = addImage(wb, summary, images.method, cursor, `Revenue by Payment Method (${t.currency})`) + 1; + } // ── By Period sheet ────────────────────────────────────────────────────── const byPeriod = wb.addWorksheet('By Period', { views: [{ state: 'frozen', ySplit: 1 }] }); - byPeriod.columns = [{ width: 18 }, { width: 14 }, { width: 20 }]; - addTableHeader(byPeriod, 1, ['Period', 'Bookings', 'Revenue (ETB)'], new Set([1, 2])); + byPeriod.columns = [{ width: 18 }, { width: 12 }, { width: 14 }, { width: 20 }]; + addTableHeader(byPeriod, 1, ['Period', 'Currency', 'Bookings', 'Revenue'], new Set([1, 2])); const periodRows = [...report.byPeriod].sort((a, b) => a.key.localeCompare(b.key)); periodRows.forEach((p, i) => { const r = byPeriod.getRow(i + 2); - r.getCell(1).value = periodLabel(p.key, report.granularity); - r.getCell(2).value = p.bookingCount; - r.getCell(2).alignment = { horizontal: 'right' }; - r.getCell(3).value = p.revenueEtbMinor / 100; - r.getCell(3).numFmt = CURRENCY_FMT; + r.getCell(1).value = periodLabel(p.label, report.granularity); + r.getCell(2).value = p.currency; + r.getCell(3).value = p.bookingCount; r.getCell(3).alignment = { horizontal: 'right' }; - bandRow(byPeriod, i + 2, 3, i % 2 === 1); + r.getCell(4).value = p.revenueMinor / 100; + r.getCell(4).numFmt = currencyFmt(p.currency); + r.getCell(4).alignment = { horizontal: 'right' }; + bandRow(byPeriod, i + 2, 4, i % 2 === 1); }); - byPeriod.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: 3 } }; + byPeriod.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: 4 } }; // ── By Segment sheet ───────────────────────────────────────────────────── const bySegment = wb.addWorksheet('By Segment', { views: [{ state: 'frozen', ySplit: 1 }] }); - bySegment.columns = [{ width: 34 }, { width: 14 }, { width: 20 }]; - addTableHeader(bySegment, 1, ['Origin → Destination', 'Bookings', 'Revenue (ETB)'], new Set([1, 2])); + bySegment.columns = [{ width: 34 }, { width: 12 }, { width: 14 }, { width: 20 }]; + addTableHeader(bySegment, 1, ['Origin → Destination', 'Currency', 'Bookings', 'Revenue'], new Set([1, 2])); report.bySegment.forEach((s, i) => { const r = bySegment.getRow(i + 2); r.getCell(1).value = s.label; - r.getCell(2).value = s.bookingCount; - r.getCell(2).alignment = { horizontal: 'right' }; - r.getCell(3).value = s.revenueEtbMinor / 100; - r.getCell(3).numFmt = CURRENCY_FMT; + r.getCell(2).value = s.currency; + r.getCell(3).value = s.bookingCount; r.getCell(3).alignment = { horizontal: 'right' }; - bandRow(bySegment, i + 2, 3, i % 2 === 1); + r.getCell(4).value = s.revenueMinor / 100; + r.getCell(4).numFmt = currencyFmt(s.currency); + r.getCell(4).alignment = { horizontal: 'right' }; + bandRow(bySegment, i + 2, 4, i % 2 === 1); }); - bySegment.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: 3 } }; + bySegment.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: 4 } }; // ── By Method sheet ────────────────────────────────────────────────────── + // Share is computed against the grand total for that same currency (`totals`), never + // against a sum spanning multiple currencies. const byMethod = wb.addWorksheet('By Method', { views: [{ state: 'frozen', ySplit: 1 }] }); - byMethod.columns = [{ width: 20 }, { width: 14 }, { width: 20 }, { width: 12 }]; - addTableHeader(byMethod, 1, ['Payment Method', 'Bookings', 'Revenue (ETB)', 'Share'], new Set([1, 2, 3])); - const methodTotal = report.byMethod.reduce((sum, m) => sum + m.revenueEtbMinor, 0); + byMethod.columns = [{ width: 20 }, { width: 12 }, { width: 14 }, { width: 20 }, { width: 12 }]; + addTableHeader(byMethod, 1, ['Payment Method', 'Currency', 'Bookings', 'Revenue', 'Share'], new Set([1, 2, 3])); + const totalByCurrency = new Map(totals.map((t) => [t.currency, t.revenueMinor])); report.byMethod.forEach((m, i) => { const r = byMethod.getRow(i + 2); - r.getCell(1).value = methodLabel(m.key); - r.getCell(2).value = m.bookingCount; - r.getCell(2).alignment = { horizontal: 'right' }; - r.getCell(3).value = m.revenueEtbMinor / 100; - r.getCell(3).numFmt = CURRENCY_FMT; + const currencyTotal = totalByCurrency.get(m.currency) ?? 0; + r.getCell(1).value = methodLabel(m.label); + r.getCell(2).value = m.currency; + r.getCell(3).value = m.bookingCount; r.getCell(3).alignment = { horizontal: 'right' }; - r.getCell(4).value = methodTotal > 0 ? m.revenueEtbMinor / methodTotal : 0; - r.getCell(4).numFmt = '0.0%'; + r.getCell(4).value = m.revenueMinor / 100; + r.getCell(4).numFmt = currencyFmt(m.currency); r.getCell(4).alignment = { horizontal: 'right' }; - bandRow(byMethod, i + 2, 4, i % 2 === 1); + r.getCell(5).value = currencyTotal > 0 ? m.revenueMinor / currencyTotal : 0; + r.getCell(5).numFmt = '0.0%'; + r.getCell(5).alignment = { horizontal: 'right' }; + bandRow(byMethod, i + 2, 5, i % 2 === 1); }); - byMethod.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: 4 } }; + byMethod.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: 5 } }; // ── Detail sheet — every row, unpaginated ─────────────────────────────── const detail = wb.addWorksheet('Detail', { views: [{ state: 'frozen', ySplit: 1 }] }); - detail.columns = [{ width: 18 }, { width: 34 }, { width: 18 }, { width: 14 }, { width: 20 }]; - addTableHeader(detail, 1, ['Period', 'Origin → Destination', 'Payment Method', 'Bookings', 'Revenue (ETB)'], new Set([2, 3])); + detail.columns = [{ width: 18 }, { width: 34 }, { width: 18 }, { width: 12 }, { width: 14 }, { width: 20 }]; + addTableHeader(detail, 1, ['Period', 'Origin → Destination', 'Payment Method', 'Currency', 'Bookings', 'Revenue'], new Set([2, 3])); report.rows.forEach((row, i) => { const r = detail.getRow(i + 2); r.getCell(1).value = periodLabel(row.period, report.granularity); r.getCell(2).value = row.segmentLabel; r.getCell(3).value = methodLabel(row.method); - r.getCell(4).value = row.bookingCount; - r.getCell(4).alignment = { horizontal: 'right' }; - r.getCell(5).value = row.revenueEtbMinor / 100; - r.getCell(5).numFmt = CURRENCY_FMT; + r.getCell(4).value = row.currency; + r.getCell(5).value = row.bookingCount; r.getCell(5).alignment = { horizontal: 'right' }; - bandRow(detail, i + 2, 5, i % 2 === 1); + r.getCell(6).value = row.revenueMinor / 100; + r.getCell(6).numFmt = currencyFmt(row.currency); + r.getCell(6).alignment = { horizontal: 'right' }; + bandRow(detail, i + 2, 6, i % 2 === 1); }); - detail.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: 5 } }; + detail.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: 6 } }; const buffer = await wb.xlsx.writeBuffer(); return new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); 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 60cd350a2..722ee75e2 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 @@ -3,6 +3,7 @@ import { useForm, useFieldArray } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; +import { useQuery } from '@tanstack/react-query'; import { useRouter } from 'next/navigation'; import { useBookingStore } from '@/lib/booking-store'; import { useAuthStore } from '@/lib/auth-store'; @@ -614,35 +615,9 @@ const passengerSchema = z.object({ if (data.gender !== 'Male' && data.gender !== 'Female') { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Gender is required', path: ['gender'] }); } - const isNonEthiopian = data.nationality !== 'ETHIOPIAN' && data.nationality !== 'Ethiopian'; - if (isNonEthiopian) { - const passportNum = data.passportNumber?.trim() ?? ''; - if (!passportNum) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Passport number is required', path: ['passportNumber'] }); - } else if (/[^A-Za-z0-9]/.test(passportNum)) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Passport number must not contain special characters', path: ['passportNumber'] }); - } else if (passportNum.length < 6 || passportNum.length > 12) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Passport number must be between 6 and 12 characters', path: ['passportNumber'] }); - } - if (!data.passportCountry || data.passportCountry.trim().length === 0) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Issuing country is required', path: ['passportCountry'] }); - } - if (data.passportIssueDate) { - const issue = new Date(data.passportIssueDate); - if (!isNaN(issue.getTime()) && issue > new Date()) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Passport issue date cannot be in the future', path: ['passportIssueDate'] }); - } - } - if (data.passportExpiryDate) { - const expiry = new Date(data.passportExpiryDate); - if (!isNaN(expiry.getTime()) && expiry <= new Date()) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Passport expiry date must be in the future', path: ['passportExpiryDate'] }); - } - } - } }); -function createFormSchema(adultCount: number) { +function createFormSchema(adultCount: number, isOriginOutsideEthiopia: boolean) { return z.object({ passengers: z.array(passengerSchema), createAccount: z.boolean(), @@ -660,6 +635,9 @@ function createFormSchema(adultCount: number) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid email format', path: ['passengers', i, 'email'] }); } } + // Phone format stays scoped to the passenger's actual nationality regardless of the + // passport-flow override below — an Ethiopian is still validated against Ethiopian + // number ranges even when their origin station forces the foreigner document flow. const phoneError = validatePhone(p.phone, p.nationality); if (phoneError) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: phoneError, path: ['passengers', i, 'phone'] }); @@ -667,11 +645,44 @@ function createFormSchema(adultCount: number) { } const age = calculateAge(p.dateOfBirth); - if (age === null) return; - if (isAdult && age <= 5) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Adult passengers must be older than 5 years', path: ['passengers', i, 'dateOfBirth'] }); - } else if (!isAdult && age > 5) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Child passengers must be 5 years old or younger', path: ['passengers', i, 'dateOfBirth'] }); + if (age !== null) { + if (isAdult && age <= 5) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Adult passengers must be older than 5 years', path: ['passengers', i, 'dateOfBirth'] }); + } else if (!isAdult && age > 5) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Child passengers must be 5 years old or younger', path: ['passengers', i, 'dateOfBirth'] }); + } + } + + // Fayda's SMS-based OTP only reaches Ethiopian phone numbers inside Ethiopia, so a + // passenger boarding from a station outside Ethiopia can't complete it even when their + // nationality is Ethiopian — they (like any genuinely non-Ethiopian national) fall back + // to the same passport document requirements as a foreigner. Nationality and phone + // validation are unaffected by this — only the identity-document requirement changes. + const isNonEthiopianNationality = p.nationality !== 'ETHIOPIAN' && p.nationality !== 'Ethiopian'; + if (isNonEthiopianNationality || isOriginOutsideEthiopia) { + const passportNum = p.passportNumber?.trim() ?? ''; + if (!passportNum) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Passport number is required', path: ['passengers', i, 'passportNumber'] }); + } else if (/[^A-Za-z0-9]/.test(passportNum)) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Passport number must not contain special characters', path: ['passengers', i, 'passportNumber'] }); + } else if (passportNum.length < 6 || passportNum.length > 12) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Passport number must be between 6 and 12 characters', path: ['passengers', i, 'passportNumber'] }); + } + if (!p.passportCountry || p.passportCountry.trim().length === 0) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Issuing country is required', path: ['passengers', i, 'passportCountry'] }); + } + if (p.passportIssueDate) { + const issue = new Date(p.passportIssueDate); + if (!isNaN(issue.getTime()) && issue > new Date()) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Passport issue date cannot be in the future', path: ['passengers', i, 'passportIssueDate'] }); + } + } + if (p.passportExpiryDate) { + const expiry = new Date(p.passportExpiryDate); + if (!isNaN(expiry.getTime()) && expiry <= new Date()) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Passport expiry date must be in the future', path: ['passengers', i, 'passportExpiryDate'] }); + } + } } }); }); @@ -684,6 +695,16 @@ function PassengersForm() { const { searchCriteria, passengers: storedPassengers, setPassengers, setCreateAccount, packageId } = useBookingStore(); const { user, isAuthenticated, updateUser } = useAuthStore(); const isInitialized = useAuthStore((s) => s.isInitialized); + // Fayda's SMS-based OTP is Ethiopia-only — a passenger boarding from a station outside + // Ethiopia can't receive it, so they need the passport flow below even if their nationality + // is Ethiopian. Looked up by ID rather than trusting a `country`/`countryCode` field carried + // on searchCriteria, since the origin station isn't otherwise threaded through this store. + const { data: originStation, isLoading: isOriginStationLoading } = useQuery({ + queryKey: ['station', searchCriteria?.originStationId], + queryFn: () => apiClient.get<{ id: string; countryCode?: string }>(`/stations/${searchCriteria!.originStationId}`), + enabled: !!searchCriteria?.originStationId, + }); + const isOriginOutsideEthiopia = !!originStation?.countryCode && originStation.countryCode !== 'ET'; const [faydaEnabled, setFaydaEnabled] = useState(true); // "Skip for now" (bypasses Fayda verification) is only offered on local dev and the // staging/test domain — never on an unrecognized host, which would include production. @@ -715,7 +736,7 @@ function PassengersForm() { const adultCount = searchCriteria?.adultCount || 1; const { register, control, handleSubmit, setValue, watch, formState: { errors } } = useForm({ - resolver: zodResolver(createFormSchema(adultCount) as any), + resolver: zodResolver(createFormSchema(adultCount, isOriginOutsideEthiopia) as any), mode: 'onChange', defaultValues: { passengers: Array.from({ length: totalPassengers }, (_, i) => { @@ -931,6 +952,10 @@ function PassengersForm() { setFormInitialized(true); return; } + // Whether the Fayda gate applies below depends on the origin station's country — wait for + // that lookup to settle instead of gating on a stale "inside Ethiopia" default, which + // would flash the wrong screen for an Ethiopian departing from outside Ethiopia. + if (isOriginStationLoading) return; try { // Fetch passenger profile from backend. This may be null (e.g. no Passenger row linked @@ -953,8 +978,9 @@ function PassengersForm() { // A logged-in but NOT Fayda-verified Ethiopian must pass the Fayda gate exactly like a // guest. Prefilling their identity and expanding the form would let them submit the // booking without ever verifying — only a verified passenger may pass. When Fayda is - // globally disabled there is no gate, so the restriction doesn't apply. - const mustVerifyFayda = isEthiopian && !isVerified && faydaEnabled; + // globally disabled, or the origin station is outside Ethiopia (SMS OTP won't reach + // them), there is no gate, so the restriction doesn't apply. + const mustVerifyFayda = isEthiopian && !isVerified && faydaEnabled && !isOriginOutsideEthiopia; // Nationality + contact aren't identity-verifying, so they're safe to prefill either way. setValue('passengers.0.nationality', nationality); @@ -996,7 +1022,7 @@ function PassengersForm() { }; populateForm(); - }, [isInitialized, isAuthenticated, user, searchCriteria, setValue]); + }, [isInitialized, isAuthenticated, user, searchCriteria, setValue, isOriginStationLoading, isOriginOutsideEthiopia]); const openFaydaVerification = async (index: number) => { if (typeof window === 'undefined') return; @@ -1047,7 +1073,7 @@ function PassengersForm() { const isEthiopian = p?.nationality === 'ETHIOPIAN'; const isChildPassenger = i >= adultCount; const isLoggedInAndVerified = i === 0 && isAuthenticated && user?.faydaVerified; - return isEthiopian && faydaEnabled && !p?.formExpanded && !isLoggedInAndVerified && !isChildPassenger; + return isEthiopian && faydaEnabled && !isOriginOutsideEthiopia && !p?.formExpanded && !isLoggedInAndVerified && !isChildPassenger; }); setSubmitError( needsFaydaVerification @@ -1145,14 +1171,18 @@ function PassengersForm() {
{fields.map((field, index) => { const isEthiopian = passengers[index]?.nationality === 'ETHIOPIAN'; + // Fayda only applies to an Ethiopian national whose origin station is inside + // Ethiopia — outside it, the SMS OTP never arrives, so they go through the same + // passport flow as a foreigner (nationality/phone stay Ethiopian regardless). + const eligibleForFayda = isEthiopian && !isOriginOutsideEthiopia; const isFormExpanded = passengers[index]?.formExpanded; const status = verificationStatus[index]; const isPrimaryPassenger = index === 0; const isChildPassenger = index >= adultCount; const isLoggedInAndVerified = isPrimaryPassenger && isAuthenticated && user?.faydaVerified; const isLoggedInNotVerified = isPrimaryPassenger && isAuthenticated && !user?.faydaVerified; - const showVerifyButton = isEthiopian && faydaEnabled && !isFormExpanded && !isLoggedInAndVerified && !isChildPassenger; - const showManualEntryLink = isEthiopian && !faydaEnabled && !isFormExpanded && !isChildPassenger; + const showVerifyButton = eligibleForFayda && faydaEnabled && !isFormExpanded && !isLoggedInAndVerified && !isChildPassenger; + const showManualEntryLink = eligibleForFayda && !faydaEnabled && !isFormExpanded && !isChildPassenger; const isVerifyingThis = verifyingIndex === index; const isVerifyingOther = verifyingIndex !== null && verifyingIndex !== index; const faydaError = faydaErrors[index]; @@ -1249,7 +1279,7 @@ function PassengersForm() { ) : (
- {isEthiopian ? ( + {eligibleForFayda ? ( <> {status === 'success' && (
diff --git a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx index 69d3859cf..bb4e2cf12 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx @@ -612,8 +612,15 @@ export default function SearchPage() { queryKey: ["stations"], // Bounded so a stalled request surfaces the "Unable to load stations" // error below instead of leaving the widget stuck loading indefinitely. - queryFn: async () => - (await apiClient.get("/stations", { timeout: 8000 })) as Station[], + queryFn: async () => { + const res = await apiClient.get("/stations", { + timeout: 8000, + }); + // apiClient unwraps `{ success, data }` envelopes, but guard against an + // unexpected non-array payload so downstream .find()/.filter() calls + // (here and in every StationSelector this list is passed to) never throw. + return Array.isArray(res) ? res : []; + }, }); const { diff --git a/apps/edr-passenger-web/portal/src/components/PackagesSection.tsx b/apps/edr-passenger-web/portal/src/components/PackagesSection.tsx index 2fdd50518..408dd3f56 100644 --- a/apps/edr-passenger-web/portal/src/components/PackagesSection.tsx +++ b/apps/edr-passenger-web/portal/src/components/PackagesSection.tsx @@ -209,7 +209,7 @@ function FeaturedCard({ pkg }: { pkg: HolidayPackage }) { {/* Name */}

- {pkg.name.trim()} + {pkg.name?.trim()}

{/* Route */} @@ -217,11 +217,11 @@ function FeaturedCard({ pkg }: { pkg: HolidayPackage }) {
- {origin.name.trim()} + {origin.name?.trim()} - {dest.name.trim()} + {dest.name?.trim()} {pkg.busTransferIncluded && ( <> @@ -353,16 +353,16 @@ function PackageCard({ pkg }: { pkg: HolidayPackage }) { {/* Content */}

- {pkg.name.trim()} + {pkg.name?.trim()}

{/* Route */} {origin && dest && (
- {origin.name.trim()} + {origin.name?.trim()} - {dest.name.trim()} + {dest.name?.trim()}
)}
{h} {periodLabel(r.period, granularity)} {r.segmentLabel} {methodLabel(r.method)}{r.currency} {r.bookingCount.toLocaleString()}{formatCurrency(r.revenueEtbMinor, 'ETB')}{formatCurrency(r.revenueMinor, r.currency)}
No rows on this pageNo rows on this page