# ET-MGN-015-004: API de Facturación y Cobros **Módulo:** MGN-015 - Billing y Suscripciones **RF Relacionado:** [RF-MGN-015-004](../../../requerimientos-funcionales/mgn-015/RF-MGN-015-004-facturacion-cobros.md) **Tipo:** Backend API **Estado:** Especificado **Fecha:** 2025-11-24 ## Descripción Técnica API REST para la gestión de facturas, cobros y comprobantes fiscales (CFDI). Incluye generación automática de facturas por renovación de suscripciones, procesamiento de pagos, y generación de CFDI para clientes mexicanos. ## Endpoints ### GET /api/v1/billing/invoices Lista las facturas del tenant. **Autorización:** `tenant_owner`, `admin` **Query Parameters:** | Parámetro | Tipo | Descripción | |-----------|------|-------------| | status | string | Filtrar por estado (pending, paid, overdue, void) | | from | date | Fecha inicio | | to | date | Fecha fin | | page | number | Página (default: 1) | | limit | number | Registros por página (default: 20) | **Response 200:** ```json { "success": true, "data": [ { "id": "uuid", "invoice_number": "INV-2024-0042", "status": "paid", "subtotal": 499.00, "tax_amount": 79.84, "total": 578.84, "currency": "MXN", "issued_at": "2024-01-15T00:00:00Z", "paid_at": "2024-01-15T10:30:00Z", "period_start": "2024-01-15", "period_end": "2024-02-14", "has_cfdi": true, "cfdi_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" } ], "meta": { "total": 12, "page": 1, "limit": 20 } } ``` ### GET /api/v1/billing/invoices/:id Obtiene detalle de una factura. **Autorización:** `tenant_owner`, `admin` **Response 200:** ```json { "success": true, "data": { "id": "uuid", "invoice_number": "INV-2024-0042", "status": "paid", "subtotal": 499.00, "tax_rate": 16, "tax_amount": 79.84, "discount_amount": 0, "total": 578.84, "currency": "MXN", "issued_at": "2024-01-15T00:00:00Z", "due_at": "2024-01-22T00:00:00Z", "paid_at": "2024-01-15T10:30:00Z", "lines": [ { "id": "uuid", "description": "Plan Profesional (Ene 15 - Feb 14, 2024)", "quantity": 1, "unit_price": 499.00, "total": 499.00 } ], "payments": [ { "id": "uuid", "amount": 578.84, "status": "completed", "payment_method": "Visa ****4242", "paid_at": "2024-01-15T10:30:00Z" } ], "cfdi": { "uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "serie": "A", "folio": "42", "fecha_timbrado": "2024-01-15T10:30:15Z", "pdf_url": "/api/v1/billing/invoices/uuid/pdf", "xml_url": "/api/v1/billing/invoices/uuid/xml" } } } ``` ### GET /api/v1/billing/invoices/:id/pdf Descarga PDF de la factura. **Autorización:** `tenant_owner`, `admin` **Response 200:** application/pdf ### GET /api/v1/billing/invoices/:id/xml Descarga XML del CFDI (si existe). **Autorización:** `tenant_owner`, `admin` **Response 200:** application/xml ### POST /api/v1/billing/invoices/:id/request-cfdi Solicita generación de CFDI para una factura pagada. **Autorización:** `tenant_owner` **Request Body:** ```json { "rfc": "DCO123456ABC", "razon_social": "Demo Company S.A. de C.V.", "regimen_fiscal": "601", "uso_cfdi": "G03", "domicilio_fiscal": "12345", "email": "facturacion@demo-company.mx" } ``` **Response 200:** ```json { "success": true, "data": { "cfdi_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "status": "timbrado" }, "message": "CFDI generado exitosamente" } ``` **Response 400:** ```json { "success": false, "error": "RFC inválido según el SAT" } ``` ### POST /api/v1/billing/invoices/:id/retry-payment Reintenta el cobro de una factura pendiente. **Autorización:** `tenant_owner` **Request Body:** ```json { "payment_method_id": "uuid" } ``` **Response 200:** ```json { "success": true, "data": { "invoice": { "status": "paid" }, "payment": { "id": "uuid", "status": "completed" } }, "message": "Pago procesado exitosamente" } ``` ### POST /api/v1/billing/invoices/:id/credit-note (Admin) Genera nota de crédito (reembolso). **Autorización:** `super_admin` **Request Body:** ```json { "amount": 578.84, "reason": "duplicate_charge", "notes": "Cobro duplicado, se reembolsa total" } ``` **Response 201:** ```json { "success": true, "data": { "credit_note": { "id": "uuid", "invoice_number": "NC-2024-0001", "amount": -578.84 }, "refund": { "id": "uuid", "status": "completed" } }, "message": "Nota de crédito generada y reembolso procesado" } ``` ## Service Implementation ```typescript // invoices.service.ts export class InvoicesService { async findAll(tenantId: string, filters: InvoiceFilters) { let query = ` SELECT i.id, i.invoice_number, i.status, i.subtotal, i.tax_amount, i.total, i.currency, i.issued_at, i.paid_at, i.period_start, i.period_end, i.cfdi_uuid IS NOT NULL as has_cfdi, i.cfdi_uuid FROM billing.invoices i WHERE i.tenant_id = $1 `; const params: any[] = [tenantId]; if (filters.status) { params.push(filters.status); query += ` AND i.status = $${params.length}`; } if (filters.from) { params.push(filters.from); query += ` AND i.issued_at >= $${params.length}`; } if (filters.to) { params.push(filters.to); query += ` AND i.issued_at <= $${params.length}`; } query += ' ORDER BY i.issued_at DESC'; query += ` LIMIT $${params.length + 1} OFFSET $${params.length + 2}`; params.push(filters.limit || 20, ((filters.page || 1) - 1) * (filters.limit || 20)); return db.query(query, params); } async findById(tenantId: string, id: string) { const invoice = await db.queryOne(` SELECT i.* FROM billing.invoices i WHERE i.id = $1 AND i.tenant_id = $2 `, [id, tenantId]); if (!invoice) { throw new NotFoundError('Factura no encontrada'); } // Obtener líneas invoice.lines = await db.query(` SELECT * FROM billing.invoice_lines WHERE invoice_id = $1 ORDER BY sort_order `, [id]); // Obtener pagos invoice.payments = await db.query(` SELECT p.*, pm.brand, pm.last_four FROM billing.payments p LEFT JOIN billing.payment_methods pm ON p.payment_method_id = pm.id WHERE p.invoice_id = $1 ORDER BY p.created_at `, [id]); return invoice; } async createForRenewal(subscription: any, amount: number) { const plan = await plansService.findById(subscription.plan_id); const taxRate = 16; // IVA México const subtotal = amount; const taxAmount = subtotal * (taxRate / 100); const total = subtotal + taxAmount; // Generar número de factura const lastInvoice = await db.queryOne(` SELECT invoice_number FROM billing.invoices WHERE invoice_number LIKE 'INV-${new Date().getFullYear()}-%' ORDER BY invoice_number DESC LIMIT 1 `); const nextNumber = lastInvoice ? parseInt(lastInvoice.invoice_number.split('-')[2]) + 1 : 1; const invoiceNumber = `INV-${new Date().getFullYear()}-${String(nextNumber).padStart(4, '0')}`; // Calcular período const periodStart = subscription.current_period_end; const periodEnd = this.calculatePeriodEnd(periodStart, subscription.billing_cycle); // Crear factura const invoice = await db.queryOne(` INSERT INTO billing.invoices ( tenant_id, subscription_id, invoice_number, status, subtotal, tax_rate, tax_amount, total, currency, issued_at, due_at, period_start, period_end ) VALUES ($1, $2, $3, 'pending', $4, $5, $6, $7, $8, NOW(), NOW() + INTERVAL '7 days', $9, $10) RETURNING * `, [ subscription.tenant_id, subscription.id, invoiceNumber, subtotal, taxRate, taxAmount, total, plan.currency || 'MXN', periodStart, periodEnd ]); // Crear línea de factura const periodLabel = this.formatPeriodLabel(periodStart, periodEnd); await db.query(` INSERT INTO billing.invoice_lines ( invoice_id, description, quantity, unit_price, total ) VALUES ($1, $2, 1, $3, $3) `, [ invoice.id, `${plan.name} (${periodLabel})`, subtotal ]); return invoice; } async processPayment(tenantId: string, invoiceId: string, paymentMethodId?: string) { const invoice = await this.findById(tenantId, invoiceId); if (invoice.status === 'paid') { throw new BadRequestError('La factura ya está pagada'); } // Procesar cobro const result = await paymentMethodsService.chargePaymentMethod(tenantId, { amount: invoice.total, currency: invoice.currency, description: `Factura ${invoice.invoice_number}`, invoice_id: invoice.id, payment_method_id: paymentMethodId }); // Registrar pago const payment = await db.queryOne(` INSERT INTO billing.payments ( tenant_id, invoice_id, payment_method_id, amount, status, gateway_transaction_id, gateway_response ) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING * `, [ tenantId, invoice.id, paymentMethodId, invoice.total, result.success ? 'completed' : 'failed', result.transaction_id, JSON.stringify(result) ]); if (result.success) { // Actualizar factura await db.query(` UPDATE billing.invoices SET status = 'paid', paid_at = NOW() WHERE id = $1 `, [invoice.id]); } else { // Registrar error await db.query(` UPDATE billing.invoices SET billing_errors = billing_errors || $1::jsonb WHERE id = $2 `, [JSON.stringify([{ date: new Date(), error: result.error }]), invoice.id]); } return { invoice: await this.findById(tenantId, invoice.id), payment }; } async generateCFDI(tenantId: string, invoiceId: string, fiscalData: CFDIRequestDto) { const invoice = await this.findById(tenantId, invoiceId); if (invoice.status !== 'paid') { throw new BadRequestError('Solo se puede generar CFDI para facturas pagadas'); } if (invoice.cfdi_uuid) { throw new BadRequestError('Esta factura ya tiene un CFDI'); } // Validar RFC con el SAT (usando PAC) const rfcValid = await pacService.validateRFC(fiscalData.rfc); if (!rfcValid) { throw new BadRequestError('RFC inválido según el SAT'); } // Construir XML del CFDI 4.0 const cfdiData = { serie: 'A', folio: invoice.invoice_number.split('-')[2], fecha: new Date().toISOString(), formaPago: '04', // Tarjeta de crédito metodoPago: 'PUE', // Pago en una sola exhibición tipoDeComprobante: 'I', // Ingreso exportacion: '01', // No aplica lugarExpedicion: process.env.CFDI_LUGAR_EXPEDICION, moneda: invoice.currency, total: invoice.total, subTotal: invoice.subtotal, emisor: { rfc: process.env.CFDI_RFC_EMISOR, nombre: process.env.CFDI_NOMBRE_EMISOR, regimenFiscal: process.env.CFDI_REGIMEN_FISCAL }, receptor: { rfc: fiscalData.rfc, nombre: fiscalData.razon_social, domicilioFiscalReceptor: fiscalData.domicilio_fiscal, regimenFiscalReceptor: fiscalData.regimen_fiscal, usoCFDI: fiscalData.uso_cfdi }, conceptos: invoice.lines.map(line => ({ claveProdServ: '81112100', // Servicios de software cantidad: line.quantity, claveUnidad: 'E48', // Unidad de servicio descripcion: line.description, valorUnitario: line.unit_price, importe: line.total, objetoImp: '02', // Sí objeto de impuesto impuestos: { traslados: [{ base: line.total, impuesto: '002', // IVA tipoFactor: 'Tasa', tasaOCuota: 0.16, importe: line.total * 0.16 }] } })), impuestos: { totalImpuestosTrasladados: invoice.tax_amount, traslados: [{ base: invoice.subtotal, impuesto: '002', tipoFactor: 'Tasa', tasaOCuota: 0.16, importe: invoice.tax_amount }] } }; // Enviar a PAC para timbrado const timbrado = await pacService.timbrar(cfdiData); // Guardar resultados await db.query(` UPDATE billing.invoices SET cfdi_uuid = $1, cfdi_xml = $2, cfdi_timbrado_at = NOW() WHERE id = $3 `, [timbrado.uuid, timbrado.xml, invoiceId]); // Guardar datos fiscales del receptor await db.query(` UPDATE auth.tenants SET fiscal_data = $1 WHERE id = $2 `, [JSON.stringify(fiscalData), tenantId]); // Enviar por email await emailService.send({ to: fiscalData.email, template: 'cfdi_generated', attachments: [ { filename: `${invoice.invoice_number}.pdf`, content: timbrado.pdf }, { filename: `${invoice.invoice_number}.xml`, content: timbrado.xml } ] }); return { cfdi_uuid: timbrado.uuid, status: 'timbrado' }; } async generatePDF(tenantId: string, invoiceId: string): Promise { const invoice = await this.findById(tenantId, invoiceId); const tenant = await db.queryOne('SELECT * FROM auth.tenants WHERE id = $1', [tenantId]); // Usar template HTML + puppeteer para generar PDF const html = await renderTemplate('invoice', { invoice, tenant }); const pdf = await pdfService.generateFromHtml(html); return pdf; } private formatPeriodLabel(start: Date, end: Date): string { const formatter = new Intl.DateTimeFormat('es-MX', { month: 'short', day: 'numeric' }); return `${formatter.format(start)} - ${formatter.format(end)}, ${end.getFullYear()}`; } private calculatePeriodEnd(start: Date, cycle: string): Date { const end = new Date(start); switch (cycle) { case 'monthly': end.setMonth(end.getMonth() + 1); break; case 'quarterly': end.setMonth(end.getMonth() + 3); break; case 'semi_annual': end.setMonth(end.getMonth() + 6); break; case 'yearly': end.setFullYear(end.getFullYear() + 1); break; } return end; } } ``` ## Integración con PAC ```typescript // services/pac.service.ts export class PACService { private client: any; constructor() { // Inicializar cliente del PAC (ejemplo con Facturama) this.client = new FacturamaClient({ user: process.env.PAC_USER, password: process.env.PAC_PASSWORD, sandbox: process.env.NODE_ENV !== 'production' }); } async validateRFC(rfc: string): Promise { try { const result = await this.client.validateRFC(rfc); return result.valid; } catch { return false; } } async timbrar(cfdiData: any): Promise<{ uuid: string; xml: string; pdf: Buffer }> { const result = await this.client.create(cfdiData); return { uuid: result.Complement.TaxStamp.Uuid, xml: result.xml, pdf: Buffer.from(result.pdf, 'base64') }; } async cancelar(uuid: string, motivo: string): Promise { const result = await this.client.cancel(uuid, motivo); return result.status === 'cancelled'; } } ``` ## Validaciones Zod ```typescript export const cfdiRequestSchema = z.object({ rfc: z.string().regex(/^[A-Z&Ñ]{3,4}[0-9]{6}[A-Z0-9]{3}$/, 'RFC inválido'), razon_social: z.string().min(1).max(254), regimen_fiscal: z.string().regex(/^[0-9]{3}$/), uso_cfdi: z.string().regex(/^[A-Z][0-9]{2}$/), domicilio_fiscal: z.string().regex(/^[0-9]{5}$/), email: z.string().email() }); export const creditNoteSchema = z.object({ amount: z.number().positive(), reason: z.enum(['duplicate_charge', 'service_issue', 'other']), notes: z.string().max(500).optional() }); ``` ## Jobs Programados ```typescript // jobs/invoice-generation.job.ts // Ejecutar diariamente a las 00:00 UTC export async function generateRenewalInvoices() { const subscriptions = await db.query(` SELECT s.* FROM billing.subscriptions s WHERE s.status = 'active' AND s.cancel_at_period_end = false AND s.current_period_end <= NOW() + INTERVAL '1 day' AND NOT EXISTS ( SELECT 1 FROM billing.invoices i WHERE i.subscription_id = s.id AND i.period_start = s.current_period_end ) `); for (const sub of subscriptions) { await invoicesService.createForRenewal(sub); } } // jobs/payment-retry.job.ts // Ejecutar cada hora export async function retryFailedPayments() { const invoices = await db.query(` SELECT i.*, s.tenant_id FROM billing.invoices i JOIN billing.subscriptions s ON i.subscription_id = s.id WHERE i.status = 'pending' AND i.due_at < NOW() AND ( i.retry_count = 0 AND i.issued_at < NOW() - INTERVAL '1 day' OR i.retry_count = 1 AND i.issued_at < NOW() - INTERVAL '3 days' OR i.retry_count = 2 AND i.issued_at < NOW() - INTERVAL '7 days' ) AND i.retry_count < 3 `); for (const invoice of invoices) { try { await invoicesService.processPayment(invoice.tenant_id, invoice.id); } catch (error) { await db.query( 'UPDATE billing.invoices SET retry_count = retry_count + 1 WHERE id = $1', [invoice.id] ); } } } ``` ## Notas de Implementación 1. **Numeración:** Facturas usan serie consecutiva por año (INV-2024-0001) 2. **CFDI:** Obligatorio para clientes con RFC en México 3. **PAC:** Usar ambiente de pruebas para desarrollo 4. **PDF:** Generar bajo demanda, cachear resultado 5. **Retención:** Mantener facturas y XMLs por mínimo 5 años