erp-core/docs/04-modelado/especificaciones-tecnicas/backend/mgn-015/ET-MGN-015-004-api-facturacion.md

18 KiB

ET-MGN-015-004: API de Facturación y Cobros

Módulo: MGN-015 - Billing y Suscripciones RF Relacionado: RF-MGN-015-004 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:

{
  "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:

{
  "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:

{
  "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:

{
  "success": true,
  "data": {
    "cfdi_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
    "status": "timbrado"
  },
  "message": "CFDI generado exitosamente"
}

Response 400:

{
  "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:

{
  "payment_method_id": "uuid"
}

Response 200:

{
  "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:

{
  "amount": 578.84,
  "reason": "duplicate_charge",
  "notes": "Cobro duplicado, se reembolsa total"
}

Response 201:

{
  "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

// 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<Buffer> {
    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

// 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<boolean> {
    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<boolean> {
    const result = await this.client.cancel(uuid, motivo);
    return result.status === 'cancelled';
  }
}

Validaciones Zod

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

// 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