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 = { 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 { 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 { 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 { 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 { if (fromCurrency === toCurrency) { return amountMinor; } const rate = await this.getExchangeRate(fromCurrency, toCurrency); return amountMinor * rate; } async getExchangeRate( fromCurrency: Currency, toCurrency: Currency, ): Promise { 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; } }