/** * Invoices Service * * Service for managing invoices */ import { Repository, DataSource, MoreThanOrEqual, LessThanOrEqual } from 'typeorm'; import { Invoice, InvoiceItem, InvoiceStatus, TenantSubscription, UsageTracking } from '../entities'; import { CreateInvoiceDto, UpdateInvoiceDto, RecordPaymentDto, VoidInvoiceDto, RefundInvoiceDto, GenerateInvoiceDto, InvoiceFilterDto, } from '../dto'; export class InvoicesService { private invoiceRepository: Repository; private itemRepository: Repository; private subscriptionRepository: Repository; private usageRepository: Repository; constructor(private dataSource: DataSource) { this.invoiceRepository = dataSource.getRepository(Invoice); this.itemRepository = dataSource.getRepository(InvoiceItem); this.subscriptionRepository = dataSource.getRepository(TenantSubscription); this.usageRepository = dataSource.getRepository(UsageTracking); } /** * Create invoice manually */ async create(dto: CreateInvoiceDto): Promise { const invoiceNumber = await this.generateInvoiceNumber(); // Calculate totals let subtotal = 0; for (const item of dto.items) { const itemTotal = item.quantity * item.unitPrice; const discount = itemTotal * ((item.discountPercent || 0) / 100); subtotal += itemTotal - discount; } const taxAmount = subtotal * 0.16; // 16% IVA for Mexico const total = subtotal + taxAmount; const invoice = this.invoiceRepository.create({ tenantId: dto.tenantId, subscriptionId: dto.subscriptionId, invoiceNumber, invoiceDate: dto.invoiceDate || new Date(), periodStart: dto.periodStart, periodEnd: dto.periodEnd, billingName: dto.billingName, billingEmail: dto.billingEmail, billingAddress: dto.billingAddress || {}, taxId: dto.taxId, subtotal, taxAmount, discountAmount: 0, total, currency: dto.currency || 'MXN', status: 'draft', dueDate: dto.dueDate, notes: dto.notes, internalNotes: dto.internalNotes, }); const savedInvoice = await this.invoiceRepository.save(invoice); // Create items for (const itemDto of dto.items) { const itemTotal = itemDto.quantity * itemDto.unitPrice; const discount = itemTotal * ((itemDto.discountPercent || 0) / 100); const item = this.itemRepository.create({ invoiceId: savedInvoice.id, itemType: itemDto.itemType, description: itemDto.description, quantity: itemDto.quantity, unitPrice: itemDto.unitPrice, discountPercent: itemDto.discountPercent || 0, subtotal: itemTotal - discount, metadata: itemDto.metadata || {}, }); await this.itemRepository.save(item); } return this.findById(savedInvoice.id) as Promise; } /** * Generate invoice automatically from subscription */ async generateFromSubscription(dto: GenerateInvoiceDto): Promise { const subscription = await this.subscriptionRepository.findOne({ where: { id: dto.subscriptionId }, relations: ['plan'], }); if (!subscription) { throw new Error('Subscription not found'); } const items: CreateInvoiceDto['items'] = []; // Base subscription fee items.push({ itemType: 'subscription', description: `Suscripcion ${subscription.plan.name} - ${subscription.billingCycle === 'annual' ? 'Anual' : 'Mensual'}`, quantity: 1, unitPrice: Number(subscription.currentPrice), }); // Include usage charges if requested if (dto.includeUsageCharges) { const usage = await this.usageRepository.findOne({ where: { tenantId: dto.tenantId, periodStart: dto.periodStart, }, }); if (usage) { // Extra users const extraUsers = Math.max( 0, usage.activeUsers - (subscription.contractedUsers || subscription.plan.maxUsers) ); if (extraUsers > 0) { items.push({ itemType: 'overage', description: `Usuarios adicionales (${extraUsers})`, quantity: extraUsers, unitPrice: 10, // $10 per extra user metadata: { metric: 'extra_users' }, }); } // Extra branches const extraBranches = Math.max( 0, usage.activeBranches - (subscription.contractedBranches || subscription.plan.maxBranches) ); if (extraBranches > 0) { items.push({ itemType: 'overage', description: `Sucursales adicionales (${extraBranches})`, quantity: extraBranches, unitPrice: 20, // $20 per extra branch metadata: { metric: 'extra_branches' }, }); } // Extra storage const extraStorageGb = Math.max( 0, Number(usage.storageUsedGb) - subscription.plan.storageGb ); if (extraStorageGb > 0) { items.push({ itemType: 'overage', description: `Almacenamiento adicional (${extraStorageGb} GB)`, quantity: Math.ceil(extraStorageGb), unitPrice: 0.5, // $0.50 per GB metadata: { metric: 'extra_storage' }, }); } } } // Calculate due date (15 days from invoice date) const dueDate = new Date(); dueDate.setDate(dueDate.getDate() + 15); return this.create({ tenantId: dto.tenantId, subscriptionId: dto.subscriptionId, periodStart: dto.periodStart, periodEnd: dto.periodEnd, billingName: subscription.billingName, billingEmail: subscription.billingEmail, billingAddress: subscription.billingAddress, taxId: subscription.taxId, dueDate, items, }); } /** * Find invoice by ID */ async findById(id: string): Promise { return this.invoiceRepository.findOne({ where: { id }, relations: ['items'], }); } /** * Find invoice by number */ async findByNumber(invoiceNumber: string): Promise { return this.invoiceRepository.findOne({ where: { invoiceNumber }, relations: ['items'], }); } /** * Find invoices with filters */ async findAll(filter: InvoiceFilterDto): Promise<{ data: Invoice[]; total: number }> { const query = this.invoiceRepository .createQueryBuilder('invoice') .leftJoinAndSelect('invoice.items', 'items'); if (filter.tenantId) { query.andWhere('invoice.tenantId = :tenantId', { tenantId: filter.tenantId }); } if (filter.status) { query.andWhere('invoice.status = :status', { status: filter.status }); } if (filter.dateFrom) { query.andWhere('invoice.invoiceDate >= :dateFrom', { dateFrom: filter.dateFrom }); } if (filter.dateTo) { query.andWhere('invoice.invoiceDate <= :dateTo', { dateTo: filter.dateTo }); } if (filter.overdue) { query.andWhere('invoice.dueDate < :now', { now: new Date() }); query.andWhere("invoice.status IN ('sent', 'partial')"); } const total = await query.getCount(); query.orderBy('invoice.invoiceDate', 'DESC'); if (filter.limit) { query.take(filter.limit); } if (filter.offset) { query.skip(filter.offset); } const data = await query.getMany(); return { data, total }; } /** * Update invoice */ async update(id: string, dto: UpdateInvoiceDto): Promise { const invoice = await this.findById(id); if (!invoice) { throw new Error('Invoice not found'); } if (invoice.status !== 'draft') { throw new Error('Only draft invoices can be updated'); } Object.assign(invoice, dto); return this.invoiceRepository.save(invoice); } /** * Send invoice */ async send(id: string): Promise { const invoice = await this.findById(id); if (!invoice) { throw new Error('Invoice not found'); } if (invoice.status !== 'draft') { throw new Error('Only draft invoices can be sent'); } invoice.status = 'sent'; // TODO: Send email notification to billing email return this.invoiceRepository.save(invoice); } /** * Record payment */ async recordPayment(id: string, dto: RecordPaymentDto): Promise { const invoice = await this.findById(id); if (!invoice) { throw new Error('Invoice not found'); } if (invoice.status === 'void' || invoice.status === 'refunded') { throw new Error('Cannot record payment for voided or refunded invoice'); } const newPaidAmount = Number(invoice.paidAmount) + dto.amount; const total = Number(invoice.total); invoice.paidAmount = newPaidAmount; invoice.paymentMethod = dto.paymentMethod; invoice.paymentReference = dto.paymentReference; if (newPaidAmount >= total) { invoice.status = 'paid'; invoice.paidAt = dto.paymentDate || new Date(); } else if (newPaidAmount > 0) { invoice.status = 'partial'; } return this.invoiceRepository.save(invoice); } /** * Void invoice */ async void(id: string, dto: VoidInvoiceDto): Promise { const invoice = await this.findById(id); if (!invoice) { throw new Error('Invoice not found'); } if (invoice.status === 'paid' || invoice.status === 'refunded') { throw new Error('Cannot void paid or refunded invoice'); } invoice.status = 'void'; invoice.internalNotes = `${invoice.internalNotes || ''}\n\nVoided: ${dto.reason}`.trim(); return this.invoiceRepository.save(invoice); } /** * Refund invoice */ async refund(id: string, dto: RefundInvoiceDto): Promise { const invoice = await this.findById(id); if (!invoice) { throw new Error('Invoice not found'); } if (invoice.status !== 'paid' && invoice.status !== 'partial') { throw new Error('Only paid invoices can be refunded'); } const refundAmount = dto.amount || Number(invoice.paidAmount); if (refundAmount > Number(invoice.paidAmount)) { throw new Error('Refund amount cannot exceed paid amount'); } invoice.status = 'refunded'; invoice.internalNotes = `${invoice.internalNotes || ''}\n\nRefunded: ${refundAmount} - ${dto.reason}`.trim(); // TODO: Process actual refund through payment provider return this.invoiceRepository.save(invoice); } /** * Mark overdue invoices */ async markOverdueInvoices(): Promise { const now = new Date(); const result = await this.invoiceRepository .createQueryBuilder() .update(Invoice) .set({ status: 'overdue' }) .where("status IN ('sent', 'partial')") .andWhere('dueDate < :now', { now }) .execute(); return result.affected || 0; } /** * Get invoice statistics */ async getStats(tenantId?: string): Promise<{ total: number; byStatus: Record; totalRevenue: number; pendingAmount: number; overdueAmount: number; }> { const query = this.invoiceRepository.createQueryBuilder('invoice'); if (tenantId) { query.where('invoice.tenantId = :tenantId', { tenantId }); } const invoices = await query.getMany(); const byStatus: Record = { draft: 0, sent: 0, paid: 0, partial: 0, overdue: 0, void: 0, refunded: 0, }; let totalRevenue = 0; let pendingAmount = 0; let overdueAmount = 0; const now = new Date(); for (const invoice of invoices) { byStatus[invoice.status]++; if (invoice.status === 'paid') { totalRevenue += Number(invoice.paidAmount); } if (invoice.status === 'sent' || invoice.status === 'partial') { const pending = Number(invoice.total) - Number(invoice.paidAmount); pendingAmount += pending; if (invoice.dueDate < now) { overdueAmount += pending; } } } return { total: invoices.length, byStatus, totalRevenue, pendingAmount, overdueAmount, }; } /** * Generate unique invoice number */ private async generateInvoiceNumber(): Promise { const now = new Date(); const year = now.getFullYear(); const month = String(now.getMonth() + 1).padStart(2, '0'); // Get last invoice number for this month const lastInvoice = await this.invoiceRepository .createQueryBuilder('invoice') .where('invoice.invoiceNumber LIKE :pattern', { pattern: `INV-${year}${month}%` }) .orderBy('invoice.invoiceNumber', 'DESC') .getOne(); let sequence = 1; if (lastInvoice) { const lastSequence = parseInt(lastInvoice.invoiceNumber.slice(-4), 10); sequence = lastSequence + 1; } return `INV-${year}${month}-${String(sequence).padStart(4, '0')}`; } }