Files
HoruxDespachos/docs/plans/2026-04-21-session-2-mp-setup-and-bugfixes.md
2026-04-27 22:09:36 -06:00

54 KiB
Raw Permalink Blame History

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:

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)
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)

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.sqlnuevo
  • 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
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:

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):

    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):

<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():

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:

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.tsDESPACHO_PLAN_PRICES ahora { firstYear, renewal } por plan + nuevos helpers DespachoPricePhase y despachoPlanTieneDualidad().
  • apps/api/src/services/payment/subscription.service.tsgetPlanPrice 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.tssaveCfdis y saveMetadata marcan invalidación por cada CFDI insertado/updated, usando fecha_pago_p para tipo P.
  • apps/api/src/controllers/facturacion.controller.tscancelar 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:945startSync 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:1058retryFailedJobs 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.tsnuevo. 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.

// 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.sqlnuevo (fix CSF)
  • apps/api/src/migrations/tenant/026_normalize_cfdi_uuid_case.sqlnuevo (cleanup duplicados UUID case-sensitive)
  • apps/api/src/migrations/tenant/027_cfdi_uuid_unique_case_insensitive.sqlnuevo (índice funcional defensivo)
  • apps/api/src/controllers/alertas.controller.ts — 1 línea en getDiscrepanciaRegimen (fix descartes)
  • apps/api/src/controllers/cfdi.controller.tsdrillDown filtra por contribuyenteId + usa FECHA_EFECTIVA (CASE por tipo P)
  • apps/api/prisma/schema.prismaenum Plan extendido con 2 valores (Tanda 1 MP)
  • apps/api/prisma/migrations/20260421062505_despacho_plan_enum_values/migration.sqlnuevo (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.tsVALID_PLANS + DESPACHO_ONLY_ANNUAL + validación annual-only (Tanda 1 MP)
  • apps/api/src/services/sat/sat.service.tssaveCfdis + saveMetadata normalizan UUID a lowercase y matchean case-insensitive
  • apps/api/src/services/dashboard.service.tsFECHA_PAGO_RANGO + getFechaPagoRango(); 4 queries de P usan ${FR_PAGO} (fix fecha efectiva)
  • apps/api/src/services/reportes.service.tsRANGO_PAGO + TO_CHAR(fecha_pago_p); calcularFlujoPorMes.q() branchea por tipo (fix fecha efectiva)
  • apps/api/src/services/impuestos.service.tsFECHA_EFECTIVA (CASE inline) en FECHA_RANGO; EXTRACT en getIvaMensual (fix fecha efectiva)
  • apps/api/src/services/documentos-extras.service.tsnuevo (tab Extras #12)
  • apps/api/src/migrations/tenant/028_documentos_extras.sqlnuevo (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.tsfix 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.tsnuevo (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.tsnuevo (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.tsTanda 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.tsnuevo (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.tsB: 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.tsrejection 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.tsC: 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.sqlnuevo: agrega contribuyente_id a subscription_addons, elimina UNIQUE(subscription, addon), agrega índice (subscription, contribuyente)
  • apps/api/prisma/schema.prismaSubscriptionAddon.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.tssubscribeAddon 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.tsgetMyAddons lee ?contribuyenteId= del query; addMyAddon lee contribuyenteId del body
  • apps/web/lib/api/addons.ts + apps/web/lib/hooks/use-addons.tsnuevos: cliente API + hooks para /subscriptions/me/addons?contribuyenteId=
  • apps/web/app/(dashboard)/contribuyentes/addons-dialog.tsxnuevo: 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.tsgetPlanPrice(phase) + subscribe() con firstYear y reason explicativo (Opción B dualidad)
  • apps/api/src/services/despacho.service.tssignupDespacho refactorizado para usar subscribe() (registra Subscription en BD + aplica dualidad)
  • apps/api/src/controllers/despacho.controller.tsgetMyPlan retorna objeto subscription con status/plan/amount/periodo

Shared

  • packages/shared/src/constants/despacho-plans.tsDESPACHO_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)

  1. 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.
  2. Validación preventiva CSD↔RFC en uploadCsdContribuyente: que el RFC del certificado coincida con el RFC del contribuyente antes de subirlo.
  3. Prueba cross-contribuyente end-to-end: Horux 360 → Carlos → (sin org) → validar aislamiento, reset de formulario, mensajes de error.
  4. 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)

  1. 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.
  2. 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)

  1. Convertir /pendientes → "Despacho" con KPIs del despacho.
  2. Pestaña "Extras" en /documentos (PDFs libres con categoría).
  3. 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.