This commit is contained in:
Roba Boru
2026-06-29 16:37:37 +03:00
15 changed files with 217 additions and 125 deletions

View File

@@ -117,12 +117,11 @@ CREATE TABLE IF NOT EXISTS "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")
);
-- Insert the configurable fares feature flag (idempotent)
-- Insert the configurable fares feature flag
INSERT INTO "system_features" ("id", "feature_name", "is_enabled", "config", "updated_at")
VALUES ('cf-001', 'USE_CONFIGURABLE_FARES', false, '{"rollout_percentage": 0}', CURRENT_TIMESTAMP)
ON CONFLICT ("feature_name") DO NOTHING;
VALUES ('cf-001', 'USE_CONFIGURABLE_FARES', false, '{"rollout_percentage": 0}', CURRENT_TIMESTAMP);

View File

@@ -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<string, number> = {
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<number> {
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<number> {
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,

View File

@@ -4,14 +4,14 @@ import { Type } from 'class-transformer';
import { Currency } from '@prisma/client';
// Nationality → home currency mapping (keys are uppercase for case-insensitive lookup)
export const NATIONALITY_CURRENCY_MAP: Record<string, Currency> = {
ETHIOPIAN: Currency.ETB,
DJIBOUTIAN: Currency.DJF,
export const NATIONALITY_CURRENCY_MAP: Record<string, string> = {
ETHIOPIAN: 'ETB',
DJIBOUTIAN: 'DJF',
};
export function resolveCurrencyFromNationality(nationality?: string): Currency {
if (!nationality) return Currency.ETB;
return NATIONALITY_CURRENCY_MAP[nationality.toUpperCase()] ?? Currency.USD;
if (!nationality) return 'ETB' as Currency;
return (NATIONALITY_CURRENCY_MAP[nationality.toUpperCase()] ?? 'USD') as Currency;
}
export class FareCalculateDto {

View File

@@ -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,

View File

@@ -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(),
],

View File

@@ -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",
}),

View File

@@ -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<InitiateResponseDto> {
// 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 },
@@ -690,21 +675,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({

View File

@@ -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,

View File

@@ -11,7 +11,7 @@ export type PassengerSeedRole = {
};
export const EDR_PASSENGER_APPLICATION = {
id: 'd2000001-0001-4000-8000-000000000001',
id: '921cd1a4-98a7-4601-bfb1-6fe19518be52',
key: 'edr_passenger_app',
name: {
am: 'EDR Passenger App',

View File

@@ -15,26 +15,26 @@ const perm = (id: string, key: string, en: string): PassengerPermissionSeed => (
});
export const PASSENGER_PERMISSIONS: PassengerPermissionSeed[] = [
perm('c1000001-0001-4000-8000-000000000001', 'edr_passenger_app:bookings:view', 'View bookings'),
perm('c1000001-0001-4000-8000-000000000002', 'edr_passenger_app:bookings:manage', 'Manage bookings'),
perm('c1000001-0001-4000-8000-000000000003', 'edr_passenger_app:bookings:cancel', 'Cancel bookings'),
perm('c1000001-0001-4000-8000-000000000004', 'edr_passenger_app:passengers:view', 'View passengers'),
perm('c1000001-0001-4000-8000-000000000005', 'edr_passenger_app:passengers:manage', 'Manage passengers'),
perm('c1000001-0001-4000-8000-000000000006', 'edr_passenger_app:tickets:view', 'View tickets'),
perm('c1000001-0001-4000-8000-000000000007', 'edr_passenger_app:tickets:manage', 'Manage tickets'),
perm('c1000001-0001-4000-8000-000000000008', 'edr_passenger_app:payments:view_all', 'View all payments'),
perm('c1000001-0001-4000-8000-000000000009', 'edr_passenger_app:payments:refund', 'Refund payments'),
perm('c1000001-0001-4000-8000-00000000000a', 'edr_passenger_app:payments:manage_methods', 'Manage payment methods'),
perm('c1000001-0001-4000-8000-00000000000b', 'edr_passenger_app:reports:view', 'View reports'),
perm('c1000001-0001-4000-8000-00000000000c', 'edr_passenger_app:fraud:view', 'View fraud alerts'),
perm('c1000001-0001-4000-8000-00000000000d', 'edr_passenger_app:fraud:manage', 'Manage fraud rules'),
perm('c1000001-0001-4000-8000-00000000000e', 'edr_passenger_app:audit:view', 'View audit logs'),
perm('c1000001-0001-4000-8000-00000000000f', 'edr_passenger_app:agents:view', 'View agents'),
perm('c1000001-0001-4000-8000-000000000010', 'edr_passenger_app:agents:manage', 'Manage agents'),
perm('c1000001-0001-4000-8000-000000000011', 'edr_passenger_app:currencies:manage', 'Manage currencies'),
perm('c1000001-0001-4000-8000-000000000012', 'edr_passenger_app:notifications:send', 'Send notifications'),
perm('c1000001-0001-4000-8000-000000000013', 'edr_passenger_app:dashboard:view', 'View dashboard'),
perm('c1000001-0001-4000-8000-000000000014', 'edr_passenger_app:admin', 'Full admin access'),
perm('40f1b49c-c33d-4563-a6bb-9373eabbde9b', 'edr_passenger_app:bookings:view', 'View bookings'),
perm('62810ae5-315e-4ae5-8ed1-33cead51b95a', 'edr_passenger_app:bookings:manage', 'Manage bookings'),
perm('b593adf3-2060-48b0-b35d-ff9ff5d72bc4', 'edr_passenger_app:bookings:cancel', 'Cancel bookings'),
perm('566c968f-71f1-462d-9824-4b7cd33cecbb', 'edr_passenger_app:passengers:view', 'View passengers'),
perm('ff5d33a0-0fe7-427f-a065-46dd14ac1da0', 'edr_passenger_app:passengers:manage', 'Manage passengers'),
perm('326ec767-1da8-4c7e-b557-d4d2f9dd6d2c', 'edr_passenger_app:tickets:view', 'View tickets'),
perm('8ec5697f-d2d4-40a2-a365-ad624991a2ab', 'edr_passenger_app:tickets:manage', 'Manage tickets'),
perm('736aca18-6660-4865-9773-81a636f51fa0', 'edr_passenger_app:payments:view_all', 'View all payments'),
perm('44065042-b4af-4af2-b213-34a823f78be1', 'edr_passenger_app:payments:refund', 'Refund payments'),
perm('558f0172-ab9f-4d13-9477-4ca247d94f3c', 'edr_passenger_app:payments:manage_methods', 'Manage payment methods'),
perm('bf6cbd48-7a4a-46ec-91f3-0a23245da0a4', 'edr_passenger_app:reports:view', 'View reports'),
perm('53b49280-a272-4688-8345-e14fbedce50e', 'edr_passenger_app:fraud:view', 'View fraud alerts'),
perm('834f576c-afe2-41f4-9e6e-5f87b155fbf4', 'edr_passenger_app:fraud:manage', 'Manage fraud rules'),
perm('988b680d-df5a-4e79-9c01-c9441753ce1a', 'edr_passenger_app:audit:view', 'View audit logs'),
perm('38736897-545f-47dc-9531-7fc381478a1f', 'edr_passenger_app:agents:view', 'View agents'),
perm('30ada94a-cd15-4588-97bd-cd2f9d33ad7d', 'edr_passenger_app:agents:manage', 'Manage agents'),
perm('75b5ff62-a8e4-4331-b6e6-d53e1456d10e', 'edr_passenger_app:currencies:manage', 'Manage currencies'),
perm('4a47da9b-cf6e-4240-aff8-aadf01641c54', 'edr_passenger_app:notifications:send', 'Send notifications'),
perm('bfe3428f-8b85-4a36-87c6-33063b084bf3', 'edr_passenger_app:dashboard:view', 'View dashboard'),
perm('49fd28cd-5b58-4403-8e53-1df4b93cbbd2', 'edr_passenger_app:admin', 'Full admin access'),
];
export const PASSENGER_PERMISSION_KEYS = PASSENGER_PERMISSIONS.map((p) => p.key);

View File

@@ -185,7 +185,7 @@ function BookingsPageContent() {
},
},
{
key: 'contact', label: 'Contact',
key: 'contact', label: 'Primary contact',
render: (booking: any) => (
<div>
<div className="font-medium">{booking.contactPhone || booking.passenger?.phone}</div>

View File

@@ -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 (
<div>
@@ -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 (
<div>
<div className="font-medium">
{ticket.schedule?.originStation?.name || 'N/A'} {ticket.schedule?.destinationStation?.name || 'N/A'}
</div>
<div className="text-xs text-muted-foreground">
{ticket.schedule?.departureAt ? formatDateTimeShort(ticket.schedule.departureAt) : 'N/A'}
{isRoundTrip && (
<span> {returnArrivalAt ? formatDateTimeShort(returnArrivalAt) : 'N/A'}</span>
{!isRoundTrip ? (
<span>{ticket.schedule?.departureAt ? formatDateTimeShort(ticket.schedule.departureAt) : 'N/A'}</span>
) : (
<span>
{ticket.schedule?.departureAt ? formatDateTimeShort(ticket.schedule.departureAt) : 'N/A'} ·
{returnDeparture ? formatDateTimeShort(returnDeparture) : 'N/A'}
</span>
)}
</div>
</div>
@@ -425,9 +424,26 @@ export default function TicketsPage() {
{
key: 'arrivalDate',
label: 'Arrival Date',
render: (ticket: any) => (
<span className="text-sm">{ticket.schedule?.arrivalAt ? new Date(ticket.schedule.arrivalAt).toLocaleDateString() : '—'}</span>
),
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 (
<span className="text-sm">
{outboundArrival ? new Date(outboundArrival).toLocaleDateString() : '—'}
</span>
);
}
return (
<div className="flex flex-col gap-0.5 text-sm">
<span> {outboundArrival ? new Date(outboundArrival).toLocaleDateString() : '—'}</span>
<span> {returnArrival ? new Date(returnArrival).toLocaleDateString() : '—'}</span>
</div>
);
},
},
{
key: 'boardingTimes',

View File

@@ -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 ?? "",

View File

@@ -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<string>("dmoney.language") ?? "en";
}
private get currency(): string {
return this.config.get<string>("dmoney.currency") ?? "FDJ";
}
private get privateKey(): string {
return this.config.get<string>("dmoney.privateKey") ?? "";
}

View File

@@ -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<string>("waafi.paymentMethod") ?? "MWALLET_ACCOUNT";
}
private get currency(): string {
return this.config.get<string>("waafi.currency") ?? "";
}
private get successUrl(): string {
return this.config.get<string>("waafi.successUrl") ?? "";
}