54 KiB
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:
- CSF auto-fill rompe en despachos con contribuyentes multi-régimen (Alexa) — schema.
- Descartes de CFDI no aparecen surtidos en el drilldown — query filter.
- CFDIs duplicados por UUID case-sensitive (1426 duplicados en Patito) — sync SAT + constraint.
- Drill-downs no filtraban por contribuyente — fix en
cfdi.controller.ts+ frontend. - Cobro MP para planes despacho (Tanda 1) — enum, catálogo, flujo completo.
- 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.sqldeclaróregimen_fiscal varchar(3), asumiendo un solo régimen por contribuyente. sincronizarDatosFiscalesenconstancia.service.tsgenera 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):texthorux_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) |
✅ 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.contribuyentesfila 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
numeroInteriorpor 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:
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 agetDiscrepanciaRegimen
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_PLANSenpackages/shared/src/constants/despacho-plans.tscon 3 entradas:trial,business_control,business_cloudcon límites, features ydbMode. - Página
/configuracion/planes-despacho/page.tsxcon 2 cards (Business Control, Business Cloud) y precios ya mostrados. - Endpoint
GET /api/despachos/me/planque devuelve{plan, dbMode, trialEndsAt, isTrialActive}. - Flujo completo Horux360:
subscription.service.tsconsubscribe,startTrial,scheduleChange,initiateUpgrade,reactivateSubscription,cancelSubscription. Todos usangetPlanPrice(plan, frequency)que consulta tablaplan_pricesen BD central. - Webhook MP en
webhook.controller.tscon routing porexternal_reference(timbres-pack:,proration:,addon:, o UUID tenant). - Middleware
plan-limits.middleware.tsque bloquea escrituras si la sub no estáauthorizedopending(permite GETs). - Helper
isDespachoTenant(rfc)endespacho-plans.tsque detecta por prefijoDESPACHO_. Subscription.plan,pendingPlan,upgradeTargetPlanyTenant.plantodos sonPlan(enum Prisma).
Lo que falta (gaps identificados):
- Enum Prisma
Planno contienebusiness_controlnibusiness_cloud. Sus valores actuales:starter, business, business_ia, custom, enterprise. VALID_PLANSensubscription.controller.tssólo acepta los 4 de Horux360.getPlanPriceconsultaplan_pricestabla; los precios despacho están en constants, no en BD.validatePlanFrequencyno fuerzafrequency: 'annual'para planes despacho (el usuario pidió solo anual).- Página
/configuracion/planes-despachono tiene botón "Suscribirse" ni flujo de checkout. - Middleware de bloqueo permite GETs; el usuario pidió bloquear todo menos
/subscriptions/*cuando no paga (pendiente de Tanda 2). - Add-on recurrente
$45/RFC/mespara Business Cloud: el sistema de addons existe pero no hay catálogo pararfc_despachoni wiring al crear contribuyente (pendiente de Tanda 2).
Plan acordado
Tanda 1 (esta sesión, cobro funcional):
- Extender enum Prisma
Planconbusiness_control,business_cloud. - Agregar
DESPACHO_PLAN_PRICESendespacho-plans.ts. - Extender type local
Plan+getPlanPriceensubscription.service.tspara branchear a constantes cuando es despacho. - Extender
VALID_PLANSen controller + validaciónannual-only para despacho. - Agregar botones "Suscribirse" / "Cambiar plan" / "Plan actual" en la página
/configuracion/planes-despacho. window.open(paymentUrl, '_blank')al recibir URL del endpoint.pnpm typecheckfinal.
Tanda 2 (próxima sesión):
- Bloqueo total fuera de
/subscriptions/*para despachos sin pago activo. - Add-on recurrente
$45/RFC/mescon 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:
-
Enum Prisma extendido:
apps/api/prisma/schema.prisma→ agregadosbusiness_control,business_cloudal enumPlan.pnpm prisma migrate dev --name despacho_plan_enum_valuesgeneróapps/api/prisma/migrations/20260421062505_despacho_plan_enum_values/migration.sqlcon soloALTER TYPE ... ADD VALUE(verificado — no reescribe enum).- Prisma client regenerado con tipos actualizados.
-
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). -
Branch de precio en servicio (
apps/api/src/services/payment/subscription.service.ts):- Type local
Planextendido conbusiness_control | business_cloud. getPlanPricebranchea: siisDespachoPaidPlan(plan)→ valida quefrequency === 'annual'y devuelveDESPACHO_PLAN_PRICES[plan]. Si no → sigue consultandoplan_prices.- Cero cambios a
subscribe,scheduleChange,initiateUpgrade,reactivateSubscription— todos reutilizangetPlanPricey funcionan out-of-the-box para despacho. El webhook routing porexternal_reference = tenantIdsigue igual.
- Type local
-
Validación en controller (
apps/api/src/controllers/subscription.controller.ts):VALID_PLANSextendido a los 6 valores.validatePlanFrequencyrechazamonthlypara planes despacho con mensaje claro.- Defensa-en-profundidad:
getPlanPricevalida de nuevo en capa de servicio.
-
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 achangeMyPlan→ 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.
-
Typecheck limpio en api y web en los archivos tocados.
-
Dev server reiniciado con los cambios de Prisma + código aplicados. Extracción Zorro quedó en
pendingcon 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/mespara Business Cloud (tablaplan_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):
- Al contratar, el backend crea el preapproval MP con
amount = firstYear($21,000). Elreasondel preapproval explica ambos montos para que el usuario vea en MP:"Plan business_control - $21,000 primer año, $15,000 renovaciones". - User autoriza una vez en MP → paga $21,000 ahora.
- MP envía webhook
payment.approved. - El webhook, al detectar la transición
pending → authorized(primer pago) de una sub de plan despacho con dualidad, llamampService.updatePreapprovalAmountpara bajar el monto recurrente arenewal($15,000). Actualiza tambiénSubscription.amount = 15000. - 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_PRICESahora{ firstYear, renewal }por plan + nuevos helpersDespachoPricePhaseydespachoPlanTieneDualidad().apps/api/src/services/payment/subscription.service.ts—getPlanPriceacepta parámetrophase: 'firstYear' | 'renewal'con default'renewal';subscribe()usafirstYeary armareasonexplicativo si hay dualidad.apps/api/src/controllers/webhook.controller.ts— rama nueva al primer pago aprobado que baja preapproval y actualizaSubscription.amount.apps/api/src/services/despacho.service.ts— usa catálogo únicoDESPACHO_PLAN_PRICESen 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 usarsubscribe()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/planahora retorna camposubscription: { status, plan, amount, currentPeriodStart, currentPeriodEnd }además del plan info básico. Query a la tablasubscriptionsfiltra porstatus ∈ { 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
$15Kpara 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 (soloNODE_ENV=production).apps/api/src/services/sat/sat.service.ts—saveCfdisysaveMetadatamarcan invalidación por cada CFDI insertado/updated, usandofecha_pago_ppara tipo P.apps/api/src/controllers/facturacion.controller.ts—cancelarcaptura 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) — confecha_pago_ppara tipo P - SAT sync
saveMetadata(nuevo) - Cancelación via Facturapi (
cancelaren 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(llamacalcularIngresosPorRegimenpor 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,getResumenIvaen impuestos (usan queries directas sobre cfdis, no viacalcularIngresosPorRegimen)getFlujoEfectivo,calcularFlujoPorMesen 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:
contribuyenteIddebe 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).fechaIniciodebe ser día 1 del mes.fechaFindebe 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_cobradoeiva_retenido_pagadoalINSERT, la cláusulaVALUES, elUPDATE SETdelON 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 helpersplanCache,readIngresosFromCache,readEgresosFromCache+ integración al top decalcularIngresosPorRegimenycalcularEgresosPorRegimen.
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:
calcularIvaBalancePorRegimenahora hace read-through. Leeiva_trasladado_total - iva_acreditablepor régimen agregado del rango cacheado. Beneficia también acalcularIvaAFavorAcumuladoque llama a esta función mes-a-mes en bucle (cache hit por mes). - Impuestos:
getResumenIvaahora hace read-through. Leeiva_trasladado_total,iva_acreditable,iva_retenido_cobradopor régimen. Calcula totales y desgloses desde la fila cacheada. ElacumuladoAnual(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 recibidaAcreditable= Σ acreditable bruto sobre 3 buckets: (Recib+I+PUE), (Recib+P), (Emit+E+PUE) — NC emitidaRetenido= 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:getResumenIvareescrito. 2 queries agregadas (una por lado causado/acreditable, agrupada por régimen del tenant conCASE WHEN type='EMITIDO' THEN regimen_fiscal_emisor ELSE regimen_fiscal_receptor END). Filtra porTODOS_REGIMENESpara 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:readIvaBalanceFromCachereescrito para leer las tres columnas (iva_trasladado_total,iva_acreditable,iva_retenido_cobrado) y computarmonto = T − A − R. Antes solo hacíaT − 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:calcularIvaBalancePorRegimenahora 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—startSyncusa ahorawhere: { 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—retryFailedJobsaplica 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 comofailedmanualmente 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:
pendingconnextRetryAt < now − STALE_PENDING_HOURS(default 12h).runningconstartedAt < 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.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 engetDiscrepanciaRegimen(fix descartes)apps/api/src/controllers/cfdi.controller.ts—drillDownfiltra porcontribuyenteId+ usaFECHA_EFECTIVA(CASE por tipo P)apps/api/prisma/schema.prisma—enum Planextendido 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 + typePlan+ branch engetPlanPrice(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+saveMetadatanormalizan UUID a lowercase y matchean case-insensitiveapps/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) enFECHA_RANGO;EXTRACTengetIvaMensual(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:upsertMetricaMensualahora incluyeiva_retenido_cobradoeiva_retenido_pagadoen INSERT/UPDATE (antes se quedaban en DEFAULT 0)apps/api/src/utils/metricas-cache.ts— nuevo (Tanda B.2):planCache+CacheRangeextraídos para reuso entre dashboard e impuestos; respetaMETRICAS_BYPASS_CACHE=1para validaciónapps/api/src/services/impuestos.service.ts— Tanda B.2:getResumenIvaleemetricas_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/Acreditableusan los 6 buckets del dashboard (3 causado + 3 acreditable con PUE + NC);Retenido= retCausado − retAcreditable (neta). FiltraTODOS_REGIMENES. Constantes SQL elevadas a file-level:IVA_TRAS_EXPR,IVA_RET_EXPR,REGIMEN_TENANT,BUCKET_CAUSADO,BUCKET_ACREDITABLEapps/api/src/services/dashboard.service.ts— Tanda B.2:calcularIvaBalancePorRegimenleeiva_trasladado_total - iva_acreditabledesdemetricas_mensuales;planCache/CacheRangeahora importados del util compartido. Tanda B.3:readIvaBalanceFromCacheahora resta tambiéniva_retenido_cobrado(fórmulaT − A − R);calcularIvaBalancePorRegimenexportada para validación externaapps/api/scripts/validate-dashboard-impuestos.ts— nuevo (Tanda B.3): comparadashboard.balancevsimpuestos.resultadoen 5 muestras por contribuyente. SoportaMETRICAS_BYPASS_CACHE=1para on-the-fly puroapps/api/src/services/sat/sat.service.ts— Tanda B.4: lock destartSyncyretryFailedJobsahora es a nivel(tenantId, contribuyenteId), no tenant-wide. Permite sync paralelo entre contribuyentes de un mismo despachoapps/api/scripts/sweep-stale-sat-jobs.ts— nuevo (Tanda B.5): CLI watchdog para marcar comofailedjobspendingconnextRetryAt> 12h atrás orunningconstartedAt> 4h atrás. Dry-run por default;--applyejecutaapps/api/src/index.ts— B: crons ahora arrancan en dev conENABLE_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 esrejected/failed,verifySatRequestretorna mensaje conSAT code=N request=EntryId(value) msg="..."en vez del genérico wrapperapps/api/src/services/impuestos.service.ts— C:getIvaMensualrefactorizado. 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. HelperreadIvaMensualFromCachenuevoapps/api/prisma/migrations/20260422172323_subscription_addons_contribuyente_id/migration.sql— nuevo: agregacontribuyente_idasubscription_addons, elimina UNIQUE(subscription, addon), agrega índice (subscription, contribuyente)apps/api/prisma/schema.prisma—SubscriptionAddon.contribuyenteId String?opcional; sin@@uniquecompuesto (validación a nivel app)apps/api/prisma/seed.ts— agrega 2 add-ons al catálogo central:lolita_ia_contribuyente($250/mes) ycontribuyente_extra_business_cloud($45/mes)apps/api/src/services/payment/addon.service.ts—subscribeAddonaceptacontribuyenteIdopcional; validación "ya tienes activo" ahora considera contribuyenteId;listActiveAddonsfiltra por contribuyenteId; elreasondel preapproval incluye prefix del RFC cuando aplicaapps/api/src/controllers/subscription.controller.ts—getMyAddonslee?contribuyenteId=del query;addMyAddonleecontribuyenteIddel bodyapps/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álogoADDONS_POR_CONTRIBUYENTE(ahora solololita_ia_contribuyentea $250/mes). Muestra estado, fecha próximo cobro, botones Contratar (abre MP en nueva pestaña) / Cancelarapps/web/app/(dashboard)/contribuyentes/page.tsx— iconoSparklespor contribuyente abre el dialog- Revertido: el primer modelo (tabla tenant
contribuyente_addonscon feature toggles) fue el diseño equivocado. Los add-ons reales son servicios de cobro recurrente ligados aSubscriptionAddon(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—signupDespachorefactorizado para usarsubscribe()(registra Subscription en BD + aplica dualidad)apps/api/src/controllers/despacho.controller.ts—getMyPlanretorna objetosubscriptioncon status/plan/amount/periodo
Shared
packages/shared/src/constants/despacho-plans.ts—DESPACHO_PLAN_PRICEScon 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 componenteExtrasTab+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, domiciliopara Alexa (RFCTORA0007099R6)UPDATE sat_sync_jobs SET status='pending', next_retry_at=NOW()+5minpara 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 deven el archivo de migración listado arriba.)
Externo
- Ninguno.
Pendientes derivados de esta sesión
Nuevos (agregados al cierre)
-
Filtro "solo vigentes" en discrepancia-régimen. Verificar que el drilldown
GET /alertas/drilldown/discrepancia-regimeny 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.
- Ahora mismo la query ya filtra
-
UI para restaurar CFDIs descartados. El endpoint
DELETE /alertas/descartarya existe pero no hay botón en UI. Propuesta:- Tab "Descartados" en
/alertas/discrepancia-regimenque muestra los CFDIs encfdi_descartadoscon botón "Restaurar". - O mostrarlos en la lista principal con un toggle "Mostrar descartados" con badge y botón por fila.
- Tab "Descartados" en
De sesión anterior (siguen abiertos)
- Recrear org Facturapi de Carlos (TORC9611214CA): el usuario elimina +
crea nueva; después hay que actualizar
facturapi_orgs.facturapi_org_iden BD tenant de Patito. - Validación preventiva CSD↔RFC en
uploadCsdContribuyente: que el RFC del certificado coincida con el RFC del contribuyente antes de subirlo. - Prueba cross-contribuyente end-to-end: Horux 360 → Carlos → (sin org) → validar aislamiento, reset de formulario, mensajes de error.
- 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 degetIsrMensual. 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_mensualescon 35+ columnas (IVA por tasa, ISR, ingresos devengados/cobrados, flujo, CxC/CxP) + tablametricas_invalidaciones. - Funciones
upsertMetricaMensual,getMetricasMensuales,markForInvalidation,getPendingInvalidations,clearInvalidation,closeMonth,closeYear. - Invalidación ya activa en
cfdi.service.ts:455y: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_invalidacionescada N minutos, recomputa y llamaclearInvalidation. 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,calcularIvaBalancePorRegimenen dashboard para leer demetricas_mensualescuando el mes es pasado. - Ídem
getIvaMensual,getIsrMensual,getResumenIva,getResumenIsren impuestos. - Ídem
calcularFlujoPorMes,getFlujoEfectivoen 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)
- Bloqueo total fuera de
/subscriptions/*para despachos sin pago activo. Modificarplan-limits.middleware.tspara que despachos bloqueados (sub inactiva + trial vencido) solo puedan acceder a endpoints de suscripción. Frontend interceptor que redirija a/configuracion/planes-despachoal recibir 403 con mensaje específico. - Add-on recurrente $45/RFC/mes para Business Cloud. Requiere:
- Row en
plan_addon_catalogoconkey='rfc_despacho_managed', precio $45. - Hook al crear contribuyente: agregar addon al
Subscriptionactivo. - Hook al eliminar contribuyente: cancelar addon correspondiente.
- Multi-preapproval MP (patrón ya existe en
addon.service.tspara Horux360).
- Row en
Features originales del pivote (de 2026-04-19-pending-features.md)
- Convertir
/pendientes→ "Despacho" con KPIs del despacho. - Pestaña "Extras" en
/documentos(PDFs libres con categoría). - 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
pendingconnext_retry_at = 2026-04-21 06:28:42UTC 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 prodprisma migrate deployaplicará los mismosALTER TYPE ... ADD VALUE— son compatibles hacia atrás, no requieren ventana de mantenimiento. - Cobro MP en dev: requiere
MP_ACCESS_TOKENválido en.envdel api para crear preapprovals reales (o sandbox). Si no está configurado, el endpoint devuelve 503 con mensaje claro.