Initial commit: Horux Despachos project

This commit is contained in:
consultoria-as
2026-04-27 01:11:06 -06:00
commit 56a05ba767
604 changed files with 121723 additions and 0 deletions

View File

@@ -0,0 +1,391 @@
# Sesión 2026-04-22 — Cierre de pendientes y add-ons por contribuyente
Sesión continuación del trabajo de Tanda A / B documentado en
`docs/plans/2026-04-21-session-2-mp-setup-and-bugfixes.md`. Esta sesión cubrió:
1. **Tanda B.2-B.5** — Extensión del cache read-through, alineación
dashboard ≡ impuestos, lock SAT por contribuyente, watchdog CLI
(ver doc 2026-04-21 § "Tanda B.2" en adelante).
2. **Pendientes derivados de hoy** — A, B, C, D + mejora de logging SAT.
3. **Feature: Add-ons por contribuyente** — infraestructura para cobro
recurrente mensual por RFC, con preapproval MP independiente de la
licencia anual del despacho. Primer add-on: Lolita IA ($250/mes).
---
## Pendientes derivados cerrados
### A. Watchdog CLI de SAT stale jobs
**Problema:** jobs `pending` con `nextRetryAt` vencido o `running`
huérfanos (proceso crasheó a mitad del sync) quedaban invisibles y
bloqueaban futuros syncs por el lock.
**Solución:** `apps/api/scripts/sweep-stale-sat-jobs.ts`. Dos categorías
con thresholds sobreescribibles por env:
- `pending` con `nextRetryAt` < now `STALE_PENDING_HOURS` (default 12)
- `running` con `startedAt` < now `STALE_RUNNING_HOURS` (default 4)
Dry-run por default; `--apply` ejecuta. Verificado con un dry-run
limpio (0 stale) mientras Manuel corría.
Pendiente: wiring como cron cada 2h en `sat-sync.job.ts`.
### B. Crons en dev con flag
**Problema:** todos los crons estaban gateados con
`env.NODE_ENV === 'production'`. En dev ningún cron arrancaba — por eso
el job de Alexa (status pending con `nextRetryAt = +5min`) quedó
colgado: el cron horario `retryTimedOutJobs` nunca corrió.
**Solución:** en `apps/api/src/index.ts`, partir el gate en dos:
```ts
const cronsEnabled = env.NODE_ENV === 'production' || process.env.ENABLE_CRONS_IN_DEV === '1';
const sendRealEmails = env.NODE_ENV === 'production';
if (cronsEnabled) {
startSatSyncJob();
startMetricasInvalidationsJob();
if (sendRealEmails) startWeeklyUpdateJob();
}
```
`weekly-update` sigue prod-only para no mandar emails a owners reales
desde dev. Confirmado al restart: `[Cron] Jobs omitidos en dev (usar
ENABLE_CRONS_IN_DEV=1 para activar)`.
### C. Cache en `getIvaMensual` + refactor a fórmula canónica
**Problema doble:** la fórmula de `getIvaMensual` divergía de la del
dashboard/impuestos (no filtraba PUE, no manejaba NC, retenido gross).
Además no leía de `metricas_mensuales` para años cerrados.
**Solución:**
- Reescribir con los 6 buckets canónicos (ver `getResumenIva` en 2026-04-21 § Tanda B.3).
- Cache read-through desde `metricas_mensuales` cuando año < actual,
sin conciliación, con contribuyente seleccionado. Helper nuevo
`readIvaMensualFromCache` agrega T/A/R por mes.
- On-the-fly: 2 queries (una por lado causado/acreditable) grouped por mes.
`getIsrMensual` y `getResumenIsr` siguen on-the-fly — requieren tarifas
progresivas y no están en `metricas_mensuales`. Fuera de alcance.
### D. Cache en `calcularFlujoPorMes` — **fuera de alcance**
**Problema:** `calcularFlujoPorMes` usa `total_mxn`/`monto_pago_mxn`
(IVA incluido) pero los campos stored `flujo_entradas/salidas/neto` en
`metricas_mensuales` se poblan desde ingresos/egresos NETOS (sin IVA).
**Decisión:** no cachear hasta tener columnas `flujo_bruto_*`
separadas o reescribir el concepto. El cómputo on-the-fly ya es
eficiente (6 queries agregadas por año). Costo/beneficio no lo justifica
ahora. Documentado como pendiente.
### Extra. Logging informativo de rejections SAT
**Problema:** durante el sync de Manuel, 9 bloques consecutivos de
emitidos cayeron en `rejected`. El mensaje `verifyResult.message` era
el genérico `"Solicitud Aceptada"` del wrapper HTTP. La razón real
(códigos 5001, 5002, 5003, 5005, etc.) quedaba enterrada.
**Solución:** en `sat-client.service.ts:verifySatRequest`, cuando
`status` es `rejected`/`failed`, construir el message con
`SAT code=N request=EntryId(value) msg="..."` que incluye:
- `statusCode` (código numérico SAT)
- `entryId` (etiqueta del `StatusRequest`)
- `value` (valor numérico del `StatusRequest`)
- `msg` (mensaje del wrapper, ya existente)
---
## Feature: Add-ons por contribuyente
### Modelo de negocio
- **Lolita IA** — $250/mes por cada contribuyente que lo active.
Cualquier plan puede contratarlo.
- **Contribuyente adicional Business Cloud** — $45/mes por RFC extra
(el plan incluye 3; del 4º en adelante). Automático por count,
no opt-in. Modelado como add-on para que el preapproval MP lo cubra.
Ambos add-ons son **mensuales**; la licencia del despacho es **anual**.
→ Requieren preapproval MP **independiente** por add-on — cancelación
granular sin tocar la suscripción base.
### Ruta descartada
Primer intento fue una tabla tenant `contribuyente_addons
(contribuyente_id, addon_key, enabled, config)` con feature-toggles
(facturación/conciliación/documentos/calendario/reportes). Modelo
incorrecto: los add-ons reales son servicios de cobro recurrente, no
switches de features. Revertido completo antes de iterar.
### Ruta correcta — extender `SubscriptionAddon` existente
Ya existía infraestructura a nivel tenant (`plan_addon_catalogo` +
`subscription_addons` + `addon.service.ts` con preapproval MP por
add-on). Extensión:
**Schema (Prisma):**
```prisma
model SubscriptionAddon {
contribuyenteId String? @map("contribuyente_id") // NULL = tenant-level
// ... resto igual
@@index([subscriptionId, contribuyenteId])
// @@unique([subscriptionId, planAddonCatalogoId]) ← REMOVIDO
}
```
Sin `@@unique` compuesto porque Postgres trata `NULL != NULL` y no hay
forma trivial de enforcar "un solo addon activo por (sub, codename,
contribuyente?)" con Prisma. Validación queda a nivel app en
`subscribeAddon.findFirst`.
**Migration SQL:**
- Agrega `contribuyente_id TEXT NULL`
- Elimina UNIQUE(subscription_id, plan_addon_catalogo_id)
- Agrega índice (subscription_id, contribuyente_id)
**Catálogo (seed.ts):** 2 nuevos add-ons:
- `lolita_ia_contribuyente` — $250/mes, `verticalProfile=CONTABLE`
- `contribuyente_extra_business_cloud` — $45/mes, `verticalProfile=CONTABLE`
**Service (`addon.service.ts`):**
- `subscribeAddon` acepta `contribuyenteId: string | null`. El reason
del preapproval incluye prefix del RFC cuando aplica
(`"Horux Despachos - Lolita IA (RFC abcd1234) x1 - Zorro Despacho"`).
- `listActiveAddons(tenantId, contribuyenteId?)` filtra por RFC cuando
se pasa el param. Sin param → retorna todos los add-ons del tenant
(incluye tenant-level y per-contribuyente).
- La validación "ya tienes activo" ahora considera `contribuyenteId`:
mismo addon en 2 contribuyentes distintos es OK; 2 veces para el
mismo contribuyente rechaza.
**Controller (`subscription.controller.ts`):**
- `GET /subscriptions/me/addons?contribuyenteId=...` — filtra por RFC.
- `POST /subscriptions/me/addons` acepta `{ addonCodename, quantity,
contribuyenteId }` en body.
**Frontend:**
- `apps/web/lib/api/addons.ts` + `use-addons.ts` hooks.
- `apps/web/app/(dashboard)/contribuyentes/addons-dialog.tsx`:
catálogo `ADDONS_POR_CONTRIBUYENTE` (hoy solo Lolita IA). Muestra
precio, descripción, estado (activo/pending/sin contratar), fecha
del próximo cobro. Botón "Contratar" abre MP init_point en nueva
pestaña; "Cancelar" pide confirmación y revoca el preapproval.
- `contribuyentes/page.tsx`: botón `Sparkles` por contribuyente abre
el dialog.
### Cableado automático del overage Business Cloud
El add-on `contribuyente_extra_business_cloud` ($45/mes) ahora se ajusta
automáticamente al crear o desactivar un contribuyente.
**Modelo:** un único `SubscriptionAddon` a nivel tenant
(`contribuyenteId = null`, `codename = contribuyente_extra_business_cloud`)
con `quantity = max(0, activeCount 3)`. El monto del preapproval MP
refleja `precio × quantity`. Cuando `quantity` cambia, se actualiza vía
`updatePreapprovalAmount` (sin re-autorización del usuario).
**Función:** `adjustBusinessCloudOverage(tenantId, activeContribuyenteCount)`
en `addon.service.ts`. Idempotente. Maneja los 5 casos:
- Plan ≠ `business_cloud` → `'skipped'`
- `overage = 0` sin addon → `'none'`
- `overage = 0` con addon → `'cancelled'` (revoca preapproval)
- `overage > 0` sin addon → `'created'` (crea addon + preapproval, retorna `paymentUrl`)
- `overage > 0` con addon, quantity ya coincide → `'none'` (idempotente)
- `overage > 0` con addon, quantity distinto → `'updated'` (updatePreapprovalAmount)
**Integración:**
- `contribuyente.controller.ts:create` y `:deactivate` llaman
`countActiveContribuyentes(pool)` + `adjustBusinessCloudOverage(tenantId, count)`
tras la operación. Fail-soft: si el ajuste falla, el contribuyente queda
creado/desactivado y el error se loguea (no bloquea la respuesta).
- Frontend (`contribuyentes/page.tsx`): si `result.overage.action === 'created'`
+ `paymentUrl`, muestra alerta y abre MP en nueva pestaña. Para `'updated'`
o `'cancelled'` muestra toast informativo.
**Transparencia de cobro:**
- Plan `business_cloud` = $15K/año (licencia, anual).
- Addon overage = $45/mes × quantity (mensual).
- MercadoPago cobra ambos independientemente. Cancelar la licencia
cancela su preapproval; cancelar RFCs baja el quantity del addon
automáticamente.
### Casos de uso validados
| Escenario | Estado |
|---|---|
| Tenant nuevo, crea 3 RFCs | Sin addon (no excede) |
| Crea 4º RFC → overage=1 | Addon `created`, paymentUrl devuelto, $45/mes pending |
| Crea 5º RFC → overage=2 | Addon `updated`, `updatePreapprovalAmount($90)` |
| Desactiva 1 (quedan 4) → overage=1 | Addon `updated`, `updatePreapprovalAmount($45)` |
| Desactiva otro (quedan 3) → overage=0 | Addon `cancelled`, preapproval MP revocado |
| Tenant en `business_control` crea 10º RFC | `skipped` (plan no aplica) |
| Tenant sin suscripción activa | `skipped` (catch-all) |
---
## Puesta en marcha de datos para testing
### Backfill de suscripciones de despacho
Los tenants Zorro (`DESPACHO_MO7JE8BZ_VDOPR`) y Patito (`DESPACHO_MO3NI6U8_B9VGG`)
fueron provisionados directamente como admin (sin pasar por el flujo
self-serve de MP), por lo que no tenían `Subscription` en BD central. Esto
bloqueaba el testing de add-ons (gate en `subscribeAddon`).
Se insertaron manualmente suscripciones `authorized` con `mpPreapprovalId=null`
(licencia por arreglo directo, cobro de add-ons va por separado):
| Tenant | Plan | Amount | Frequency | Period |
|---|---|---|---|---|
| Zorro | `business_cloud` | $15,000 | annual | 2026-04-23 → 2027-04-23 |
| Patito | `business_control` | $21,000 | annual | 2026-04-23 → 2027-04-23 |
`Tenant.plan` también se actualizó al valor correcto (antes Zorro estaba
en `enterprise` y Patito en `business`).
### Configuración MercadoPago sandbox
Agregado a `.env`:
```
MP_ACCESS_TOKEN=TEST-...
```
**Gotcha descubierto:** MP rechaza `http://localhost:3000` como `back_url`
del preapproval (requiere HTTPS público). Durante el testing se cambió
`FRONTEND_URL` a `https://horuxfin.com` temporalmente y se revirtió al
terminar. Solución durable pendiente (doc más abajo).
### Add-ons Lolita IA activos
| Contribuyente | Despacho | addonId | preapprovalId | status |
|---|---|---|---|---|
| Alexa G. Torres Romero (TORA0007099R6) | Zorro | `0cfb5c0b-…` | `b0dd70c3…` | `authorized` |
| Carlos H. Torres Romero (TORC9611214CA) | Patito | `17ed5185-…` | `48e20f17…` | `authorized` |
Preapprovals reales en MP sandbox. Status movido manualmente a `authorized`
via `handleAddonPayment(addonId, 'manual-sim', 'authorized')` porque no hay
webhook configurado. En prod esto lo hace automáticamente
`POST /api/webhooks/mercadopago`.
Period mensual: 2026-04-23 → 2026-05-23. El próximo ciclo se renovaría con
MP webhook real (pendiente Cloudflare Tunnel).
---
## Archivos tocados esta sesión
### Backend
- `apps/api/src/index.ts` — gate de crons con `ENABLE_CRONS_IN_DEV`
- `apps/api/src/services/sat/sat-client.service.ts` — rejection logging informativo
- `apps/api/src/services/impuestos.service.ts` — `getIvaMensual` refactor + cache (helper `readIvaMensualFromCache`); constantes SQL elevadas a file-level en Tanda B.3 (§ sesión 2026-04-21)
- `apps/api/src/services/dashboard.service.ts` — (ver Tanda B.3 en sesión 2026-04-21)
- `apps/api/src/services/sat/sat.service.ts` — (ver Tanda B.4 en sesión 2026-04-21)
- `apps/api/src/services/metricas.service.ts` — (ver Tanda A bugfix en sesión 2026-04-21)
- `apps/api/src/services/payment/addon.service.ts` — `contribuyenteId` en `subscribeAddon` + `listActiveAddons`; **nueva función `adjustBusinessCloudOverage`** para cableado automático del overage
- `apps/api/src/controllers/subscription.controller.ts` — `getMyAddons` + `addMyAddon` aceptan contribuyenteId
- `apps/api/src/controllers/contribuyente.controller.ts` — `create` y `deactivate` llaman `adjustBusinessCloudOverage` tras la operación; helper `countActiveContribuyentes`
- `apps/api/prisma/schema.prisma` — `SubscriptionAddon.contribuyenteId` opcional
- `apps/api/prisma/migrations/20260422172323_subscription_addons_contribuyente_id/migration.sql` — **nuevo**
- `apps/api/prisma/seed.ts` — 2 addons nuevos
- `apps/api/scripts/sweep-stale-sat-jobs.ts` — **nuevo** (watchdog CLI)
- `apps/api/scripts/validate-dashboard-impuestos.ts` — (ver Tanda B.3 en sesión 2026-04-21)
### Frontend
- `apps/web/lib/api/addons.ts` — **nuevo** (cliente API)
- `apps/web/lib/hooks/use-addons.ts` — **nuevo** (hooks React Query)
- `apps/web/app/(dashboard)/contribuyentes/addons-dialog.tsx` — **nuevo**
- `apps/web/app/(dashboard)/contribuyentes/page.tsx` — botón Sparkles + wiring del dialog
### Data directa
- `horux_despachos` (central):
- `planAddonCatalogo` upsert con 2 filas nuevas (`lolita_ia_contribuyente`
$250/mes, `contribuyente_extra_business_cloud` $45/mes). Aplicado vía
script temporal ya borrado.
- `subscriptions` INSERT manual para Zorro (`business_cloud`, $15K/año) y
Patito (`business_control`, $21K/año). Status `authorized`,
`mpPreapprovalId=null`. Script temporal borrado.
- `Tenant.plan` UPDATE en Zorro (de `enterprise`) y Patito (de `business`)
al plan real.
- `subscription_addons` INSERT para Alexa (Zorro) y Carlos (Patito) con
codename `lolita_ia_contribuyente`, preapproval MP real (sandbox).
Posteriormente se marcaron `authorized` simulando el webhook (script
temporal que llama `handleAddonPayment(id, 'manual-sim', 'authorized')`,
ya borrado).
- `.env`:
- Agregado `MP_ACCESS_TOKEN` (sandbox).
- `FRONTEND_URL` cambiado temporalmente a HTTPS y revertido a localhost
al cerrar. **Próxima vez que se teste MP en dev:** cambiarlo a una URL
HTTPS pública (Cloudflare Tunnel, ngrok) o a `https://horuxfin.com`.
### Documentación
- `docs/plans/2026-04-21-session-2-mp-setup-and-bugfixes.md` — extendido
con Tanda B.2-B.5 y referencia al add-on model.
- `docs/Horux_despachos-vs-Horux360.md` — extendido con §9 crons dev,
§10 rejection logging, §11 getIvaMensual refactor. Add-ons
NO incluidos (exclusivos del fork multi-contribuyente).
---
## Pendientes vigentes al cierre
### Derivados de hoy
- ✅ Wiring del watchdog (`sweep-stale-sat-jobs.ts`) como cron cada 2h en
`sat-sync.job.ts` — completado 2026-04-23 (refactorizado a función
exportable `sweepStaleSatJobs` en `services/sat/sweep-stale-jobs.service.ts`;
cron `WATCHDOG_CRON_SCHEDULE = '0 */2 * * *'` en `startSatSyncJob`).
- ✅ Cableado automático del add-on `contribuyente_extra_business_cloud`
completado en esta sesión.
- **Cloudflare Tunnel en prod** para `MP_NOTIFICATION_URL` — endpoint
`POST /api/webhooks/mercadopago`. Sin esto, addons pagados en MP se
quedan `pending` en BD hasta que manualmente se llame `handleAddonPayment`.
- `FRONTEND_URL` en dev vs MP sandbox — MP rechaza `http://localhost`.
Solución durable: setear una URL HTTPS de dev (Cloudflare Tunnel,
ngrok) o un dominio propio permanente.
- **Investigación SAT rejections** — completado 2026-04-23:
- `sat-client.service.ts:verifySatRequest` ahora expone `codeRequest`
(método `getCodeRequest()` de la lib) con su valor numérico + entryId
+ message descriptivo en el debug log y en el error message. Los 5
códigos SAT posibles son: `5000 Accepted`, `5002 Exhausted`, `5003
MaximumLimit`, `5004 EmptyResult`, `5005 Duplicated`.
- Patrón observado en Manuel: 9 rejections de emitidos (bloques 3-9
y 12-13), pero bloques 10-11 sí funcionaron — NO es rate limit
constante. Hipótesis más probable: **5005 Duplicated** (solicitudes
previas stale para rangos similares que quedaron huérfanas y nuevo
re-sync es considerado duplicado por el SAT). Requiere capturar
un caso nuevo con el código mejorado para confirmar.
- Si se confirma 5005: solución es limpiar solicitudes previas en el
SAT antes de reintentar (no trivial — SAT no ofrece endpoint de
cancelación), o esperar ~72h entre intentos. Si es 5003 (MaximumLimit):
reducir tamaño de rango. Si es 5002 (Exhausted): cambiar FIEL /
esperar 24h.
- Re-sync custom de los rangos de emitidos faltantes de Manuel (bloques
3-9 del XML initial) — pendiente, depende del diagnóstico del punto
anterior (capturar el `codeRequest` real cuando vuelva a ocurrir).
- ✅ Validación preventiva CSD↔RFC en `uploadCsdContribuyente` —
completado 2026-04-23. Ahora valida: (1) cert no es FIEL, (2) RFC del
cert coincide con contribuyente, (3) no vencido. Mensajes de error
específicos. Usa `@nodecfdi/credentials`.
- Recomputar overage al cambiar de plan (ej. downgrade business_cloud →
business_control debería cancelar el addon overage si existe). Hoy
solo se dispara desde create/deactivate contribuyente.
### De sesiones anteriores (abiertos)
- Recrear org Facturapi de Carlos (TORC9611214CA)
- Validación preventiva CSD↔RFC en `uploadCsdContribuyente`
- Prueba cross-contribuyente end-to-end
- Typecheck web cleanup (~12 errores preexistentes en sidebar/cfdi/usuarios)
### Features pending
Ver `docs/plans/2026-04-19-pending-features.md`. De esa lista:
-#8 Extras en Documentos — completado en sesión anterior
-#5 Add-ons por contribuyente — **Lolita IA completado hoy**; falta
overage business_cloud automático
- #1 Editar contribuyentes asignados a cliente
- #2 Convertir Pendientes → Despacho con métricas
- #6 Enlazar obligaciones ↔ declaraciones
- #7 Colores obligaciones en calendario
- #9 Avisos por correo al subir declaración / doc extra
- #10 Alertas de obligaciones — bug de filtros per-contribuyente