erp-transportistas-backend-v2/src/modules/core/currency-rates.service.ts
Adrian Flores Cortes 95c6b58449 feat: Add base modules from erp-core following SIMCO-REUSE directive
Phase 0 - Base modules (100% copy):
- shared/ (errors, middleware, services, utils, types)
- auth, users, tenants (multi-tenancy)
- ai, audit, notifications, mcp, payment-terminals
- billing-usage, branches, companies, core

Phase 1 - Modules to adapt (70-95%):
- partners (for shippers/consignees)
- inventory (for refacciones)
- financial (for transport costing)

Phase 2 - Pattern modules (50-70%):
- ordenes-transporte (from sales)
- gestion-flota (from products)
- viajes (from projects)

Phase 3 - New transport-specific modules:
- tracking (GPS, events, alerts)
- tarifas-transporte (pricing, surcharges)
- combustible-gastos (fuel, tolls, expenses)
- carta-porte (CFDI complement 3.1)

Estimated token savings: ~65% (~10,675 lines)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 10:10:19 -06:00

270 lines
7.6 KiB
TypeScript

import { Repository, LessThanOrEqual } from 'typeorm';
import { AppDataSource } from '../../config/typeorm.js';
import { CurrencyRate, RateSource } from './entities/currency-rate.entity.js';
import { Currency } from './entities/currency.entity.js';
import { NotFoundError } from '../../shared/errors/index.js';
import { logger } from '../../shared/utils/logger.js';
export interface CreateCurrencyRateDto {
tenantId?: string;
fromCurrencyCode: string;
toCurrencyCode: string;
rate: number;
rateDate: Date;
source?: RateSource;
createdBy?: string;
}
export interface CurrencyRateFilter {
tenantId?: string;
fromCurrencyCode?: string;
toCurrencyCode?: string;
dateFrom?: Date;
dateTo?: Date;
limit?: number;
}
export interface ConvertCurrencyDto {
amount: number;
fromCurrencyCode: string;
toCurrencyCode: string;
date?: Date;
tenantId?: string;
}
class CurrencyRatesService {
private repository: Repository<CurrencyRate>;
private currencyRepository: Repository<Currency>;
constructor() {
this.repository = AppDataSource.getRepository(CurrencyRate);
this.currencyRepository = AppDataSource.getRepository(Currency);
}
async findAll(filter: CurrencyRateFilter = {}): Promise<CurrencyRate[]> {
logger.debug('Finding currency rates', { filter });
const query = this.repository
.createQueryBuilder('rate')
.leftJoinAndSelect('rate.fromCurrency', 'fromCurrency')
.leftJoinAndSelect('rate.toCurrency', 'toCurrency');
if (filter.tenantId) {
query.andWhere('(rate.tenantId = :tenantId OR rate.tenantId IS NULL)', {
tenantId: filter.tenantId,
});
}
if (filter.fromCurrencyCode) {
query.andWhere('fromCurrency.code = :fromCode', {
fromCode: filter.fromCurrencyCode.toUpperCase(),
});
}
if (filter.toCurrencyCode) {
query.andWhere('toCurrency.code = :toCode', {
toCode: filter.toCurrencyCode.toUpperCase(),
});
}
if (filter.dateFrom) {
query.andWhere('rate.rateDate >= :dateFrom', { dateFrom: filter.dateFrom });
}
if (filter.dateTo) {
query.andWhere('rate.rateDate <= :dateTo', { dateTo: filter.dateTo });
}
query.orderBy('rate.rateDate', 'DESC');
if (filter.limit) {
query.take(filter.limit);
}
return query.getMany();
}
async findById(id: string): Promise<CurrencyRate> {
logger.debug('Finding currency rate by id', { id });
const rate = await this.repository.findOne({
where: { id },
relations: ['fromCurrency', 'toCurrency'],
});
if (!rate) {
throw new NotFoundError('Tipo de cambio no encontrado');
}
return rate;
}
async getRate(
fromCurrencyCode: string,
toCurrencyCode: string,
date: Date = new Date(),
tenantId?: string
): Promise<number | null> {
logger.debug('Getting currency rate', { fromCurrencyCode, toCurrencyCode, date, tenantId });
// Same currency = rate 1
if (fromCurrencyCode.toUpperCase() === toCurrencyCode.toUpperCase()) {
return 1;
}
// Find direct rate
const directRate = await this.repository
.createQueryBuilder('rate')
.leftJoin('rate.fromCurrency', 'fromCurrency')
.leftJoin('rate.toCurrency', 'toCurrency')
.where('fromCurrency.code = :fromCode', { fromCode: fromCurrencyCode.toUpperCase() })
.andWhere('toCurrency.code = :toCode', { toCode: toCurrencyCode.toUpperCase() })
.andWhere('rate.rateDate <= :date', { date })
.andWhere('(rate.tenantId = :tenantId OR rate.tenantId IS NULL)', { tenantId: tenantId || null })
.orderBy('rate.rateDate', 'DESC')
.addOrderBy('rate.tenantId', 'DESC', 'NULLS LAST')
.getOne();
if (directRate) {
return Number(directRate.rate);
}
// Try inverse rate
const inverseRate = await this.repository
.createQueryBuilder('rate')
.leftJoin('rate.fromCurrency', 'fromCurrency')
.leftJoin('rate.toCurrency', 'toCurrency')
.where('fromCurrency.code = :toCode', { toCode: toCurrencyCode.toUpperCase() })
.andWhere('toCurrency.code = :fromCode', { fromCode: fromCurrencyCode.toUpperCase() })
.andWhere('rate.rateDate <= :date', { date })
.andWhere('(rate.tenantId = :tenantId OR rate.tenantId IS NULL)', { tenantId: tenantId || null })
.orderBy('rate.rateDate', 'DESC')
.addOrderBy('rate.tenantId', 'DESC', 'NULLS LAST')
.getOne();
if (inverseRate) {
return 1 / Number(inverseRate.rate);
}
return null;
}
async convert(dto: ConvertCurrencyDto): Promise<{ amount: number; rate: number } | null> {
logger.debug('Converting currency', dto);
const rate = await this.getRate(
dto.fromCurrencyCode,
dto.toCurrencyCode,
dto.date || new Date(),
dto.tenantId
);
if (rate === null) {
return null;
}
return {
amount: dto.amount * rate,
rate,
};
}
async create(dto: CreateCurrencyRateDto): Promise<CurrencyRate> {
logger.info('Creating currency rate', { dto });
// Get currency IDs
const fromCurrency = await this.currencyRepository.findOne({
where: { code: dto.fromCurrencyCode.toUpperCase() },
});
if (!fromCurrency) {
throw new NotFoundError(`Moneda ${dto.fromCurrencyCode} no encontrada`);
}
const toCurrency = await this.currencyRepository.findOne({
where: { code: dto.toCurrencyCode.toUpperCase() },
});
if (!toCurrency) {
throw new NotFoundError(`Moneda ${dto.toCurrencyCode} no encontrada`);
}
// Check if rate already exists for this date
const existing = await this.repository.findOne({
where: {
tenantId: dto.tenantId || undefined,
fromCurrencyId: fromCurrency.id,
toCurrencyId: toCurrency.id,
rateDate: dto.rateDate,
},
});
if (existing) {
// Update existing rate
existing.rate = dto.rate;
existing.source = dto.source || 'manual';
return this.repository.save(existing);
}
const rate = this.repository.create({
tenantId: dto.tenantId || null,
fromCurrencyId: fromCurrency.id,
toCurrencyId: toCurrency.id,
rate: dto.rate,
rateDate: dto.rateDate,
source: dto.source || 'manual',
createdBy: dto.createdBy || null,
});
return this.repository.save(rate);
}
async delete(id: string): Promise<void> {
logger.info('Deleting currency rate', { id });
const rate = await this.findById(id);
await this.repository.remove(rate);
}
async getHistory(
fromCurrencyCode: string,
toCurrencyCode: string,
days: number = 30,
tenantId?: string
): Promise<CurrencyRate[]> {
logger.debug('Getting rate history', { fromCurrencyCode, toCurrencyCode, days, tenantId });
const dateFrom = new Date();
dateFrom.setDate(dateFrom.getDate() - days);
return this.findAll({
fromCurrencyCode,
toCurrencyCode,
dateFrom,
tenantId,
limit: days,
});
}
async getLatestRates(baseCurrencyCode: string = 'MXN', tenantId?: string): Promise<Map<string, number>> {
logger.debug('Getting latest rates', { baseCurrencyCode, tenantId });
const rates = new Map<string, number>();
const currencies = await this.currencyRepository.find({ where: { active: true } });
for (const currency of currencies) {
if (currency.code === baseCurrencyCode.toUpperCase()) {
rates.set(currency.code, 1);
continue;
}
const rate = await this.getRate(baseCurrencyCode, currency.code, new Date(), tenantId);
if (rate !== null) {
rates.set(currency.code, rate);
}
}
return rates;
}
}
export const currencyRatesService = new CurrencyRatesService();