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
- Numeración: Facturas usan serie consecutiva por año (INV-2024-0001)
- CFDI: Obligatorio para clientes con RFC en México
- PAC: Usar ambiente de pruebas para desarrollo
- PDF: Generar bajo demanda, cachear resultado
- Retención: Mantener facturas y XMLs por mínimo 5 años