Files
HoruxDespachos/CLAUDE.md
2026-04-27 22:09:36 -06:00

46 KiB
Raw Permalink Blame History

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 Facturapi
  • horux_<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)
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/precios
  • platform_sales — Crear/editar clientes; NO precios
  • platform_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 persistente trial_usages — sobrevive borrado/recreación del tenant, bloquea abuso).
  • subscribe — crea preapproval MP con auto_recurring mensual (months/1) o anual (months/12).
  • change — downgrades y cambios de frecuencia van a pendingPlan/pendingEffectiveAt = currentPeriodEnd.
  • upgrade — plan más caro con misma frecuencia cobra prorateo inmediato vía MP Preference + updatePreapprovalAmount al recibir webhook. external_reference = 'proration:${tenantId}:${subscriptionId}' es el marcador que el webhook usa.
  • cancelstatus=cancelled + cancelPreapproval en MP. Middleware respeta currentPeriodEnd.
  • reactivate — revive suscripción cancelada dentro del período pagado. Crea preapproval nuevo con start_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 al público en general vía Facturapi (org de Horux 360, RESICO PM, clave prod/serv 81112502, use: S01). El primer pago aprobado de cada tenant se skip-ea (lo factura manual el admin para capturar datos fiscales); los subsecuentes van auto. 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.

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.

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.ZodErrornext(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
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 — Seguros
  • 93161608 — Servicios gubernamentales
  • 85101501 — Servicios de salud
  • 85121800 — 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 → usar regimen_fiscal_emisor
  • type = 'RECIBIDO' → el tenant es el receptor → usar regimen_fiscal_receptor

Filtro de Conciliación (Dashboard + Impuestos)

Toggle global que cambia el comportamiento de todas las métricas:

  • Desactivado (default): Usa fecha_emision y todos los CFDIs vigentes
  • Activado: Solo CFDIs con id_conciliacion IS NOT NULL, usa fecha_de_pago de tabla conciliaciones
  • 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_mxn en vez de total_mxn
  • Auto-conciliación PPD: cuando una factura P con saldo_pendiente = 0 se concilia, la PPD relacionada (via uuid_relacionado) se auto-concilia con los mismos datos.
  • Score cards: PPD auto-conciliadas tienen montoMxn = 0 para 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):

  1. Sondeo: metadata del rango completo (2 solicitudes) → determina volumen
  2. XMLs vigentes: bloques de 6 meses (≤15k CFDIs) o 3 meses (>15k)
  3. Metadata vigentes+cancelados: bloques de 3 años
  4. XMLs se guardan en disco (data/xmls/<rfc>/<tipo>/<packageId>/) antes de procesar
  5. RFCs se upsert en tabla rfcs con rfc_emisor_id/rfc_receptor_id FK
  6. Metadata: inserta CFDIs cancelados sin XML, actualiza status de existentes

Retry automático en timeouts:

  • Poll interval: 60 segundos, máximo 30 intentos (30 min)
  • Si timeout: job queda pending con nextRetryAt en +6 horas
  • Cron horario revisa y reintenta (máx 3 veces)
  • Tras 3 fallos: "Fallo conexión SAT, vuelve a intentar con un rango de fechas menor."

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_id vincula 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 en timbre_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 via resetExpiredMonthlyTimbres, no acumulable). consumeTimbre(tenantId) en $transaction: primero pool mensual, luego TimbrePaquete con menor expiraEn (FIFO anti-desperdicio). Compra self-serve en /facturacion/timbres (owner/cfo): POST /api/facturacion/timbres/paquetes/comprar crea Payment(kind=timbres_pack) + MP Preference con external_reference=timbres-pack:{paymentId}. El webhook MP aprueba el Payment, llama activarPaqueteTrasPago (crea TimbrePaquete con expiraEn = now + 1 año), y dispara invoicingService.emitInvoiceIfApplicable que ramifica por Payment.kind (concepto "{cantidad} timbres adicionales" vs "Suscripción {plan}"). Payment.kind enum (subscription | timbres_pack) discrimina flujos en un solo modelo.
  • Al emitir: guarda en cfdis con source: '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 /cfdi para facturas con source='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/:uuid con { motive, substitution? }. El estatus en BD local pasa a Cancelado al confirmar, pero el SAT puede dejarla en "pendiente" si requiere aceptación del receptor (copy en el modal lo advierte). Distinta a DELETE /cfdi/:id que 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_serv soporta 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 country en 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.

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_case mapeados por Prisma a camelCase
  • BD tenant: SQL directo, alias explícitos en queries (rfc_emisor as "rfcEmisor")
  • CFDI_SELECT constant en cfdi.service.ts define todos los campos mapeados
  • Status vigente: WHERE status NOT IN ('Cancelado', '0')

Tenant provisioning

Al crear un tenant (TenantConnectionManager.provisionDatabase(rfc)):

  1. Crea BD horux_<rfc_lowercase>
  2. Ejecuta createTables(): crea rfcs, bancos, cfdis, cfdi_conceptos, conciliaciones, alertas, recordatorios
  3. Ejecuta createIndexes(): índices B-tree + trigram (pg_trgm) + FK diferida para id_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:

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.

  1. Schema drift multi-tenant: Resuelto. Migraciones SQL numeradas en apps/api/src/migrations/tenant/. Se aplican eager (pnpm db:migrate-tenants) en deploy y lazy (auto en getPool()) como safety net. Para agregar un cambio de schema tenant: crear NNN_description.sql en el directorio de migraciones.

    Términos y Condiciones: fuente legal = docs/legal/Terminos y condiciones.pdf. Pipeline: pnpm legal:sync (corre scripts/extract-terminos.mjs usando pdf-parse v2 con PDFParse.getText()) copia el PDF a apps/web/public/legal/terminos-y-condiciones.pdf y genera apps/web/content/terminos.ts con 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 en docs/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 /terminos que embebe un PDF del mismo origen). X-Content-Type-Options: nosniff previene MIME sniffing. Referrer-Policy: strict-origin-when-cross-origin evita leak de URLs completas al navegar externo. Strict-Transport-Security: max-age=31536000; includeSubDomains fuerza HTTPS (ignorado en dev). Si en el futuro se necesita embeber Horux 360 en otro dominio propio (app móvil híbrida, portal partner), extender frame-ancestors 'self' https://otro-dominio.

    BD central: usa Prisma migrations en apps/api/prisma/migrations/. Baseline 20260414152220_initial_schema_v0_9_2/migration.sql consolida 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 usar prisma db push en prod — se pierde el trail.

  2. Nómina (pendiente): Tipo de comprobante N no implementado en facturación. Requiere complemento de nómina con datos del empleado, percepciones, deducciones.

  3. Carta Porte (pendiente): Complemento para facturas tipo T. Facturapi lo soporta en Beta. No implementado.

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

  5. 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.

  6. SMTP local: No configurado en desarrollo. Emails se logean a consola. Configurar SMTP_USER y SMTP_PASS en .env para envío real.