Files
edr-platform/apps/edr-passenger-api/src/modules/currency/currency.service.ts
2026-07-08 18:16:57 +03:00

236 lines
8.0 KiB
TypeScript

import {
Injectable,
Logger,
NotFoundException,
BadRequestException,
} from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { firstValueFrom } from 'rxjs';
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,
private readonly httpService: HttpService,
private readonly configService: ConfigService,
) {}
/**
* Converts a stored display-currency minor amount to the charge major amount
* sent to the payment provider, without hitting the DB for an exchange rate.
* Use this when the payment method's settlement currency matches the booking's
* displayCurrency — the rate is already baked into displayTotalMinor.
*/
displayMinorToChargeMajor(displayMinor: number, currency: string): number {
const decimals = CHARGE_CURRENCY_DECIMALS[currency.toUpperCase()];
if (decimals === undefined) {
throw new BadRequestException(`Unsupported charge currency: ${currency}`);
}
return this.roundTo(displayMinor / 100, decimals);
}
async convertEtbMinorToChargeMinor(
amountMinorEtb: number,
targetCurrency: string,
): Promise<number> {
const target = targetCurrency.toUpperCase();
if (CHARGE_CURRENCY_DECIMALS[target] === undefined) {
throw new BadRequestException(`Unsupported charge currency: ${targetCurrency}`);
}
if (target === Currency.ETB) {
return amountMinorEtb;
}
// Convert ETB minor → target minor: apply exchange rate, keep as minor units.
const rate = await this.getRateOrThrow(Currency.ETB, target as Currency);
return Math.round(amountMinorEtb * rate);
}
/**
* Converts an ETB minor-unit amount to the charge major-unit amount sent to the
* payment provider. Applies the exchange rate for foreign currencies then divides
* by 100 to yield major units (e.g. 300000 ETB minor → 3000.00 ETB major).
*/
async convertEtbMinorToChargeMajor(
amountMinorEtb: number,
targetCurrency: string,
): Promise<number> {
const target = targetCurrency.toUpperCase();
if (CHARGE_CURRENCY_DECIMALS[target] === undefined) {
throw new BadRequestException(`Unsupported charge currency: ${targetCurrency}`);
}
const decimals = CHARGE_CURRENCY_DECIMALS[target];
if (target === Currency.ETB) {
return this.roundTo(amountMinorEtb / 100, decimals);
}
const rate = await this.getRateOrThrow(Currency.ETB, target as Currency);
return this.roundTo((amountMinorEtb * rate) / 100, 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,
toCurrency: Currency,
): Promise<number> {
if (fromCurrency === toCurrency) {
return amountMinor;
}
const rate = await this.getExchangeRate(fromCurrency, toCurrency);
return Math.round(amountMinor * rate);
}
async getExchangeRate(
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) {
this.logger.warn(
`No exchange rate found for ${fromCurrency} to ${toCurrency}, using 1.0`,
);
return 1.0;
}
const ageMs = Date.now() - exchangeRate.effectiveDate.getTime();
if (ageMs > 2 * 24 * 60 * 60 * 1000) {
this.logger.warn(
`Stale exchange rate for ${fromCurrency}->${toCurrency}: last updated ${exchangeRate.effectiveDate.toISOString()}`,
);
}
return Number(exchangeRate.rate);
}
async syncExchangeRates(): Promise<void> {
this.logger.log('Syncing exchange rates from central bank API');
const today = this.todayUtc();
// Fallback rates used when the API is unreachable
const fallbackRates = [
{ from: Currency.ETB, to: Currency.ETB, rate: 1.0 },
{ from: Currency.ETB, to: Currency.DJF, rate: 3.25 },
{ from: Currency.ETB, to: Currency.USD, rate: 0.018 },
{ from: Currency.DJF, to: Currency.ETB, rate: 0.3077 },
{ from: Currency.USD, to: Currency.ETB, rate: 55.56 },
];
const apiUrl = this.configService.get<string>('EXCHANGE_RATE_API_URL');
if (apiUrl) {
try {
const response = await firstValueFrom(
this.httpService.get<Record<string, number>>(apiUrl, { timeout: 5000 }),
);
// Expected response shape: { "ETB_DJF": 3.25, "ETB_USD": 0.018, ... }
const data = response.data;
const apiRates = [
{ from: Currency.ETB, to: Currency.ETB, rate: 1.0 },
{ from: Currency.ETB, to: Currency.DJF, rate: data['ETB_DJF'] ?? fallbackRates[1].rate },
{ from: Currency.ETB, to: Currency.USD, rate: data['ETB_USD'] ?? fallbackRates[2].rate },
{ from: Currency.DJF, to: Currency.ETB, rate: data['DJF_ETB'] ?? fallbackRates[3].rate },
{ from: Currency.USD, to: Currency.ETB, rate: data['USD_ETB'] ?? fallbackRates[4].rate },
];
for (const { from, to, rate } of apiRates) {
await this.upsertRate(from, to, rate, today, 'CENTRAL_BANK_API');
}
this.logger.log('Exchange rates synced from central bank API');
return;
} catch (err) {
this.logger.warn(
`Central bank API unreachable (${(err as Error).message}), falling back to configured rates`,
);
}
}
// Fallback: persist the static rates so the DB always has a current row
for (const { from, to, rate } of fallbackRates) {
await this.upsertRate(from, to, rate, today, 'FALLBACK');
}
this.logger.log('Exchange rates synced using fallback values');
}
async listRates() {
return this.prisma.currencyExchangeRate.findMany({
orderBy: [{ fromCurrency: 'asc' }, { toCurrency: 'asc' }, { effectiveDate: 'desc' }],
});
}
async upsertRate(
fromCurrency: Currency,
toCurrency: Currency,
rate: number,
effectiveDate?: Date,
source = 'MANUAL',
) {
const date = effectiveDate ?? this.todayUtc();
return this.prisma.currencyExchangeRate.upsert({
where: { fromCurrency_toCurrency_effectiveDate: { fromCurrency, toCurrency, effectiveDate: date } },
update: { rate, source },
create: { fromCurrency, toCurrency, rate, effectiveDate: date, source },
});
}
async updateRateById(id: string, rate: number, source = 'MANUAL') {
const existing = await this.prisma.currencyExchangeRate.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('Exchange rate not found');
return this.prisma.currencyExchangeRate.update({ where: { id }, data: { rate, source } });
}
async deleteRate(id: string) {
return this.prisma.currencyExchangeRate.delete({ where: { id } });
}
/** Returns midnight UTC for today — used as the date-only key for upserts. */
private todayUtc(): Date {
const d = new Date();
d.setUTCHours(0, 0, 0, 0);
return d;
}
}