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") ?? ""; }