67 KiB
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
- Eliminar E PUE de cálculo de ingresos (Grupos 1 y 3)
- Eliminar E PUE de cálculo de deducciones
- Cards NCs Emitidas + NCs Recibidas (surface)
- Restauración compensación I/07 PPD ↔ E (opción C)
- Base gravable: nueva fórmula con NCs
- Persistencia en
metricas_mensuales(migración 042) - Switch "Considerar NCs" extendido
- Drill-downs: remover E de ingresos/gastos + propagar régimen + drill propio para NCs
- Art. 27 fracción III LISR — gastos efectivo > $2k no deducibles
- Layout final cards en /impuestos > ISR 11–21. (secciones agregadas durante la sesión — ver headers)
- Auto-facturación con datos del cliente (Fases 1 + 2)
- Onboarding auto-dismiss (4 logins ó pasos completados)
- Soporte wide-screen (breakpoints 3xl/4xl + 3 páginas)
- Alerta RESICO PF cerca del límite anual ($2.5M / $3M)
- 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 − ncsEmitidasdeduccionNeta = deducciones − ncsRecibidasbase = 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 facturascon 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:drillDownacepta query paramsconsiderarActivosyconsiderarNCs, aplicaextraal WHERE finaldrillUrlen/impuestospropaga los toggles cuando están OFF (default ON omite el param, backend interpreta como true por convención)drillUrlen/dashboardno 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:
calcularIngresosPorRegimencalcularEgresosPorRegimencalcularIvaBalancePorRegimen(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 ...)→NULLNOT NULL→NULLWHERE NULLexcluye 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 → Emanualmente 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 | SÍ | 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: NULLfecha_pago_p: NULLiva_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_mxnfecha_pago_piva_traslado_pago+iva_traslado_pago_mxniva_retencion_pago+iva_retencion_pago_mxnieps_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 llenatenants.codigo_postal/calle/colonia/...automáticamente tenant_regimenes_activoscon 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
/configuracioncon iconoReceipty 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 usap-6 space-y-6sin 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):
- El user acumuló > 4 logins exitosos — significa que ya navegó la plataforma varias veces, presumiblemente porque conoce el flujo.
- El user completó todos los pasos requeridos — `cuenta + contribuyente
- FIEL + CSD
(los 4 no-opcionales del arraysteps`).
- FIEL + CSD
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 enrefreshTokens(). 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 |
|---|---|---|---|
| 1–4 | null | incompletos | Muestra onboarding |
| 1–4 | null | todos completos | Muestra onboarding (con auto-dismiss en useEffect) → próximo login va a dashboard |
| 1–4 | 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:
- 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.
- Resistente a
localStorage.clear(): el contador no se pierde si el user limpia caché del browser. - 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 1–4, 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:
- Hay un contribuyente seleccionado — la alerta es per-entidad fiscal, no per-tenant
- RFC de 13 caracteres — Persona Física (RESICO PM no tiene este límite, se filtra por longitud de RFC)
- 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_mxncon 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
generarAlertasAutomaticasarray dePromise.all - Sigue el patrón de las otras 12 alertas: shape
AlertaAuto+ returnnullsi 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.tso a una tablaparametros_fiscalesen 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:
- Lee
job.satRequestIds[kindKey]→ si existe, intenta reuso verifySatRequest(existingId):ready→ salta polling, descarga directaprocessing/pending→ entra al polling con MISMO idfailed/rejected/excepción → fallback aquerySat(crea nuevo)
- 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=0 → nextRetryNumber(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. isCustomRangecomo 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 ainitial.incrementalsin 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_RETRIESen 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
satRequestIdscuando 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
verifySatRequestdevuelvefailed/rejectedy 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
- IVA No Acreditable sin drill-down ni cache (sección 11)
- Backfill en producción de P emitidos vía Facturapi (sección 20) —
correr
apps/api/scripts/backfill-pago-fields.tsdespués de deploy +refresh-metricas-cache.tspara invalidar meses afectados - Refresh-metricas-cache debe correrse cada vez que cambia una fórmula fiscal — automatizar como part del deploy hook si los cambios son frecuentes
- Predicados NULL-unsafe sospechosos en el codebase (sección 15) —
auditar
cfdi_tipo_relacion = '07',metodo_pago = 'PUE', etc. cuando se usan con NOT - 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