# 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 ``), no cuándo se emitió el complemento. **Evidencia dura** (Astorga folio 60, mayo 2025): ```xml ``` 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` 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 | 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.