diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx index 8845b13cd..5a623e296 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx @@ -11,6 +11,7 @@ import { Alert, Box, Button, + FileInput, Group, Modal, Stack, @@ -24,6 +25,7 @@ import { ChevronLeft, ChevronRight, Send, + Upload, XCircle, } from "lucide-react"; import { useMemo, useState } from "react"; @@ -36,15 +38,17 @@ import { contractFormSchema, contractStepFields, initialContractFormValues, + OPERATION_TYPES, type ContractFormValues, type OperationType, } from "./new-contract-form/schema"; import { - allowedOperationsForProfiles, getRouteDirection, operationToProfileType, operationToTradeDirection, } from "./new-contract-form/helpers"; +import { PROFILE_TYPE_LABELS } from "@/constants/profileMode"; +import type { ProfileTypeValue } from "@/services/companies.service"; import { StepIndicator } from "./new-contract-form/StepIndicator"; import { clearContractDraft, @@ -240,19 +244,92 @@ export default function NewContractPage() { [auth.company], ); + // All operation types are always selectable. Picking one the company has no + // profile for prompts a license upload that creates the profile on the fly + // (mirrors the header "Add service" flow). const allowedOperations = useMemo( - () => allowedOperationsForProfiles(profileTypes), - [profileTypes], + () => [...OPERATION_TYPES], + [], ); + // Create-profile modal state (license upload → createProfileAndSwitch). + const [createTarget, setCreateTarget] = useState( + null, + ); + const [pendingOperation, setPendingOperation] = + useState(null); + const [licenseFiles, setLicenseFiles] = useState([]); + const [createError, setCreateError] = useState(null); + + const createProfileMutation = useMutation({ + mutationFn: async ({ + type, + files, + }: { + type: ProfileTypeValue; + files: File[]; + }) => { + const res = await auth.createProfileAndSwitch(type, files); + if (!res.success) { + throw new Error(res.error?.message ?? "Failed to create profile"); + } + }, + onSuccess: () => { + // The operation was already set on the form when the modal opened. + setCreateTarget(null); + setPendingOperation(null); + setLicenseFiles([]); + setCreateError(null); + }, + onError: (err) => { + setCreateError( + err instanceof Error ? err.message : "Failed to create profile", + ); + }, + }); + const handleOperationSelect = (op: OperationType) => { + // Intercity (domestic) runs on any existing customer profile — no switch. if (op === "intercity") return; - const target = operationToProfileType(op, profileTypes); + const target = operationToProfileType(op, profileTypes) as ProfileTypeValue; + const hasProfile = profileTypes.includes(target); + if (!hasProfile) { + // No matching profile — collect a license and create one. + setPendingOperation(op); + setCreateTarget(target); + setLicenseFiles([]); + setCreateError(null); + return; + } if (auth.activeProfileType !== target) { void auth.switchMode(target as never); } }; + const handleCreateProfileConfirm = () => { + if (!createTarget) return; + if (licenseFiles.length === 0) { + setCreateError("Please upload at least one business license file."); + return; + } + createProfileMutation.mutate({ type: createTarget, files: licenseFiles }); + }; + + const handleCreateProfileCancel = () => { + // Roll back the operation selection that triggered the modal. + if (pendingOperation) { + form.setValue("operationType", undefined as never, { shouldDirty: true }); + } + setCreateTarget(null); + setPendingOperation(null); + setLicenseFiles([]); + setCreateError(null); + }; + + const createTargetLabel = createTarget + ? (PROFILE_TYPE_LABELS[createTarget] ?? createTarget) + : ""; + const onboardingDocs = useMemo(() => { const profiles = auth.company?.company?.companyProfiles ?? []; const active = @@ -811,6 +888,54 @@ export default function NewContractPage() { )} + + {/* Create-profile modal — opens when the chosen operation type has no + matching company profile yet. Collects a license, creates the profile, + and switches to it (mirrors the header "Add service" flow). */} + { + if (!createProfileMutation.isPending) handleCreateProfileCancel(); + }} + title={`Set up your ${createTargetLabel} profile`} + centered + radius="lg" + > + + + You don't have a {createTargetLabel.toLowerCase()} profile yet. Add + your business license to create one and continue this contract as{" "} + {createTargetLabel.toLowerCase()}. + + } + placeholder="Select license file(s)" + value={licenseFiles} + onChange={(files) => setLicenseFiles(files ?? [])} + error={createError ?? undefined} + /> + + + + + + ); } diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step2-service-type.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step2-service-type.tsx index ff8b847b9..3d9bd4ec1 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step2-service-type.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step2-service-type.tsx @@ -6,7 +6,7 @@ import { FileCheck2, FileText, Info, - PackageCheck, + // PackageCheck, ShieldCheck, TrainFront, Truck, diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx index ebf6b0bd8..9cda1985d 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx @@ -239,7 +239,7 @@ export function Step3CargoScope({ label={`${size} cap (containers)`} placeholder="0 = unlimited" min={0} - value={field.value ?? 0} + value={Number(field.value ?? 0)} onChange={(v) => field.onChange(Number(v) || 0)} radius={10} styles={fieldStyles} @@ -258,7 +258,7 @@ export function Step3CargoScope({ label="Total cap (tons / items)" placeholder="0 = unlimited" min={0} - value={field.value ?? 0} + value={Number(field.value ?? 0)} onChange={(v) => field.onChange(Number(v) || 0)} radius={10} styles={fieldStyles} 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/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/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/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-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', 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") ?? ""; }