mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: ( payments ) convert ETB to method currency before charging
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
],
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
|
||||
@@ -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 },
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user