# 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_` → 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/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. - `cancel` — `status=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 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` — 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` ### 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_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////`) 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 (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_POLICIES` en `sat.service.ts`): - `daily` → 2 retries a T+6h, T+12h - `initial` con `isCustomRange=true` (rango UI) → 2 retries a T+6h, T+12h - `initial` bootstrap (sin fechas) → 3 retries a T+6h, T+12h, T+24h - `incremental` → **0 retries** (próximo cron cada 4h cubre el gap, dedup por UUID) - Cron horario revisa `pending` con `nextRetryAt <= now` y reintenta. Tras agotar retries: `failed` con 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_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--` 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_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_` 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 ```bash # 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) ```bash 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. 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 ``** — 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 ` 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.