Files
edr-platform/apps/edr-passenger-api/src/modules/currency/currency.service.ts

201 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
}
}