1020 lines
54 KiB
Markdown
1020 lines
54 KiB
Markdown
# Sesión 2026-04-21 (Parte 2) — Setup cobro MP + bugfixes CSF y descartes
|
||
|
||
## Resumen ejecutivo
|
||
|
||
Sesión continuada del mismo día. Arrancó con intención de implementar cobro
|
||
MercadoPago para planes despacho (Tanda 1), pero durante la auditoría y pruebas
|
||
emergieron varios bugs fiscales severos que se resolvieron integralmente:
|
||
|
||
1. **CSF auto-fill rompe en despachos con contribuyentes multi-régimen** (Alexa) — schema.
|
||
2. **Descartes de CFDI no aparecen surtidos en el drilldown** — query filter.
|
||
3. **CFDIs duplicados por UUID case-sensitive** (1426 duplicados en Patito) — sync SAT + constraint.
|
||
4. **Drill-downs no filtraban por contribuyente** — fix en `cfdi.controller.ts` + frontend.
|
||
5. **Cobro MP para planes despacho (Tanda 1)** — enum, catálogo, flujo completo.
|
||
6. **Ingresos de CFDIs tipo P agrupados por fecha_emision en vez de fecha_pago_p** — afecta dashboard, reportes, impuestos IVA/ISR, drill-down. Fix integral (Opción B).
|
||
|
||
La extracción SAT que se interrumpió para aplicar la migración Prisma se dejó
|
||
en `pending` con `next_retry_at = +5 min` para que el cron horario la retome
|
||
automáticamente tras el reinicio del dev server.
|
||
|
||
---
|
||
|
||
## 1. Fix CSF auto-fill — regimen_fiscal ampliado a TEXT
|
||
|
||
**Problema reportado:** Al agregar a la contribuyente Alexa en el despacho Zorro,
|
||
se descargó la CSF correctamente pero los campos `regimen_fiscal`, `codigo_postal`
|
||
y `domicilio` de la tabla `contribuyentes` quedaron NULL.
|
||
|
||
**Causa raíz:**
|
||
- Migración tenant `010_contribuyentes.sql` declaró `regimen_fiscal varchar(3)`,
|
||
asumiendo un solo régimen por contribuyente.
|
||
- `sincronizarDatosFiscales` en `constancia.service.ts` genera CSV cuando hay
|
||
múltiples regímenes activos (ej. `"626,605"` para Alexa: RESICO + Sueldos).
|
||
- Postgres rechaza el UPDATE con `"el valor es demasiado largo para el tipo
|
||
character varying(3)"`.
|
||
- El error se captura silenciosamente en `constancia.service.ts:386` → la CSF
|
||
queda guardada pero la sincronización nunca se aplica.
|
||
|
||
**Por qué Patito sí funcionaba:** alguien aplicó un `ALTER TABLE` manual en el
|
||
pasado (sin migración). Verificado via `information_schema.columns`:
|
||
- `horux_despacho_mo3ni6u8_b9vgg` (Patito): `text`
|
||
- `horux_despacho_mo7je8bz_vdopr` (Zorro): `varchar(3)` (original)
|
||
|
||
**Fix:** Nueva migración tenant `025_contribuyentes_regimen_fiscal_text.sql`:
|
||
|
||
```sql
|
||
ALTER TABLE contribuyentes ALTER COLUMN regimen_fiscal TYPE text;
|
||
|
||
INSERT INTO tenant_migrations (scope, version, name)
|
||
VALUES ('vertical-contable', 25, '025_contribuyentes_regimen_fiscal_text')
|
||
ON CONFLICT (scope, version) DO NOTHING;
|
||
```
|
||
|
||
Aplicada manualmente via psql a Zorro (no se tocó Patito porque ya estaba
|
||
correcto). Idempotente: en tenants ya normalizados, es no-op funcional.
|
||
|
||
**Estado por despacho:**
|
||
|
||
| Despacho | Schema `regimen_fiscal` | Migración 025 registrada |
|
||
|---|---|---|
|
||
| Zorro | `text` (aplicado por esta sesión) | ✅ Sí |
|
||
| Patito | `text` (parche manual previo) | ⚠️ Se auto-registra al próximo lazy-migrate en `getPool()` |
|
||
| Nuevos despachos | `varchar(3)` → `text` (migraciones 010 y 025 en orden) | ✅ Sí |
|
||
|
||
**Data fix de Alexa:**
|
||
|
||
`UPDATE contribuyentes SET regimen_fiscal, codigo_postal, domicilio` con los
|
||
datos parseados de su CSF ya guardada en BD — evita regenerar otra solicitud al
|
||
SAT. Valores aplicados:
|
||
|
||
- `regimen_fiscal = '626,605'`
|
||
- `codigo_postal = '44230'`
|
||
- `domicilio` = JSON completo (calle PONTEVEDRA, colonia SANTA ELENA ESTADIO,
|
||
GUADALAJARA, JALISCO, etc.)
|
||
|
||
**Archivos tocados:**
|
||
- `apps/api/src/migrations/tenant/025_contribuyentes_regimen_fiscal_text.sql` — **nuevo**
|
||
- Data directa: `horux_despacho_mo7je8bz_vdopr.contribuyentes` fila de Alexa
|
||
|
||
**Propagación:**
|
||
- Tenants existentes con el schema ya corregido: no requieren acción.
|
||
- Tenants existentes con el schema sin corregir: al próximo acceso a
|
||
`getPool(tenantId)` se aplica la migración 025 automáticamente (lazy-migrate).
|
||
- Despachos nuevos: la migración 025 se aplica en orden via el migration runner
|
||
tras la 010.
|
||
|
||
**Mejoras futuras (fuera de alcance de esta sesión):**
|
||
- El parser de CSF a veces mete el valor de "Nombre de la Colonia" dentro del
|
||
campo `numeroInterior` por layout de dos columnas en el PDF. Ya existe código
|
||
de cleanup (`cleanDomField`, `extractEmbedded`) pero no es 100% robusto.
|
||
- No hay backfill para otros contribuyentes que pudieran haber fallado el sync
|
||
antes de este fix (verificado: Alexa era la única).
|
||
|
||
---
|
||
|
||
## 2. Fix discrepancia-regimen — drilldown excluye CFDIs descartados
|
||
|
||
**Problema reportado:** En `/alertas/discrepancia-regimen`, al seleccionar CFDIs
|
||
y pulsar "Descartar", los mismos CFDIs seguían apareciendo en la lista → parecía
|
||
que el botón no guardaba nada.
|
||
|
||
**Causa raíz:** Había dos queries separadas sobre discrepancias de régimen, y
|
||
solo una excluía CFDIs descartados:
|
||
|
||
| Query | Archivo | Excluía `cfdi_descartados`? |
|
||
|---|---|---|
|
||
| Panel de alertas (contador agregado) | `alertas-auto.service.ts:42` | ✅ Sí |
|
||
| Drilldown (`GET /alertas/drilldown/discrepancia-regimen`) | `alertas.controller.ts:286` | ❌ **No** |
|
||
|
||
Por eso:
|
||
- Contador de la alerta del panel bajaba al descartar (servicio sí filtraba).
|
||
- Lista del drilldown seguía mostrando los mismos CFDIs (controller no filtraba).
|
||
- El usuario percibía que el descarte no se guardaba.
|
||
|
||
De hecho **sí se guardaban**: verificado que Zorro ya tenía 27 filas en
|
||
`cfdi_descartados`.
|
||
|
||
**Fix:** una línea SQL en `alertas.controller.ts:306`:
|
||
|
||
```sql
|
||
AND id NOT IN (SELECT cfdi_id FROM cfdi_descartados WHERE tipo_alerta = 'discrepancia-regimen')
|
||
```
|
||
|
||
Con esto, el comentario del frontend en `page.tsx:70` (`// descartados already
|
||
excluded by backend`) se vuelve verdad. Antes era un supuesto incorrecto que
|
||
ocultó el defecto.
|
||
|
||
**Archivos tocados:**
|
||
- `apps/api/src/controllers/alertas.controller.ts` — 1 línea agregada a
|
||
`getDiscrepanciaRegimen`
|
||
|
||
**Efecto:** los 27 CFDIs ya descartados en Zorro dejan de aparecer en la lista.
|
||
El endpoint de restauración (`DELETE /alertas/descartar`) existe para revertir
|
||
si algún descarte fue por error (el botón UI para restaurar no fue revisado
|
||
esta sesión).
|
||
|
||
---
|
||
|
||
## 3. Cobro MP para planes despacho (Tanda 1 — COMPLETADA)
|
||
|
||
**Objetivo:** Permitir que el owner de un despacho (ej. `jd@demo.com` para
|
||
Patito) se suscriba a `business_control` ($21,000/año) o `business_cloud`
|
||
($15,000/año) vía MercadoPago, con upgrade/change/cancel/reactivate soportados.
|
||
|
||
### Auditoría del estado previo
|
||
|
||
Lo que **ya existe**:
|
||
|
||
- Catálogo `DESPACHO_PLANS` en `packages/shared/src/constants/despacho-plans.ts`
|
||
con 3 entradas: `trial`, `business_control`, `business_cloud` con límites,
|
||
features y `dbMode`.
|
||
- Página `/configuracion/planes-despacho/page.tsx` con 2 cards (Business
|
||
Control, Business Cloud) y precios ya mostrados.
|
||
- Endpoint `GET /api/despachos/me/plan` que devuelve `{plan, dbMode, trialEndsAt,
|
||
isTrialActive}`.
|
||
- Flujo completo Horux360: `subscription.service.ts` con `subscribe`,
|
||
`startTrial`, `scheduleChange`, `initiateUpgrade`, `reactivateSubscription`,
|
||
`cancelSubscription`. Todos usan `getPlanPrice(plan, frequency)` que consulta
|
||
tabla `plan_prices` en BD central.
|
||
- Webhook MP en `webhook.controller.ts` con routing por `external_reference`
|
||
(`timbres-pack:`, `proration:`, `addon:`, o UUID tenant).
|
||
- Middleware `plan-limits.middleware.ts` que bloquea escrituras si la sub no
|
||
está `authorized` o `pending` (permite GETs).
|
||
- Helper `isDespachoTenant(rfc)` en `despacho-plans.ts` que detecta por prefijo
|
||
`DESPACHO_`.
|
||
- `Subscription.plan`, `pendingPlan`, `upgradeTargetPlan` y `Tenant.plan` todos
|
||
son `Plan` (enum Prisma).
|
||
|
||
Lo que **falta** (gaps identificados):
|
||
|
||
1. Enum Prisma `Plan` no contiene `business_control` ni `business_cloud`. Sus
|
||
valores actuales: `starter, business, business_ia, custom, enterprise`.
|
||
2. `VALID_PLANS` en `subscription.controller.ts` sólo acepta los 4 de Horux360.
|
||
3. `getPlanPrice` consulta `plan_prices` tabla; los precios despacho están en
|
||
constants, no en BD.
|
||
4. `validatePlanFrequency` no fuerza `frequency: 'annual'` para planes despacho
|
||
(el usuario pidió solo anual).
|
||
5. Página `/configuracion/planes-despacho` **no tiene botón "Suscribirse"** ni
|
||
flujo de checkout.
|
||
6. Middleware de bloqueo permite GETs; el usuario pidió bloquear todo menos
|
||
`/subscriptions/*` cuando no paga (pendiente de Tanda 2).
|
||
7. Add-on recurrente `$45/RFC/mes` para Business Cloud: el sistema de addons
|
||
existe pero no hay catálogo para `rfc_despacho` ni wiring al crear
|
||
contribuyente (pendiente de Tanda 2).
|
||
|
||
### Plan acordado
|
||
|
||
**Tanda 1 (esta sesión, cobro funcional):**
|
||
1. Extender enum Prisma `Plan` con `business_control`, `business_cloud`.
|
||
2. Agregar `DESPACHO_PLAN_PRICES` en `despacho-plans.ts`.
|
||
3. Extender type local `Plan` + `getPlanPrice` en `subscription.service.ts`
|
||
para branchear a constantes cuando es despacho.
|
||
4. Extender `VALID_PLANS` en controller + validación `annual`-only para
|
||
despacho.
|
||
5. Agregar botones "Suscribirse" / "Cambiar plan" / "Plan actual" en la página
|
||
`/configuracion/planes-despacho`.
|
||
6. `window.open(paymentUrl, '_blank')` al recibir URL del endpoint.
|
||
7. `pnpm typecheck` final.
|
||
|
||
**Tanda 2 (próxima sesión):**
|
||
- Bloqueo total fuera de `/subscriptions/*` para despachos sin pago activo.
|
||
- Add-on recurrente `$45/RFC/mes` con multi-preapproval MP.
|
||
|
||
### Ejecución (continuación el mismo día, tras marcar la extracción en pending+retry)
|
||
|
||
La extracción se marcó como `pending` con `next_retry_at = NOW() + 5 min` para
|
||
que el cron horario del SAT sync la retomara automáticamente al relanzar el dev
|
||
server. Después:
|
||
|
||
1. **Enum Prisma extendido:**
|
||
- `apps/api/prisma/schema.prisma` → agregados `business_control`,
|
||
`business_cloud` al enum `Plan`.
|
||
- `pnpm prisma migrate dev --name despacho_plan_enum_values` generó
|
||
`apps/api/prisma/migrations/20260421062505_despacho_plan_enum_values/migration.sql`
|
||
con solo `ALTER TYPE ... ADD VALUE` (verificado — no reescribe enum).
|
||
- Prisma client regenerado con tipos actualizados.
|
||
|
||
2. **Catálogo de precios despacho** (`packages/shared/src/constants/despacho-plans.ts`):
|
||
```ts
|
||
export const DESPACHO_PLAN_PRICES = {
|
||
business_control: 21000,
|
||
business_cloud: 15000,
|
||
} as const;
|
||
|
||
export type DespachoPaidPlan = keyof typeof DESPACHO_PLAN_PRICES;
|
||
export function isDespachoPaidPlan(plan: string): plan is DespachoPaidPlan { ... }
|
||
```
|
||
Fuente de verdad única de precios despacho. No se usa `plan_prices` (esa
|
||
tabla es solo para planes Horux360 editables por admin).
|
||
|
||
3. **Branch de precio en servicio** (`apps/api/src/services/payment/subscription.service.ts`):
|
||
- Type local `Plan` extendido con `business_control | business_cloud`.
|
||
- `getPlanPrice` branchea: si `isDespachoPaidPlan(plan)` → valida que
|
||
`frequency === 'annual'` y devuelve `DESPACHO_PLAN_PRICES[plan]`. Si no →
|
||
sigue consultando `plan_prices`.
|
||
- **Cero cambios a `subscribe`, `scheduleChange`, `initiateUpgrade`,
|
||
`reactivateSubscription`** — todos reutilizan `getPlanPrice` y funcionan
|
||
out-of-the-box para despacho. El webhook routing por `external_reference =
|
||
tenantId` sigue igual.
|
||
|
||
4. **Validación en controller** (`apps/api/src/controllers/subscription.controller.ts`):
|
||
- `VALID_PLANS` extendido a los 6 valores.
|
||
- `validatePlanFrequency` rechaza `monthly` para planes despacho con mensaje
|
||
claro.
|
||
- Defensa-en-profundidad: `getPlanPrice` valida de nuevo en capa de servicio.
|
||
|
||
5. **UI de contratación** (`apps/web/app/(dashboard)/configuracion/planes-despacho/page.tsx`):
|
||
- Reescrita con botón de acción por card.
|
||
- Flujo inteligente: primer intento `subscribeMe({plan, frequency:'annual'})`;
|
||
si el backend responde "Ya existe una suscripción...", cae automáticamente
|
||
a `changeMyPlan` → el usuario ve UN botón sin tener que elegir entre
|
||
acciones.
|
||
- Botón contextual: "Contratar plan" / "Contratar (terminar prueba)" /
|
||
"Cambiar a este plan" / "Plan actual".
|
||
- Link pequeño abajo "Cancelar suscripción" → solo visible con plan pagable
|
||
activo (llama `cancelMySubscription`).
|
||
- `window.open(paymentUrl, '_blank')` tras subscribe exitoso.
|
||
- Toast de éxito/error.
|
||
|
||
6. **Typecheck limpio** en api y web en los archivos tocados.
|
||
|
||
7. **Dev server reiniciado** con los cambios de Prisma + código aplicados.
|
||
Extracción Zorro quedó en `pending` con retry automático por el cron del
|
||
SAT sync.
|
||
|
||
### Lo que quedó fuera de Tanda 1 (va a Tanda 2)
|
||
|
||
- Bloqueo total fuera de `/subscriptions/*` para despachos sin pago activo.
|
||
- Add-on recurrente `$45/RFC/mes` para Business Cloud (tabla
|
||
`plan_addon_catalogo` + wiring al crear contribuyente + multi-preapproval MP).
|
||
|
||
---
|
||
|
||
## 4. Fix fecha efectiva para CFDIs tipo P (flujo de efectivo real)
|
||
|
||
**Problema reportado:** KPI "Ingresos del Mes" para Husberto Astorga (TOAH,
|
||
régimen 612) en mayo 2025 mostraba $1,372,731 pero el valor fiscalmente correcto
|
||
validado por el usuario era **$1,132,806**. El drill-down mostraba además un
|
||
monto distinto ($1,314,054 con impuestos) que tampoco cuadraba.
|
||
|
||
**Causa raíz:** Todas las queries fiscales del sistema (dashboard, reportes,
|
||
impuestos) sumaban los CFDIs tipo P filtrando por `fecha_emision` — la fecha en
|
||
que se emitió el complemento de pago. Eso es incorrecto: el ingreso se reconoce
|
||
en la **fecha real del pago** (`fecha_pago_p`, del atributo `FechaPago` del nodo
|
||
`<pago20:Pago>`), no cuándo se emitió el complemento.
|
||
|
||
**Evidencia dura** (Astorga folio 60, mayo 2025):
|
||
```xml
|
||
<cfdi:Comprobante Fecha="2025-05-17T20:53:25" TipoDeComprobante="P">
|
||
<pago20:Pago FechaPago="2024-11-11T12:00:00" Monto="278313.00">
|
||
<pago20:DoctoRelacionado IdDocumento="1A9EF6D6-...-51262" Folio="1077" ...>
|
||
```
|
||
El cliente pagó el 11-nov-2024, pero el complemento se emitió 6 meses después.
|
||
Triangulado contra la PPD folio 1077 emitida el 14-nov-2024 — consistente.
|
||
|
||
**Alcance del bug:** Cualquier P emitido en un mes distinto al del cobro real
|
||
caía en el mes equivocado. Común en despachos donde el contador consolida
|
||
pagos mensuales en complementos emitidos con retraso. Afectaba:
|
||
- Dashboard: "Ingresos del Mes" y "Gastos del Mes" (flujo efectivo PF Empresarial)
|
||
- Dashboard: "Balance IVA" (s2/r2)
|
||
- Reportes: flujo de efectivo mensual + serie anual
|
||
- Impuestos: IVA mensual trasladado/acreditable, `getResumenIva` (totales y
|
||
por régimen), acumulado anual
|
||
- Drill-down: las fechas no coincidían con los KPIs
|
||
|
||
**Fix integral (Opción B):**
|
||
|
||
Dos patrones complementarios:
|
||
|
||
**A. `dashboard.service.ts` y `reportes.service.ts`** — helper paralelo
|
||
`FECHA_PAGO_RANGO` + `getFechaPagoRango()`:
|
||
```ts
|
||
const FECHA_PAGO_RANGO = `fecha_pago_p >= $1::date AND fecha_pago_p < ($2::date + interval '1 day')`;
|
||
function getFechaPagoRango(conciliacion?: boolean): string {
|
||
return conciliacion ? FECHA_RANGO_CONCILIACION : FECHA_PAGO_RANGO;
|
||
}
|
||
```
|
||
Las 4 queries de P en `calcularIngresosPorRegimen` Grupo PF, `calcularEgresosPorRegimen`,
|
||
y las 2 queries de P en `calcularIvaBalancePorRegimen` (`s2` y `r2`) cambiaron
|
||
de `${FR}` a `${FR_PAGO}`.
|
||
|
||
En reportes, los 2 queries de `getFlujoEfectivo` (entradasPago, salidasPago)
|
||
usan `TO_CHAR(fecha_pago_p, 'YYYY-MM')` + `RANGO_PAGO`. En `calcularFlujoPorMes`
|
||
la función `q()` branchea: si `tc === 'P'` usa `fecha_pago_p` para `EXTRACT` y
|
||
rango; si no, `fecha_emision`.
|
||
|
||
**B. `impuestos.service.ts`** — helper `FECHA_EFECTIVA` con CASE inline:
|
||
```ts
|
||
const FECHA_EFECTIVA = `CASE WHEN tipo_comprobante = 'P' THEN fecha_pago_p ELSE fecha_emision END`;
|
||
const FECHA_RANGO = `${FECHA_EFECTIVA} >= $1::date AND ${FECHA_EFECTIVA} < ($2::date + interval '1 day')`;
|
||
```
|
||
Con solo cambiar la definición del constant, **todas** las queries que usan
|
||
`${FR}` se arreglan automáticamente. Es la elegancia de indirection — no tocamos
|
||
ninguna query existente que ya usaba `${FR}`.
|
||
|
||
En `getIvaMensual` sí hubo que modificar las queries que usan `EXTRACT(MONTH FROM
|
||
fecha_emision)` directo (sin `${FR}`) para que usen `EXTRACT(MONTH FROM
|
||
${FECHA_EFECTIVA})` en trasladado y acreditable. El query de retención conserva
|
||
`fecha_emision` — los complementos P no tienen retención propia.
|
||
|
||
**C. `cfdi.controller.ts:drillDown`** — el filtro de fecha usa el mismo pattern
|
||
CASE para que el drill-down sea coherente con los KPIs. Un P que cobró en
|
||
noviembre 2024 y se emitió en mayo 2025 aparecerá en el drill-down de noviembre,
|
||
no en el de mayo.
|
||
|
||
**Validación del fix:**
|
||
Ejecutado SQL idéntico al que correrá el código. Para Husberto Astorga régimen
|
||
612 mayo 2025: `I PUE (915,134.22) + P con fecha_pago_p en mayo (217,671.37) =
|
||
**$1,132,805.59**` — match exacto con la validación del usuario.
|
||
|
||
**Efecto colateral esperado:** los ingresos de meses anteriores donde había
|
||
pagos recibidos pero con complemento P emitido después aparecerán correctamente
|
||
ahora. Si un mes se validó antes del fix, su número cambiará — correctamente.
|
||
|
||
**No cambia:** IVA retenido (los complementos P no cargan retención propia),
|
||
queries sobre N (nóminas), queries sobre E (notas de crédito), modo
|
||
conciliación (ese usa la tabla `conciliaciones` por diseño).
|
||
|
||
---
|
||
|
||
## 5. Dualidad de precio Business Control ($21K primer año, $15K renovaciones)
|
||
|
||
**Necesidad:** Business Control cobra $21,000 el primer año (setup + primera
|
||
anualidad) y $15,000 cada renovación anual. MP Preapproval no soporta "primer
|
||
año X, después Y" en una sola suscripción — siempre cobra el mismo monto.
|
||
|
||
**Opción B implementada (un solo click, un solo preapproval):**
|
||
|
||
1. Al contratar, el backend crea el preapproval MP con `amount = firstYear`
|
||
($21,000). El `reason` del preapproval explica ambos montos para que el
|
||
usuario vea en MP: `"Plan business_control - $21,000 primer año, $15,000
|
||
renovaciones"`.
|
||
2. User autoriza una vez en MP → paga $21,000 ahora.
|
||
3. MP envía webhook `payment.approved`.
|
||
4. El webhook, al detectar la transición `pending → authorized` (primer pago)
|
||
de una sub de plan despacho con dualidad, llama `mpService.updatePreapprovalAmount`
|
||
para bajar el monto recurrente a `renewal` ($15,000). Actualiza también
|
||
`Subscription.amount = 15000`.
|
||
5. Al aniversario, MP cobra $15,000 automáticamente via preapproval. Sin
|
||
intervención adicional.
|
||
|
||
Patrón tomado de `applyApprovedUpgrade` (usado en upgrades con prorateo) — ya
|
||
probado en producción. Fail-safe: si `updatePreapprovalAmount` falla, el
|
||
webhook no se rompe; queda log para intervención manual.
|
||
|
||
**Archivos tocados:**
|
||
- `packages/shared/src/constants/despacho-plans.ts` — `DESPACHO_PLAN_PRICES`
|
||
ahora `{ firstYear, renewal }` por plan + nuevos helpers `DespachoPricePhase`
|
||
y `despachoPlanTieneDualidad()`.
|
||
- `apps/api/src/services/payment/subscription.service.ts` — `getPlanPrice`
|
||
acepta parámetro `phase: 'firstYear' | 'renewal'` con default `'renewal'`;
|
||
`subscribe()` usa `firstYear` y arma `reason` explicativo si hay dualidad.
|
||
- `apps/api/src/controllers/webhook.controller.ts` — rama nueva al primer
|
||
pago aprobado que baja preapproval y actualiza `Subscription.amount`.
|
||
- `apps/api/src/services/despacho.service.ts` — usa catálogo único
|
||
`DESPACHO_PLAN_PRICES` en lugar de precios hardcoded.
|
||
|
||
**Planes que afecta:**
|
||
|
||
| Plan | firstYear | renewal | ¿Tiene dualidad? |
|
||
|---|---|---|---|
|
||
| business_control | $21,000 | $15,000 | ✅ Sí — webhook baja tras primer pago |
|
||
| business_cloud | $15,000 | $15,000 | ❌ No — flujo normal sin ajustes |
|
||
|
||
**Planes Horux360 estándar** (starter/business/business_ia/enterprise): el
|
||
parámetro `phase` se ignora para ellos. `getPlanPrice` sigue leyendo de la
|
||
tabla `plan_prices`. Cero regresión.
|
||
|
||
**Flujos con dualidad aplicada:**
|
||
- `subscribe()` (self-serve desde `/configuracion/planes-despacho`) → `firstYear` + webhook downgrade ✅
|
||
- `signupDespacho()` (registro nuevo en `/register-despacho`) — refactorizado para usar `subscribe()` internamente ✅
|
||
|
||
**Flujos sin dualidad (usan `renewal` por default):**
|
||
- `scheduleChange()` — el cambio se aplica en el próximo periodo; MP ya cobró
|
||
el periodo actual en el otro plan; no hay "primer año" del nuevo plan.
|
||
- `initiateUpgrade()` — upgrade con prorateo; el cliente ya está en un plan,
|
||
no es primera contratación.
|
||
- `reactivateSubscription()` — el cliente canceló pero está dentro de periodo
|
||
pagado; reactivar lo pone de vuelta al ciclo recurrente normal.
|
||
|
||
Si en el futuro quieres que `reactivate` o `upgrade` apliquen `firstYear`, se
|
||
añade el parámetro `phase` explícito en el callsite correspondiente.
|
||
|
||
---
|
||
|
||
## 6. Refactor signup de despacho + UI de estado de suscripción
|
||
|
||
Dos seguimientos a la dualidad de precio (Tanda Opción B).
|
||
|
||
### 6a. `signupDespacho` ahora usa `subscribe()` internamente
|
||
|
||
**Bug encontrado:** El registro nuevo de despacho (`/register-despacho`) creaba
|
||
el preapproval MP directamente con `mpService.createPreapproval()`, **sin crear
|
||
la fila `Subscription` en BD**. Consecuencia: cuando MP enviaba el webhook
|
||
`payment.approved`, el handler buscaba una subscription por tenantId y no
|
||
encontraba nada → no podía aplicar la lógica de downgrade firstYear→renewal.
|
||
Los usuarios que se registraban por esa ruta pagaban $21K cada año en lugar
|
||
de bajar a $15K en la renovación.
|
||
|
||
**Fix:** `despacho.service.ts:signupDespacho` refactorizado para llamar
|
||
`subscriptionService.subscribe({ tenantId, plan, frequency: 'annual', payerEmail })`
|
||
tras crear tenant/user/membership. Esto crea la fila `Subscription` correcta
|
||
(con status=pending, amount=firstYear, mpPreapprovalId guardado) y retorna
|
||
`paymentUrl` igual que antes — misma API para el frontend.
|
||
|
||
Manejo de errores preservado: si el cobro falla, se elimina tenant/user/
|
||
membership (rollback) con mensajes diferenciados según causa (MP no configurado
|
||
vs otro error).
|
||
|
||
Después del fix, el flujo de signup completo soporta dualidad.
|
||
|
||
### 6b. UI de estado de suscripción
|
||
|
||
**Problema:** Tras contratar, el usuario veía "Plan actual" en su card pero no
|
||
tenía forma de saber si el primer pago fue exitoso, cuándo será la próxima
|
||
renovación ni cuánto le cobrarán.
|
||
|
||
**Fix:**
|
||
|
||
- **Backend:** `/api/despachos/me/plan` ahora retorna campo `subscription: { status, plan, amount, currentPeriodStart, currentPeriodEnd }` además del plan info básico. Query a la tabla `subscriptions` filtra por `status ∈ { authorized, pending, paused, trial }` tomando la más reciente.
|
||
- **Frontend `/configuracion/planes-despacho/page.tsx`:** nuevo banner verde debajo del trial banner (solo visible con plan pagable activo) que muestra:
|
||
- Plan contratado
|
||
- Fecha de próxima renovación
|
||
- Monto del próximo cobro
|
||
- Nota "(tarifa de renovación — el primer año ya fue cubierto)" cuando detecta que el amount actual es `$15K` para business_control (heurística de dualidad aplicada).
|
||
|
||
**Patrón del banner:** similar al trial banner — layout, colores, iconografía
|
||
consistentes, usa `CheckCircle2` en verde.
|
||
|
||
---
|
||
|
||
## 7. Activación sistema hot/cold métricas — Tanda A (cimientos)
|
||
|
||
**Objetivo:** poblar y mantener actualizada la tabla `metricas_mensuales` para
|
||
que consumers (dashboard/impuestos/reportes) puedan en Tanda B leer métricas
|
||
pre-calculadas en lugar de recomputar. Infraestructura encontrada al 30%
|
||
(tablas + funciones write/read + invalidación parcial), el 70% restante era
|
||
compute/backfill/cron/invalidación completa.
|
||
|
||
### Archivos nuevos
|
||
|
||
- `apps/api/src/services/metricas-compute.service.ts` — core:
|
||
`computeMetricaMensual` (agrega desde cfdis raw y upsert 1 fila por régimen),
|
||
`backfillTenant`, `processInvalidations`, `processAllTenantsInvalidations`.
|
||
- `apps/api/src/jobs/metricas-invalidations.job.ts` — cron cada 15 min con
|
||
lock anti-solapamiento.
|
||
- `apps/api/scripts/backfill-metricas.ts` — CLI con `--dry`, filtros por
|
||
tenant y rango de años via env vars.
|
||
- `apps/api/scripts/validate-metricas.ts` — validación automatizada: toma 5
|
||
muestras aleatorias por contribuyente y compara tabla vs on-the-fly.
|
||
|
||
### Archivos modificados
|
||
|
||
- `apps/api/src/index.ts` — registra el cron (solo `NODE_ENV=production`).
|
||
- `apps/api/src/services/sat/sat.service.ts` — `saveCfdis` y `saveMetadata`
|
||
marcan invalidación por cada CFDI insertado/updated, usando `fecha_pago_p`
|
||
para tipo P.
|
||
- `apps/api/src/controllers/facturacion.controller.ts` — `cancelar` captura
|
||
fecha del CFDI antes del UPDATE y marca invalidación para el mes afectado.
|
||
|
||
### Campos poblados (MVP)
|
||
|
||
`ingresos_cobrados`, `egresos_pagados`, `iva_trasladado_total`, `iva_acreditable`,
|
||
`iva_retenido_cobrado`, `iva_resultado`, `utilidad_realizada`, `flujo_entradas/
|
||
salidas/neto`, `cfdis_emitidos_count`, `cfdis_recibidos_count`, `cfdis_cancelados_count`.
|
||
|
||
### Campos en 0 (iteración futura)
|
||
|
||
Desglose IVA por tasa (16/8/0/exento), `iva_retenido_pagado`, `iva_a_favor_mes`,
|
||
ISR completo (`isr_ingresos_brutos`, `base`, `causado`, `retenido`, `a_pagar`),
|
||
IEPS, CxC/CxP, `ingresos_devengados/egresos_devengados/utilidad_devengada`.
|
||
|
||
### Backfill inicial aplicado
|
||
|
||
- Patito: 3 contribuyentes → 284 filas en `metricas_mensuales`.
|
||
- Zorro: 1 contribuyente (Alexa) → 62 filas.
|
||
- **Total: 346 filas.**
|
||
|
||
### Validación ejecutada
|
||
|
||
20 muestras aleatorias (5 por contribuyente × 4 contribuyentes) — cobertura:
|
||
régimenes 601/605/612/616/621/626, años 2020-2025, ambos tenants. Cada muestra
|
||
compara 6 campos (ingresos, egresos, 4 de IVA). **Total 120 comparaciones con
|
||
tolerancia $0.01: 100% PASS.**
|
||
|
||
Esto confirma que el pipeline de escritura reutiliza la lógica canónica de los
|
||
servicios on-the-fly y produce los mismos valores — condición necesaria para
|
||
Tanda B (switch de consumers).
|
||
|
||
### Invalidación: puntos cubiertos vs pendientes
|
||
|
||
**Cubiertos:**
|
||
- Upload manual XML (ya existía) — `cfdi.service.ts:455, :521`
|
||
- SAT sync `saveCfdis` (nuevo) — con `fecha_pago_p` para tipo P
|
||
- SAT sync `saveMetadata` (nuevo)
|
||
- Cancelación via Facturapi (`cancelar` en facturacion.controller) (nuevo)
|
||
|
||
**Limitación conocida:** cambios en el flag `id_conciliacion` de CFDIs (toggle
|
||
de conciliación) no disparan invalidación. Si el tenant usa el modo conciliación
|
||
activa, las métricas cached no reflejarán cambios hasta el próximo backfill
|
||
completo. Queda como iteración futura.
|
||
|
||
---
|
||
|
||
## 8. Activación sistema hot/cold métricas — Tanda B (read-through)
|
||
|
||
**Objetivo:** que los consumers (dashboard/impuestos/reportes) lean métricas
|
||
pre-calculadas de `metricas_mensuales` para meses pasados en vez de recomputar
|
||
desde CFDIs raw. Solo para rangos de meses completos en años pasados con
|
||
contribuyente seleccionado; año actual y rangos parciales siguen on-the-fly.
|
||
|
||
### Alcance de Tanda B MVP
|
||
|
||
Solo `calcularIngresosPorRegimen` y `calcularEgresosPorRegimen` en
|
||
`dashboard.service.ts` tienen read-through cache activo. Por transitividad se
|
||
benefician también:
|
||
- `getIsrMensual` (llama `calcularIngresosPorRegimen` por mes)
|
||
- `getResumenIsr` (idem)
|
||
- Los KPIs del dashboard principal
|
||
- Cualquier otro consumer que dependa de esas dos funciones
|
||
|
||
**NO cubiertos por cache** (siguen on-the-fly al 100%):
|
||
- `getIvaMensual`, `getResumenIva` en impuestos (usan queries directas sobre
|
||
cfdis, no via `calcularIngresosPorRegimen`)
|
||
- `getFlujoEfectivo`, `calcularFlujoPorMes` en reportes
|
||
- Queries directas de drill-down en cfdi.controller.ts
|
||
|
||
Ampliar a estos consumers es iteración futura — requiere agregar campos al
|
||
cache (desglose IVA por tasa, retenidos por direction, etc.).
|
||
|
||
### Helper `planCache`
|
||
|
||
`dashboard.service.ts` ahora incluye un helper que determina si una query
|
||
puede servirse desde cache:
|
||
- `contribuyenteId` debe estar presente (sin él, no hay filas en la tabla).
|
||
- `conciliacion = false` (la tabla no modela id_conciliacion).
|
||
- `fechaFin < primer día del año actual` (año en curso siempre on-the-fly).
|
||
- `fechaInicio` debe ser día 1 del mes.
|
||
- `fechaFin` debe ser último día del mes.
|
||
|
||
Si cumple: `readIngresosFromCache`/`readEgresosFromCache` hace `SUM` agregando
|
||
sobre `make_date(anio, mes, 1) BETWEEN $start AND $end` y retorna `porRegimen`
|
||
como lo haría on-the-fly. Si no hay filas cacheadas: retorna `null` y el
|
||
caller cae al path on-the-fly existente.
|
||
|
||
### Validación
|
||
|
||
Re-corrida del script `validate-metricas.ts`: 3 ejecuciones consecutivas, 20
|
||
muestras cada una (5 por contribuyente × 4 contribuyentes), **100% PASS para
|
||
campos `ingresos_cobrados` y `egresos_pagados`** — los campos servidos por el
|
||
cache.
|
||
|
||
### Bug encontrado durante validación — RESUELTO
|
||
|
||
Al correr `validate-metricas.ts` detecté que `iva_retenido_cobrado` en
|
||
`metricas_mensuales` estaba almacenado como `0.00` cuando el valor on-the-fly
|
||
calculado por `getResumenIva` era distinto de cero (caso confirmado: Horux
|
||
360 Dec 2025 régimen 612 debía ser `$3,904.96`).
|
||
|
||
**Diagnóstico inicial incorrecto:** sospeché contaminación entre llamadas
|
||
secuenciales a `getResumenIva` (por pool compartido bajo límite de 3
|
||
conexiones). Instrumenté `DEBUG_RESUMEN_IVA` en `impuestos.service.ts:279` y
|
||
`contribuyente-context.ts:26`. El log demostró que `getResumenIva` retornaba
|
||
los valores correctos en cada llamada — no había contaminación.
|
||
|
||
**Causa raíz:** `upsertMetricaMensual` en `apps/api/src/services/metricas.service.ts`
|
||
nunca escribía los campos `iva_retenido_cobrado` ni `iva_retenido_pagado`.
|
||
Las columnas existen en el schema (migración 014) con `DEFAULT 0`, el tipo
|
||
`MetricaMensual` las declara, `computeMetricaMensual` las calcula
|
||
correctamente y las pasa en el objeto `data` — pero la lista de columnas
|
||
del `INSERT` (y el `UPDATE SET` del `ON CONFLICT`) las omitía. Todas las
|
||
filas heredaban silenciosamente el `DEFAULT 0`.
|
||
|
||
TypeScript no lo detectó porque `data: Partial<MetricaMensual>` permite
|
||
campos faltantes por diseño. El default de la columna enmascaraba el gap:
|
||
un mes sin retenciones legítimamente se veía idéntico a un mes con
|
||
retenciones no persistidas.
|
||
|
||
**Fix aplicado (`metricas.service.ts:148-191`):**
|
||
- Agregadas las columnas `iva_retenido_cobrado` e `iva_retenido_pagado` al
|
||
`INSERT`, la cláusula `VALUES`, el `UPDATE SET` del `ON CONFLICT`, y el
|
||
array de parámetros.
|
||
- Renumerados placeholders: ahora `$1..$29` (antes `$1..$27`).
|
||
|
||
**Backfill re-ejecutado:** 346 filas rescritas (Patito 284 + Zorro 62).
|
||
|
||
**Validación:** `validate-metricas.ts` — 20/20 PASS (5 muestras aleatorias
|
||
× 4 contribuyentes). Todos los campos IVA, no solo `iva_retenido_cobrado`,
|
||
validan céntimo por céntimo contra el cálculo on-the-fly.
|
||
|
||
**Limpieza:** removida la instrumentación `DEBUG_RESUMEN_IVA` de
|
||
`impuestos.service.ts` y `contribuyente-context.ts` (solo se usó durante
|
||
el diagnóstico).
|
||
|
||
### Archivos modificados
|
||
|
||
- `apps/api/src/services/dashboard.service.ts` — nuevos helpers `planCache`,
|
||
`readIngresosFromCache`, `readEgresosFromCache` + integración al top de
|
||
`calcularIngresosPorRegimen` y `calcularEgresosPorRegimen`.
|
||
|
||
### Efecto esperado en performance
|
||
|
||
Para consultas históricas de dashboard (meses pasados con contribuyente
|
||
seleccionado), el cache reduce de ~10-15 queries SQL (los 3 grupos de
|
||
ingresos/egresos con sus sub-queries) a 1 query SQL agregada. Reducción
|
||
aproximada de 90% en queries al Postgres del tenant. El año actual y rangos
|
||
parciales siguen on-the-fly — sin regresión.
|
||
|
||
### Tanda B.2 — Extensión a IVA (dashboard + impuestos)
|
||
|
||
Tras resolver el bug de `iva_retenido_cobrado`, las columnas IVA de
|
||
`metricas_mensuales` son confiables y se usan como fuente del cache.
|
||
|
||
**Cobertura agregada:**
|
||
- Dashboard: `calcularIvaBalancePorRegimen` ahora hace read-through. Lee
|
||
`iva_trasladado_total - iva_acreditable` por régimen agregado del rango
|
||
cacheado. Beneficia también a `calcularIvaAFavorAcumulado` que llama a esta
|
||
función mes-a-mes en bucle (cache hit por mes).
|
||
- Impuestos: `getResumenIva` ahora hace read-through. Lee `iva_trasladado_total`,
|
||
`iva_acreditable`, `iva_retenido_cobrado` por régimen. Calcula totales y
|
||
desgloses desde la fila cacheada. El `acumuladoAnual` (rango distinto:
|
||
enero-fechaFin) sigue on-the-fly — una sola query.
|
||
|
||
**Refactor:** se extrajo `planCache` y `CacheRange` de `dashboard.service.ts`
|
||
a `apps/api/src/utils/metricas-cache.ts` para que ambos servicios lo importen
|
||
sin duplicación.
|
||
|
||
**Escape hatch para validación:** `planCache` respeta `METRICAS_BYPASS_CACHE=1`
|
||
y retorna `null` aunque el rango califique. Permite que `validate-metricas.ts`
|
||
compare cache-vs-stored y on-the-fly-vs-stored sin mantener una segunda copia
|
||
de la lógica.
|
||
|
||
**Validación:**
|
||
- `METRICAS_BYPASS_CACHE=1 pnpm exec tsx scripts/validate-metricas.ts` →
|
||
20/20 PASS (raw on-the-fly vs metricas_mensuales).
|
||
- Sin la flag → 20/20 PASS (cache path vs metricas_mensuales).
|
||
- Combinados: cache path ≡ raw on-the-fly céntimo por céntimo para los 6
|
||
campos validados (ingresos, egresos, ivaTras, ivaAcr, ivaRet, ivaResultado).
|
||
|
||
**Fuera de alcance Tanda B.2:** `getIvaMensual`, `getIsrMensual`,
|
||
`getResumenIsr` siguen on-the-fly (requieren tarifas progresivas / lógica que
|
||
no está pre-calculada). `calcularFlujoPorMes` en reportes igual — pendiente
|
||
de evaluación si vale el esfuerzo dado que el flujo en cache (`flujo_neto`)
|
||
es por mes pero el reporte presenta serie temporal.
|
||
|
||
### Tanda B.3 — Alineación dashboard.balance ≡ impuestos.resultado
|
||
|
||
**Problema reportado:** el usuario notó diferencia entre "Balance IVA" del
|
||
dashboard y "Resultado" de Control de Impuestos. Ambos deberían coincidir, y
|
||
el usuario confirmó que la fórmula correcta es la del dashboard (IVA neto
|
||
embebido en cada bucket); el bug estaba en Control de Impuestos.
|
||
|
||
**Fórmula canónica (post-refactor):** `Resultado = Trasladado − Acreditable − Retenido`
|
||
donde las tres tarjetas usan los 6 buckets del dashboard, con retención
|
||
exhibida en su propia tarjeta:
|
||
|
||
- `Trasladado` = Σ causado **bruto** sobre 3 buckets: (Emit+I+PUE), (Emit+P), (Recib+E+PUE) — NC recibida
|
||
- `Acreditable` = Σ acreditable **bruto** sobre 3 buckets: (Recib+I+PUE), (Recib+P), (Emit+E+PUE) — NC emitida
|
||
- `Retenido` = retención(causado) − retención(acreditable) — retención **neta**
|
||
|
||
Algebraicamente `T − A − R ≡ dashboard.balance`. La ventaja es que Control de
|
||
Impuestos ahora puede mostrar la retención por separado (útil para
|
||
declaraciones) sin perder alineación con el balance.
|
||
|
||
**Cambios:**
|
||
- `impuestos.service.ts`: `getResumenIva` reescrito. 2 queries agregadas
|
||
(una por lado causado/acreditable, agrupada por régimen del tenant con
|
||
`CASE WHEN type='EMITIDO' THEN regimen_fiscal_emisor ELSE regimen_fiscal_receptor END`).
|
||
Filtra por `TODOS_REGIMENES` para alinear exclusión de régimen 616/etc.
|
||
Acumulado anual aplicó la misma fórmula y el mismo filtro.
|
||
- Se elevaron a constantes de archivo: `IVA_TRAS_EXPR`, `IVA_RET_EXPR`,
|
||
`REGIMEN_TENANT`, `BUCKET_CAUSADO`, `BUCKET_ACREDITABLE` — usados tanto
|
||
por la función on-the-fly como por el cache.
|
||
- `dashboard.service.ts`: `readIvaBalanceFromCache` reescrito para leer las
|
||
tres columnas (`iva_trasladado_total`, `iva_acreditable`, `iva_retenido_cobrado`)
|
||
y computar `monto = T − A − R`. Antes solo hacía `T − A`, lo que dejaba
|
||
fuera la retención neta cuando el cache devolvía datos post-refactor
|
||
(los valores almacenados son brutos, no netos).
|
||
- `apps/api/src/services/dashboard.service.ts`:`calcularIvaBalancePorRegimen`
|
||
ahora se exporta para que el validador la pueda llamar externamente.
|
||
|
||
**Backfill:** re-ejecutado dos veces — una tras el refactor de fórmula, otra
|
||
tras agregar el filtro `TODOS_REGIMENES`. 346 filas rescritas cada vez.
|
||
|
||
**Validación:** nuevo script `scripts/validate-dashboard-impuestos.ts` que
|
||
toma 5 muestras aleatorias por contribuyente y compara
|
||
`dashboard.calcularIvaBalancePorRegimen().total` vs `impuestos.getResumenIva().resultado`.
|
||
- `METRICAS_BYPASS_CACHE=1` → 20/20 PASS (on-the-fly puro).
|
||
- Sin bypass → 20/20 PASS (cache + cache).
|
||
- Juntos: el cache de dashboard y el cache de impuestos leen de las mismas
|
||
columnas almacenadas y producen totales idénticos.
|
||
|
||
**Bug sutil descubierto durante la validación:** el primer intento con cache
|
||
reportó 1 FAIL en Horux Dec 2025 (Δ=$3,904.96). Causa: `readIvaBalanceFromCache`
|
||
en dashboard solo restaba A, no R. Fix aplicado arriba. Cuando el refactor
|
||
cambia la semántica de las columnas almacenadas (de "IVA neto embebido" a
|
||
"componentes brutos + retención separada"), todas las funciones que leen del
|
||
cache deben actualizarse en lockstep — si no, hit rates divergen de on-the-fly.
|
||
|
||
### Tanda B.4 — Lock de sync SAT por contribuyente (no tenant)
|
||
|
||
**Problema reportado:** al iniciar la sincronización inicial de Manuel el
|
||
usuario recibió "Ya hay una sincronización en curso". La investigación mostró
|
||
que había un job `pending` de Alexa del **2026-04-21 05:12** con
|
||
`errorMessage = "Interrupted for MP setup; auto-retry scheduled"` — quedó
|
||
colgado porque el cron horario no lo retomó (posiblemente por ventana entre
|
||
`nextRetryAt` y el tick siguiente, o cron inactivo en dev).
|
||
|
||
**Causa raíz estructural:** el lock en `satSyncJob` era a nivel **tenant**
|
||
(`where: { tenantId, status: in [pending, running] }`). En un despacho con N
|
||
contribuyentes, cada uno tiene su propio FIEL y su propio conjunto de CFDIs
|
||
— serializarlos no tiene sentido.
|
||
|
||
**Cambios:**
|
||
- `apps/api/src/services/sat/sat.service.ts:945` — `startSync` usa ahora
|
||
`where: { tenantId, contribuyenteId: contribuyenteId ?? null, status: ... }`.
|
||
Contribuyentes distintos dentro del mismo despacho pueden sincronizar en
|
||
paralelo; `null` (modo Horux 360) solo choca contra sí mismo.
|
||
- `apps/api/src/services/sat/sat.service.ts:1058` — `retryFailedJobs` aplica
|
||
el mismo patrón: solo pospone el reintento si hay otro sync corriendo
|
||
para el **mismo** (tenant, contribuyente).
|
||
- Job stale de Alexa (`id=830bac32-…`) marcado como `failed` manualmente
|
||
con mensaje `"Abandoned: stale job from MP setup session"`. La sincronización
|
||
inicial de Alexa se puede relanzar desde el UI cuando se requiera.
|
||
|
||
**Pendiente derivado:** revisar el cron de retry (`sat-sync.job.ts`) para
|
||
confirmar que en dev con `NODE_ENV !== 'production'` igual se ejecuta. Si el
|
||
cron solo arranca en prod, habría que cambiar el gate o correr retry
|
||
manualmente tras reinicios largos. **Confirmado 2026-04-22:** `apps/api/src/index.ts:16`
|
||
gate es `env.NODE_ENV === 'production'` → en dev **ningún cron arranca**,
|
||
incluyendo retry y watchdog. Por eso Alexa quedó colgada. Fix pendiente:
|
||
levantar los crons también en dev, o proveer endpoint admin para dispararlos.
|
||
|
||
### Tanda B.5 — Watchdog CLI para SAT stale jobs
|
||
|
||
**Contexto:** tras Tanda B.4 quedó claro que el cron `retryTimedOutJobs`
|
||
confía en que sí corra. Si no (dev, crash, despliegue largo), los jobs
|
||
pendientes con `nextRetryAt` pasado quedan invisibles hasta que alguien
|
||
choca con el lock. Además, jobs `running` pueden quedar huérfanos si el
|
||
proceso de Node se reinicia con un sync de fondo en curso — la solicitud al
|
||
SAT se pierde pero el registro en BD queda para siempre.
|
||
|
||
**Solución:** `apps/api/scripts/sweep-stale-sat-jobs.ts` — **nuevo**. CLI
|
||
que marca como `failed` dos categorías:
|
||
|
||
- `pending` con `nextRetryAt < now − STALE_PENDING_HOURS` (default 12h).
|
||
- `running` con `startedAt < now − STALE_RUNNING_HOURS` (default 4h).
|
||
|
||
Dry-run por default, requiere `--apply` para escribir. Thresholds
|
||
sobreescribibles vía env. Pattern similar al que usé manualmente para
|
||
marcar el job de Alexa.
|
||
|
||
**Verificado 2026-04-22:** dry-run limpio (0 pending stale, 0 running stale)
|
||
con Manuel corriendo a los ~35 min (bajo el umbral de 4h).
|
||
|
||
**Pendiente:** integrar como cron en `sat-sync.job.ts` (p. ej. cada 2h)
|
||
para que sea automático. Requiere reinicio del API para wiring — queda
|
||
documentado abajo.
|
||
|
||
```ts
|
||
// Agregar en sat-sync.job.ts junto a los demás cron.schedule:
|
||
const WATCHDOG_CRON_SCHEDULE = '0 */2 * * *'; // cada 2 horas
|
||
let watchdogTask: ReturnType<typeof cron.schedule> | null = null;
|
||
|
||
watchdogTask = cron.schedule(WATCHDOG_CRON_SCHEDULE, async () => {
|
||
try {
|
||
// importar de scripts/sweep-stale-sat-jobs.ts refactorizado a función exportable,
|
||
// o duplicar la lógica inline.
|
||
await sweepStaleSatJobs({ apply: true });
|
||
} catch (err) {
|
||
console.error('[SAT Watchdog] Error:', (err as Error).message);
|
||
}
|
||
}, { timezone: 'America/Mexico_City' });
|
||
```
|
||
|
||
(Para Horux 360, las mismas constantes y la misma CLI — ver
|
||
`docs/Horux_despachos-vs-Horux360.md`.)
|
||
|
||
---
|
||
|
||
## Archivos tocados esta sesión
|
||
|
||
### Backend
|
||
- `apps/api/src/migrations/tenant/025_contribuyentes_regimen_fiscal_text.sql` — **nuevo** (fix CSF)
|
||
- `apps/api/src/migrations/tenant/026_normalize_cfdi_uuid_case.sql` — **nuevo** (cleanup duplicados UUID case-sensitive)
|
||
- `apps/api/src/migrations/tenant/027_cfdi_uuid_unique_case_insensitive.sql` — **nuevo** (índice funcional defensivo)
|
||
- `apps/api/src/controllers/alertas.controller.ts` — 1 línea en `getDiscrepanciaRegimen` (fix descartes)
|
||
- `apps/api/src/controllers/cfdi.controller.ts` — `drillDown` filtra por `contribuyenteId` + usa `FECHA_EFECTIVA` (CASE por tipo P)
|
||
- `apps/api/prisma/schema.prisma` — `enum Plan` extendido con 2 valores (Tanda 1 MP)
|
||
- `apps/api/prisma/migrations/20260421062505_despacho_plan_enum_values/migration.sql` — **nuevo** (Tanda 1 MP)
|
||
- `apps/api/src/services/payment/subscription.service.ts` — import + type `Plan` + branch en `getPlanPrice` (Tanda 1 MP)
|
||
- `apps/api/src/controllers/subscription.controller.ts` — `VALID_PLANS` + `DESPACHO_ONLY_ANNUAL` + validación annual-only (Tanda 1 MP)
|
||
- `apps/api/src/services/sat/sat.service.ts` — `saveCfdis` + `saveMetadata` normalizan UUID a lowercase y matchean case-insensitive
|
||
- `apps/api/src/services/dashboard.service.ts` — `FECHA_PAGO_RANGO` + `getFechaPagoRango()`; 4 queries de P usan `${FR_PAGO}` (fix fecha efectiva)
|
||
- `apps/api/src/services/reportes.service.ts` — `RANGO_PAGO` + `TO_CHAR(fecha_pago_p)`; `calcularFlujoPorMes.q()` branchea por tipo (fix fecha efectiva)
|
||
- `apps/api/src/services/impuestos.service.ts` — `FECHA_EFECTIVA` (CASE inline) en `FECHA_RANGO`; `EXTRACT` en `getIvaMensual` (fix fecha efectiva)
|
||
- `apps/api/src/services/documentos-extras.service.ts` — **nuevo** (tab Extras #12)
|
||
- `apps/api/src/migrations/tenant/028_documentos_extras.sql` — **nuevo** (tab Extras #12)
|
||
- `apps/api/src/controllers/documentos.controller.ts` — 5 handlers de extras (tab Extras #12)
|
||
- `apps/api/src/routes/documentos.routes.ts` — 5 rutas `/documentos/extras/*` (tab Extras #12)
|
||
- `apps/api/src/services/metricas.service.ts` — **fix bug Tanda A**: `upsertMetricaMensual` ahora incluye `iva_retenido_cobrado` e `iva_retenido_pagado` en INSERT/UPDATE (antes se quedaban en DEFAULT 0)
|
||
- `apps/api/src/utils/metricas-cache.ts` — **nuevo** (Tanda B.2): `planCache` + `CacheRange` extraídos para reuso entre dashboard e impuestos; respeta `METRICAS_BYPASS_CACHE=1` para validación
|
||
- `apps/api/src/services/impuestos.service.ts` — Tanda B.2: `getResumenIva` lee `metricas_mensuales` (trasladado/acreditable/retenido por régimen) cuando rango cae en años pasados con contribuyente seleccionado. **Tanda B.3:** refactor completo para alinear con dashboard — `Trasladado`/`Acreditable` usan los 6 buckets del dashboard (3 causado + 3 acreditable con PUE + NC); `Retenido` = retCausado − retAcreditable (neta). Filtra `TODOS_REGIMENES`. Constantes SQL elevadas a file-level: `IVA_TRAS_EXPR`, `IVA_RET_EXPR`, `REGIMEN_TENANT`, `BUCKET_CAUSADO`, `BUCKET_ACREDITABLE`
|
||
- `apps/api/src/services/dashboard.service.ts` — Tanda B.2: `calcularIvaBalancePorRegimen` lee `iva_trasladado_total - iva_acreditable` desde `metricas_mensuales`; `planCache`/`CacheRange` ahora importados del util compartido. **Tanda B.3:** `readIvaBalanceFromCache` ahora resta también `iva_retenido_cobrado` (fórmula `T − A − R`); `calcularIvaBalancePorRegimen` exportada para validación externa
|
||
- `apps/api/scripts/validate-dashboard-impuestos.ts` — **nuevo** (Tanda B.3): compara `dashboard.balance` vs `impuestos.resultado` en 5 muestras por contribuyente. Soporta `METRICAS_BYPASS_CACHE=1` para on-the-fly puro
|
||
- `apps/api/src/services/sat/sat.service.ts` — **Tanda B.4**: lock de `startSync` y `retryFailedJobs` ahora es a nivel `(tenantId, contribuyenteId)`, no tenant-wide. Permite sync paralelo entre contribuyentes de un mismo despacho
|
||
- `apps/api/scripts/sweep-stale-sat-jobs.ts` — **nuevo (Tanda B.5)**: CLI watchdog para marcar como `failed` jobs `pending` con `nextRetryAt` > 12h atrás o `running` con `startedAt` > 4h atrás. Dry-run por default; `--apply` ejecuta
|
||
- `apps/api/src/index.ts` — **B**: crons ahora arrancan en dev con `ENABLE_CRONS_IN_DEV=1` (weekly-update sigue prod-only para no mandar emails reales)
|
||
- `apps/api/src/services/sat/sat-client.service.ts` — **rejection logging**: cuando status es `rejected`/`failed`, `verifySatRequest` retorna mensaje con `SAT code=N request=EntryId(value) msg="..."` en vez del genérico wrapper
|
||
- `apps/api/src/services/impuestos.service.ts` — **C**: `getIvaMensual` refactorizado. On-the-fly usa los 6 buckets del dashboard (alineación con getResumenIva); cache read-through desde metricas_mensuales para años pasados con contribuyente. Helper `readIvaMensualFromCache` nuevo
|
||
- `apps/api/prisma/migrations/20260422172323_subscription_addons_contribuyente_id/migration.sql` — **nuevo**: agrega `contribuyente_id` a `subscription_addons`, elimina UNIQUE(subscription, addon), agrega índice (subscription, contribuyente)
|
||
- `apps/api/prisma/schema.prisma` — `SubscriptionAddon.contribuyenteId String?` opcional; sin `@@unique` compuesto (validación a nivel app)
|
||
- `apps/api/prisma/seed.ts` — agrega 2 add-ons al catálogo central: `lolita_ia_contribuyente` ($250/mes) y `contribuyente_extra_business_cloud` ($45/mes)
|
||
- `apps/api/src/services/payment/addon.service.ts` — `subscribeAddon` acepta `contribuyenteId` opcional; validación "ya tienes activo" ahora considera contribuyenteId; `listActiveAddons` filtra por contribuyenteId; el `reason` del preapproval incluye prefix del RFC cuando aplica
|
||
- `apps/api/src/controllers/subscription.controller.ts` — `getMyAddons` lee `?contribuyenteId=` del query; `addMyAddon` lee `contribuyenteId` del body
|
||
- `apps/web/lib/api/addons.ts` + `apps/web/lib/hooks/use-addons.ts` — **nuevos**: cliente API + hooks para `/subscriptions/me/addons?contribuyenteId=`
|
||
- `apps/web/app/(dashboard)/contribuyentes/addons-dialog.tsx` — **nuevo**: dialog dedicado con catálogo `ADDONS_POR_CONTRIBUYENTE` (ahora solo `lolita_ia_contribuyente` a $250/mes). Muestra estado, fecha próximo cobro, botones Contratar (abre MP en nueva pestaña) / Cancelar
|
||
- `apps/web/app/(dashboard)/contribuyentes/page.tsx` — icono `Sparkles` por contribuyente abre el dialog
|
||
- **Revertido**: el primer modelo (tabla tenant `contribuyente_addons` con feature toggles) fue el diseño equivocado. Los add-ons reales son servicios de cobro recurrente ligados a `SubscriptionAddon` (BD central) con preapproval MP independiente (la licencia es anual, los add-ons mensuales)
|
||
- `apps/api/src/controllers/webhook.controller.ts` — downgrade preapproval tras primer pago (Opción B dualidad)
|
||
- `apps/api/src/services/payment/subscription.service.ts` — `getPlanPrice(phase)` + `subscribe()` con firstYear y reason explicativo (Opción B dualidad)
|
||
- `apps/api/src/services/despacho.service.ts` — `signupDespacho` refactorizado para usar `subscribe()` (registra Subscription en BD + aplica dualidad)
|
||
- `apps/api/src/controllers/despacho.controller.ts` — `getMyPlan` retorna objeto `subscription` con status/plan/amount/periodo
|
||
|
||
### Shared
|
||
- `packages/shared/src/constants/despacho-plans.ts` — `DESPACHO_PLAN_PRICES` con shape `{ firstYear, renewal }`, `DespachoPaidPlan`, `DespachoPricePhase`, `isDespachoPaidPlan`, `despachoPlanTieneDualidad` (Tanda 1 MP + Opción B dualidad)
|
||
|
||
### Frontend
|
||
- `apps/web/app/(dashboard)/configuracion/planes-despacho/page.tsx` — flujo de contratar/cambiar/cancelar + banner de estado de suscripción activa (Tanda 1 MP + UI dualidad)
|
||
- `apps/web/app/(dashboard)/documentos/page.tsx` — nueva pestaña "Extras" con componente `ExtrasTab` + `UploadExtraDialog` (#12)
|
||
- `apps/web/lib/api/documentos.ts` — 5 funciones cliente para extras (#12)
|
||
- `apps/web/app/(auth)/register-despacho\page.tsx` — precios actualizados $21K/$45
|
||
|
||
### Data directa
|
||
- `horux_despacho_mo7je8bz_vdopr` (Zorro):
|
||
- `ALTER TABLE contribuyentes ALTER COLUMN regimen_fiscal TYPE text;`
|
||
- `INSERT INTO tenant_migrations (...) VALUES ('vertical-contable', 25, ...)`
|
||
- `UPDATE contribuyentes SET regimen_fiscal, codigo_postal, domicilio` para
|
||
Alexa (RFC `TORA0007099R6`)
|
||
- `UPDATE sat_sync_jobs SET status='pending', next_retry_at=NOW()+5min` para
|
||
el job de extracción inicial de 6 años (se marcó pending para dejar que el
|
||
cron retomara tras reinicio).
|
||
- `horux_despachos` (central):
|
||
- `ALTER TYPE "Plan" ADD VALUE 'business_control'`
|
||
- `ALTER TYPE "Plan" ADD VALUE 'business_cloud'`
|
||
- (Estos fueron después formalizados por `prisma migrate dev` en el archivo
|
||
de migración listado arriba.)
|
||
|
||
### Externo
|
||
- Ninguno.
|
||
|
||
---
|
||
|
||
## Pendientes derivados de esta sesión
|
||
|
||
### Nuevos (agregados al cierre)
|
||
|
||
1. **Filtro "solo vigentes" en discrepancia-régimen.** Verificar que el
|
||
drilldown `GET /alertas/drilldown/discrepancia-regimen` y el panel de
|
||
alertas (`alertas-auto.service.ts`) excluyan CFDIs cancelados. Revisar:
|
||
- Ahora mismo la query ya filtra `status NOT IN ('Cancelado', '0') AND
|
||
fecha_cancelacion IS NULL` — verificar que sea suficiente, o si hay CFDIs
|
||
con estatus raro (p.ej. "Proceso de cancelación") que se estén colando.
|
||
- Considerar añadir filtro explícito `status = 'Vigente'` como defense-in-depth.
|
||
|
||
2. **UI para restaurar CFDIs descartados.** El endpoint
|
||
`DELETE /alertas/descartar` ya existe pero no hay botón en UI. Propuesta:
|
||
- Tab "Descartados" en `/alertas/discrepancia-regimen` que muestra los CFDIs
|
||
en `cfdi_descartados` con botón "Restaurar".
|
||
- O mostrarlos en la lista principal con un toggle "Mostrar descartados"
|
||
con badge y botón por fila.
|
||
|
||
### De sesión anterior (siguen abiertos)
|
||
|
||
3. **Recrear org Facturapi de Carlos** (TORC9611214CA): el usuario elimina +
|
||
crea nueva; después hay que actualizar `facturapi_orgs.facturapi_org_id` en
|
||
BD tenant de Patito.
|
||
4. **Validación preventiva CSD↔RFC** en `uploadCsdContribuyente`: que el RFC
|
||
del certificado coincida con el RFC del contribuyente antes de subirlo.
|
||
5. **Prueba cross-contribuyente end-to-end**: Horux 360 → Carlos → (sin org) →
|
||
validar aislamiento, reset de formulario, mensajes de error.
|
||
6. **Regeneración de métricas ISR pre-calculadas**: las tablas
|
||
`metricas_mensuales` / `acumuladas_anuales` (años pasados en hot/cold) pueden
|
||
haberse calculado con la lógica antigua de `getIsrMensual`. Verificar si
|
||
requieren recompute para años previos.
|
||
|
||
### Activación del sistema hot/cold de métricas (nuevo — grande)
|
||
|
||
La infraestructura existe al 30%:
|
||
- Tabla `metricas_mensuales` con 35+ columnas (IVA por tasa, ISR, ingresos
|
||
devengados/cobrados, flujo, CxC/CxP) + tabla `metricas_invalidaciones`.
|
||
- Funciones `upsertMetricaMensual`, `getMetricasMensuales`, `markForInvalidation`,
|
||
`getPendingInvalidations`, `clearInvalidation`, `closeMonth`, `closeYear`.
|
||
- Invalidación ya activa en `cfdi.service.ts:455` y `:521` (upload manual XML).
|
||
|
||
Lo que falta (70%):
|
||
|
||
**Tanda A — cimientos (1 sesión dedicada):**
|
||
- `computeMetricaMensual(pool, contribuyenteId, anio, mes, regimen)` que agregue
|
||
los 35+ campos desde CFDIs raw. Consolidar lógica que hoy vive dispersa en
|
||
dashboard/impuestos/reportes services.
|
||
- Script `backfillMetricas()` para poblar histórico (~540 filas en Patito: 3
|
||
contribuyentes × 5 años × 12 meses × ~3 regímenes).
|
||
- Cron de recomputación que consume `metricas_invalidaciones` cada N minutos,
|
||
recomputa y llama `clearInvalidation`. Lock para evitar double-compute.
|
||
- Invalidación en los paths faltantes: SAT sync (`saveCfdis`, `saveMetadata`),
|
||
cancelaciones, conciliación, cancelaciones retroactivas.
|
||
- **Ningún consumer cambia en Tanda A** — la tabla se llena y mantiene, pero
|
||
dashboard/impuestos/reportes siguen on-the-fly. Riesgo bajo.
|
||
|
||
**Tanda B — read-through (1 sesión):**
|
||
- Modificar `calcularIngresosPorRegimen`, `calcularEgresosPorRegimen`,
|
||
`calcularIvaBalancePorRegimen` en dashboard para leer de `metricas_mensuales`
|
||
cuando el mes es pasado.
|
||
- Ídem `getIvaMensual`, `getIsrMensual`, `getResumenIva`, `getResumenIsr` en
|
||
impuestos.
|
||
- Ídem `calcularFlujoPorMes`, `getFlujoEfectivo` en reportes.
|
||
- Cada consumer se valida en paralelo (tabla vs on-the-fly) para 1 mes conocido
|
||
antes de commitir — cualquier diferencia céntimo por céntimo es regresión.
|
||
- Riesgo alto: afecta KPIs principales del sistema.
|
||
|
||
**Alternativa mínima (si solo importa performance del dashboard principal):**
|
||
Implementar Tanda A cubriendo solo `ingresos_cobrados`, `egresos_pagados`,
|
||
`utilidad_realizada`. Sin IVA ni ISR. Reduce a 1 sesión total, no cubre
|
||
impuestos ni reportes.
|
||
|
||
**Cuándo vale hacerlo:** cuando el dashboard tarde notoriamente al consultar
|
||
años pasados con volumen alto, o al escalar a múltiples despachos con histórico
|
||
grande. Hoy en Patito (3 contribuyentes, 5 años) el costo on-the-fly es bajo y
|
||
el beneficio es teórico.
|
||
|
||
---
|
||
|
||
### Tanda 2 MP (cobro despacho, continuación)
|
||
|
||
7. **Bloqueo total fuera de `/subscriptions/*`** para despachos sin pago activo.
|
||
Modificar `plan-limits.middleware.ts` para que despachos bloqueados (sub
|
||
inactiva + trial vencido) solo puedan acceder a endpoints de suscripción.
|
||
Frontend interceptor que redirija a `/configuracion/planes-despacho` al
|
||
recibir 403 con mensaje específico.
|
||
8. **Add-on recurrente $45/RFC/mes** para Business Cloud. Requiere:
|
||
- Row en `plan_addon_catalogo` con `key='rfc_despacho_managed'`, precio $45.
|
||
- Hook al crear contribuyente: agregar addon al `Subscription` activo.
|
||
- Hook al eliminar contribuyente: cancelar addon correspondiente.
|
||
- Multi-preapproval MP (patrón ya existe en `addon.service.ts` para Horux360).
|
||
|
||
### Features originales del pivote (de `2026-04-19-pending-features.md`)
|
||
|
||
9. Convertir `/pendientes` → "Despacho" con KPIs del despacho.
|
||
10. Pestaña "Extras" en `/documentos` (PDFs libres con categoría).
|
||
11. Avisos por correo al subir declaraciones / documentos.
|
||
|
||
---
|
||
|
||
## Estado TS al cierre
|
||
|
||
`pnpm typecheck` en `@horux/api`: **0 errores**.
|
||
|
||
Errores pendientes en `@horux/web`: todos pre-existentes, no tocados esta
|
||
sesión.
|
||
|
||
---
|
||
|
||
## Riesgos / notas operacionales
|
||
|
||
- **Extracción SAT en Zorro**: se dejó en `pending` con `next_retry_at =
|
||
2026-04-21 06:28:42` UTC antes de reiniciar el dev server. El cron horario
|
||
del SAT sync la retomará automáticamente. Si al siguiente chequeo sigue sin
|
||
progresar, reiniciarla manualmente con rango menor desde la UI.
|
||
- **Migración tenant 025 no registrada en Patito**: se auto-registrará via
|
||
lazy-migrate en el próximo `getPool(patitoId)`. No requiere acción manual.
|
||
- **27 CFDIs descartados en Zorro**: dejan de aparecer en el drilldown tras el
|
||
fix. El botón UI para restaurar es pendiente #2 (ver arriba).
|
||
- **Migración Prisma `20260421062505_despacho_plan_enum_values`**: archivo
|
||
ligado a la BD local. En prod `prisma migrate deploy` aplicará los mismos
|
||
`ALTER TYPE ... ADD VALUE` — son compatibles hacia atrás, no requieren
|
||
ventana de mantenimiento.
|
||
- **Cobro MP en dev**: requiere `MP_ACCESS_TOKEN` válido en `.env` del api para
|
||
crear preapprovals reales (o sandbox). Si no está configurado, el endpoint
|
||
devuelve 503 con mensaje claro.
|