51 KiB
Horux360 - Contexto para Claude
Qué es esto
Plataforma SaaS de análisis financiero y gestión fiscal para empresas mexicanas. Maneja CFDIs (facturas electrónicas del SAT), cálculos de IVA/ISR, reportes financieros, alertas de cumplimiento fiscal, conciliación bancaria, sincronización directa con el SAT y emisión de facturas vía Facturapi.
Producción: https://horuxfin.com Autor: Carlos e Ivan (Horux 360) (RFC: HTS240708LJA)
Arquitectura en 30 segundos
Monorepo (pnpm + Turborepo)
├── apps/api → Express + TypeScript (tsx, puerto 4000)
├── apps/web → Next.js 14 + App Router (puerto 3000)
└── packages/shared → Tipos, constantes e interfaces compartidas
Multi-tenant database-per-tenant:
horux360→ BD central (Prisma): tenants, users, roles, subscriptions, catálogos fiscales, catálogos SAT, timbres Facturapihorux_<rfc>→ BD por tenant (pg Pool + raw SQL): cfdis, cfdi_conceptos, rfcs, bancos, conciliaciones, alertas, recordatorios, contribuyentes, carteras, obligaciones, tareas, papelería
Dos capas de acceso a datos (esto es intencional):
- Prisma para la BD central (ORM, migraciones, tipos generados)
- pg Pool directo para BDs de tenant (queries SQL complejos de cálculos fiscales)
Archivos clave por área
Configuración
| Archivo | Qué hace |
|---|---|
apps/api/src/config/env.ts |
Variables de entorno validadas con Zod (incluye FACTURAPI_USER_KEY) |
apps/api/src/config/database.ts |
Prisma client + TenantConnectionManager (pools, provisioning, lazy migration) |
apps/api/src/config/tenant-migrations.ts |
migrate(), migrateAll(), getMigrationFiles() — sistema de migraciones SQL para BDs tenant |
apps/api/src/migrations/tenant/*.sql |
Archivos SQL numerados (001_initial_schema.sql, etc.) |
apps/api/.env |
Credenciales locales (DB, JWT, SMTP, MercadoPago, FIEL, Facturapi, Metabase) |
apps/api/prisma/schema.prisma |
Schema de la BD central |
Autenticación y seguridad
| Archivo | Qué hace |
|---|---|
src/middlewares/auth.middleware.ts |
JWT verify + authorize(...roles) |
src/middlewares/tenant.middleware.ts |
Resuelve pool de BD del tenant, cache 5min, X-View-Tenant para admin global |
src/middlewares/plan-limits.middleware.ts |
Verifica suscripción, limita CFDIs, read-only si inactiva |
src/middlewares/feature-gate.middleware.ts |
requireFeature('reportes') por plan |
src/utils/global-admin.ts |
Admin global = tenant con RFC HTS240708LJA |
packages/shared/src/constants/roles.ts |
GLOBAL_ADMIN_RFC + isGlobalAdminRfc() compartido frontend/backend |
Roles (tabla roles en BD central)
| id | nombre | Label UI | Acceso |
|---|---|---|---|
| 1 | owner |
Dueño | Todo + gestión usuarios + configuración + reportes |
| 7 | cfo |
CFO | Todo (mismo nivel que owner) |
| 2 | contador |
Contador | Dashboard, CFDI, Impuestos, Calendario, Alertas, Conciliación, Facturación (puede completar alertas y crear recordatorios) |
| 8 | auxiliar |
Auxiliar | Mismos permisos que contador |
| 3 | visor |
Visor | CFDI, Impuestos, Calendario, Alertas, Conciliación (solo lectura) |
"Admin global" (platform staff) — tabla user_platform_roles: staff interno de Horux 360 con acceso transversal. 5 roles en enum PlatformRole:
platform_admin— Todo (gestión staff, precios, clientes, facturas)platform_ti— Mismos permisos que admin (equipo TI, trazabilidad distinta en audit)platform_support— Ver tenants, tickets; NO facturación/preciosplatform_sales— Crear/editar clientes; NO preciosplatform_finance— Pagos, facturas manuales, editar precios
platform_admin y platform_ti son supersets (implican todos los demás). Se checa vía helper: hasPlatformRole(userId, role), canManageTenants(), canEditPrices(), canEmitInvoicesManual(), isPlatformStaff() en apps/api/src/utils/platform-admin.ts. JWT incluye platformRoles[] al login.
isGlobalAdmin() compat: apps/api/src/utils/global-admin.ts es shim que re-exporta de platform-admin.ts — resuelve "admin global" vía tabla (busca rol superset), fallback a RFC HTS240708LJA si tabla vacía. El concepto UX "admin global" se preserva; la implementación migró de hardcode a tabla.
isGlobalAdminRfc(rfc, role, platformRoles?) en shared: tercer parámetro opcional — prioriza platformRoles si existe, fallback al RFC check.
UI /admin/staff: gestión de roles (solo platform_admin/platform_ti). Protección "último superset": no te puedes quitar si serías el único con acceso transversal. Ver docs/plans/2026-04-14-platform-admin-roles.md — sección "Implementación ejecutada".
Planes (5 planes)
| Plan | CFDIs | Usuarios | Features |
|---|---|---|---|
| starter | 0 | 1 | dashboard, cfdi_basic, iva_isr |
| business | 50 | 3 | + reportes, alertas, calendario, conciliacion, forecasting, xml_sat, documentos |
| business_ia | 50 | 3 | Mismas que business + Lolita (feature ia_lolita, agente IA fiscal) |
| custom | 50 | 3 | Mismas que business_ia, precio variable por tenant |
| enterprise | 100 | ilimitado | + api |
Cada empresa requiere su propia suscripción. multi_empresa no existe como feature.
Suscripciones (self-serve con MercadoPago)
Precios: tabla plan_prices en BD central — 8 filas (4 planes × monthly/annual). Custom no se guarda aquí (el admin fija monto por tenant). Editables desde /configuracion/suscripcion (admin global) o SQL directo. Los cambios aplican solo a suscripciones nuevas; vigentes conservan su amount.
Estados de Subscription.status: trial, trial_converted, trial_expired, pending, authorized, paused, cancelled.
Flujos self-serve (endpoints /api/subscriptions/me/*):
trial— 30 días gratis sin tarjeta. Una sola vez por RFC (padrón persistentetrial_usages— sobrevive borrado/recreación del tenant, bloquea abuso).subscribe— crea preapproval MP conauto_recurringmensual (months/1) o anual (months/12).change— downgrades y cambios de frecuencia van apendingPlan/pendingEffectiveAt = currentPeriodEnd.upgrade— plan más caro con misma frecuencia cobra prorateo inmediato vía MP Preference +updatePreapprovalAmountal recibir webhook.external_reference = 'proration:${tenantId}:${subscriptionId}'es el marcador que el webhook usa.cancel—status=cancelled+cancelPreapprovalen MP. Middleware respetacurrentPeriodEnd.reactivate— revive suscripción cancelada dentro del período pagado. Crea preapproval nuevo constart_date=currentPeriodEnd(sin doble cobro). Limpia pending/upgrade residuales.
Cron applyPendingChanges + expireTrials: 2:30 AM daily (30 2 * * *) — aplica cambios programados y transiciona trials vencidos.
Auth pattern: endpoints /subscriptions/:tenantId, /:tenantId/payments, /:tenantId/generate-link usan requireOwnTenantOrGlobalAdmin (el dueño puede consultar su propia sub, admin global puede ver cualquiera). mark-paid, listar todas, editar precios solo admin global.
Webhook external_reference routing:
proration:*→applyApprovedUpgrade(upgrade payment confirmado)${tenantId}UUID → flujo recurrente
Plan Custom: sólo lo activa el admin global al provisionar tenant. El self-serve self-serve lo rechaza con error explícito.
Auto-facturación de pagos (invoicing.service.ts): cada webhook payment.approved dispara emitInvoiceIfApplicable(payment.id). Emite CFDI vía Facturapi (org de Horux 360, RESICO PM, clave prod/serv 81112502). El primer pago aprobado de cada tenant se skip-ea (lo factura manual el admin para capturar datos fiscales); los subsecuentes van auto. Receptor según preferencia del tenant (Tenant.factPreferencia — mis_datos por default vs publico_general): si tiene CSF cargada + factPreferencia='mis_datos' se factura con datos reales del cliente usando factUsoCfdi (default G03) y factRegimenPreferido (o primer régimen activo si no se especifica); si falta cualquier dato fiscal cae automáticamente a Público en General + S01. Configurable desde /configuracion/facturacion (UI limita uso CFDI a G03/S01). Idempotente por Payment.facturapiInvoiceId. Fail-soft (error en Facturapi → log + invoice null + webhook sigue retornando 200). Ver docs/plans/2026-04-13-auto-invoicing-mp-payments.md y docs/plans/2026-05-02-session.md sección 22.
Ver docs/plans/2026-04-13-subscriptions-self-serve.md para diseño completo y flujos.
Schema tenant — single source of truth: las migraciones SQL en apps/api/src/migrations/tenant/*.sql son el único lugar donde se declara el schema de las BDs tenant. El seed (prisma/seed.ts) ya NO hardcodea CREATE TABLE — llama a migrate() del runner. Añadir una migración = crear NNN_descripcion.sql. Se aplica eager via pnpm db:migrate-tenants y lazy en getPool().
Audit log (utils/audit.ts): helper auditLog()/auditFromReq() fire-and-forget que escribe a tabla audit_log (BD central). Instrumentado en 12+ eventos críticos (login, subscribe, cancel, plan change, price edit, invoice auto, password reset, etc.). UI admin global en /admin/audit-log con filtros + expand JSON metadata. NUNCA debe romper la acción principal — cualquier fallo al escribir se logea en consola pero no re-lanza. Ver docs/plans/2026-04-14-audit-log.md.
Recuperación de contraseña: /login tiene link "¿Olvidaste tu contraseña?" → /forgot-password (solicita token por email) → /reset-password?token=xxx (nueva password). Backend: tabla password_reset_tokens (32 bytes hex, 1h expiry, single-use), endpoints POST /auth/password-reset/{request,confirm} con rate limits 3/h y 10/h. Anti-enumeration (respuesta genérica). Al confirmar reset se borran todos los refresh tokens del user. SMTP logea a consola en dev — copia el link del log del API para testing local. Ver docs/plans/2026-04-14-password-reset.md.
Onboarding auto-dismiss: /onboarding muestra los pasos de configuración inicial (cuenta → contribuyente → FIEL → CSD → equipo opc. → plan opc.) para owner/cfo/contador. Deja de mostrarse cuando se cumple cualquiera de las dos condiciones (lo que pase primero): (1) User.loginCount > 4 — incrementado solo en auth.service.login(), NO en refresh, para que el threshold sea sesiones reales y no horas; (2) User.onboardingDismissedAt != null — seteado por POST /auth/onboarding/dismiss (idempotente, preserva timestamp original) que la página /onboarding invoca automáticamente cuando todos los pasos requeridos están completos. Helper compartido en apps/web/lib/onboarding.ts:shouldShowOnboarding(user) con ONBOARDING_LOGIN_THRESHOLD = 4 exportado. El LoginResponse.user incluye ambos campos para que el frontend decida sin round-trip extra. Roles cliente/auxiliar/supervisor y admin global nunca ven onboarding (van directo a /dashboard o /clientes). Ver docs/plans/2026-05-02-session.md sección 23.
Rate limiting por endpoint (middlewares/rate-limit.middleware.ts): 4 tiers con key por userId (no IP, para no bloquear usuarios detrás de NAT compartido). Admin global (superset platform_admin/platform_ti) exento vía skip. Tiers: veryStrictLimit (2/día) en sync SAT manual y opinión cumplimiento; strictLimit (10/h) en emisión/cancelación factura, CFDI bulk, subs subscribe/change/upgrade, password-change; normalLimit (100/15min) a router level en dashboard/reportes/impuestos; relaxedLimit (500/15min) en catalogos/regimenes. /auth/login y /auth/register conservan sus propios limiters específicos. Headers RateLimit-* estándar. Key generator: usa ipKeyGenerator(req.ip) de express-rate-limit para el fallback anónimo — normaliza IPv6 correctamente (sin esto el lib emitía warning de potential bypass). Frontend (lib/api/client.ts) preserva el message del 429 para los try/catch existentes. Ver docs/plans/2026-04-14-rate-limiting-expansion.md.
Input validation con Zod (defense-in-depth): todos los controllers críticos que reciben req.body validan el shape con Zod antes de pasar al service. Prisma ya protege de SQL injection, pero Zod evita que un payload malformado llegue hasta la capa de persistencia y produzca 500s o comportamiento raro. Cobertura: auth.controller (register/login/refresh/reset/change/logout-all/switch-tenant), bancos.controller (create/update), alertas.controller (create/update), calendario.controller (create/update recordatorio), usuarios.controller (invite/update/updateGlobal), tenants.controller (addMyTenant), subscription.controller (varios), platform-staff.controller. Patrón uniforme: schema arriba del archivo, .parse(req.body) en el handler, catch z.ZodError → next(new AppError(400, error.errors[0].message)). Los schemas tipo UserInvite/UserUpdate en @horux/shared se mantienen alineados con los Zod enums.
Helmet configurado explícitamente (app.ts): helmet({ contentSecurityPolicy: false, crossOriginResourcePolicy: 'cross-origin' }). CSP se desactiva en el API porque (a) el API solo sirve JSON y binarios (PDF/XML) sin contenido HTML que requiera CSP, y (b) el default chocaba con el iframe del PDF que el frontend embebe en /terminos. Los security headers del frontend (Next) siguen siendo la capa de CSP activa: ver apps/web/next.config.js con X-Frame-Options: SAMEORIGIN, Content-Security-Policy: frame-ancestors 'self', X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, HSTS.
Multi-tenant memberships (owner-multi-rfc, en progreso): Nueva tabla tenant_memberships (user_id, tenant_id, rol_id, is_owner, active, joined_at) con @@unique([userId, tenantId]). Permite que un mismo user pertenezca a múltiples tenants con rol distinto en cada uno. Backfilled idempotente desde User.tenantId+rolId (1 membership por user existente). User.tenantId y User.rolId se mantienen durante la transición como "default tenant" para UX al login. Login y refresh responses incluyen user.tenants: [{id, nombre, rfc, plan, role, isOwner}] derivado de memberships activas. Endpoint POST /auth/switch-tenant { tenantId, refreshToken } valida membership, revoca el refresh actual, emite nuevo par apuntando al target con el rol de ese tenant (audit event user.tenant_switched). Helpers en apps/api/src/utils/memberships.ts: getUserTenants(), verifyMembership(). Frontend: MembershipSwitcher en el header (apps/web/components/membership-switcher.tsx) visible cuando tenants.length > 1 y NO es admin global; click llama switchTenant + queryClient.clear() + reload. Coexiste con TenantSelector (admin global usa impersonación X-View-Tenant, modelo distinto). Página /mis-empresas (owner-only) lista solo tenants donde isOwner=true con su subscripción joined; modal "Agregar empresa" llama POST /api/tenants/mine (gateado por isOwnerSomewhere(userId) en backend → 403 si no eres owner en ningún lado). Sidebar item "Mis empresas" usa flag requireOwnerSomewhere (no roles[]) para que un híbrido contador+owner siga viéndolo desde cualquier contexto activo. Helpers: addTenantToOwner(), getMyTenantsDetailed() en tenants.service.ts; isOwnerSomewhere(), getTenantOwnerEmail() en utils/memberships.ts. Trial gate por owner: startTrial() acepta ownerUserId opcional — si se pasa, busca otro tenant donde el user es owner activo Y tiene trialEndsAt set → si encuentra, rechaza con mensaje citando el RFC previo. Combinado con trial_usages (gate por RFC), cubre todos los vectores de abuso del trial. F6 cleanup completo (2026-04-14): User.tenantId y User.rolId eliminados del schema. User.lastTenantId String? agregado para "remember last tenant" UX. auth.login resuelve activeMembership desde tenant_memberships (prefiere lastTenantId, fallback al primer membership por joinedAt). 5 callsites de subscription.service migrados a getTenantOwnerEmail(). usuarios.service todo via memberships. Fases 1-6 ✅ (refactor completo). Ver docs/plans/2026-04-14-owner-multi-rfc-subscriptions.md.
Sistema de emails (apps/api/src/services/email/): transporte SMTP via Nodemailer (config en .env: SMTP_HOST/PORT/USER/PASS). Si SMTP no está configurado, los emails se logean a consola — útil en dev. 9 templates en templates/: welcome, password-reset, payment-confirmed, payment-failed, subscription-cancelled, subscription-expiring, fiel-notification, new-client-admin, weekly-update. Layout común en base.ts con identidad visual de horux360.com: header con gradiente azul→morado (#2563EB → #7C3AED), tipografía Inter (Google Fonts con fallback a system-ui), tokens de marca exportados como BRAND_COLORS. Helpers primaryButton, infoBox, heading para componentes consistentes. Importante: las familias de fuentes en style="..." deben usar comillas simples ('Inter', ...) — comillas dobles rompen el atributo HTML. Para previsualizar sin SMTP: pnpm email:preview genera HTMLs estáticos en apps/api/email-previews/ (gitignored) con datos de ejemplo + index navegable. Ejecutar tras cualquier cambio de template para verificar visualmente.
Reporte semanal automático (weekly-update.job.ts): cron Lunes 8:00 AM (0 8 * * 1 America/Mexico_City) que itera todos los tenants activos y envía a cada owner activo el correo "Actualización semanal — {empresa}". Solo arranca en NODE_ENV=production. Contenido: KPIs del mes en curso (ingresos/egresos/utilidad/IVA balance/CFDIs emitidos+recibidos vía getKpis()), alertas activas (rojo/ámbar/azul por prioridad vía generarAlertasAutomaticas()), y breakdown de discrepancias de régimen por mes en los últimos 6 meses (getDiscrepanciasPorMes(pool, tenantId, 6)). Recipientes resueltos vía tenant_memberships.where(isOwner=true, active=true). Por-tenant + por-recipient try/catch — un fallo individual no bloquea al resto. sendWeeklyUpdateForTenant(tenantId) exportado para disparo manual desde un endpoint admin futuro.
Revocación de JWT (tokenVersion): User.tokenVersion (entero, default 0) se incluye en cada JWT. authenticate middleware compara payload.tokenVersion contra el actual en BD (cache 30s + broadcast PM2 via process.send). Incrementar el version invalida todos los access tokens del user en el siguiente request. Disparadores: (1) cambio de password autenticado (POST /auth/password-change), (2) "cerrar todas las sesiones" (POST /auth/logout-all), (3) confirmar password reset (POST /auth/password-reset/confirm). UI en /configuracion/seguridad. Backward compat: JWTs pre-deploy no incluyen el campo → se interpreta como 0 → matchea con default, no hay re-login forzado masivo. Ver docs/plans/2026-04-14-jwt-revocation.md.
Lógica fiscal (lo más complejo)
| Archivo | Qué hace |
|---|---|
src/services/dashboard.service.ts |
KPIs: ingresos/egresos/IVA/adquisición mercancías por régimen, IVA a favor acumulado. Filtro de conciliación. |
src/services/impuestos.service.ts |
IVA mensual, resumen IVA/ISR, coeficiente de utilidad |
src/services/cfdi.service.ts |
CRUD CFDIs, filtros, búsqueda, resúmenes, bulk insert |
src/services/conciliacion.service.ts |
Conciliación bancaria: vincular CFDIs con pagos |
src/services/bancos.service.ts |
CRUD bancos por tenant |
src/services/reportes.service.ts |
Estado de resultados, flujo efectivo, comparativo, CxP, CxC |
src/services/alertas-auto.service.ts |
Alertas automáticas: lista negra, concentración, discrepancias, cancelación retroactiva, TipoRelacion sospechoso, tareas próximas a vencer, RESICO PF cerca de límite anual |
src/services/calendario-fiscal.service.ts |
Genera calendario fiscal desde catálogo + días inhábiles |
src/services/regimen.service.ts |
Catálogo de regímenes, configuración activos/ignorados por tenant |
src/services/recordatorios.service.ts |
CRUD recordatorios custom por tenant (público/privado) |
src/services/tareas.service.ts |
Tareas operativas recurrentes por contribuyente con materialización lazy de periodos |
src/services/papeleria.service.ts |
Papelería de trabajo: archivos por contribuyente con flujo opcional de aprobación |
src/services/despacho-stats.service.ts |
Métricas del módulo Despacho: contribuyentes, mis-asignados, equipo (jerárquico) |
src/services/notification-preferences.service.ts |
Preferencias de email por contribuyente (JSONB en contribuyentes) |
Claves de concepto excluidas de cálculos fiscales
Los conceptos con las siguientes clave_prod_serv se excluyen de todos los cálculos de ingresos, egresos, IVA e ISR (dashboard + impuestos, base y conciliación):
84121603— Seguros93161608— Servicios gubernamentales85101501— Servicios de salud85121800— Servicios médicos
Implementado con subqueries correlacionados a cfdi_conceptos que restan los montos de conceptos excluidos por CFDI. Si un CFDI tiene otros conceptos válidos, solo se excluyen los que coinciden con estas claves.
Regla clave: régimen del tenant en queries fiscales
type = 'EMITIDO'→ el tenant es el emisor → usarregimen_fiscal_emisortype = 'RECIBIDO'→ el tenant es el receptor → usarregimen_fiscal_receptor
Alerta RESICO PF — límite anual ($3.5M Art. 113-E LISR)
alertaResicoPfLimiteIngresos en alertas-auto.service.ts aplica solo cuando el contribuyente: (1) tiene RFC de 13 chars (PF), y (2) tiene 626 en su CSV regimen_fiscal. El cálculo de ingresos NO filtra por régimen — el SAT considera ingresos acumulados de TODOS los regímenes para el límite del Art. 113-E ($3.5M). Thresholds: $2.5M (media), $3M (alta), $3.5M (alta + "ya saliste del régimen"). Suma I PUE + P − E PUE del año en curso usando total_mxn (con IVA — proxy conservador para alerta preventiva). RESICO PM (RFC 12) no aplica — ese régimen tiene reglas distintas y se filtra por longitud de RFC.
Filtro de Conciliación (Dashboard + Impuestos)
Toggle global que cambia el comportamiento de todas las métricas:
- Desactivado (default): Usa
fecha_emisiony todos los CFDIs vigentes - Activado: Solo CFDIs con
id_conciliacion IS NOT NULL, usafecha_de_pagode tablaconciliaciones - Implementado con
getFechaRango(conciliacion)que retorna el SQL fragment correcto - Afecta: ingresos, egresos, adquisición mercancías, IVA balance, IVA mensual, resumen IVA, resumen ISR, IVA a favor, regímenes del periodo, chart ingresos/egresos
Métricas del Dashboard
- Ingresos del Mes — Por régimen del emisor, filtrable
- Gastos del Mes — Por régimen del receptor, filtrable
- Adquisición de Mercancías — Egresos con uso_cfdi G01, por régimen, filtrable
- Utilidad — Ingresos - Egresos
- Balance IVA — Causado - Acreditable, por régimen
- CFDIs Emitidos/Recibidos — Contadores
- IVA a Favor — Acumulado anual e histórico (5 años)
Métricas de Impuestos
IVA: Trasladado, Acreditable, Retenido, Resultado, Acumulado Anual — todos filtrables por régimen con trasladadoPorRegimen, acreditablePorRegimen, retenidoPorRegimen
ISR: Ingresos acumulados, Deducciones, Base gravable, ISR causado, ISR retenido, ISR a pagar — todos filtrables por régimen con ingresosPorRegimen, deduccionesPorRegimen, baseGravablePorRegimen
Conciliación — reglas importantes
- Recibidos: excluye PPD (solo PUE y pagos)
- Emitidos: excluye PPD para todos los regímenes excepto 605 y 616
- Facturas tipo P: usan
monto_pago_mxnen vez detotal_mxn - Auto-conciliación PPD: cuando una factura P con
saldo_pendiente = 0se concilia, la PPD relacionada (viauuid_relacionado) se auto-concilia con los mismos datos. - Score cards: PPD auto-conciliadas tienen
montoMxn = 0para no duplicar montos
SAT — Sincronización
| Archivo | Qué hace |
|---|---|
src/services/sat/sat.service.ts |
Orquestación: sync jobs, chunking inteligente, saveCfdis, saveMetadata |
src/services/sat/sat-client.service.ts |
HTTP client SAT: query, verify, download. DocumentStatus 'active' para XMLs |
src/services/sat/sat-parser.service.ts |
Parseo de XML CFDI + metadata CSV del SAT |
src/services/fiel.service.ts |
FIEL: upload, encriptación AES-256-GCM, validación |
src/jobs/sat-sync.job.ts |
Cron 03:00 AM: sync automático para todos los tenants con FIEL |
Cuatro modos de sincronización:
| Modo | Cuándo | XMLs | Metadata |
|---|---|---|---|
initial |
Primera sync del tenant | Sondeo → bloques 3/6 meses | Bloques de 3 años |
daily |
Cron 3:00 AM (todos los planes) | Últimos 7 días | Año fiscal completo (ene → hoy) |
incremental |
Cron 11:00/15:00/19:00 (solo plan Enterprise) | Últimas 8 horas | Últimas 8 horas |
custom (rango personalizado) |
Botón en UI | Directo si ≤6m, bloques de 6m si >6m | Rango completo |
Incremental Enterprise: Reduce latencia de CFDIs nuevos a ~4h en horario laboral. Ventana de 8h cubre el gap 03:00→11:00 y solape con disparos siguientes (dedup por UUID). Requiere initial completado previamente — el job se omite si el tenant no tiene backfill. Ver docs/plans/2026-04-13-sat-incremental-enterprise.md.
Flujo de sincronización (initial):
- Sondeo: metadata del rango completo (2 solicitudes) → determina volumen
- XMLs vigentes: bloques de 6 meses (≤15k CFDIs) o 3 meses (>15k)
- Metadata vigentes+cancelados: bloques de 3 años
- XMLs se guardan en disco (
data/xmls/<rfc>/<tipo>/<packageId>/) antes de procesar - RFCs se upsert en tabla
rfcsconrfc_emisor_id/rfc_receptor_idFK - Metadata: inserta CFDIs cancelados sin XML, actualiza status de existentes
Retry automático en timeouts (con políticas por tipo):
- Poll interval: 60 segundos, máximo 45 intentos (45 min) →
MAX_POLL_ATTEMPTS - Tiempos de retry absolutos desde
startedAt(no desde el timeout): si la sync arrancó a las 3:00 y timeoutea a 3:45, el primer retry queda programado para 9:00 exacto, no 9:45. - Política por tipo (
RETRY_POLICIESensat.service.ts):daily→ 2 retries a T+6h, T+12hinitialconisCustomRange=true(rango UI) → 2 retries a T+6h, T+12hinitialbootstrap (sin fechas) → 3 retries a T+6h, T+12h, T+24hincremental→ 0 retries (próximo cron cada 4h cubre el gap, dedup por UUID)
- Cron horario revisa
pendingconnextRetryAt <= nowy reintenta. Tras agotar retries:failedcon mensaje "Fallo conexión SAT, vuelve a intentar con un rango de fechas menor."
Reuso de satRequestId en retries (sat_request_ids JSONB map): cada vez que requestAndDownload necesita un request al SAT, primero busca en job.satRequestIds[kindKey] (kindKey = ${requestType}-${tipoCfdi}-${dateFrom}-${dateTo}). Si encuentra uno, llama verifySatRequest directo: si está ready salta polling y descarga; si processing continúa polling con MISMO id; si failed/rejected o lanza excepción, fallback a crear nuevo. Esto evita agotar la cuota de solicitudes activas del SAT en reintentos. satRequestId (singular) sigue actualizándose al último creado para backward-compat de queries que ya lo leen. Persistencia atómica vía jsonb || jsonb SQL en persistSatRequestId().
Pool management: ctx.getPool() (no estático) para mantener lastAccess fresco durante syncs largos.
Facturapi — Emisión de facturas
| Archivo | Qué hace |
|---|---|
src/services/facturapi.service.ts |
Facturapi: organizaciones, CSD, clientes, emisión, cancelación, descargas, logo, color, envío email |
src/controllers/facturacion.controller.ts |
Endpoints: emitir, cancelar, PDF/XML, timbres, búsqueda RFCs, conceptos previos, datos fiscales, personalización |
src/controllers/catalogos.controller.ts |
Catálogos SAT: forma pago, uso CFDI, clave prod/serv (con búsqueda sin acentos), unidades, etc. |
src/routes/facturacion.routes.ts |
Rutas de facturación con tenantMiddleware |
src/routes/catalogos.routes.ts |
Rutas de catálogos SAT |
Modelo Facturapi:
- Una cuenta Horux360 (User Key
sk_user_...) con organizaciones por tenant tenants.facturapi_org_idvincula tenant ↔ organización Facturapi- API key de org se obtiene via HTTP directo (
/v2/organizations/{id}/apikeys/test) — el SDK tiene bugs con este endpoint - CSD (Certificado de Sello Digital) se sube por organización (no requerido en modo test)
- Timbres controlados por
timbre_suscripciones(50/mes o 600/año, pool del plan) +timbre_paquetes(compras adicionales, vigencia 1 año). Catálogo de paquetes vendibles entimbre_paquetes_catalogo(100/$200, 1000/$1400, 10000/$8600 por default, editable por admin global en/configuracion/precios-timbres). Pool mensual se resetea a 0 al final del periodo (cron diario 2:30 AM viaresetExpiredMonthlyTimbres, no acumulable).consumeTimbre(tenantId)en$transaction: primero pool mensual, luegoTimbrePaquetecon menorexpiraEn(FIFO anti-desperdicio). Compra self-serve en/facturacion/timbres(owner/cfo):POST /api/facturacion/timbres/paquetes/comprarcreaPayment(kind=timbres_pack)+ MP Preference conexternal_reference=timbres-pack:{paymentId}. El webhook MP aprueba el Payment, llamaactivarPaqueteTrasPago(creaTimbrePaqueteconexpiraEn = now + 1 año), y disparainvoicingService.emitInvoiceIfApplicableque ramifica porPayment.kind(concepto "{cantidad} timbres adicionales" vs "Suscripción {plan}").Payment.kindenum (subscription | timbres_pack) discrimina flujos en un solo modelo. - Al emitir: guarda en
cfdisconsource: 'facturapi'+facturapi_id, upsert RFCs con regimen/CP - Envío automático por email al receptor si tiene email configurado
- Cancelación: botón en
/cfdipara facturas consource='facturapi'y status vigente. Modal con los 4 motivos SAT (02 = errores sin relación, default; 01 = errores con relación, requiere UUID sustituto; 03 = no se llevó a cabo; 04 = incluida en factura global).POST /facturacion/cancelar/:uuidcon{ motive, substitution? }. El estatus en BD local pasa aCanceladoal confirmar, pero el SAT puede dejarla en "pendiente" si requiere aceptación del receptor (copy en el modal lo advierte). Distinta aDELETE /cfdi/:idque solo borra el registro local. - Personalización por organización: logo (PNG/JPG ≤2MB) y color (HEX)
Catálogos SAT en BD central (9 tablas):
cat_forma_pago(22),cat_metodo_pago(2),cat_uso_cfdi(24),cat_moneda(7)cat_clave_unidad(26),cat_clave_prod_serv(52,513 — importado de phpcfdi/resources-sat-catalogs),cat_objeto_imp(4)cat_tipo_relacion(7),cat_exportacion(4)- Búsqueda de
cat_clave_prod_servsoporta búsqueda sin acentos (regex PostgreSQL)
Tipos de comprobante soportados:
| Tipo | Secciones dinámicas |
|---|---|
| I - Ingreso | Conceptos con traslados/retenciones por concepto (IVA, ISR, IEPS, impuestos locales), exportación, serie/folio, condiciones, descuento, objeto de impuesto. Factura global (periodicidad, mes, año). Autocompletar receptor desde tabla RFCs. Búsqueda de conceptos previos en facturas. |
| E - Egreso | Igual que Ingreso + documento relacionado (UUID + tipo relación 01-04) |
| P - Pago | Solo comprobante (tipo+serie/folio) + receptor + complemento de pago. Autocompletar UUID desde CFDIs PPD pendientes del receptor con saldo. Desglose IVA del pago (base, tasa, monto). |
| T - Traslado | Solo comprobante (tipo+serie/folio) + receptor + conceptos sin precio/impuestos. Unidades filtradas (sin servicio). |
Recomendación automática de retenciones (tipo I):
- Emisor PF (13 chars RFC) con régimen 612, 626, o 606
- Receptor PM (12 chars RFC) con régimen distinto a 612/626/606
- Unidad de servicio (E48, ACT)
- → Auto-agrega: IVA retenido 10.6667% (2/3) + ISR retenido (1.25% RESICO, 10% otros)
Cliente extranjero:
- Detectado al escribir RFC
XEXX010101000 - Auto-llena: régimen 616, CP del tenant
- Campos adicionales: Tax ID extranjero + País (selector con ~50 países)
- Backend envía
countryen address, Facturapi agrega RFC genérico extranjero
Factura global:
- Toggle en tipo I que auto-llena receptor con PUBLICO EN GENERAL (XAXX010101000, régimen 616)
- Campos: periodicidad (day/week/fortnight/month/two_months), mes (01-18), año
- CP se auto-llena desde datos fiscales del tenant
Datos fiscales del tenant (tabla tenants):
- Columnas: codigo_postal, calle, num_exterior, num_interior, colonia, ciudad, municipio, estado, telefono
- Editable desde Configuración > Domicilio Fiscal
- Se usa para: CP en facturas globales, datos del emisor
Calendario y Recordatorios
| Archivo | Qué hace |
|---|---|
src/controllers/calendario.controller.ts |
GET eventos fiscales + recordatorios custom, CRUD recordatorios |
src/services/recordatorios.service.ts |
CRUD recordatorios en tabla recordatorios del tenant |
Recordatorios custom:
- Solo fechas únicas (sin recurrencia)
- Público (visible para todo el equipo) o privado (solo el creador)
- Admin y contador pueden crear/editar/eliminar
- Estilo morado en el calendario
- Se mezclan con eventos fiscales generados, ordenados por fecha
Recordatorios automáticos de e.firma:
- Al subir la FIEL, se crean 3 recordatorios públicos: 60, 30 y 7 días antes del vencimiento
- Prefijo
[e.firma]en el título para identificarlos - Al re-subir la FIEL, se eliminan los anteriores y se crean nuevos
- Solo se crean si la fecha no ha pasado
Documentos — Opinión de Cumplimiento
| Archivo | Qué hace |
|---|---|
src/services/opinion-cumplimiento.service.ts |
Orquestación: decrypt FIEL → Playwright headless → scrape PDF → parse → guardar en BD tenant |
src/services/sat/sat-opinion-login.ts |
Playwright: navegación portal SAT → login FIEL → reporte opinión |
src/services/sat/sat-opinion-scraper.ts |
4 estrategias para extraer PDF base64 del DOM Angular del SAT |
src/services/sat/sat-opinion-parser.ts |
Parseo de texto del PDF con regex (RFC, razón social, estatus, folio, cadena original) |
src/controllers/documentos.controller.ts |
Endpoints: listar opiniones, descargar PDF, trigger manual |
src/routes/documentos.routes.ts |
Rutas con tenantMiddleware + requireFeature('documentos') |
Cron semanal: Domingos 4:00 AM (0 4 * * 0). Descarga opinión para todos los tenants con FIEL. Limpia registros > 6 meses.
Seguridad FIEL: Archivos temporales con 0o600 en tmpdir con UUID. Cleanup garantizado en finally. Contraseña solo en memoria. Playwright headless. Timeout 3 min por tenant.
Almacenamiento: PDF completo en BYTEA en tabla opiniones_cumplimiento (BD tenant). 6 meses de retención. Frontend muestra últimas 5.
Alerta automática: Si última opinión no es Positiva → alerta alta en alertas automáticas.
Feature gate: documentos — disponible en planes Business y Enterprise.
Documentos — Declaraciones Provisionales
| Archivo | Qué hace |
|---|---|
src/migrations/tenant/003_create_declaraciones_provisionales.sql |
Tabla declaraciones_provisionales con 1 normal única por (año, mes) + N complementarias |
src/migrations/tenant/004_declaraciones_liga_pago_pdf.sql |
Reemplaza link_pago TEXT por pdf_liga_pago BYTEA + filename (la liga de pago es PDF, no URL) |
src/services/declaraciones.service.ts |
CRUD + auto-resolución de alertas por impuesto/mes + purge 5 años |
Flujo: el contador sube 3 PDFs por mes — declaración (obligatorio), liga de pago (opcional), comprobante de pago (opcional). Al subir la declaración se marcan como resueltos los recordatorios decl-<impuesto>-<mes> correspondientes a los impuestos seleccionados (IVA, ISR, IEPS, SUELDOS, DIOT, OTRO). Si es tipo complementaria, también se resuelven los recordatorios pago-* del mes (la complementaria sustituye a la normal en pago). Al subir comprobante de pago después, se resuelven los pago-* del mes. Retención 5 años (CFF Art. 30) purgada en cron lifecycle diario 2:30 AM. El flujo manual de "marcar como realizado" desde /alertas se mantiene para usuarios que no quieran subir documento.
UI: pestaña "Declaraciones Provisionales" en /documentos con selector de año, tabla mensual con badges tipo/impuestos y botones descarga/subir-pago/eliminar. Roles con upload: owner, cfo, contador, auxiliar.
Documentos — Constancia de Situación Fiscal (CSF)
| Archivo | Qué hace |
|---|---|
src/migrations/tenant/005_create_constancias_situacion_fiscal.sql |
Tabla con PDF BYTEA + datos JSONB (shape completo) + retención 5 años |
src/services/sat/sat-csf-login.ts |
Playwright: página pública SAT → popup SERVICIO → login FIEL (con retry dispatchEvent si el click sintético se pierde) |
src/services/sat/sat-csf-scraper.ts |
Busca "Generar Constancia" en cualquier frame() (vive en iframe JSF legacy rfcampc.siat.sat.gob.mx). 3 rutas: download event, popup viewer, response interception |
src/services/sat/sat-csf-parser.ts |
Parser PF+PM: labels key:value + 3 tablas (actividades/regímenes/obligaciones, agrupadas por "chunk termina en dd/mm/yyyy") + sellos |
src/services/constancia.service.ts |
Orquestación + sincronizarDatosFiscales(tenantId, csf) — auto-fill domicilio tenant (codigoPostal, calle, numExterior/Interior, colonia, ciudad, municipio, estado) y regímenes activos (matcheados contra catálogo regimenes por nombre normalizado) |
Cron mensual: Día 1 de cada mes 04:00 AM (0 4 1 * *). Descarga CSF para todos los tenants con FIEL. Por-tenant try/catch — un fallo no bloquea al resto.
Retención: 5 años purgados junto con declaraciones en cron lifecycle diario 2:30 AM.
Trigger on first-upload FIEL: En fiel.service.ts, al primer upload exitoso (existingFiel nulo o inactivo) se disparan en background Opinión de Cumplimiento + CSF con import() fire-and-forget. No bloquea la respuesta al usuario.
Headless por default: chromium.launch({ headless: true }). El fix clave es en sat-csf-login.ts: el click sintético a "e.firma" del portal SAT a veces no dispara el handler, por eso se espera a que aparezca input[type=file] (10s) y si no llega, reintenta con dispatchEvent('click'). Para debug visual temporal, setear SAT_HEADLESS=false en .env.
UI: pestaña "Constancia de Situación Fiscal" en /documentos con último CSF expandido (identificación, domicilio, regímenes activos, obligaciones), historial de 12 con detalle desplegable, descarga PDF, y botón "Consultar ahora" (owner/cfo). La UI refleja datos: JSONB de la BD sin re-parsear el PDF.
Shape ConstanciaSituacionFiscal: rfc, curp?, idCIF, nombre?/primerApellido?/segundoApellido?/razonSocial?, estatusPadron, fechaInicioOperaciones, lugarFechaEmision, domicilio (11 campos), actividadesEconomicas[], regimenes[], obligaciones[], cadenaOriginalSello, selloDigital.
Integración Metabase
apps/api/src/services/metabase.service.ts auto-registra cada BD postgres de tenant nueva en Metabase para BI (queries/dashboards). Auth con session token cacheado 13 días via POST /api/session → header X-Metabase-Session. Funciones expuestas: registerDatabase({nombre, dbName}) y deleteDatabase(databaseName) (busca por details.dbname o name contains, DELETE).
Integrado en tenants.service.ts en 3 puntos: createTenant, addTenantToOwner, deleteTenant. Todas son llamadas fire-and-forget con .catch() — un fallo de Metabase NO bloquea la creación/borrado del tenant.
7 variables .env (todas opcionales): METABASE_URL, METABASE_USERNAME, METABASE_PASSWORD, METABASE_PG_HOST, METABASE_PG_PORT, METABASE_PG_USER, METABASE_PG_PASSWORD. Sin METABASE_PASSWORD o METABASE_PG_PASSWORD, el service skip-ea cada llamada con un log [METABASE] Skipping... y el sistema funciona sin Metabase. No expone routes HTTP propias — es solo integración interna; los usuarios consultan dashboards directo en la URL del Metabase.
Frontend
| Archivo | Qué hace |
|---|---|
apps/web/lib/api/client.ts |
Axios instance con auto-refresh JWT + X-View-Tenant |
apps/web/stores/auth-store.ts |
Zustand: user, tokens, logout (persist localStorage) |
apps/web/stores/tenant-view-store.ts |
Zustand: impersonación de tenant (admin global) |
apps/web/stores/theme-store.ts |
Zustand: tema visual (light/dark) |
apps/web/lib/export-excel.ts |
Utilidad client-side para export Excel |
apps/web/lib/hooks/use-facturacion.ts |
Hooks para facturación + catálogos SAT |
apps/web/lib/hooks/use-calendario.ts |
Hooks para calendario + recordatorios |
Tenant-aware query keys: Todos los hooks de datos (use-dashboard, use-bancos, use-calendario, use-facturacion) incluyen viewingTenantId en los query keys de React Query para refetchear al cambiar de empresa.
Temas: Solo Light y Dark habilitados. Fondo Light = lavanda sutil (270 50% 98%).
Convenciones importantes
Cálculos fiscales por régimen
Los ingresos/egresos/IVA se calculan de forma diferente según el grupo de régimen:
- PF Empresarial (606, 612, 621, 625, 626): Facturas PUE + Pagos - Notas de crédito PUE
- Sueldos (605): Nóminas recibidas PUE
- PM y otros (601, 603, 607...): Facturas PUE+PPD - Notas de crédito PUE
Los montos se calculan sin impuestos (total - IVA trasladado - IEPS - impuestos locales).
Los montos en MXN se usan siempre para cálculos (campo _mxn).
Convenciones de BD
- BD central: nombres en
snake_casemapeados por Prisma acamelCase - BD tenant: SQL directo, alias explícitos en queries (
rfc_emisor as "rfcEmisor") CFDI_SELECTconstant encfdi.service.tsdefine todos los campos mapeados- Status vigente:
WHERE status NOT IN ('Cancelado', '0')
Tenant provisioning
Al crear un tenant (TenantConnectionManager.provisionDatabase(rfc)):
- Crea BD
horux_<rfc_lowercase> - Ejecuta
createTables(): crearfcs,bancos,cfdis,cfdi_conceptos,conciliaciones,alertas,recordatorios - Ejecuta
createIndexes(): índices B-tree + trigram (pg_trgm) + FK diferida paraid_conciliacion
Tablas por tenant
| Tabla | Propósito |
|---|---|
rfcs |
Catálogo de RFCs (id, rfc, razon_social, regimen_fiscal, codigo_postal) |
bancos |
Cuentas bancarias (id, banco, terminacion_cuenta) |
cfdis |
Facturas electrónicas (100+ columnas, incluye conciliado, id_conciliacion, facturapi_id, source, cfdi_tipo_relacion, cfdis_relacionados, saldo_pendiente_mxn) |
cfdi_conceptos |
Líneas de detalle por CFDI |
cfdi_descartados |
CFDIs marcados como ignorados por tipo de alerta (whitelist por contador) |
conciliaciones |
Registros de conciliación (id, anio, mes, id_cfdi, fecha_de_pago, id_banco) |
alertas |
Alertas manuales persistidas |
recordatorios |
Recordatorios custom del calendario (título, fecha, público/privado, creado_por) |
entidades_gestionadas |
Entidades del despacho (clientes/contribuyentes) — tipo, nombre, supervisor_user_id |
contribuyentes |
Contribuyentes con FK a entidades_gestionadas (rfc, regimen_fiscal CSV, domicilio, email_preferences jsonb) |
carteras |
Carteras del despacho con supervisor_user_id, auxiliar_user_id, parent_id (subcarteras) |
cartera_entidades |
M:N cartera ↔ contribuyente |
cartera_auxiliares |
M:N cartera ↔ auxiliar (legacy, ahora en auxiliar_user_id directo) |
auxiliar_supervisores |
Override 1:1 auxiliar → supervisor (editado desde /usuarios) |
obligaciones_contribuyente |
Catálogo de obligaciones por contribuyente |
obligacion_periodos |
Instancias mensuales de cada obligación; estado completada |
tareas_catalogo |
Tareas operativas recurrentes por contribuyente (semanal a anual) |
tarea_periodos |
Instancias materializadas con fecha_limite y estado |
papeleria_trabajo |
Archivos del despacho (PDF/Word/Excel ≤5MB) por contribuyente con flujo opcional de aprobación |
declaraciones_provisionales |
PDFs de declaraciones por (contribuyente, año, mes, tipo) con liga de pago + comprobante de pago |
documentos_extras |
PDFs libres por contribuyente con categoría y descripción |
opiniones_cumplimiento |
Cache 6 meses de Opinión SAT (PDF + datos parseados) |
constancias_situacion_fiscal |
Cache 5 años de CSF (PDF + datos JSONB) |
facturapi_orgs |
Org Facturapi por contribuyente (api_key cacheada, csd_uploaded, last_lco_rejection_at) |
Campos source en cfdis
'manual'— Cargado por usuario via XML upload'sat'— Descargado del SAT via sync (tiene xml_original)'sat-metadata'— Solo metadata del SAT (sin XML, típicamente cancelados)'facturapi'— Emitido desde Horux360 via Facturapi (tiene facturapi_id)
Impersonación de tenant (X-View-Tenant)
El admin global puede ver/gestionar datos de otros tenants. Los endpoints que lo soportan usan:
- Backend:
tenantMiddleware+effectiveTenantId(req)=req.viewingTenantId || req.user!.tenantId - Frontend:
useTenantViewStore()+ tenant key en query keys de React Query
Rutas con tenantMiddleware: dashboard, cfdi, impuestos, reportes, conciliación, bancos, calendario, regímenes, fiel, sat, facturación.
Setup local
# Requisitos: Node 20+, pnpm 9+, PostgreSQL 16+
pnpm install
pnpm db:generate # Genera Prisma client
pnpm dev # Arranca API (4000) + Web (3000) via Turborepo
Bootstrap desde BD vacía (deploy fresco)
cd apps/api
pnpm prisma migrate deploy # schema central
pnpm db:seed # catálogos + tenant demo + roles
pnpm bootstrap:admin-global # tenant Horux 360 (HTS240708LJA)
pnpm import:lista-negra # opcional
bootstrap:admin-global usa tenantsService.createTenant() con overrides enterprise (cfdiLimit: -1, usersLimit: 10) + eleva la subscripción a authorized por 1 año. Imprime password temporal del admin en consola. Configurable vía HORUX_ADMIN_EMAIL / HORUX_ADMIN_NOMBRE.
Variables de entorno: apps/api/.env y apps/web/.env.local
BD de prueba: tenant EDE123456AB1 → BD horux_ede123456ab1
Credenciales demo:
- admin@demo.com / demo123 (admin)
- contador@demo.com / demo123 (contador)
- visor@demo.com / demo123 (visor)
Credenciales Horux 360:
- carlos@horuxfin.com / (password en consola del bootstrap) — owner + platform_admin
- ivan@horuxfin.com / (password en consola del bootstrap) — contador + platform_ti (TI superset)
Producción
- Servidor: Ubuntu 24.04, 22GB RAM, 8 cores
- Process manager: PM2 (fork mode)
- Proxy: Nginx con SSL (Let's Encrypt)
- Cron: SAT sync 03:00 AM, Backups 01:00 AM
- Deploy:
git pull → pnpm install → pnpm build → pnpm db:migrate-tenants → pm2 restart all
Problemas conocidos / pendientes
Typecheck limpio. pnpm typecheck pasa 0 errores en @horux/api y @horux/shared tras el cleanup del 2026-04-13. El proyecto corre con tsx watch que no valida tipos en runtime, por eso agregar pnpm typecheck a CI/pre-commit es recomendado para no re-acumular deuda. Ver docs/plans/2026-04-13-typescript-debt-cleanup.md para el inventario completo y decisiones descartadas (incluye por qué no se downgradeo @types/express@5 ni se migró a "type": "module").
Express version mismatch: @types/express@^5.0.0 corre sobre express@^4.21.0. La diferencia se manifiesta en req.params.id tipado como string | string[]. Solución aplicada: cast String(req.params.id) en los 8 controllers afectados. Cuando migren a Express 5, los casts siguen siendo correctos.
Facturapi SDK interop: el paquete facturapi@4.14.2 no declara "exports" en su package.json, solo main/module. Esto impide migrar el api a "type": "module" sin workarounds en cada import Facturapi from 'facturapi' (el default export se trata como namespace bajo ESM estricto). tenant-migrations.ts usa __dirname directo para evitar import.meta y mantenerse compatible con el modo CJS efectivo.
-
Schema drift multi-tenant:Resuelto. Migraciones SQL numeradas enapps/api/src/migrations/tenant/. Se aplican eager (pnpm db:migrate-tenants) en deploy y lazy (auto engetPool()) como safety net. Para agregar un cambio de schema tenant: crearNNN_description.sqlen el directorio de migraciones.Términos y Condiciones: fuente legal =
docs/legal/Terminos y condiciones.pdf. Pipeline:pnpm legal:sync(correscripts/extract-terminos.mjsusandopdf-parsev2 conPDFParse.getText()) copia el PDF aapps/web/public/legal/terminos-y-condiciones.pdfy generaapps/web/content/terminos.tscon el texto extraído (cheap insurance para indexado/búsqueda futura). Página/terminos(pública, fuera de(auth)/(dashboard)) embebe el PDF con<object type="application/pdf">— usa el viewer nativo del navegador (PDF.js/QuickLook) y preserva fidelidad total de formato (tipografía, tablas, listas). Header con "Descargar" (download attribute) y "Abrir" (nueva pestaña). Fallback dentro del object para móviles sin inline PDF: botones centrados. Register checkbox obligatorio con link a/terminos(target=_blank). Workflow para actualizar: reemplazar el PDF endocs/legal/con el mismo nombre →pnpm legal:sync→ commit los 3 artefactos (PDF fuente + PDF copia +terminos.ts).Security headers (
next.config.js): aplicados a todas las rutas para proteger contra clickjacking y otros vectores.X-Frame-Options: SAMEORIGIN+Content-Security-Policy: frame-ancestors 'self'impiden que sitios externos embeban Horux 360 en iframes propios (preservando el/terminosque embebe un PDF del mismo origen).X-Content-Type-Options: nosniffpreviene MIME sniffing.Referrer-Policy: strict-origin-when-cross-originevita leak de URLs completas al navegar externo.Strict-Transport-Security: max-age=31536000; includeSubDomainsfuerza HTTPS (ignorado en dev). Si en el futuro se necesita embeber Horux 360 en otro dominio propio (app móvil híbrida, portal partner), extenderframe-ancestors 'self' https://otro-dominio.BD central: usa Prisma migrations en
apps/api/prisma/migrations/. Baseline20260414152220_initial_schema_v0_9_2/migration.sqlconsolida todo el schema acumulado hasta v0.9.2. Para futuros cambios:pnpm prisma migrate dev --name <descripción>genera SQL versionado. En prod:pnpm prisma migrate deploy. NO usarprisma db pushen prod — se pierde el trail. -
Nómina (pendiente): Tipo de comprobante N no implementado en facturación. Requiere complemento de nómina con datos del empleado, percepciones, deducciones.
-
Carta Porte (pendiente): Complemento para facturas tipo T. Facturapi lo soporta en Beta. No implementado.
-
Notificaciones por email de alertas/recordatorios: El sistema de email existe (Nodemailer + Gmail) pero no envía alertas/recordatorios automáticos. Solo bienvenida, pagos, notificaciones admin y facturas (via Facturapi).
-
Catálogo c_ClaveProdServ: 52,513 registros importados desde phpcfdi/resources-sat-catalogs. Si se necesita actualizar, re-importar desde la BD SQLite del release.
-
SMTP local: No configurado en desarrollo. Emails se logean a consola. Configurar
SMTP_USERySMTP_PASSen.envpara envío real.