Files
HoruxDespachosNuevo/docs/plans/2026-05-02-session.md

67 KiB
Raw Blame History

Sesión 2026-05-02 — Refactor fiscal ISR: separación NCs, compensación I/07 PPD, no deducibles efectivo > $2k

Sesión enfocada en limpiar las fórmulas de cálculo fiscal para ISR — separar las notas de crédito (E PUE) de los cálculos de ingresos/deducciones para exhibirlas en cards independientes, ajustar la base gravable para regímenes con fórmula ingresos-deducciones, restaurar la compensación I/07 PPD ↔ E con interpretación fiscal explícita, agregar visibilidad de gastos no deducibles por Art. 27 fracción III LISR, alinear drill-downs y persistir las nuevas métricas en cache metricas_mensuales.

Contexto fiscal: las NCs recibidas/emitidas estaban opacas dentro del cálculo de ingresos/deducciones. El contador no podía ver "qué facturé en bruto vs qué cancelé"; ambos quedaban netos en una sola card. Además los gastos pagados en efectivo > $2,000 (no deducibles por Art. 27) se deducían incorrectamente, sub-calculando el ISR a pagar.


Índice

  1. Eliminar E PUE de cálculo de ingresos (Grupos 1 y 3)
  2. Eliminar E PUE de cálculo de deducciones
  3. Cards NCs Emitidas + NCs Recibidas (surface)
  4. Restauración compensación I/07 PPD ↔ E (opción C)
  5. Base gravable: nueva fórmula con NCs
  6. Persistencia en metricas_mensuales (migración 042)
  7. Switch "Considerar NCs" extendido
  8. Drill-downs: remover E de ingresos/gastos + propagar régimen + drill propio para NCs
  9. Art. 27 fracción III LISR — gastos efectivo > $2k no deducibles
  10. Layout final cards en /impuestos > ISR 1121. (secciones agregadas durante la sesión — ver headers)
  11. Auto-facturación con datos del cliente (Fases 1 + 2)
  12. Onboarding auto-dismiss (4 logins ó pasos completados)
  13. Soporte wide-screen (breakpoints 3xl/4xl + 3 páginas)
  14. Alerta RESICO PF cerca del límite anual ($2.5M / $3M)
  15. SAT — reuso de requestIds en retries + políticas de retry por tipo

1. Eliminar E PUE de cálculo de ingresos

Grupo 3 (PMs y otros — 601, 603, 607...)

Antes:

ingresos = I (PUE+PPD)  E PUE

Ahora:

ingresos = I (PUE+PPD)

apps/api/src/services/dashboard.service.ts:calcularIngresosPorRegimen — removida la query g3NC y el término nc del loop. Las E PUE se contabilizan del lado del receptor (gastos), no como reducción del ingreso del emisor. Criterio fiscal vigente para PMs.

Grupo 1 (PF Empresarial — 606, 612, 621, 625, 626)

Antes:

ingresos = I PUE + P + I/07_PPD_compensación  E PUE

Después del cambio inicial:

ingresos = I PUE + P

(Eliminada también la compensación I/07 PPD ↔ E porque su raison d'être era neutralizar la sobre-resta de E. Sin E restando, no hay ciclo que cerrar.)

Tras restauración de la compensación (sección 4):

ingresos = I PUE + P + I/07_PPD_compensación

Más detalle del razonamiento en sección 4.

2. Eliminar E PUE de cálculo de deducciones

apps/api/src/services/dashboard.service.ts:calcularEgresosPorRegimen.

Antes:

deducciones = I PUE + P + I/07_PPD_compensación + N  E PUE

Después del cambio inicial:

deducciones = I PUE + P + N

Tras restauración de la compensación (sección 4):

deducciones = I PUE + P + I/07_PPD_compensación + N

Removidas las queries nc (E PUE recibidas) y restaurada i07PpdComp. Las E recibidas se exhiben aparte como "NCs Recibidas" (surface-only).

3. Cards NCs Emitidas + NCs Recibidas

Nuevas funciones backend surface-only (no afectan cálculos de ingresos / deducciones / ISR):

Función Lado Descripción
calcularNcsEmitidasPorRegimen EMISOR (rfc_emisor) E PUE que el contribuyente emitió a sus clientes
calcularNcsRecibidasPorRegimen RECEPTOR (rfc_receptor) E PUE que el contribuyente recibió de proveedores

Misma fórmula neta que ingresos/deducciones:

neto = total_mxn  (IVA_traslado + IEPS + impuestos_locales)  conceptos_excluidos

Excluye conceptos con clave_prod_serv en ('84121603','93161608','85101501','85121800').

Wire en getResumenIsr via Promise.all con ingresos/deducciones up-front (necesario para fórmula de base gravable, ver sección 5).

Tipo shared extendido (packages/shared/src/types/impuestos.ts:ResumenIsr):

ncsEmitidas: number;
ncsEmitidasPorRegimen: IsrRegimenDetalle[];
ncsRecibidas: number;
ncsRecibidasPorRegimen: IsrRegimenDetalle[];

Renombre semántico: la card empezó como "Egresos Emitidos" pero se renombró a "NCs Emitidas" para simetría con "NCs Recibidas" — refleja mejor el contenido (notas de crédito tipo E PUE).

4. Restauración compensación I/07 PPD ↔ E

Decisión fiscal explícita del cliente (opción C en la conversación). En lugar de eliminar la compensación junto con la E (matemáticamente consistente pero quita una pieza fiscal), se restauró en ambos lados con interpretación fiscal nueva:

La compensación reconoce que la porción del servicio asociada al anticipo se reconoce como ingreso/gasto al emitir/recibir la I/07 PPD, independientemente de si la E del mismo mes resta o no.

Estado matemático

Para un par I/07 PPD ↔ E en el mismo mes/año:

Cobro real Antes (E restaba) Ahora (E no resta, comp se mantiene)
Anticipo I PUE $100 $100 + $100 $100 = $100 $100 + $100 = $200

La nueva interpretación reconoce ambos: el anticipo cobrado y la porción del servicio aplicada vía la I/07 PPD. Semánticamente discutible pero queda bajo criterio fiscal del despacho y documentado.

Aplica a

  • Grupo 1 ingresos (PF Empresarial 606, 612, 621, 625, 626)
  • Deducciones (todos los regímenes via lado RECEPTOR)

NO aplica a Grupo 3 ingresos (PMs) — esos nunca tuvieron compensación porque devengan al emitir, no al cobrar.

5. Base gravable: nueva fórmula con NCs

Antes:

baseGravable = max(0, ingresos  deducciones)        ← ingresos-deducciones
baseGravable = max(0, ingresos)                       ← ingresos (sin cambio)

Ahora:

baseGravable = max(0, ingresos  ncsEm  deducciones + ncsRec)   ← ingresos-deducciones
baseGravable = max(0, ingresos)                                    ← ingresos (sin cambio)

Donde:

  • ingresosNeto = ingresosNominales ncsEmitidas
  • deduccionNeta = deducciones ncsRecibidas
  • base = max(0, ingresosNeto deduccionNeta) simplificado

Solo aplica a regímenes con fórmula ingresos-deducciones (606, 612, 626 PM).

Promise.all movido al inicio de getResumenIsr para tener NCs disponibles antes del loop. Set regimenesConDatos extendido para incluir regímenes que solo tengan NCs (sin ingresos ni deducciones — caso edge cubierto).

6. Persistencia en metricas_mensuales (migración 042)

Nuevas columnas para cache:

-- 042_metricas_ncs.sql
ALTER TABLE metricas_mensuales
  ADD COLUMN IF NOT EXISTS ncs_emitidas  numeric(18,2) DEFAULT 0,
  ADD COLUMN IF NOT EXISTS ncs_recibidas numeric(18,2) DEFAULT 0;

Aplicada a 4 tenants (Patito, Zorro, Demo, Horux 360). Writer metricas-compute.service.ts:computeMetricaMensual extendido — calcula NCs por régimen via Promise.all con ingresos/egresos/IVA y las pasa al upsertMetricaMensual.

Reader getMetricasMensuales SELECT extendido. Cron de invalidaciones recompute las NCs junto con las demás métricas — sin cambios estructurales.

Script apps/api/scripts/refresh-metricas-cache.ts sigue funcionando para invalidación masiva post-cambio fiscal.

7. Switch "Considerar NCs" extendido

Antes: OFF excluía solo tipo_comprobante = 'E' AND tipo_relacion = '01'.

Ahora: OFF excluye TODAS las E (cualquier tipo_relacion: 01-07) + salta la compensación I/07 PPD ↔ E.

apps/api/src/services/_shared/cfdi-filters.ts:

parts.push(`AND NOT (tipo_comprobante = 'E')`);

Compensación gateada en dashboard.service.ts:

const g1I07PpdComp = considerarNCs
  ? (await pool.query(...)).rows
  : [];

Aplica al lado EMISOR (Grupo 1 ingresos) y RECEPTOR (deducciones). Tooltip del botón actualizado. Ahora el switch tiene impacto significativo en la base gravable cuando el contador quiere simular "sin notas de crédito".

8. Drill-downs

Buckets ingresos y gastos — remover E

apps/api/src/controllers/cfdi.controller.ts:drillDown actualizado para alinear con las nuevas fórmulas (sin E PUE):

Bucket Antes Ahora
ingresos Grupo 1 I PUE + P + E PUE I PUE + P
ingresos Grupo 3 I (PUE+PPD) + E PUE I (PUE+PPD)
gastos I PUE + P + E PUE I PUE + P
causado (IVA) sin cambio sin cambio
acreditable (IVA) sin cambio sin cambio

Régimen filter para todos los buckets

drillUrl en /impuestos y /dashboard ahora propaga el régimen seleccionado para cualquier bucket (no solo cuando hay type explícito):

if (filters.bucket === 'ingresos') {
  if (regimenSeleccionado === '605') p.set('regimenReceptor', '605');
  else p.set('regimenEmisor', regimenSeleccionado);
}
else if (filters.bucket === 'causado' || filters.bucket === 'ncs_emitidas') p.set('regimenEmisor', regimenSeleccionado);
else if (filters.bucket === 'gastos' || filters.bucket === 'acreditable' || filters.bucket === 'ncs_recibidas' || filters.bucket === 'no_deducibles_efectivo') p.set('regimenReceptor', regimenSeleccionado);

Edge case 605 (Sueldos) — en bucket ingresos va por regimenReceptor porque es nómina recibida.

Buckets nuevos: ncs_emitidas, ncs_recibidas, no_deducibles_efectivo

} else if (bucketStr === 'ncs_emitidas') {
  where += ` AND (
    ${esEmisor}
    AND tipo_comprobante = 'E' AND metodo_pago = 'PUE'
    AND regimen_fiscal_emisor IS NOT NULL
  ) ${NO_IGNORADO_EMISOR}`;
}

Cards "NCs Emitidas", "NCs Recibidas" y "No Deducibles" tienen href={drillUrl(...)}.

Fix de alineación con calcular: el drill quitó la restricción regimen_fiscal_emisor IN (TODOS_REGS) que sí estaba en buckets ingresos/gastos. El calcular function no la tenía — aceptaba cualquier régimen no-NULL no-ignorado (incluyendo 616 Extranjero, etc.). Los drills mostraban menos filas que lo que la card sumaba; ahora alineados con IS NOT NULL + NO_IGNORADO_*.

9. Art. 27 fracción III LISR — gastos efectivo > $2k no deducibles

Regla fiscal: las erogaciones cuyo monto exceda $2,000 MXN solo son deducibles si el pago se hace por transferencia, cheque nominativo, tarjeta crédito/débito o monedero electrónico — NO en efectivo (forma_pago='01').

Implementación completa (Opción A)

Capa Cambio
Filtro en calcularEgresosPorRegimen Excluye I PUE recibidas y P recibidos con forma_pago='01' AND total_mxn (o monto_pago_mxn) > 2000
Surface backend Nueva calcularGastosNoDeduciblesEfectivoPorRegimen — computa el monto excluido por régimen
Wire getResumenIsr Retorna gastosNoDeduciblesEfectivo + gastosNoDeduciblesEfectivoPorRegimen
Drill-down Bucket no_deducibles_efectivo en cfdi controller
Cache Migración 043 + columna gastos_no_deducibles_efectivo + writer + reader
Alerta alertaRiesgoTransaccional reemplazada — ahora apunta al monto $$ no deducible específicamente, no al % de facturas en efectivo
Shared types ResumenIsr extendido + MetricaMensual extendido
Frontend card "No Deducibles" en /impuestos > ISR con subtitle "Efectivo > $2,000"
Frontend drill drillUrl mapea bucket → regimen_fiscal_receptor

Constantes SQL

const NO_DEDUCIBLE_EFECTIVO_I_PUE = `(forma_pago = '01' AND COALESCE(total_mxn, 0) > 2000)`;
const NO_DEDUCIBLE_EFECTIVO_P    = `(forma_pago = '01' AND COALESCE(monto_pago_mxn, 0) > 2000)`;

Particularidades:

  • Para CFDIs tipo I PUE: comparación con total_mxn
  • Para complementos P: comparación con monto_pago_mxn (cada P es pago independiente; un P de $3k efectivo aplicado a una I PPD de $10k bloquea solo esos $3k)
  • Nómina (N) no aplica — tiene sus propias reglas
  • Combustibles (regla especial Art. 27 fracción III párrafo último) no se modela aún

Migración 043

ALTER TABLE metricas_mensuales
  ADD COLUMN IF NOT EXISTS gastos_no_deducibles_efectivo numeric(18,2) DEFAULT 0;

Aplicada a 4 tenants.

Alerta automática nueva

alertas-auto.service.ts:alertaRiesgoTransaccional reemplazada:

  • Antes: medía % de facturas con forma_pago=01, sin distinguir monto ni lado emisor/receptor
  • Ahora: mide monto $$ específico de I PUE recibidas con forma_pago=01 y total > $2,000. Threshold $50k para prioridad alta. Mensaje cita Art. 27 fracción III LISR.

10. Layout final

/impuestos > ISR con 5 cards en fila 1, 3 en fila 2 (lg:grid-cols-5):

Col 1 Col 2 Col 3 Col 4 Col 5
Fila 1 Ingresos Nominales NCs Emitidas Deducciones NCs Recibidas Base Gravable
Fila 2 ISR a Pagar Utilidad del Periodo No Deducibles

5 cards informacionales en fila 1, 3 cards de "resumen final" en fila 2.

Pendiente abierto

Art. 5 LIVA fracción I: el IVA acreditable de los gastos en efectivo

$2k tampoco es válido. Hoy: la deducción para ISR se filtra ✓, pero el IVA acreditable sigue acreditándose. Implementación similar al filtro de ISR pero en getResumenIva — ~30 min si se decide cubrirlo.

Archivos modificados

Backend

apps/api/src/services/dashboard.service.ts          (calcular* sin E PUE, +calcularNcsEmitidas/Recibidas/GastosNoDeducibles, compensación I/07 PPD gateada por considerarNCs)
apps/api/src/services/impuestos.service.ts          (getResumenIsr con Promise.all up-front, base gravable nueva fórmula, surface NCs + no deducibles)
apps/api/src/services/_shared/cfdi-filters.ts       (considerarNCs=false excluye TODAS las E)
apps/api/src/services/metricas-compute.service.ts   (Promise.all extendido, upsert con NCs + no deducibles)
apps/api/src/services/metricas.service.ts           (interface MetricaMensual + SELECT + upsert con nuevas columnas)
apps/api/src/services/alertas-auto.service.ts       (alertaRiesgoTransaccional reescrita: monto $ específico)
apps/api/src/controllers/cfdi.controller.ts         (drillDown: 3 buckets nuevos + remover E de ingresos/gastos)
apps/api/src/migrations/tenant/042_metricas_ncs.sql                      (NUEVO)
apps/api/src/migrations/tenant/043_metricas_no_deducibles.sql            (NUEVO)
apps/api/scripts/apply-migration-042.ts             (NUEVO — aplicar migraciones a todos los tenants)
apps/api/scripts/refresh-metricas-cache.ts          (NUEVO — DELETE FROM metricas_mensuales por tenant)
apps/api/scripts/debug-drill-buckets.ts             (NUEVO — debug ad-hoc de buckets)

Shared

packages/shared/src/types/impuestos.ts              (ResumenIsr +ncsEmitidas/Recibidas/gastosNoDeducibles + porRegimen)

Frontend

apps/web/app/(dashboard)/impuestos/page.tsx        (Ingresos Nominales rename, +cards NCs Emitidas/Recibidas/No Deducibles, drillUrl extendido para nuevos buckets, layout grid-cols-5 con 2 filas, tooltip "Considerar NCs" actualizado)
apps/web/app/(dashboard)/dashboard/page.tsx        (drillUrl extendido para propagar régimen en buckets)

11. IVA No Acreditable (Art. 5 LIVA fracción I)

Implementación end-to-end del paralelo IVA del Art. 27 fracción III LISR. Si un gasto no es deducible por pagarse en efectivo > $2k, su IVA tampoco es acreditable.

Capa Cambio
Constantes compartidas NO_DEDUCIBLE_EFECTIVO_I_PUE y _P exportadas desde dashboard.service.ts para reuso en impuestos.service.ts
Filtro IVA Acreditable bucketAcreditablePos (impuestos.service.ts) excluye I PUE + P recibidos en efectivo > $2k
Filtro dashboard IVA calcularIvaBalancePorRegimen r1/r2 mismo filtro
Surface backend Nueva calcularIvaNoAcreditableEfectivoPorRegimen — IVA neto excluido por régimen
Wire getResumenIva Retorna ivaNoAcreditableEfectivo + ivaNoAcreditableEfectivoPorRegimen
Cache fallback readResumenIvaFromCache retorna 0/empty para los nuevos campos (cache aún no los persiste — TODO)
Shared type ResumenIva extendido
Frontend card "IVA No Acreditable" en /impuestos > IVA con subtitle "Efectivo > $2,000"

Layout final /impuestos > IVA

Col 1 Col 2 Col 3 Col 4 Col 5
Fila 1 IVA Trasladado IVA Acreditable IVA Retenido Resultado del Periodo Acumulado Anual
Fila 2 IVA No Acreditable

Coherencia fiscal end-to-end

Sección Antes Ahora
ISR Deducciones Filtra Filtra
ISR Card "No Deducibles" Surface Surface
ISR Base Gravable Correcta Correcta
IVA Acreditable Acreditaba todo Filtra
IVA Card "No Acreditable" N/A Surface
IVA Resultado Sub-pago Correcto

12. Restauración guards admin global (post-testing)

Post-validación del flujo MP, se restauraron los guards [TEMP] que se habían dejado abiertos para probar pago desde Horux 360:

Archivo Restaurado
subscription-banner.tsx `if (isGlobalAdmin
use-nav-gate.ts const needsRenewal = !isGlobalAdmin && ...
.env MP_USE_SANDBOX=false, MP_TEST_PAYER_EMAIL= (vacío)

Tarea #35 cerrada.

Side-fix: Zod MP_TEST_PAYER_EMAIL= rechazado

Al vaciar la línea, Zod tiraba Invalid email. Fix con z.preprocess:

MP_TEST_PAYER_EMAIL: z.preprocess(
  v => (v === '' ? undefined : v),
  z.string().email().optional(),
),

Permite que prod tenga la línea declarada vacía sin romper arranque. Patrón aplicable a cualquier env opcional con validación específica.

13. Bug fix: drill-down ignoraba toggles "Considerar activos/NCs"

Diagnóstico: un usuario reportó que el CFDI 8ec2eaf3-7879-11f0-81a8-8daae9822b10 (P de pago $295,100 cuyo uuid_relacionado apunta a una I con uso_cfdi=I03 = Equipo de transporte) seguía apareciendo en el drill-down de Deducciones con "Considerar activos" desactivado.

Script scripts/debug-cfdi-activos.ts confirmó que el predicado lo detectaba correctamente como activo (regla2 (P paga I activo): true). El bug estaba en el endpoint del drill-down — no aplicaba buildExtraFilters.

Fix:

  • apps/api/src/controllers/cfdi.controller.ts:drillDown acepta query params considerarActivos y considerarNCs, aplica extra al WHERE final
  • drillUrl en /impuestos propaga los toggles cuando están OFF (default ON omite el param, backend interpreta como true por convención)
  • drillUrl en /dashboard no se modifica (esa página no tiene toggles propios)
const considerarActivosBool = considerarActivos !== '0' && considerarActivos !== 'false';
const considerarNCsBool     = considerarNCs     !== '0' && considerarNCs     !== 'false';
const extra = buildExtraFilters(considerarActivosBool, considerarNCsBool);
// ...
where += extra;

14. Bug fix: cache metricas_mensuales ignoraba toggles

Síntoma: al desactivar "Considerar activos", la card de Deducciones solo bajaba ~$2,000 cuando el CFDI excluido valía ~$295,100.

Diagnóstico (parte 1): el cache metricas_mensuales se consultaba sin considerar los toggles. El cache se escribió con flags default (true), por lo tanto al consultar con considerarActivos=false, devolvía el valor cacheado sin aplicar el filtro.

Patrón ya correcto en getResumenIva (línea 695); replicado en los 3 calcular del dashboard:

const cacheRange = considerarActivos && considerarNCs
  ? planCache(fechaInicio, fechaFin, conciliacion, contribuyenteId)
  : null;

Aplicado a:

  • calcularIngresosPorRegimen
  • calcularEgresosPorRegimen
  • calcularIvaBalancePorRegimen (no recibía los toggles, no aplica)

15. Bug fix: NULL semantics en NO_DEDUCIBLE_EFECTIVO_*

Diagnóstico (parte 2 — el bug real): después del fix de cache, las deducciones seguían bajando solo $1,549 al desactivar activos. Script scripts/debug-deducciones-husberto.ts reveló:

Total P recibidos en agosto 2025 (sin filtros): 8 (suma $317,979)
P recibidos SIN filtro activos (CON filtro no-deducible): n=6, bruto=$3,064

Solo 6 de 8 P entraban al cálculo. El P de $295,100 y otro de $19,815 quedaban fuera. Sus forma_pago eran NULL. El predicado:

NO_DEDUCIBLE_EFECTIVO_P = (forma_pago = '01' AND COALESCE(monto_pago_mxn, 0) > 2000)

Cuando forma_pago=NULL:

  • (NULL = '01' AND ...)NULL
  • NOT NULLNULL
  • WHERE NULL excluye el row

Postgres trata WHERE NULL como WHERE FALSE. Eso significa que TODOS los CFDIs sin forma_pago se excluían de las deducciones vía AND NOT NO_DEDUCIBLE_EFECTIVO_*. Para Husberto agosto 2025: $317,979 brutos en P se reducían a $3,064 — pérdida del 99% de las deducciones de pagos.

Fix: COALESCE para hacer el predicado NULL-safe:

export const NO_DEDUCIBLE_EFECTIVO_I_PUE = `(COALESCE(forma_pago, '') = '01' AND COALESCE(total_mxn, 0) > 2000)`;
export const NO_DEDUCIBLE_EFECTIVO_P    = `(COALESCE(forma_pago, '') = '01' AND COALESCE(monto_pago_mxn, 0) > 2000)`;

Verificación post-fix

Antes:                                       Después:
SIN filtro activos:  n=6, bruto=$3,064       n=8, bruto=$317,979 ✓
CON filtro activos:  n=4, bruto=$1,515       n=5, bruto=$21,330  ✓
Reducción esperada:  $1,549                  $296,649 brutos / $255,720 neto ✓

Auditoría sugerida

NULL en SQL es viral. Cualquier expresión que toca NULL retorna NULL, y WHERE NULL excluye el row. Predicados sospechosos en el codebase con el mismo patrón:

  • cfdi_tipo_relacion = '07'
  • uso_cfdi IN (...)
  • metodo_pago = 'PUE'

Si alguno se usa con NOT (...) y la columna puede ser NULL, hay riesgo del mismo bug. Vale una pasada de tests sobre el path "calcular con flags non-default" para evitar más sorpresas.

Archivos modificados (post-V.1.0.15 / parte 2)

Backend

apps/api/src/services/dashboard.service.ts          (+calcularIvaNoAcreditableEfectivoPorRegimen, NO_DEDUCIBLE_EFECTIVO_* exportadas + COALESCE fix, cache gating por toggles en calcularIngresos/Egresos, filtros NO_DEDUCIBLE en r1/r2 IVA)
apps/api/src/services/impuestos.service.ts          (bucketAcreditablePos con filtro efectivo, getResumenIva con surface IVA No Acreditable, cache fallback con 0/empty)
apps/api/src/controllers/cfdi.controller.ts         (drillDown acepta considerarActivos/NCs + aplica buildExtraFilters)
apps/api/src/config/env.ts                          (MP_TEST_PAYER_EMAIL con z.preprocess para empty string)
apps/api/scripts/debug-cfdi-activos.ts              (NUEVO — debug ad-hoc del filtro de activos para un CFDI)
apps/api/scripts/debug-deducciones-husberto.ts      (NUEVO — debug del cálculo de deducciones de un contribuyente)

Shared

packages/shared/src/types/impuestos.ts              (ResumenIva +ivaNoAcreditableEfectivo + porRegimen)

Frontend

apps/web/app/(dashboard)/impuestos/page.tsx        (+card "IVA No Acreditable" en pestaña IVA, drillUrl propaga toggles)
apps/web/app/(dashboard)/dashboard/page.tsx        (drillUrl: comentario aclaratorio sobre toggles)
apps/web/components/subscription-banner.tsx        (guard admin global RESTAURADO)
apps/web/lib/hooks/use-nav-gate.ts                 (guard admin global RESTAURADO)

.env

apps/api/.env  MP_USE_SANDBOX=false, MP_TEST_PAYER_EMAIL=  (vaciado, no eliminado)

16. Eliminación definitiva de la compensación I/07 PPD ↔ E

Decisión del cliente tras revisar el caso de Husberto agosto 2025: eliminar completamente la compensación I/07 PPD ↔ E del cálculo de ingresos y deducciones. Rationale:

"Es una buena idea, pero va a confundir a los contadores, además de que no es un cálculo oficial del SAT. Lo mejor va a ser que ellos hagan su conciliación."

Esto invalida la decisión anterior (sección 4) de restaurar la compensación con interpretación fiscal explícita. La fórmula final no usa términos derivados de E.

Cambios

Lugar Antes Ahora
calcularIngresosPorRegimen Grupo 1 (PF Empresarial) I PUE + P + I/07_PPD_comp I PUE + P
calcularEgresosPorRegimen lado RECEPTOR I PUE + P + I/07_PPD_comp + N I PUE + P + N

Comentarios en código incluyen explicit "no reintroducir" + fecha + razón para evitar que alguien la "redescubra" como mejora fiscal en el futuro.

Caso Husberto agosto 2025 (validación)

  • Antes: la I/07 PPD 5c874749... ($454K, uso_cfdi=I03) aportaba $136,206.90 a la compensación de deducciones vía las E del mismo mes que la cancelaban
  • Ahora: no aporta nada — el contador concilia el ciclo anticipo → I/07 PPD → E manualmente al armar la declaración

17. Filtro de activos — regla 4: anticipos vinculados a activos

Diagnóstico: un anticipo I PUE no tiene uso_cfdi de activo (SAT no permite I01-I08 en facturas de anticipo según Anexo 20). El usuario observó que cuando se desactiva "Considerar activos", el anticipo seguía contando como deducción aunque eventualmente alimenta una compra de activo.

Solución: nueva regla 4 que mira hacia adelante en la cadena fiscal — si el anticipo es referenciado por una I/07 PPD con uso_cfdi de activo, también se filtra.

AND NOT (tipo_comprobante = 'I' AND EXISTS (
  SELECT 1 FROM cfdis i07_act
  WHERE i07_act.tipo_comprobante = 'I'
    AND i07_act.metodo_pago = 'PPD'
    AND COALESCE(i07_act.cfdi_tipo_relacion, '') = '07'
    AND i07_act.uso_cfdi IN ('I01','I02','I03','I04','I05','I06','I07','I08')
    AND i07_act.status NOT IN ('Cancelado', '0')
    AND i07_act.cfdis_relacionados IS NOT NULL
    AND LOWER(cfdis.uuid) = ANY(string_to_array(LOWER(i07_act.cfdis_relacionados), '|'))
))

Cobertura completa post-fix

Regla Captura
1 I directo con uso_cfdi I01-I08
2 P pagando una I de activo
3 E referenciando una I/P de activo
4 (nuevo) Anticipo (I) referenciado por una I/07 PPD con uso_cfdi de activo

Aplicado en cfdi-filters.ts:activosExclusionNoAlias y activosExclusionAlias. Comentario header del archivo actualizado.

Verificación caso Husberto

Anticipo 729109fc... ($148,000, uso_cfdi=CP01):

  • regla 1: false
  • regla 2: false
  • regla 3: false
  • regla 4: TRUE ← capturado
  • → FILTRADO al desactivar "Considerar activos"

18. ISR causado para 626 RESICO PM — tasa 30% directa

Decisión del cliente sobre el cálculo de ISR para régimen 626 RESICO PM (RFC 12 chars):

  • Base gravable: misma fórmula que PF Empresarial: max(0, ingresos ncsEm deducciones + ncsRec)
  • ISR causado: 30% directo sobre la base gravable (NO Art. 96 progresivo, NO base × coeficiente × 30%)

Razonamiento fiscal: RESICO PM ya restó sus deducciones efectivas en la base, así que aplicar coeficiente_utilidad encima sería doble descuento. La tasa fija de 30% es la oficial Art. 206-208 LISR para este régimen.

Estado final del switch case

if (reg.regimenClave === '626' && rfcLength === 13) {
  // RESICO PF: tasa plana por bracket (Art. 113-E)
  regIsrCausado = await calcularIsrResicoPF(reg.baseGravable, anio);
} else if (reg.regimenClave === '626' && rfcLength === 12) {
  // RESICO PM: tasa fija 30% directa
  regIsrCausado = reg.baseGravable * 0.30;
} else if (['606', '612', '621', '625'].includes(reg.regimenClave)) {
  // PF Empresarial: tarifa progresiva Art. 96
  regIsrCausado = await calcularIsrProgresivo(reg.baseGravable, anio);
} else {
  // PM Grupo 3: base × coeficiente × 30% (Art. 14)
  const basePM = reg.baseGravable * (coeficiente || 0.30);
  regIsrCausado = basePM * 0.30;
}

Diferencia clave RESICO PM vs PM Grupo 3

RESICO PM (626) PM Grupo 3 (601, 603, 607...)
Base gravable ing ncsEm ded + ncsRec (resta deducciones) ingresos (no resta deducciones aquí)
Tasa 30% directo × coeficiente_utilidad × 30%

PM Grupo 3 usa coeficiente como proxy de "qué % del ingreso es utilidad" porque sus deducciones reales (depreciación, costo de ventas) requieren contabilidad fiscal completa. RESICO PM tiene contabilidad simplificada basada en flujo efectivo, así que las deducciones SÍ entran a la base.

19. Utilidad del Periodo — fórmula simétrica con Base Gravable

Antes: utilidad = ingresos deducciones (no consideraba NCs).

Después: utilidad = ingresos ncsEmitidas deducciones + ncsRecibidas.

Misma fórmula que baseGravable para regímenes con formula ingresos-deducciones, con dos diferencias deliberadas:

Utilidad del Periodo Base Gravable
Clamp a 0 NO (puede ser negativa) SÍ (max(0, ...))
Aplica a todos los regímenes Solo ingresos-deducciones; en ingresos la base es solo max(0, ingresos)

La utilidad debe poder ser negativa para que el contador vea la pérdida real del período. El clamp de base gravable es por regla SAT (no se puede declarar ISR negativo provisional), no es un principio universal.

Subtitle removido por petición del cliente.

20. Bug fix CRÍTICO — Facturapi no persistía campos del complemento P

Diagnóstico: el usuario reportó que 2 P emitidos vía Facturapi (CFACB97E... y 384CF943...) no aparecían como ingresos para Horux 360.

El XML traía datos correctos:

<pago20:Pago FechaPago="2026-04-27T12:00:00" Monto="600.00">
  <pago20:ImpuestosP>
    <pago20:TrasladosP>
      <pago20:TrasladoP ImporteP="82.758400" .../>
    </pago20:TrasladosP>
  </pago20:ImpuestosP>
</pago20:Pago>

Pero la BD tenía:

  • monto_pago_mxn: NULL
  • fecha_pago_p: NULL
  • iva_traslado_pago_mxn: NULL

Causa: apps/api/src/controllers/facturacion.controller.ts:218-250 — el INSERT que persiste CFDIs emitidos vía Facturapi NO incluía las columnas del complemento P, aunque el parseXml SÍ las extraía. Comparado con sat.service.ts:232 (sync SAT) que sí las maneja correctamente, el path Facturapi quedó incompleto desde la integración inicial.

Impacto histórico

Cualquier complemento P emitido vía Facturapi desde la integración inicial quedó con fecha_pago_p y monto_pago_mxn NULL → no aportaba ingresos al cálculo. En PostgreSQL, NULL >= '2026-05-01' evalúa NULL → row excluido del WHERE. Por eso ni en mayo ni en abril aparecían.

Si el cliente emite muchos P (cobros parciales de PPD), el reporte de ingresos estaba subreportando significativamente. Solo los CFDIs tipo I (no P) entraban correctamente.

Fix

facturacion.controller.ts:218-274 — INSERT extendido con las 7 columnas del complemento P:

  • monto_pago + monto_pago_mxn
  • fecha_pago_p
  • iva_traslado_pago + iva_traslado_pago_mxn
  • iva_retencion_pago + iva_retencion_pago_mxn
  • ieps_traslado_pago + ieps_traslado_pago_mxn

El parseXml ya devuelve todos esos campos en parsed.montoPago, parsed.fechaPagoP, etc. Solo faltaba pasarlos al INSERT.

const fechaPagoP = parsed.fechaPagoP
  ? new Date(String(parsed.fechaPagoP).split('|')[0])
  : null;

(El parser concatena múltiples FechaPago con '|' cuando un complemento trae varios; tomamos la primera.)

Backfill histórico

scripts/backfill-pago-fields.ts — itera todos los tenants, busca CFDIs con source='facturapi' AND tipo_comprobante='P' AND xml_original IS NOT NULL AND (monto_pago_mxn IS NULL OR fecha_pago_p IS NULL), re-parsea el XML y hace UPDATE con COALESCE para no sobrescribir valores ya presentes.

Idempotente. Ejecutado en local tras el fix:

DESPACHO_MO3NI6U8_B9VGG (Patito): 2 P por backfill
  384CF943-EFB0-475A-B6B6-240E96088B37: monto=$1856 fecha_pago=2026-04-27 iva=$256
  CFACB97E-5426-48D4-A3B9-06B5D160F307: monto=$600  fecha_pago=2026-04-27 iva=$82.7584
[Backfill] 2/2 actualizadas

En producción debe ejecutarse después de deploy para corregir el historial completo de P emitidos. El cache metricas_mensuales de los meses afectados debe invalidarse después con refresh-metricas-cache.ts.

Archivos modificados (post-V.1.0.15 / parte 3)

Backend

apps/api/src/services/dashboard.service.ts          (compensación I/07 PPD ELIMINADA en Grupo 1 ingresos + deducciones, NULL-safe en NO_DEDUCIBLE_EFECTIVO_*, cache gating por toggles)
apps/api/src/services/_shared/cfdi-filters.ts       (regla 4: anticipos vinculados a activos via I/07 PPD)
apps/api/src/services/impuestos.service.ts          (RESICO PM 626 ahora usa 30% directo)
apps/api/src/controllers/facturacion.controller.ts  (INSERT con campos del complemento P — bug crítico)
apps/api/scripts/backfill-pago-fields.ts            (NUEVO — backfill P de Facturapi)
apps/api/scripts/debug-i07-ppd.ts                   (NUEVO — debug de compensación)
apps/api/scripts/debug-compensacion-cfdi.ts         (NUEVO — debug de aporte de un CFDI específico)
apps/api/scripts/debug-deducciones-husberto.ts      (NUEVO — debug suma de deducciones)
apps/api/scripts/debug-cfdi-activos.ts              (actualizado — incluye regla 4)
apps/api/scripts/debug-p-mayo.ts                    (NUEVO — debug búsqueda CFDI multi-tenant)

Frontend

apps/web/app/(dashboard)/impuestos/page.tsx  (Utilidad del Periodo con NCs simétrica, subtitle removido)

21. Sección "Cálculo de ISR del Periodo" — incluye NCs

El card de desglose mensual del ISR (/impuestos > ISR > Cálculo de ISR del Periodo) mostraba la fórmula vieja: ingresos deducciones = base gravable. Ahora refleja la fórmula vigente con NCs.

Líneas agregadas

Ingresos del periodo
(+) Ingresos acumulados anteriores
() NCs Emitidas del periodo               ← NUEVO
() NCs Emitidas acumuladas anteriores     ← NUEVO
() Deducciones del periodo
() Deducciones acumuladas anteriores
(+) NCs Recibidas del periodo              ← NUEVO
(+) NCs Recibidas acumuladas anteriores    ← NUEVO
(=) Base gravable acumulada
ISR causado (acumulado)
() ISR retenido (acumulado)
ISR a pagar

Lógica de visibilidad (showNcs)

Las 4 líneas de NCs solo se muestran cuando aplican fiscalmente:

Caso Visible
Régimen seleccionado con formula='ingresos-deducciones' (606, 612, 626 RESICO PM)
Régimen seleccionado con formula='ingresos' (RIF, Plataformas, RESICO PF, PMs Grupo 3)
Sin régimen seleccionado + alguna NC > 0
Sin régimen seleccionado + todas las NCs = 0

Se usa el campo formula que ya viene en BaseGravableRegimen — single source of truth con determinarFormulaBaseGravable. Si en el futuro alguien modifica qué regímenes usan ingresos-deducciones, el desglose se actualiza automáticamente.

Coherencia visual con la fórmula

El orden de las líneas refleja exactamente:

ingresos  ncsEm  ded + ncsRec = baseGravable

NCs Emitidas restan justo después de ingresos (cancelaciones del lado emisor), NCs Recibidas suman justo después de deducciones (reducen el gasto efectivo). Lectura arriba-abajo coincide con la mate.

Campos consumidos

useResumenIsrDesglosado retorna delPeriodo, anteriores, total — cada uno con ncsEmitidas, ncsEmitidasPorRegimen, ncsRecibidas, ncsRecibidasPorRegimen (campos que ya estaban en ResumenIsr desde sección 3). No requirió cambios backend.

Archivos modificados (post-V.1.0.15 / parte 4)

Frontend

apps/web/app/(dashboard)/impuestos/page.tsx  (Cálculo de ISR del Periodo: +4 líneas NCs con showNcs gating)

22. Auto-facturación con datos del cliente (Fases 1 + 2)

Contexto

Hasta este punto, invoicing.service.ts siempre emitía CFDIs al Público en General (XAXX010101000) — un fallback conservador heredado de cuando no sabíamos si el tenant tenía datos fiscales completos. Pero hoy ya guardamos:

  • CSF cargada con sincronizarDatosFiscales(tenantId) que llena tenants.codigo_postal/calle/colonia/... automáticamente
  • tenant_regimenes_activos con los regímenes parseados del CSF

Si el cliente paga $399/mes y la factura sale al Público en General, no puede deducirla. Era un bug de UX serio para clientes B2B.

Fase 1 — Auto-detección con CSF

apps/api/src/services/payment/invoicing.service.ts

Helper nuevo getCustomerFromTenant(payerTenantId):

async function getCustomerFromTenant(payerTenantId: string): Promise<CustomerData | null> {
  const tenant = await prisma.tenant.findUnique({
    where: { id: payerTenantId },
    select: {
      rfc: true, nombre: true, codigoPostal: true,
      factPreferencia: true, factRegimenPreferido: true,
      regimenesActivos: { select: { regimen: { select: { clave: true } } }, orderBy: { createdAt: 'asc' } },
    },
  });
  if (!tenant) return null;
  if (tenant.factPreferencia === 'publico_general') return null;
  if (!tenant.rfc || !tenant.nombre || !tenant.codigoPostal) return null;

  // Si el tenant elige régimen explícito, usarlo. Sino primer activo por createdAt.
  let regimenClave: string | undefined;
  if (tenant.factRegimenPreferido) {
    const match = tenant.regimenesActivos.find(ra => ra.regimen.clave === tenant.factRegimenPreferido);
    regimenClave = match?.regimen.clave;
  }
  if (!regimenClave && tenant.regimenesActivos.length > 0) {
    regimenClave = tenant.regimenesActivos[0].regimen.clave;
  }
  if (!regimenClave) return null;
  ...
}

buildInvoicePayload(params: { ..., customer: CustomerData | null, usoCfdi: string }) acepta un customer opcional. Si es null, cae al fallback (XAXX010101000 + S01); si tiene valor, usa los datos reales con el usoCfdi configurado.

emitInvoiceIfApplicable orquesta:

const customer = await getCustomerFromTenant(payment.tenantId);
const tenantPref = await prisma.tenant.findUnique({
  where: { id: payment.tenantId },
  select: { factUsoCfdi: true },
});
const usoCfdi = customer ? (tenantPref?.factUsoCfdi || DEFAULT_USE_CFDI) : FALLBACK_USE_CFDI;

const payload = buildInvoicePayload({ ..., customer, usoCfdi });

Fase 2 — Preferencias persistidas + UI

Migración 20260502170000_add_tenant_fact_preferencias:

ALTER TABLE tenants
  ADD COLUMN fact_preferencia VARCHAR(20) NOT NULL DEFAULT 'mis_datos',
  ADD COLUMN fact_uso_cfdi VARCHAR(5) NOT NULL DEFAULT 'G03',
  ADD COLUMN fact_regimen_preferido VARCHAR(3);

Schema Prisma (Tenant):

factPreferencia        String  @default("mis_datos") @map("fact_preferencia") @db.VarChar(20)
factUsoCfdi            String  @default("G03")       @map("fact_uso_cfdi") @db.VarChar(5)
factRegimenPreferido   String? @map("fact_regimen_preferido") @db.VarChar(3)

Service tenants.service.ts:

  • getPreferenciasFacturacion(id) — devuelve { factPreferencia, factUsoCfdi, factRegimenPreferido, regimenesActivos: [{clave, descripcion}] } (regímenes joineados para que el dropdown UI no requiera segunda llamada)
  • updatePreferenciasFacturacion(id, data) — patch idempotente

Endpoints facturacion.routes.ts:

router.get('/preferencias-facturacion', facturacionController.getPreferenciasFacturacion);
router.put('/preferencias-facturacion', facturacionController.updatePreferenciasFacturacion);

Controller con Zod (facturacion.controller.ts):

const PreferenciasFacturacionSchema = z.object({
  factPreferencia: z.enum(['publico_general', 'mis_datos']).optional(),
  factUsoCfdi: z.string().min(2).max(5).optional(),
  factRegimenPreferido: z.string().max(3).nullable().optional(),
});

Página apps/web/app/(dashboard)/configuracion/facturacion/page.tsx:

  • Toggle "Mis datos fiscales" / "Público en general" (cards seleccionables grandes)
  • Dropdown Uso CFDI limitado a G03 (Gastos en general) y S01 (Sin obligaciones) via filter client-side sobre getUsosCfdi()
  • Dropdown Régimen preferido (solo si tiene >1 régimen activo)
  • Banner ámbar con <AlertCircle> si eligió "mis datos" pero no tiene regímenes (CSF pendiente)
  • Botón "Guardar preferencias" con feedback de éxito/error

Comportamiento end-to-end

Estado del tenant factPreferencia Resultado
Sin CSF mis_datos (default) Público en General (fallback automático)
Sin CSF publico_general Público en General
Con CSF + 1 régimen mis_datos Datos reales del cliente, régimen único
Con CSF + N regímenes mis_datos, sin preferido Datos reales, primer régimen por createdAt
Con CSF + N regímenes mis_datos, preferido='612' Datos reales, régimen 612
Con CSF publico_general (override explícito) Público en General

Por qué limitar el dropdown a G03/S01

El catálogo SAT cat_uso_cfdi tiene 24 valores oficiales, pero para una factura de servicios SaaS realísticamente solo aplican 2:

  • G03 Gastos en general — el 95% de los casos (servicio deducible)
  • S01 Sin obligaciones fiscales — para PFs que no requieren deducir

Otros usos como D02 (gastos médicos), I01 (construcciones), etc. el SAT los rechaza con CFDI40165 cuando el concepto no matchea. Filtrar el UI client-side previene el error sin tocar el endpoint compartido /catalogos/uso-cfdi (que el módulo de emisión de CFDIs sí necesita completo).

UX adicionales

  • Card en /configuracion con icono Receipt y descripción explicando que define cómo se factura la suscripción a Horux 360. Posicionado entre "Notificaciones" y "Seguridad" siguiendo el orden visual existente.
  • Página sin max-w-3xl — abarca el ancho completo del main para consistencia con el resto del dashboard que usa p-6 space-y-6 sin clamp.

Archivos modificados/creados

NUEVO  apps/api/prisma/migrations/20260502170000_add_tenant_fact_preferencias/migration.sql
MOD    apps/api/prisma/schema.prisma                                (+3 fields en Tenant)
MOD    apps/api/src/services/payment/invoicing.service.ts           (getCustomerFromTenant + signature de buildInvoicePayload)
MOD    apps/api/src/services/tenants.service.ts                     (getPreferenciasFacturacion + update)
MOD    apps/api/src/controllers/facturacion.controller.ts           (2 endpoints + Zod)
MOD    apps/api/src/routes/facturacion.routes.ts                    (+2 routes)
NUEVO  apps/web/app/(dashboard)/configuracion/facturacion/page.tsx
MOD    apps/web/app/(dashboard)/configuracion/page.tsx              (Card "Preferencias de Facturación")

Nota deploy

Cero migración de datos requerida — los tenants existentes heredan los defaults (mis_datos + G03 + null). Como el primer pago de cada tenant sigue siendo skip-eado por el Gate 4 (isFirstApprovedPayment), incluso si un tenant existente tiene CSF cargada y datos correctos, el primer cobro post-deploy lo facturará el admin manualmente y los subsecuentes ya saldrán con datos reales automáticamente.


23. Onboarding auto-dismiss (4 logins ó pasos completados)

Contexto

La pantalla /onboarding (los pasos: cuenta creada → contribuyente → FIEL → CSD → equipo opcional → plan opcional) se mostraba cada login porque el único mecanismo para marcarla como vista era el flag de localStorage horux360:onboarding_seen_v1, que solo lo seteaba el OnboardingScreen del video de bienvenida — un componente paralelo casi en desuso. La página de pasos nunca tocaba el flag, así que owner/contador la veían eternamente hasta que fueran al video, lo cual nunca pasaba.

Reglas de auto-dismiss

El onboarding ya no se muestra cuando se cumple cualquiera de estas condiciones (lo que pase primero):

  1. El user acumuló > 4 logins exitosos — significa que ya navegó la plataforma varias veces, presumiblemente porque conoce el flujo.
  2. El user completó todos los pasos requeridos — `cuenta + contribuyente
    • FIEL + CSD(los 4 no-opcionales del arraysteps`).

Los logins se cuentan en backend para sobrevivir cambio de dispositivo / limpieza de cookies. El dismiss se persiste como timestamp users.onboarding_dismissed_at.

Schema (migración 20260502190000_add_user_login_count_onboarding_dismissed)

ALTER TABLE "users"
  ADD COLUMN "login_count" INTEGER NOT NULL DEFAULT 0,
  ADD COLUMN "onboarding_dismissed_at" TIMESTAMP(3);

Prisma User:

loginCount             Int       @default(0) @map("login_count")
onboardingDismissedAt  DateTime? @map("onboarding_dismissed_at")

Backend

auth.service.ts:login() ahora incrementa loginCount en la misma transacción que actualiza lastLogin y lastTenantId:

const updatedUser = await prisma.user.update({
  where: { id: user.id },
  data: {
    lastLogin: new Date(),
    lastTenantId: activeTenant.id,
    loginCount: { increment: 1 },
  },
  select: { loginCount: true, onboardingDismissedAt: true },
});

Importante: se incrementa solo en login(), NO en refreshTokens(). Los refresh tokens disparan cada hora — si los contáramos, el threshold se cumpliría en horas, no en sesiones reales.

El LoginResponse.user extendido con los 2 campos:

loginCount: updatedUser.loginCount,
onboardingDismissedAt: updatedUser.onboardingDismissedAt,

auth.service.ts:dismissOnboarding(userId) — idempotente. Si ya estaba dismissed, devuelve el timestamp original (no sobrescribe):

if (user.onboardingDismissedAt) {
  return { onboardingDismissedAt: user.onboardingDismissedAt };
}
const updated = await prisma.user.update({
  where: { id: userId },
  data: { onboardingDismissedAt: new Date() },
  select: { onboardingDismissedAt: true },
});

Endpoint: POST /auth/onboarding/dismiss (autenticado, sin body).

Frontend

apps/web/lib/onboarding.ts (nuevo):

export const ONBOARDING_LOGIN_THRESHOLD = 4;

export function shouldShowOnboarding(user): boolean {
  if (!user) return false;
  if (user.onboardingDismissedAt) return false;
  if ((user.loginCount ?? 0) > ONBOARDING_LOGIN_THRESHOLD) return false;
  return true;
}

app/(auth)/login/page.tsx — la lógica de redirect post-login para owner/cfo/contador pasa de chequear localStorage a usar el helper:

} else {
  router.push(shouldShowOnboarding(response.user) ? '/onboarding' : '/dashboard');
}

app/(dashboard)/onboarding/page.tsx — useEffect que dispara el dismiss cuando allRequiredDone se vuelve true:

useEffect(() => {
  if (!allRequiredDone || dismissed || !user || user.onboardingDismissedAt) return;
  setDismissed(true);
  dismissOnboarding()
    .then((res) => {
      // Sync al store para que el siguiente login vaya directo a dashboard
      // sin esperar a que el backend incremente loginCount > threshold.
      setUser({ ...user, onboardingDismissedAt: res.onboardingDismissedAt });
    })
    .catch((err) => {
      console.warn('[onboarding] Failed to mark as dismissed:', err);
      setDismissed(false); // permite reintentar
    });
}, [allRequiredDone, dismissed, user, setUser]);

dismissed es un flag local que evita el round-trip duplicado si la página se re-renderiza por refetch de queries. El setUser({...user, onboardingDismissedAt}) sincroniza el store para que el helper shouldShowOnboarding lo respete en futuros logins sin esperar al backend.

Tabla de comportamiento

loginCount onboardingDismissedAt Pasos requeridos Resultado
14 null incompletos Muestra onboarding
14 null todos completos Muestra onboarding (con auto-dismiss en useEffect) → próximo login va a dashboard
14 timestamp cualquiera Va directo a dashboard
5+ null/timestamp cualquiera Va directo a dashboard

Decisión: backend vs localStorage

Se eligió backend (User.loginCount) sobre localStorage por:

  1. Cross-device: un owner que usa Horux 360 en laptop + celular acumula logins en ambos. Con localStorage cada dispositivo arrancaría su propio contador y vería el onboarding 4 veces más por cada uno.
  2. Resistente a localStorage.clear(): el contador no se pierde si el user limpia caché del browser.
  3. Consistencia con el resto de preferencias persistentes (todo en BD).

El localStorage horux360:onboarding_seen_v1 queda huérfano — ya no se lee en ningún lado. No se borra explícitamente porque es 1 línea de tech-debt sin impacto. Si se quiere limpieza, agregar a auth-store.logout():

localStorage.removeItem('horux360:onboarding_seen_v1');

Threshold = 4

Constante exportada en apps/web/lib/onboarding.ts. Ajustable en un solo punto. Lectura literal del requerimiento ("después del 4to inicio de sesión"): se muestra durante logins 14, desaparece a partir del 5to. Condición: loginCount > 4.

Nota deploy

Tenants/users existentes tras el deploy arrancan con loginCount = 0 (default SQL). Aunque hayan iniciado sesión cientos de veces históricamente, el contador se construye desde cero post-deploy — verán onboarding hasta acumular 5 sesiones nuevas O hasta completar pasos requeridos. Dado que el flujo viejo ya les mostraba el onboarding cada login, esto no es regresión.

Archivos modificados/creados

NUEVO  apps/api/prisma/migrations/20260502190000_add_user_login_count_onboarding_dismissed/migration.sql
MOD    apps/api/prisma/schema.prisma                     (User: +2 fields)
MOD    apps/api/src/services/auth.service.ts             (login increment + dismissOnboarding)
MOD    apps/api/src/controllers/auth.controller.ts       (handler dismissOnboarding)
MOD    apps/api/src/routes/auth.routes.ts                (POST /auth/onboarding/dismiss)
MOD    packages/shared/src/types/auth.ts                 (UserInfo: +2 optional fields)
NUEVO  apps/web/lib/onboarding.ts                        (helper + threshold constant)
MOD    apps/web/lib/api/auth.ts                          (dismissOnboarding fetcher)
MOD    apps/web/app/(auth)/login/page.tsx                (usa shouldShowOnboarding)
MOD    apps/web/app/(dashboard)/onboarding/page.tsx      (useEffect auto-dismiss + setUser sync)

24. Soporte wide-screen (breakpoints 3xl/4xl + 3 páginas)

Contexto

Tailwind defaults terminan en 2xl: 1536px. A 2560×1600 (target del cliente Horux 360), grids tipo lg:grid-cols-3 se quedan en 3 columnas y dejan mucho whitespace lateral. Y páginas con max-w-5xl mx-auto (1024px) dejan un canal central angosto rodeado de vacío.

Cambio 1 — Breakpoints custom

apps/web/tailwind.config.ts:

screens: {
  '3xl': '1920px',
  '4xl': '2560px',
},

Tailwind tree-shakea las clases por content scan — agregarlas al config sin usarlas en el código no las activa. Por eso solo aparecen las que se aplican en las páginas modificadas abajo.

Cambio 2 — 3 páginas top con peor adaptación

Auditoría previa ranqueó las páginas por impacto (lista en mensaje del user

  • asistente del chat). Top 3 elegidas:

/dashboard línea 256 — desglose por régimen escala a 3 cols a 1920px+:

- <div className="grid gap-4 md:grid-cols-2">
+ <div className="grid gap-4 md:grid-cols-2 3xl:grid-cols-3">

(Hasta 3 cards: ingresos/egresos/IVA por régimen — antes quedaba la 3ª en fila aparte a wide.)

/contribuyentes — lista de RFCs gestionados:

- <div className="p-6 max-w-5xl mx-auto space-y-6">
+ <div className="p-6 space-y-6">
- <div className="grid gap-3">{contribuyentes.map(...)}
+ <div className="grid gap-3 lg:grid-cols-2 3xl:grid-cols-3 4xl:grid-cols-4">{contribuyentes.map(...)}

Lista en stack vertical → grid escalable. A 2560px = hasta 4 cards por fila.

/despachos/contribuyentes — vista de stats del despacho (3 cards):

- <main className="p-6 max-w-5xl mx-auto">
+ <main className="p-6 max-w-7xl mx-auto">

(2 ocurrencias: vista "no enabled" y vista "enabled".) Las 3 cards ya escalan a lg:grid-cols-3 — solo se desclampea el contenedor de 1024 a 1280px. No se quita totalmente porque 3 cards a ancho infinito se ven ridículamente estiradas.

Páginas NO tocadas y por qué

Página Motivo
/configuracion hub Cards <Link> están en stack vertical (space-y-6), no grid. Refactorizar a grid implicaría tocar la estructura de cada card. ROI bajo.
/onboarding, /seguridad, /configuracion/notificaciones, /mis-empresas Forms — max-w-* es intencional (forms anchos cansan vista)
/cfdi (lista principal) Tabla expande naturalmente. Modales con max-w-lg/3xl están bien.
/dashboard KPI cards (línea 206) 4 KPIs fijos en lg:grid-cols-4. Agregar variantes wide significaría columnas vacías.
/impuestos Ya usa lg:grid-cols-5 en KPIs
/configuracion/planes-despacho Ya escala con max-w-7xl + lg:grid-cols-4

Forms vs listas — la regla

Regla de pulgar aplicada en este cambio:

  • Listas / tablas / dashboards → full width o max-w-7xl (1280px) máximo
  • Forms / wizards / inputs → clamp a 800-1000px (max-w-3xl-max-w-5xl)

El codebase ya respeta esto en muchos lados — los cambios fueron quirúrgicos sobre los outliers que rompían la regla.

Archivos modificados

MOD  apps/web/tailwind.config.ts                              (+screens 3xl/4xl)
MOD  apps/web/app/(dashboard)/dashboard/page.tsx              (+3xl:grid-cols-3 desglose régimen)
MOD  apps/web/app/(dashboard)/contribuyentes/page.tsx         (-max-w-5xl + grid escalable)
MOD  apps/web/app/(dashboard)/despachos/contribuyentes/page.tsx  (max-w-5xl → max-w-7xl)

Testing recomendado

Si no tenés monitor 4K nativo, hacé Ctrl+ (zoom 50%) en una pantalla 1920×1080 — equivale a renderizar a ~3840×2160 efectivo, cubre el caso 2560 con margen.

Pendientes (segunda capa, no urgentes)

Página Mejora propuesta
/reportes 7+ grids md:grid-cols-2-4 sin variantes wide
/calendario lg:grid-cols-3 del layout principal
/despachos/mis-asignados, /despachos/equipo max-w-6xl mx-auto clamp innecesario
/configuracion hub Refactor cards a grid escalable

25. Alerta RESICO PF cerca del límite anual ($2.5M / $3M)

Contexto fiscal

LISR Art. 113-E — el contribuyente Persona Física que tributa en RESICO debe salir del régimen si sus ingresos del ejercicio exceden $3,500,000 MXN. Ahora bien:

Importante: el SAT considera ingresos acumulados de TODOS los regímenes del contribuyente, no solo los del 626. Un PF con 626 + 612 + 606 ve la suma total para el cálculo del límite.

Esto era un punto ciego del sistema — los contadores tenían que sumar manualmente ingresos de varios regímenes para saber si su cliente RESICO estaba en riesgo de salir del régimen.

Threshold elegido

Ingresos del año Prioridad Mensaje
< $2,500,000 (sin alerta)
$2,500,000 $2,999,999 media "RESICO PF cerca del límite anual"
$3,000,000 $3,499,999 alta "RESICO PF: cerca del límite ($3M+)"
≥ $3,500,000 alta "RESICO PF: límite anual EXCEDIDO" — debe salir del régimen

Nota: el user pidió alerta a $2.5M citando el límite como "$3M". El límite legal real es $3.5M (Art. 113-E LISR reformado 2023). Se implementó el threshold pedido ($2.5M para arrancar la alerta), pero el mensaje y los escalones referencian el límite legal correcto.

Reglas de aplicación

La alerta solo se genera cuando se cumplen las 3 condiciones:

  1. Hay un contribuyente seleccionado — la alerta es per-entidad fiscal, no per-tenant
  2. RFC de 13 caracteres — Persona Física (RESICO PM no tiene este límite, se filtra por longitud de RFC)
  3. Régimen 626 está en contribuyentes.regimen_fiscal (CSV)

Cálculo de ingresos

Query directo agregado, sin filtrar por regimen_fiscal_emisor:

SELECT COALESCE(SUM(
  CASE
    WHEN tipo_comprobante = 'I' AND metodo_pago = 'PUE' THEN COALESCE(total_mxn, 0)
    WHEN tipo_comprobante = 'P'                          THEN COALESCE(monto_pago_mxn, 0)
    WHEN tipo_comprobante = 'E' AND metodo_pago = 'PUE' THEN -COALESCE(total_mxn, 0)
    ELSE 0
  END
), 0)::numeric AS ingresos
FROM cfdis
WHERE type = 'EMITIDO'
  AND status NOT IN ('Cancelado', '0')
  AND EXTRACT(YEAR FROM fecha_emision) = $1
  AND contribuyente_id = $2

Componentes:

  • + I PUE — facturas cobradas al emitir (flujo efectivo simplificado)
  • + P — complementos de pago (cobros de PPD anteriores)
  • E PUE — notas de crédito netan los ingresos

Decisiones simplificatorias:

  • Sin desglose por régimen — para la alerta basta el agregado bruto
  • Sin filtro de conciliación — el toggle "Considerar conciliación" del dashboard NO aplica aquí; el SAT mira todos los CFDIs vigentes
  • Sin exclusión de claves de conceptos (seguros, salud, gobierno) — conservador, el SAT los cuenta para el límite
  • total_mxn con IVA — sobreestima ~16%, pero es proxy conservador apropiado para alerta preventiva; si ya supera $2.5M con IVA, el contador debe revisar manualmente

Implementación

apps/api/src/services/alertas-auto.service.ts:

  • Nueva función alertaResicoPfLimiteIngresos(pool, contribuyenteId)
  • Registrada en generarAlertasAutomaticas array de Promise.all
  • Sigue el patrón de las otras 12 alertas: shape AlertaAuto + return null si no aplica

ID de la alerta: resico-pf-limite-ingresos. Tipo: limite-regimen (nuevo tipo, no había alertas de este tipo antes).

Por qué no hay drill-down

A diferencia de otras alertas (discrepancia-regimen, lista-negra-clientes), esta no tiene detalle: '/alertas/...' porque no hay un listado de "CFDIs problemáticos" — el problema es la suma agregada del año. El contador puede ir a /cfdi con filtro de año si quiere ver el desglose, pero no es un drill-down específico de la alerta.

Archivos modificados

MOD  apps/api/src/services/alertas-auto.service.ts  (+~95 líneas: función nueva + registro)

Cero migraciones. Cero schema changes. Cero frontend changes (la página /alertas ya consume generarAlertasAutomaticas y muestra cualquier alerta que devuelva el array).

Pendiente futuro

  • Threshold y límite configurables — si LISR cambia el límite, hoy hay que editar 3 constantes en código. Mover a apps/api/src/config/fiscal.ts o a una tabla parametros_fiscales en BD central.
  • Variantes para otros regímenes con límite — IF (621) tiene límite $2M, PF Empresarial sin límite pero con tope de RESICO si quiere optar. Refactorizar a una función genérica alertaLimiteRegimen(pool, regimen, limite).
  • Si el cálculo "TODOS los regímenes" debe excluir alguno — por ejemplo régimen 605 (sueldos) o 614 (intereses) que se reportan distinto. Por ahora suma todos. Confirmar con contador si es exacto.

26. SAT — reuso de requestIds en retries + políticas de retry por tipo

Contexto: 2 problemas relacionados

Problema 1 (reuse): El SAT impone un límite de solicitudes activas/recientes por RFC. Cada requestAndDownload creaba una nueva solicitud al SAT, incluso en reintentos — el satRequestId original se sobrescribía sin consultarlo. Si una sync diaria timeouteaba, los 3 reintentos generaban 3 requests adicionales. En jobs initial con N bloques, podía agotarse la cuota del SAT y empezar a recibir rechazos.

Problema 2 (timing): El cálculo de nextRetryAt usaba Date.now() + 6h en el momento del timeout, NO startedAt + 6h. Si timeoutea a T+45min, el reintento quedaba programado para T+6h45min en vez de T+6h. Más críticamente: todos los tipos de sync usaban la misma política (3 retries × 6h), sin diferenciar urgencia/contexto.

Cambio 1 — Reuso de requestIds (mapa por job)

Schema (migración 20260502210000_add_sat_sync_jobs_request_ids_map):

ALTER TABLE "sat_sync_jobs"
  ADD COLUMN "sat_request_ids" JSONB NOT NULL DEFAULT '{}'::jsonb;
satRequestIds Json @default("{}") @map("sat_request_ids")

kindKey para identificar requests dentro de un job:

function makeRequestKindKey(fechaInicio, fechaFin, tipoCfdi, requestType) {
  return `${requestType}-${tipoCfdi}-${fechaInicio.slice(0,10)}-${fechaFin.slice(0,10)}`;
}

Cubre los N requests del initial (varios bloques de fechas) y los 4 del daily.

Persistencia atómica con merge SQL (evita race conditions):

async function persistSatRequestId(jobId, kindKey, requestId) {
  await prisma.$executeRawUnsafe(
    `UPDATE sat_sync_jobs
     SET sat_request_ids = COALESCE(sat_request_ids, '{}'::jsonb) || $1::jsonb,
         sat_request_id  = $2
     WHERE id = $3`,
    JSON.stringify({ [kindKey]: requestId }),
    requestId,
    jobId,
  );
}

Conserva satRequestId (singular) actualizándolo al último, para backward compat de queries existentes (getSyncStatus, UI). El mapa plural es la fuente de verdad del tracking.

Refactor requestAndDownload:

  1. Lee job.satRequestIds[kindKey] → si existe, intenta reuso
  2. verifySatRequest(existingId):
    • ready → salta polling, descarga directa
    • processing/pending → entra al polling con MISMO id
    • failed/rejected/excepción → fallback a querySat (crea nuevo)
  3. Si crea nuevo, persiste con persistSatRequestId

Beneficio principal: las 6h de espera del retry ya no son tiempo desperdiciado — el SAT puede terminar el request en background, y el retry solo verifica + descarga.

Cambio 2 — Políticas de retry por tipo

Schema (migración 20260502230000_add_sat_sync_jobs_is_custom_range):

ALTER TABLE "sat_sync_jobs"
  ADD COLUMN "is_custom_range" BOOLEAN NOT NULL DEFAULT false;

isCustomRange = type === 'initial' && (!!dateFrom || !!dateTo) — distingue bootstrap puro (sin fechas, default 6 años atrás) de UI custom range.

Constantes:

const RETRY_POLICIES = {
  daily:       { maxRetries: 2, retryAtHours: [6, 12] },
  custom:      { maxRetries: 2, retryAtHours: [6, 12] },
  initial:     { maxRetries: 3, retryAtHours: [6, 12, 24] },
  incremental: { maxRetries: 0, retryAtHours: [] },
};

function getRetryPolicy(job) {
  if (job.type === 'initial' && job.isCustomRange) return RETRY_POLICIES.custom;
  return RETRY_POLICIES[job.type];
}

Helper computeNextRetryAt — basado en startedAt, no Date.now():

function computeNextRetryAt(startedAt, nextRetryNumber, policy) {
  const idx = nextRetryNumber - 1;
  if (idx >= policy.retryAtHours.length) return null;
  return new Date(startedAt.getTime() + policy.retryAtHours[idx] * 3600_000);
}

Removidas: constantes MAX_RETRIES = 3 y RETRY_DELAY_HOURS = 6.

Tabla resumen del comportamiento

Tipo Max retries Tiempos absolutos desde startedAt
daily 2 T+6h, T+12h
initial + custom range 2 T+6h, T+12h
initial bootstrap (sin fechas) 3 T+6h, T+12h, T+24h
incremental 0 — (next cron cubre el gap)

Línea de tiempo: daily timeoutea a las 3:00 AM

Hora Evento
3:00 startSync crea Job. Genera REQ-1, persiste en mapa. Polling cada 60s
3:00 → 3:45 45 polls. SAT responde processing siempre
3:45 MAX_POLL_ATTEMPTS agotado → timeout. nextRetryAt = startedAt + 6h = 9:00 AM (no 9:45). Job → pending, retryCount=1
4:00 Cron retry corre. nextRetryAt > now, no toca
4:30 (T+90min) Job durmiendo. Watchdog ignora (no aplica thresholds)
9:00 Cron retry corre, nextRetryAt <= now. Levanta job. Verify REQ-1 → si SAT terminó en las 6h, ready → download directo (sin polling). Si sigue procesando → polling con MISMO REQ-1
Si retry 1 falla 9:45 nextRetryAt = 15:00, retryCount=2
Si retry 2 falla 15:45 2 + 1 = 3 > maxRetries(2)failed

Línea de tiempo: incremental timeoutea

Hora Evento
11:00 Cron incremental dispara, crea job. Polling
11:45 Timeout. maxRetries=0nextRetryNumber(1) > 0 → directo a failed con mensaje "Timeout en sync incremental — sin reintentos por política. Próximo cron incremental cubrirá el gap."
15:00 Cron incremental siguiente arranca un job NUEVO (no es retry). La ventana de 8h cubre los CFDIs perdidos del intento 11:00 (dedup por UUID)

Decisiones del diseño

  • Tiempos absolutos desde startedAt (no desde último intento ni desde ahora): respeta la regla "6h después" sin acumular tiempo de polling.
  • isCustomRange como flag separado vs nuevo enum value (type='custom'): evita migrar el enum SatSyncType y romper queries/tipos existentes. El flag es ortogonal al tipo y solo aplica a initial.
  • incremental sin retries: la ventana del cron (cada 4h, lookup 8h) se solapa con el siguiente — un fallo aislado se recupera automáticamente sin duplicar trabajo. Reintentar agregaría carga al SAT sin beneficio.
  • No filtrar retryCount < MAX_RETRIES en query SQL (retryTimedOutJobs): el max es por-policy (varía por type+isCustomRange). El catch del retry ya valida y marca failed si excede. Filtrar en SQL requeriría CASE complejo.

Archivos modificados

NUEVO  apps/api/prisma/migrations/20260502210000_add_sat_sync_jobs_request_ids_map/migration.sql
NUEVO  apps/api/prisma/migrations/20260502230000_add_sat_sync_jobs_is_custom_range/migration.sql
MOD    apps/api/prisma/schema.prisma                    (+satRequestIds Json, +isCustomRange Boolean)
MOD    apps/api/src/services/sat/sat.service.ts         (helpers + refactor catch en runSyncJob y retryTimedOutJobs)

Pendientes

  • Limpieza del mapa satRequestIds cuando job completa: hoy queda como audit trail. Si crece descontrolado (jobs initial con muchos bloques), considerar purgar via cron mensual.
  • Cancelar requestIds huérfanos en SAT: cuando verifySatRequest devuelve failed/rejected y el sistema crea uno nuevo, el viejo sigue existiendo en el SAT hasta que expire (~72h). No hay API explícita de "cancelar request" en el SDK actual; si la hubiera, valdría llamarla en el fallback.

Pendientes documentados

  1. IVA No Acreditable sin drill-down ni cache (sección 11)
  2. Backfill en producción de P emitidos vía Facturapi (sección 20) — correr apps/api/scripts/backfill-pago-fields.ts después de deploy + refresh-metricas-cache.ts para invalidar meses afectados
  3. Refresh-metricas-cache debe correrse cada vez que cambia una fórmula fiscal — automatizar como part del deploy hook si los cambios son frecuentes
  4. Predicados NULL-unsafe sospechosos en el codebase (sección 15) — auditar cfdi_tipo_relacion = '07', metodo_pago = 'PUE', etc. cuando se usan con NOT
  5. Auto-facturación: opción de saltar gate del primer pago — hoy el primer cobro de cualquier tenant lo factura el admin a mano. Si la confianza en la calidad de los CSF aumenta, podríamos eliminar el Gate 4 cuando el tenant tenga factPreferencia='mis_datos' + CSF + datos completos