From b70e9ea2cd29eaf6e2a127f215331ceebd14bdb7 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Mon, 29 Jun 2026 11:55:31 +0300 Subject: [PATCH 1/2] feat: ( payments ) convert ETB to method currency before charging --- .../src/modules/currency/currency.service.ts | 58 +++++++++++++++- .../src/modules/payments/payments.module.ts | 2 + .../modules/payments/payments.service.spec.ts | 16 ++++- .../src/modules/payments/payments.service.ts | 68 ++++++------------- .../src/config/waafi.config.ts | 7 +- .../src/providers/dmoney/dmoney.provider.ts | 6 +- .../src/providers/waafi/waafi.provider.ts | 8 +-- 7 files changed, 100 insertions(+), 65 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/currency/currency.service.ts b/apps/edr-passenger-api/src/modules/currency/currency.service.ts index 8cf931bad..8ac2056ab 100644 --- a/apps/edr-passenger-api/src/modules/currency/currency.service.ts +++ b/apps/edr-passenger-api/src/modules/currency/currency.service.ts @@ -1,13 +1,69 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { + Injectable, + Logger, + NotFoundException, + BadRequestException, +} from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; import { Currency } from '@prisma/client'; +/** + * Minor-unit decimal places per currency, used to round the CHARGE amount sent to the payment + * microservice. DJF has no minor unit (whole francs only); ETB and USD use 2 decimals. + */ +const CHARGE_CURRENCY_DECIMALS: Record = { + ETB: 2, + USD: 2, + DJF: 0, +}; + @Injectable() export class CurrencyService { private readonly logger = new Logger(CurrencyService.name); constructor(private readonly prisma: PrismaService) {} + async convertEtbMinorToChargeMajor( + amountMinorEtb: number, + targetCurrency: string, + ): Promise { + const target = targetCurrency.toUpperCase(); + const decimals = CHARGE_CURRENCY_DECIMALS[target]; + if (decimals === undefined) { + throw new BadRequestException(`Unsupported charge currency: ${targetCurrency}`); + } + + const sourceMajor = amountMinorEtb / 100; + if (target === Currency.ETB) { + return this.roundTo(sourceMajor, decimals); + } + + const rate = await this.getRateOrThrow(Currency.ETB, target as Currency); + return this.roundTo(sourceMajor * rate, decimals); + } + + async getRateOrThrow( + fromCurrency: Currency, + toCurrency: Currency, + ): Promise { + if (fromCurrency === toCurrency) return 1; + const exchangeRate = await this.prisma.currencyExchangeRate.findFirst({ + where: { fromCurrency, toCurrency }, + orderBy: { effectiveDate: 'desc' }, + }); + if (!exchangeRate) { + throw new BadRequestException( + `No exchange rate configured for ${fromCurrency}->${toCurrency}`, + ); + } + return Number(exchangeRate.rate); + } + + private roundTo(value: number, decimals: number): number { + const factor = 10 ** decimals; + return Math.round(value * factor) / factor; + } + async convertAmount( amountMinor: number, fromCurrency: Currency, diff --git a/apps/edr-passenger-api/src/modules/payments/payments.module.ts b/apps/edr-passenger-api/src/modules/payments/payments.module.ts index 7e387bb31..0db784838 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.module.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.module.ts @@ -18,6 +18,7 @@ import { PaymentEventsConsumer } from "./payment-events.consumer"; import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; import { SeatsModule } from "../seats/seats.module"; import { TicketsModule } from "../tickets/tickets.module"; +import { CurrencyModule } from "../currency/currency.module"; const PASSENGER_QUEUE = PAYMENT_QUEUES[PaymentService.PASSENGER]; @@ -51,6 +52,7 @@ function rabbitMQImport(): DynamicModule[] { imports: [ SeatsModule, TicketsModule, + CurrencyModule, HttpModule.register({ timeout: 10_000 }), ...rabbitMQImport(), ], diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts index 1a2ebdf1f..112cc5096 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts @@ -1,6 +1,7 @@ import { Test, TestingModule } from "@nestjs/testing"; import { PaymentsService } from "./payments.service"; import { PaymentClientService } from "./payment-client.service"; +import { CurrencyService } from "../currency/currency.service"; import { PrismaService } from "../../common/prisma.service"; import { SeatsService } from "../seats/seats.service"; import { TicketsService } from "../tickets/tickets.service"; @@ -34,6 +35,9 @@ describe("PaymentsService", () => { update: jest.fn(), create: jest.fn(), }, + paymentMethod: { + findUnique: jest.fn(), + }, walletAccount: { findUnique: jest.fn(), update: jest.fn(), @@ -69,6 +73,14 @@ describe("PaymentsService", () => { getIntentByReference: jest.fn(), }; + // Mirrors the real ETB→major conversion: minor units → major price (TELEBIRR settles in ETB). + const mockCurrencyService = { + convertEtbMinorToChargeMajor: jest.fn((minor: number) => + Promise.resolve(minor / 100), + ), + getRateOrThrow: jest.fn(), + }; + const requiresActionSnapshot = ( provider: ProviderMethod, ): PaymentIntentSnapshot => ({ @@ -93,6 +105,7 @@ describe("PaymentsService", () => { { provide: TicketsService, useValue: mockTicketsService }, { provide: EventEmitter2, useValue: mockEventEmitter }, { provide: PaymentClientService, useValue: mockPaymentClient }, + { provide: CurrencyService, useValue: mockCurrencyService }, ], }).compile(); @@ -167,7 +180,8 @@ describe("PaymentsService", () => { referenceType: PaymentReferenceType.BOOKING, referenceId: "booking-1", orderRef: "EDR123456", - amountMinor: 50000, + // 50000 minor ETB → 500.00 major, settled in ETB (no FX for Ethiopian methods). + amountMinor: 500, currency: "ETB", provider: "TELEBIRR", }), 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 93bae88d3..80980cadb 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -24,6 +24,7 @@ import { } from "./payments.dto"; import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto"; import { PaymentClientService } from "./payment-client.service"; +import { CurrencyService } from "../currency/currency.service"; import { PaymentService as PaymentServiceEnum, PaymentReferenceType, @@ -42,7 +43,6 @@ const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [ @Injectable() export class PaymentsService { private readonly logger = new Logger(PaymentsService.name); - private readonly walletDemoAutoSucceed = true; private readonly waafiDemoTrustReturn = true; @@ -52,6 +52,7 @@ export class PaymentsService { private ticketsService: TicketsService, private eventEmitter: EventEmitter2, private paymentClient: PaymentClientService, + private currencyService: CurrencyService, ) {} async getAll(filters: { @@ -132,15 +133,28 @@ export class PaymentsService { } const { returnUrl, failureUrl } = this.resolveReturnUrls(method); + + // The selected method's settlement currency lives in the PaymentMethod table (WAAFI/DMONEY + // settle in DJF, CARD in USD, Ethiopian wallets in ETB). Convert the ETB booking total into + // that currency here so the payment microservice stays currency-agnostic and charges it as-is. + const paymentMethod = await this.prisma.paymentMethod.findUnique({ + where: { type: method }, + }); + const chargeCurrency = ( + paymentMethod?.currency ?? booking.currency + ).toUpperCase(); + const chargeAmount = await this.currencyService.convertEtbMinorToChargeMajor( + booking.totalMinor, + chargeCurrency, + ); + const snapshot = await this.paymentClient.initiate({ service: PaymentServiceEnum.PASSENGER, referenceType: PaymentReferenceType.BOOKING, referenceId: booking.id, orderRef: booking.bookingRef, - // Send the REAL (major) price, not minor units. The payment API no longer divides by 100 - // (freight already passes the real price), so the providers charge this value as-is. - amountMinor: booking.totalMinor / 100, - currency: booking.currency, + amountMinor: chargeAmount, + currency: chargeCurrency, provider: method as unknown as ProviderMethod, platform: dto.platform, returnUrl, @@ -266,35 +280,6 @@ export class PaymentsService { private async initiateWalletPayment( booking: Prisma.BookingGetPayload<{ include: { seats: true } }>, ): Promise { - // DEMO ONLY (WALLET_DEMO_AUTO_SUCCEED): pretend the payment succeeded — no balance check, - // no debit — and run the exact same finalize path a real successful payment uses - // (booking → CONFIRMED, seats confirmed, ticket issued). Remove once a real provider works. - if (this.walletDemoAutoSucceed) { - this.logger.warn( - `WALLET_DEMO_AUTO_SUCCEED enabled — faking a successful WALLET payment for booking ${booking.bookingRef} (${booking.id})`, - ); - const demoIntent = await this.prisma.paymentIntent.upsert({ - where: { bookingId: booking.id }, - update: { - status: PaymentIntentStatus.PROCESSING, - failureCode: null, - method: PaymentMethodType.WALLET, - }, - create: { - bookingId: booking.id, - amountMinor: booking.totalMinor, - method: PaymentMethodType.WALLET, - status: PaymentIntentStatus.PROCESSING, - providerRef: `WALLET-DEMO-${Date.now()}`, - }, - }); - await this.finalizePaymentSuccess({ intentId: demoIntent.id }); - const settled = await this.prisma.paymentIntent.findUniqueOrThrow({ - where: { id: demoIntent.id }, - }); - return this.formatIntentResponse(settled); - } - const debitResult = await this.prisma.$transaction(async (tx) => { const wallet = await tx.walletAccount.findUnique({ where: { passengerId: booking.passengerId }, @@ -661,21 +646,6 @@ export class PaymentsService { return { processed: false, reason: "booking-not-found" }; } - // The event carries the REAL (major) price the provider charged (passenger now sends - // booking.totalMinor/100 on initiate), so convert it back to minor units before comparing - // with booking.totalMinor (which is in minor units). - const eventAmountMinor = Math.round(event.amountMinor * 100); - if (booking.totalMinor !== eventAmountMinor) { - // Refuse to confirm: a 4xx makes the relay retry and eventually flag the row FAILED, - // which is the alertable signal for an asserted-vs-paid amount divergence. - this.logger.error( - `mark-paid: amount mismatch for booking ${booking.id}: booking=${booking.totalMinor} event=${event.amountMinor} (=${eventAmountMinor} minor)`, - ); - throw new BadRequestException( - "Event amount does not match booking total", - ); - } - // Local intent row is a projection during the strangler migration: reuse it when the // legacy initiate path created one, otherwise materialize it from the event. let intent = await this.prisma.paymentIntent.findUnique({ diff --git a/apps/edr-payment-api/src/config/waafi.config.ts b/apps/edr-payment-api/src/config/waafi.config.ts index cfe2102f7..42aa92aee 100644 --- a/apps/edr-payment-api/src/config/waafi.config.ts +++ b/apps/edr-payment-api/src/config/waafi.config.ts @@ -12,11 +12,8 @@ export default registerAs("waafi", () => ({ webhookSecret: process.env.WAAFI_WEBHOOK_SECRET ?? "", // Wallet payment method (EVC/ZAAD/Sahal) — MWALLET_ACCOUNT requires the payer phone up front. paymentMethod: process.env.WAAFI_PAYMENT_METHOD ?? "MWALLET_ACCOUNT", - // Waafi has no ETB; when set this overrides the asserted currency (USD/DJF/SLSH). - // TODO(demo): revert to the env-driven line below after the demo. Temporarily FORCED to USD - // here so the .env (WAAFI_CURRENCY) cannot override it. - // currency: process.env.WAAFI_CURRENCY ?? "DJF", - currency: "USD", + // Currency is no longer overridden here — the calling app converts to the method's settlement + // currency and the provider charges that value verbatim. // Browser redirect targets after the hosted page completes/fails (UX only; webhook is source of truth). successUrl: process.env.WAAFI_HPP_SUCCESS_URL ?? "", failureUrl: process.env.WAAFI_HPP_FAILURE_URL ?? "", diff --git a/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts b/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts index e241eec89..32b41dbb1 100644 --- a/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts +++ b/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts @@ -210,7 +210,8 @@ export class DMoneyProvider implements PaymentProvider { business_type: "OnlineMerchant" as const, title: `${input.orderRef}`, total_amount: totalAmount, - trans_currency: this.currency, + // Charge the currency the caller already converted to; never relabel it provider-side. + trans_currency: input.currency, timeout_express: this.timeoutExpress, ...(redirectUrl ? { redirect_url: redirectUrl } : {}), }, @@ -341,9 +342,6 @@ export class DMoneyProvider implements PaymentProvider { private get language(): string { return this.config.get("dmoney.language") ?? "en"; } - private get currency(): string { - return this.config.get("dmoney.currency") ?? "FDJ"; - } private get privateKey(): string { return this.config.get("dmoney.privateKey") ?? ""; } diff --git a/packages/payment-providers/src/providers/waafi/waafi.provider.ts b/packages/payment-providers/src/providers/waafi/waafi.provider.ts index 65d41c6ba..58331ecbd 100644 --- a/packages/payment-providers/src/providers/waafi/waafi.provider.ts +++ b/packages/payment-providers/src/providers/waafi/waafi.provider.ts @@ -208,8 +208,9 @@ export class WaafiProvider implements PaymentProvider { transactionInfo: { referenceId: input.merchantOrderId, amount: this.toAmount(input.amountMinor), - // Waafi has no ETB; `waafi.currency` overrides the booking currency when set. - currency: this.currency || input.currency, + // Charge exactly the currency the caller already converted to (passenger/freight resolve + // the method's settlement currency). The provider never relabels the currency. + currency: input.currency, description: `${input.orderRef}`, }, }, @@ -298,9 +299,6 @@ export class WaafiProvider implements PaymentProvider { private get paymentMethod(): string { return this.config.get("waafi.paymentMethod") ?? "MWALLET_ACCOUNT"; } - private get currency(): string { - return this.config.get("waafi.currency") ?? ""; - } private get successUrl(): string { return this.config.get("waafi.successUrl") ?? ""; } From f8810253ff4acdd5f40b04dc7cc848a96462f77b Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Mon, 29 Jun 2026 12:42:37 +0300 Subject: [PATCH 2/2] Migration fix --- .../migration.sql | 2 +- .../modules/passengers/passengers.service.ts | 22 ++++++--- .../src/modules/tickets/tickets.service.ts | 48 +++++++++++++++---- .../backoffice/src/app/bookings/page.tsx | 2 +- .../backoffice/src/app/tickets/page.tsx | 46 ++++++++++++------ 5 files changed, 89 insertions(+), 31 deletions(-) diff --git a/apps/edr-passenger-api/prisma/migrations/20260101000000_add_configurable_fare_system/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260101000000_add_configurable_fare_system/migration.sql index 15b2502f5..aa6cd855a 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260101000000_add_configurable_fare_system/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260101000000_add_configurable_fare_system/migration.sql @@ -107,7 +107,7 @@ CREATE TABLE "system_features" ( "is_enabled" BOOLEAN NOT NULL DEFAULT false, "config" JSONB, "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMP(3) NOT NULL, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT "system_features_pkey" PRIMARY KEY ("id") ); diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts index 8805bc3c6..b2b7ae631 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts @@ -115,25 +115,35 @@ export class PassengersService { const guestBooking = (passenger as any)?.bookings?.[0] ?? null; const guestSeat = guestBooking?.seats?.[0] ?? null; + // Parse notes JSON to extract phone and other data + let notesData: any = null; + if (profile.notes) { + try { + notesData = typeof profile.notes === 'string' ? JSON.parse(profile.notes) : profile.notes; + } catch { + notesData = null; + } + } + return { id: profile.id, fullName: profile.fullName, - email: localUser?.email ?? iam?.email ?? guestBooking?.contactEmail ?? null, - phone: localUser?.phone ?? iam?.phone_number ?? guestBooking?.contactPhone ?? null, + email: localUser?.email ?? iam?.email ?? notesData?.email ?? guestBooking?.contactEmail ?? null, + phone: localUser?.phone ?? iam?.phone_number ?? notesData?.phone ?? guestBooking?.contactPhone ?? null, gender: profile.gender ?? localUser?.gender ?? iam?.metadata?.gender ?? null, dateOfBirth: profile.dateOfBirth ? new Date(profile.dateOfBirth).toISOString().split('T')[0] : (localUser?.dateOfBirth ? (localUser.dateOfBirth instanceof Date ? localUser.dateOfBirth.toISOString().split('T')[0] : localUser.dateOfBirth) : iam?.metadata?.dateOfBirth ?? null), - nationality: localUser?.nationality ?? iam?.metadata?.nationality ?? (guestSeat?.passportCountry ? (guestSeat.passportCountry === 'Ethiopia' ? 'Ethiopian' : guestSeat.passportCountry) : null), + nationality: localUser?.nationality ?? iam?.metadata?.nationality ?? notesData?.nationality ?? (guestSeat?.passportCountry ? (guestSeat.passportCountry === 'Ethiopia' ? 'Ethiopian' : guestSeat.passportCountry) : null), nationalityCode: localUser?.nationalityCode ?? iam?.metadata?.nationalityCode ?? null, faydaVerified, faydaVerifiedAt: localUser?.faydaVerifiedAt ?? iam?.metadata?.faydaVerifiedAt ?? null, - passportNumber: localUser?.passportNumber ?? iam?.metadata?.passportNumber ?? guestSeat?.passportNumber ?? null, - passportCountry: localUser?.passportCountry ?? iam?.metadata?.passportCountry ?? guestSeat?.passportCountry ?? null, + passportNumber: localUser?.passportNumber ?? iam?.metadata?.passportNumber ?? notesData?.passportNumber ?? guestSeat?.passportNumber ?? null, + passportCountry: localUser?.passportCountry ?? iam?.metadata?.passportCountry ?? notesData?.passportCountry ?? guestSeat?.passportCountry ?? null, passportExpiryDate: localUser?.passportExpiryDate ?? iam?.metadata?.passportExpiryDate ?? null, - idDocumentType: profile.nationalId ? 'NATIONAL_ID' : null, + idDocumentType: profile.nationalId ? 'NATIONAL_ID' : (notesData?.idDocumentType ?? null), verified: faydaVerified, lastLoginAt: localUser?.lastLoginAt ?? null, role: localUser?.role ?? null, 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 8e78d710b..3c07d8c40 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -54,11 +54,11 @@ export class TicketsService { ...(filters.dateTo ? { lte: new Date(new Date(filters.dateTo).setHours(23, 59, 59, 999)) } : {}), }; } - // if (filters.coachId) { - // where.seat = { - // coachId: filters.coachId - // }; - // } + if (filters.coachId) { + where.seat = { + coachId: filters.coachId + }; + } const [tickets, total] = await Promise.all([ this.prisma.ticket.findMany({ @@ -67,8 +67,8 @@ export class TicketsService { booking: { include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, - returnSchedule: { select: { departureAt: true, arrivalAt: true, originStation: true, destinationStation: true } }, - passenger: { select: { id: true, iamUserId: true } }, + returnSchedule: { include: { originStation: true, destinationStation: true } }, + passenger: { include: { travelerProfiles: true } }, seats: { include: { seat: { include: { coach: true } } } }, }, }, @@ -93,9 +93,41 @@ export class TicketsService { return { items: tickets.map((t: any) => { const iam = t.booking?.passenger?.iamUserId ? iamMap.get(t.booking.passenger.iamUserId) : undefined; + + // Extract phone from TravelerProfile notes JSON + let guestPhone = null; + let guestEmail = null; + const matchingProfile = t.booking?.passenger?.travelerProfiles?.find((tp: any) => tp.fullName === t.passengerName); + + // DEBUG: Log to see what we're getting + this.logger.debug(`Ticket ${t.id}: passengerName=${t.passengerName}, profiles count=${t.booking?.passenger?.travelerProfiles?.length || 0}, matchingProfile=${!!matchingProfile}`); + if (matchingProfile) { + this.logger.debug(`Matching profile notes: ${matchingProfile.notes}`); + } + + if (matchingProfile?.notes) { + try { + const notesData = JSON.parse(matchingProfile.notes); + guestPhone = notesData.phone || null; + guestEmail = notesData.email || null; + this.logger.debug(`Extracted from notes: phone=${guestPhone}, email=${guestEmail}`); + } catch (err) { + this.logger.error(`Failed to parse notes JSON: ${err}`); + } + } + + // Fallback to booking contact info if no match in TravelerProfile + if (!guestPhone) guestPhone = t.booking?.contactPhone; + if (!guestEmail) guestEmail = t.booking?.contactEmail; + + this.logger.debug(`Final values: phone=${guestPhone}, email=${guestEmail}`); + const passengerInfo = iam ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } - : { fullName: 'Guest', email: t.booking?.contactEmail, phone: null }; + : { fullName: 'Guest', email: guestEmail, phone: guestPhone }; + + this.logger.debug(`Final passenger info: ${JSON.stringify(passengerInfo)}`); + return { id: t.id, ticketNumber: t.barcodePayload, 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 b4cf005e8..d841d144d 100644 --- a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx @@ -185,7 +185,7 @@ function BookingsPageContent() { }, }, { - key: 'contact', label: 'Contact', + key: 'contact', label: 'Primary contact', render: (booking: any) => (
{booking.contactPhone || booking.passenger?.phone}
diff --git a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx index fce68fb34..31aae74fd 100644 --- a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx @@ -348,14 +348,8 @@ export default function TicketsPage() { key: 'contact', label: 'Contact', render: (ticket: any) => { - // Find the booking seat that matches this ticket's passenger - const matchingSeat = ticket.booking?.seats?.find((s: any) => - s.passengerName === ticket.passengerName && s.leg === ticket.leg - ); - - // Try to get phone from BookingSeat first, then fall back to booking contact - const phone = matchingSeat?.phone || ticket.booking?.contactPhone || ticket.booking?.passenger?.phone || 'N/A'; - const email = matchingSeat?.email || ticket.booking?.contactEmail || ticket.booking?.passenger?.email || 'N/A'; + const phone = ticket.booking?.passenger?.phone || 'N/A'; + const email = ticket.booking?.passenger?.email || 'N/A'; return (
@@ -372,16 +366,21 @@ export default function TicketsPage() { label: 'Trip', render: (ticket: any) => { const isRoundTrip = ticket.booking?.bookingType === 'ROUND_TRIP' || ticket.booking?.bookingType === 'ROUND_TRIP_TRANSIT'; - const returnArrivalAt = ticket.booking?.returnSchedule?.arrivalAt; + const returnDeparture = ticket.booking?.returnSchedule?.departureAt; + return (
{ticket.schedule?.originStation?.name || 'N/A'} → {ticket.schedule?.destinationStation?.name || 'N/A'}
- {ticket.schedule?.departureAt ? formatDateTimeShort(ticket.schedule.departureAt) : 'N/A'} - {isRoundTrip && ( - → {returnArrivalAt ? formatDateTimeShort(returnArrivalAt) : 'N/A'} + {!isRoundTrip ? ( + {ticket.schedule?.departureAt ? formatDateTimeShort(ticket.schedule.departureAt) : 'N/A'} + ) : ( + + {ticket.schedule?.departureAt ? formatDateTimeShort(ticket.schedule.departureAt) : 'N/A'} · + {returnDeparture ? formatDateTimeShort(returnDeparture) : 'N/A'} + )}
@@ -425,9 +424,26 @@ export default function TicketsPage() { { key: 'arrivalDate', label: 'Arrival Date', - render: (ticket: any) => ( - {ticket.schedule?.arrivalAt ? new Date(ticket.schedule.arrivalAt).toLocaleDateString() : '—'} - ), + render: (ticket: any) => { + const isRoundTrip = ticket.booking?.bookingType === 'ROUND_TRIP' || ticket.booking?.bookingType === 'ROUND_TRIP_TRANSIT'; + const outboundArrival = ticket.schedule?.arrivalAt; + const returnArrival = ticket.booking?.returnSchedule?.arrivalAt; + + if (!isRoundTrip) { + return ( + + {outboundArrival ? new Date(outboundArrival).toLocaleDateString() : '—'} + + ); + } + + return ( +
+ ➡ {outboundArrival ? new Date(outboundArrival).toLocaleDateString() : '—'} + ⬅ {returnArrival ? new Date(returnArrival).toLocaleDateString() : '—'} +
+ ); + }, }, { key: 'boardingTimes',