Initial commit - Horux Despachos NL

This commit is contained in:
2026-05-03 16:47:53 -06:00
commit b00b677c54
647 changed files with 133843 additions and 0 deletions

View File

@@ -0,0 +1,174 @@
# 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 | **Sí** — 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):
```prisma
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.prisma``Payment.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 → `recordPayment``emitInvoiceIfApplicable`
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).