mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 21:50:57 +00:00
236 lines
8.0 KiB
TypeScript
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;
|
|
}
|
|
}
|