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>
270 lines
7.6 KiB
TypeScript
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();
|