mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-03 03:53:39 +00:00
201 lines
6.9 KiB
TypeScript
201 lines
6.9 KiB
TypeScript
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) {}
|
||
|
||
/**
|
||
* 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 a booking's stored minor-unit amount (in its own `fromCurrency`) to the charge
|
||
* major-unit amount sent to the payment provider in `targetCurrency`. When the two currencies
|
||
* match, no exchange rate is applied — the stored amount is charged as-is. Otherwise the
|
||
* fromCurrency→targetCurrency rate is applied. In both cases the result is divided by 100 to
|
||
* yield major units and rounded to the target currency's precision
|
||
* (e.g. 300000 ETB minor → 3000.00 ETB major; DJF rounds to whole francs).
|
||
*/
|
||
async convertMinorToChargeMajor(
|
||
amountMinor: number,
|
||
fromCurrency: string,
|
||
targetCurrency: string,
|
||
): Promise<number> {
|
||
const from = fromCurrency.toUpperCase();
|
||
const target = targetCurrency.toUpperCase();
|
||
if (CHARGE_CURRENCY_DECIMALS[target] === undefined) {
|
||
throw new BadRequestException(`Unsupported charge currency: ${targetCurrency}`);
|
||
}
|
||
const decimals = CHARGE_CURRENCY_DECIMALS[target];
|
||
|
||
if (from === target) {
|
||
return this.roundTo(amountMinor / 100, decimals);
|
||
}
|
||
|
||
const rate = await this.getRateOrThrow(from as Currency, target as Currency);
|
||
return this.roundTo((amountMinor * rate) / 100, decimals);
|
||
}
|
||
|
||
async getRateOrThrow(
|
||
fromCurrency: Currency,
|
||
toCurrency: Currency,
|
||
): Promise<number> {
|
||
if (fromCurrency === toCurrency) return 1;
|
||
|
||
// Direct rate
|
||
const direct = await this.prisma.currencyExchangeRate.findFirst({
|
||
where: { fromCurrency, toCurrency },
|
||
orderBy: { effectiveDate: 'desc' },
|
||
});
|
||
if (direct) return Number(direct.rate);
|
||
|
||
// Inverse rate
|
||
const inverse = await this.prisma.currencyExchangeRate.findFirst({
|
||
where: { fromCurrency: toCurrency, toCurrency: fromCurrency },
|
||
orderBy: { effectiveDate: 'desc' },
|
||
});
|
||
if (inverse) return 1 / Number(inverse.rate);
|
||
|
||
// Bridge via ETB (e.g. DJF→USD = (DJF→ETB) × (ETB→USD))
|
||
if (fromCurrency !== Currency.ETB && toCurrency !== Currency.ETB) {
|
||
const toEtb = await this.getRateOrThrow(fromCurrency, Currency.ETB);
|
||
const etbToTarget = await this.getRateOrThrow(Currency.ETB, toCurrency);
|
||
return toEtb * etbToTarget;
|
||
}
|
||
|
||
throw new BadRequestException(
|
||
`No exchange rate configured for ${fromCurrency}->${toCurrency}`,
|
||
);
|
||
}
|
||
|
||
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 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) {
|
||
// H-2: fail closed. Never price at parity (1.0) when a required rate is absent — a silent 1.0
|
||
// substitution underprices international fares ~100×. Reject the quote/booking instead.
|
||
this.logger.error(
|
||
`No exchange rate configured for ${fromCurrency}->${toCurrency}; refusing to price at parity`,
|
||
);
|
||
throw new BadRequestException(
|
||
`No exchange rate configured for ${fromCurrency}->${toCurrency}`,
|
||
);
|
||
}
|
||
|
||
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 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;
|
||
}
|
||
}
|