Files
HoruxDespachos/docs/plans/2026-04-13-auto-invoicing-mp-payments.md
2026-04-27 22:09:36 -06:00

7.8 KiB

Auto-facturación de pagos MercadoPago con Facturapi

Resumen

Cuando MercadoPago confirma un pago aprobado (webhook), Horux 360 emite automáticamente un CFDI al público en general vía Facturapi, sin intervención manual. Aplica a pagos recurrentes (preapproval) y pagos de prorateo de upgrade (preference).

Motivación

Antes: los pagos se registraban en tabla payments pero la emisión de factura era 100% manual. Riesgo de olvido → pagos cobrados sin CFDI, cliente se queja, SAT audita.

Ahora: emisión automática como default. El admin solo toca la primera factura de cada cliente nuevo (para capturar/verificar datos fiscales); el resto va solo a público en general.

Reglas

Condición ¿Se factura?
Payment status === 'approved' + amount > 0 + hay al menos 1 payment aprobado previo del mismo tenant — auto-emit a público en general
Payment es el primer aprobado de este tenant No — admin emite manual para capturar datos fiscales del cliente
amount === 0 (trial) No
status !== 'approved' (pending, rejected) No
Payment.facturapiInvoiceId ya existe No (idempotente — webhook reintentó)
Tenant emisor (Horux 360) sin facturapiOrgId configurado No — log warning, admin configura en /configuracion
Tenant emisor sin CP en datos fiscales No — log warning

Emisor: Horux 360 (RESICO PM)

  • RFC: HTS240708LJA
  • Régimen fiscal: 626 (RESICO PM)
  • Retenciones: ninguna (RESICO PM no retiene IVA ni ISR en sus facturas emitidas)

La organización Facturapi del tenant Horux 360 es la que emite — facturapiService.createInvoice(horux360TenantId, payload) enruta al API key correcto.

Receptor: Público en general

Campo Valor
taxId XAXX010101000 (RFC genérico)
legalName PUBLICO EN GENERAL
taxSystem 616 (Sin obligaciones fiscales)
zip CP del emisor (patrón SAT)

CFDI use: S01 — Sin efectos fiscales.

Nota: esto NO es "factura global consolidada" (que requiere periodicidad/mes/año y acumula varios tickets). Es una factura simple de tipo I a público en general por cada pago.

Concepto

Campo Valor
description Suscripción ${plan} ${mensual|anual} a Horux 360
productKey 81112502 — Servicios de alojamiento de aplicaciones
unitKey E48 — Unidad de servicio
unitName Servicio
quantity 1
price Payment.amount (ya incluye IVA)
taxIncluded true — Facturapi desagrega subtotal + IVA 16%
taxes [{ type: 'IVA', rate: 0.16, factor: 'Tasa' }] — sin retenciones

Forma de pago

Mapeo del paymentMethodId que manda MercadoPago al código SAT:

MP paymentMethodId SAT forma pago
credit_card 04 — Tarjeta de crédito
debit_card 28 — Tarjeta de débito
account_money 03 — Transferencia
bank_transfer 03
otro / desconocido 03 (default conservador)

paymentMethod: PUE (pago en una sola exhibición — MP ya cobró cuando el webhook dispara).

Flujo

1. Usuario paga en MercadoPago
         ↓
2. MP POST /webhooks/mercadopago { type: 'payment', data: { id } }
         ↓
3. webhook.controller detecta tipo
   ↓                                      ↓
   proration:*                            tenantId (UUID)
   → recordPayment                        → recordPayment
   → applyApprovedUpgrade                 → subscription.status = 'authorized'
   → emitInvoiceIfApplicable(payment.id)  → emitInvoiceIfApplicable(payment.id)
         ↓
4. invoicing.service.emitInvoiceIfApplicable:
   - Gate: ya facturado? approved? amount>0? primer pago?
   - Gate: emisor Horux 360 bien configurado?
   - Build payload (público general + concepto)
   - facturapiService.createInvoice(horux360TenantId, payload)
   - UPDATE Payment.facturapiInvoiceId

Fail-soft: el webhook nunca muere por facturación

Si Facturapi está caído o devuelve error:

  • emitInvoiceIfApplicable catchea todo, logea [Invoicing] Error emitiendo factura...
  • Payment.facturapiInvoiceId queda null
  • Webhook retorna 200 a MP → no hay reintento → no se duplica Payment

El admin ve Payment.facturapiInvoiceId IS NULL AND status = 'approved' y puede re-emitir manualmente. Mejora futura: cron de reintento.

Schema

Campo nuevo en Payment (BD central):

facturapiInvoiceId String? @map("facturapi_invoice_id")

Aplicar con: pnpm prisma db push (idempotente) o prisma migrate deploy en prod.

Archivos

Nuevos

  • apps/api/src/services/payment/invoicing.service.ts — lógica completa del auto-emit

Modificados

  • apps/api/prisma/schema.prismaPayment.facturapiInvoiceId
  • apps/api/src/controllers/webhook.controller.ts — llama emitInvoiceIfApplicable(paymentRecord.id) en ambos caminos (proration + recurring) después de recordPayment si status === 'approved'

Constantes ajustables

Viven como const al inicio de invoicing.service.ts:

Constante Valor actual Cuándo cambiar
CONCEPT_PRODUCT_KEY 81112502 Si cambias clasificación SAT del servicio
CUSTOMER_TAX_ID XAXX010101000 Nunca (RFC genérico público general)
USE_CFDI S01 Nunca para público en general
IVA_RATE 0.16 Solo si cambia la ley fiscal
FORMA_PAGO_POR_METHOD mapeo Si MP agrega método no contemplado

Para cambiar:

  1. Edita la constante en el archivo
  2. pnpm typecheck (verifica que nada más se rompa)
  3. Restart server

Pruebas (requiere MP_ACCESS_TOKEN + Facturapi configurado)

Caso 1: Primer pago (manual)

  1. Usuario nuevo subscribe plan Business mensual → crea preapproval
  2. MP cobra primer pago → webhook → recordPaymentemitInvoiceIfApplicable
  3. En log: [Invoicing] Payment X es el PRIMER pago aprobado del tenant Y, skip (factura manual)
  4. Payment.facturapiInvoiceId = null
  5. Admin emite manual desde Facturapi dashboard

Caso 2: Segundo pago en adelante (auto)

  1. MP cobra recurrente del mismo tenant → webhook
  2. En log: [Invoicing] Emitiendo factura para Payment X (tenant Y, $480)
  3. Payment.facturapiInvoiceId = fac_xxxxx
  4. Ver factura en Facturapi dashboard: PUBLICO EN GENERAL, concepto "Suscripción Business mensual a Horux 360"

Caso 3: Upgrade con proration

  1. Usuario cambia de Business → Business+IA mid-period → MP Preference por diff prorateado
  2. Usuario paga → webhook con external_reference: proration:...
  3. Si NO es primer pago aprobado del tenant: auto-emit con descripción del nuevo plan
  4. Si ES primer pago (nuevo tenant que upgradea inmediatamente): skip manual

Caso 4: Facturapi caído

  1. Webhook fires
  2. createInvoice lanza error
  3. Log: [Invoicing] Error emitiendo factura para Payment X: ...
  4. Payment.facturapiInvoiceId = null — admin puede re-emitir después
  5. Webhook retorna 200 → MP no reintenta → no hay duplicación

Pendientes / mejoras posibles

  1. Cron de reintento para payments con facturapiInvoiceId IS NULL AND status='approved'. Hoy requiere acción manual del admin.
  2. Email confirmación al cliente con el PDF/XML — Facturapi tiene sendByEmail que podría llamarse después de la emisión exitosa. Requiere capturar el email del admin del tenant.
  3. UI para admin de pagos sin factura — vista que liste Payment.status='approved' AND facturapiInvoiceId IS NULL con botón "Emitir manualmente".
  4. Cancelación de factura si el pago se reembolsa — actualmente si MP reembolsa un pago, la factura queda emitida. Necesitaría llamar cancelInvoice de Facturapi.
  5. Reporte fiscal mensual — query que consolide ingresos emitidos en Facturapi del tenant Horux 360 (para declaración ISR).