import { Repository } from 'typeorm'; import { AppDataSource } from '../../config/typeorm.js'; import { PaymentTerm, PaymentTermLine, PaymentTermLineType, } from './entities/payment-term.entity.js'; import { NotFoundError, ValidationError, ConflictError } from '../../shared/errors/index.js'; import { logger } from '../../shared/utils/logger.js'; // ============================================================================ // TYPES // ============================================================================ export interface CreatePaymentTermLineDto { sequence?: number; line_type?: PaymentTermLineType | 'balance' | 'percent' | 'fixed'; lineType?: PaymentTermLineType | 'balance' | 'percent' | 'fixed'; value_percent?: number; valuePercent?: number; value_amount?: number; valueAmount?: number; days?: number; day_of_month?: number; dayOfMonth?: number; end_of_month?: boolean; endOfMonth?: boolean; } export interface CreatePaymentTermDto { code: string; name: string; description?: string; due_days?: number; dueDays?: number; discount_percent?: number; discountPercent?: number; discount_days?: number; discountDays?: number; is_immediate?: boolean; isImmediate?: boolean; lines?: CreatePaymentTermLineDto[]; } export interface UpdatePaymentTermDto { name?: string; description?: string | null; due_days?: number; dueDays?: number; discount_percent?: number | null; discountPercent?: number | null; discount_days?: number | null; discountDays?: number | null; is_immediate?: boolean; isImmediate?: boolean; is_active?: boolean; isActive?: boolean; lines?: CreatePaymentTermLineDto[]; } export interface DueDateResult { dueDate: Date; discountDate: Date | null; discountAmount: number; lines: Array<{ dueDate: Date; amount: number; percent: number; }>; } // ============================================================================ // SERVICE // ============================================================================ class PaymentTermsService { private repository: Repository; private lineRepository: Repository; constructor() { this.repository = AppDataSource.getRepository(PaymentTerm); this.lineRepository = AppDataSource.getRepository(PaymentTermLine); } /** * Calculate due date(s) based on payment term */ calculateDueDate( paymentTerm: PaymentTerm, invoiceDate: Date, totalAmount: number ): DueDateResult { logger.debug('Calculating due date', { termCode: paymentTerm.code, invoiceDate, totalAmount, }); const baseDate = new Date(invoiceDate); const lines: DueDateResult['lines'] = []; // If immediate payment if (paymentTerm.isImmediate) { return { dueDate: baseDate, discountDate: null, discountAmount: 0, lines: [{ dueDate: baseDate, amount: totalAmount, percent: 100 }], }; } // If payment term has lines, use them if (paymentTerm.lines && paymentTerm.lines.length > 0) { let remainingAmount = totalAmount; let lastDueDate = baseDate; for (const line of paymentTerm.lines.sort((a, b) => a.sequence - b.sequence)) { let lineAmount = 0; let linePercent = 0; if (line.lineType === PaymentTermLineType.BALANCE) { lineAmount = remainingAmount; linePercent = (lineAmount / totalAmount) * 100; } else if (line.lineType === PaymentTermLineType.PERCENT && line.valuePercent) { linePercent = Number(line.valuePercent); lineAmount = (totalAmount * linePercent) / 100; } else if (line.lineType === PaymentTermLineType.FIXED && line.valueAmount) { lineAmount = Math.min(Number(line.valueAmount), remainingAmount); linePercent = (lineAmount / totalAmount) * 100; } const lineDueDate = this.calculateLineDueDate(baseDate, line); lastDueDate = lineDueDate; lines.push({ dueDate: lineDueDate, amount: lineAmount, percent: linePercent, }); remainingAmount -= lineAmount; } // Calculate discount date if applicable let discountDate: Date | null = null; let discountAmount = 0; if (paymentTerm.discountPercent && paymentTerm.discountDays) { discountDate = new Date(baseDate); discountDate.setDate(discountDate.getDate() + paymentTerm.discountDays); discountAmount = (totalAmount * Number(paymentTerm.discountPercent)) / 100; } return { dueDate: lastDueDate, discountDate, discountAmount, lines, }; } // Simple due days calculation const dueDate = new Date(baseDate); dueDate.setDate(dueDate.getDate() + paymentTerm.dueDays); let discountDate: Date | null = null; let discountAmount = 0; if (paymentTerm.discountPercent && paymentTerm.discountDays) { discountDate = new Date(baseDate); discountDate.setDate(discountDate.getDate() + paymentTerm.discountDays); discountAmount = (totalAmount * Number(paymentTerm.discountPercent)) / 100; } return { dueDate, discountDate, discountAmount, lines: [{ dueDate, amount: totalAmount, percent: 100 }], }; } /** * Calculate due date for a specific line */ private calculateLineDueDate(baseDate: Date, line: PaymentTermLine): Date { const result = new Date(baseDate); result.setDate(result.getDate() + line.days); // If specific day of month if (line.dayOfMonth) { result.setDate(line.dayOfMonth); // If the calculated date is before base + days, move to next month const minDate = new Date(baseDate); minDate.setDate(minDate.getDate() + line.days); if (result < minDate) { result.setMonth(result.getMonth() + 1); } } // If end of month if (line.endOfMonth) { result.setMonth(result.getMonth() + 1); result.setDate(0); // Last day of previous month } return result; } /** * Get all payment terms for a tenant */ async findAll(tenantId: string, activeOnly: boolean = false): Promise { logger.debug('Finding all payment terms', { tenantId, activeOnly }); const query = this.repository .createQueryBuilder('pt') .leftJoinAndSelect('pt.lines', 'lines') .where('pt.tenant_id = :tenantId', { tenantId }) .orderBy('pt.sequence', 'ASC') .addOrderBy('pt.name', 'ASC'); if (activeOnly) { query.andWhere('pt.is_active = :isActive', { isActive: true }); } return query.getMany(); } /** * Get a specific payment term by ID */ async findById(id: string, tenantId: string): Promise { logger.debug('Finding payment term by id', { id, tenantId }); const paymentTerm = await this.repository.findOne({ where: { id, tenantId }, relations: ['lines'], }); if (!paymentTerm) { throw new NotFoundError('Término de pago no encontrado'); } return paymentTerm; } /** * Get a specific payment term by code */ async findByCode(code: string, tenantId: string): Promise { logger.debug('Finding payment term by code', { code, tenantId }); return this.repository.findOne({ where: { code, tenantId }, relations: ['lines'], }); } /** * Create a new payment term */ async create( dto: CreatePaymentTermDto, tenantId: string, userId?: string ): Promise { logger.debug('Creating payment term', { dto, tenantId }); // Check for existing const existing = await this.findByCode(dto.code, tenantId); if (existing) { throw new ConflictError(`Ya existe un término de pago con código ${dto.code}`); } // Normalize inputs (accept both snake_case and camelCase) const dueDays = dto.due_days ?? dto.dueDays ?? 0; const discountPercent = dto.discount_percent ?? dto.discountPercent ?? null; const discountDays = dto.discount_days ?? dto.discountDays ?? null; const isImmediate = dto.is_immediate ?? dto.isImmediate ?? false; const paymentTerm = this.repository.create({ tenantId, code: dto.code, name: dto.name, description: dto.description || null, dueDays, discountPercent, discountDays, isImmediate, createdBy: userId || null, }); const saved = await this.repository.save(paymentTerm); // Create lines if provided if (dto.lines && dto.lines.length > 0) { await this.createLines(saved.id, dto.lines); // Reload with lines return this.findById(saved.id, tenantId); } logger.info('Payment term created', { id: saved.id, code: dto.code, tenantId }); return saved; } /** * Create payment term lines */ private async createLines( paymentTermId: string, lines: CreatePaymentTermLineDto[] ): Promise { for (let index = 0; index < lines.length; index++) { const line = lines[index]; const lineTypeRaw = line.line_type ?? line.lineType ?? 'balance'; const lineType = lineTypeRaw as PaymentTermLineType; const valuePercent = line.value_percent ?? line.valuePercent ?? null; const valueAmount = line.value_amount ?? line.valueAmount ?? null; const dayOfMonth = line.day_of_month ?? line.dayOfMonth ?? null; const endOfMonth = line.end_of_month ?? line.endOfMonth ?? false; const lineEntity = this.lineRepository.create({ paymentTermId, sequence: line.sequence ?? index + 1, lineType, valuePercent, valueAmount, days: line.days ?? 0, dayOfMonth, endOfMonth, }); await this.lineRepository.save(lineEntity); } } /** * Update a payment term */ async update( id: string, dto: UpdatePaymentTermDto, tenantId: string, userId?: string ): Promise { logger.debug('Updating payment term', { id, dto, tenantId }); const existing = await this.findById(id, tenantId); // Normalize inputs const dueDays = dto.due_days ?? dto.dueDays; const discountPercent = dto.discount_percent ?? dto.discountPercent; const discountDays = dto.discount_days ?? dto.discountDays; const isImmediate = dto.is_immediate ?? dto.isImmediate; const isActive = dto.is_active ?? dto.isActive; if (dto.name !== undefined) { existing.name = dto.name; } if (dto.description !== undefined) { existing.description = dto.description; } if (dueDays !== undefined) { existing.dueDays = dueDays; } if (discountPercent !== undefined) { existing.discountPercent = discountPercent; } if (discountDays !== undefined) { existing.discountDays = discountDays; } if (isImmediate !== undefined) { existing.isImmediate = isImmediate; } if (isActive !== undefined) { existing.isActive = isActive; } existing.updatedBy = userId || null; const updated = await this.repository.save(existing); // Update lines if provided if (dto.lines !== undefined) { // Remove existing lines await this.lineRepository.delete({ paymentTermId: id }); // Create new lines if (dto.lines.length > 0) { await this.createLines(id, dto.lines); } } logger.info('Payment term updated', { id, tenantId }); return this.findById(id, tenantId); } /** * Soft delete a payment term */ async delete(id: string, tenantId: string, userId?: string): Promise { logger.debug('Deleting payment term', { id, tenantId }); const existing = await this.findById(id, tenantId); existing.deletedAt = new Date(); existing.deletedBy = userId || null; await this.repository.save(existing); logger.info('Payment term deleted', { id, tenantId }); } /** * Get common/standard payment terms */ getStandardTerms(): Array<{ code: string; name: string; dueDays: number; discountPercent?: number; discountDays?: number }> { return [ { code: 'IMMEDIATE', name: 'Pago Inmediato', dueDays: 0 }, { code: 'NET15', name: 'Neto 15 días', dueDays: 15 }, { code: 'NET30', name: 'Neto 30 días', dueDays: 30 }, { code: 'NET45', name: 'Neto 45 días', dueDays: 45 }, { code: 'NET60', name: 'Neto 60 días', dueDays: 60 }, { code: 'NET90', name: 'Neto 90 días', dueDays: 90 }, { code: '2/10NET30', name: '2% 10 días, Neto 30', dueDays: 30, discountPercent: 2, discountDays: 10 }, { code: '1/10NET30', name: '1% 10 días, Neto 30', dueDays: 30, discountPercent: 1, discountDays: 10 }, ]; } /** * Initialize standard payment terms for a tenant */ async initializeForTenant(tenantId: string, userId?: string): Promise { logger.debug('Initializing payment terms for tenant', { tenantId }); const standardTerms = this.getStandardTerms(); for (const term of standardTerms) { const existing = await this.findByCode(term.code, tenantId); if (!existing) { await this.create( { code: term.code, name: term.name, dueDays: term.dueDays, discountPercent: term.discountPercent, discountDays: term.discountDays, isImmediate: term.dueDays === 0, }, tenantId, userId ); } } logger.info('Payment terms initialized for tenant', { tenantId, count: standardTerms.length }); } } export const paymentTermsService = new PaymentTermsService();