# Horux Despachos — Diseño **Fecha:** 2026-04-16 **Status:** Diseño aprobado; pendiente plan de implementación **Autor:** Carlos e Ivan (Horux 360) **Proyecto base:** Fork de Horux360 en `Downloads/Horux_despacho` --- ## 1. Overview ### 1.1 Qué es Horux Despachos es el pivote de Horux360 (SaaS fiscal para contribuyente individual) hacia una plataforma para **despachos contables mexicanos** que gestionan carteras de múltiples contribuyentes. A futuro (no MVP), la plataforma se extenderá a otras verticales profesionales (jurídica, arquitectura). ### 1.2 Por qué El mercado de despachos contables en México paga hoy por software fragmentado (Contpaq para contabilidad, CFDIMaster para timbrado, hojas de cálculo para gestión de clientes). Ninguna solución integra: - Gestión de cartera de clientes (RFCs) con roles (supervisor/auxiliar/cliente-visor). - Motor fiscal completo (CFDI, IVA/ISR/IEPS, conciliación, SAT sync, Facturapi) a nivel despacho. - Aislamiento de datos con opción BYO-DB (soberanía en infraestructura del despacho). ### 1.3 Alcance del MVP - **Vertical única:** Contable. - **Arquitectura multi-vertical preparada** para extensiones futuras sin refactor (core + vertical). - **Hosting híbrido:** backend + frontend centralizados en infra Horux; BD del despacho en su propia infra (BYO-DB) o en cluster Horux (Managed). - **Roles del despacho:** Owner, Supervisor (antes "Contador"), Auxiliar, Cliente (visor externo read-only). - **Autorización jerárquica:** supervisores con carteras de RFCs; auxiliares heredan acceso vía carteras; cascada al revocar acceso. - **Facturapi broker** (tu cuenta maestra) con una organización por contribuyente y pool único de timbres por despacho. - **Admins globales de plataforma** con dashboard cross-despacho, impersonación auditada, y audit log expuesto al despacho. - **Monorepo unificado** con refactor preparatorio para compartir código entre Horux360 actual y Horux Despachos. ### 1.4 Fuera de alcance (no-goals MVP) - Vertical jurídica y arquitectura (esqueleto de código sí, módulos funcionales no). - Módulo de nómina (tipo CFDI "N") y complemento Carta Porte (tipo "T"). - Row-Level Security (RLS) en Postgres — queda para Fase 2 como hardening. - 2FA obligatorio para roles no-admin — opcional en Fase 2. - Sharding multi-cuenta Facturapi — arquitectura lista, implementación pospuesta. - Engine de campos custom tipo Monday/Notion (opción C descartada en brainstorm). --- ## 2. Decisiones arquitectónicas clave Todas validadas en brainstorm 2026-04-16: | # | Decisión | Justificación | |---|----------|---------------| | 1 | **Hosting Opción A:** SaaS central + BD del despacho (BYO-DB o Managed) | Aprovecha infra del despacho → precio competitivo; admins globales acceden naturalmente con pool configurado. | | 2 | **Dos tiers de BD:** BYO-DB (barato) + Managed (premium) | Reutiliza lógica de Horux360 para Managed; agrega BYO como upsell comercial. | | 3 | **Roles:** Owner / Supervisor / Auxiliar / Cliente; Supervisor = titular de RFC con carteras bajo su scope; Auxiliares heredan acceso vía carteras; Owner como Supervisor implícito | Refleja estructura real de despachos; cascada de acceso elimina inconsistencias. | | 4 | **Connector BYO-DB:** Cloudflare Tunnel con imagen Docker `horux/connector` | Sin exponer Postgres al internet; onboarding con `docker run`; auto-update patch. | | 5 | **Facturapi:** cuenta maestra Horux como broker; 1 organización por contribuyente; pool único de timbres por despacho | Onboarding simple; modelo de ingresos con margen en timbres; consolida volumen con Facturapi. | | 6 | **Pricing:** Tiers fijos (Starter/Business/Enterprise) + Add-ons recurrentes + Paquetes one-shot | Máxima flexibilidad comercial sin refactor; MercadoPago preapproval por componente. | | 7 | **Código:** Monorepo unificado con refactor preparatorio (`packages/core`, `packages/vertical-contable`, etc.) | Fix-once-apply-twice entre Horux360 y Horux Despachos; evita deuda de mantenimiento dual. | | 8 | **Admins globales:** Dashboard cross-despacho + impersonación con motivo obligatorio + audit log expuesto al despacho | Requisito legal blando (LFPDPPP) + diferenciador comercial de confianza. | | 9 | **Clientes-visores:** password + magic link fallback; multi-RFC por cliente; multi-despacho con un mismo login | UX moderna; reduce soporte de password resets. | | 10 | **Multi-vertical:** arquitectura preparada desde MVP (core + vertical contable); futuras verticales se agregan sin refactor | Roadmap declarado de jurídica/arquitectura; costo de migrar después es prohibitivo. | | 11 | **Métricas pre-calculadas** (hot/cold): año actual on-the-fly; años cerrados almacenados; invalidación dirigida por eventos | Reduce costo de cómputo ~95% en dashboards y reportes; escala lineal con histórico. | --- ## 3. Arquitectura global ``` ┌───────────────────────────────────┐ │ INTERNET / USUARIOS │ │ (platform admins, owners, │ │ supervisores, auxiliares, │ │ clientes-visores) │ └──────────────────┬────────────────┘ │ HTTPS ▼ ┌──────────────────────────────────────────────┐ │ HORUX SAAS (infra central) │ │ │ │ ┌─────────────┐ ┌─────────────────┐ │ │ │ apps/web │─────▶│ apps/api │ │ │ │ Next.js 14 │ │ Express + Prisma│ │ │ └─────────────┘ └────────┬────────┘ │ │ │ │ │ ┌──────────────────┐ ┌───────┴────┐ ┌──────────┐ │ │ BD Central │ │ Cloudflare │ │Facturapi │ │ │ Postgres privado │ │ Tunnel │ │(broker │ │ │ (ver §4.1) │ │ Broker │ │ Horux) │ │ └──────────────────┘ └─────┬──────┘ └──────────┘ └───────────────────────────────┼────────────────────┘ │ tunnel saliente TLS │ (Postgres protocol) ▼ ┌──────────────────────────────────────────────────────────────┐ │ INFRAESTRUCTURA DEL DESPACHO (solo si dbMode=BYO) │ │ │ │ ┌─────────────────┐ ┌────────────────────┐ │ │ │ horux-connector │◀────────▶│ Postgres del │ │ │ │ (Docker image) │ │ despacho │ │ │ │ cloudflared + │ │ (schema per §4.2) │ │ │ │ heartbeat │ └────────────────────┘ │ │ └─────────────────┘ │ └──────────────────────────────────────────────────────────────┘ Despachos en modo MANAGED: Postgres del despacho vive en cluster Horux, sin connector; mismo schema aplicado por lazy migration. ``` ### 3.1 Principios arquitectónicos 1. **Dos BDs por despacho:** - **BD Central** (tu cluster, Postgres privado): metadata del despacho, identidad, suscripciones, audit log, telemetría agregada, pool de timbres. - **BD del Despacho** (BYO o Managed): datos operativos y fiscales (contribuyentes, CFDIs, FIEL, alertas, métricas). 2. **`TenantConnectionManager` refactorizado** (ya existe en Horux360): sigue siendo un `Map` con cache 5 min. El `connectionString` se lee descifrado de `Despacho.dbConnectionEnc` (KMS) en lugar de construirse como `horux_`. 3. **Admins globales** tienen acceso nativo vía el mismo pool (credenciales conocidas); impersonar es setear `despachoIdActivo` en el JWT. 4. **FK cross-BD lógicas, no físicas**: UUIDs globalmente únicos; validación a nivel aplicación; job de purga de huérfanos. 5. **Todo el tráfico Horux ↔ Postgres despacho va TRIPLE-cifrado**: Postgres SSL + TLS tunnel + TLS de red Cloudflare. ### 3.2 Stack tecnológico | Capa | Tecnología | Notas | |------|------------|-------| | Frontend | Next.js 14 App Router + Tailwind + shadcn/ui + Zustand + React Query | Reutiliza `apps/web` de Horux360. | | Backend | Node.js 20+ + Express 4 + TypeScript 5 + tsx | Reutiliza `apps/api`; refactor parcial a core + vertical-contable. | | BDs | PostgreSQL 16 | Central: Prisma 5.22 ORM. Tenant: pg client directo (raw SQL + migraciones SQL numeradas). | | Auth | JWT (access 15 min) + refresh (7 días, rotación) + bcrypt rounds=12 + magic link | Hereda de Horux360; agrega magic link como fallback. | | Email | Nodemailer + SMTP (Gmail Workspace o providers) | Igual que Horux360. | | Pagos | MercadoPago (preapproval + webhooks HMAC-SHA256) | Reutiliza stack; agrega multiple preapprovals por despacho (plan + addons). | | Cron | PM2 (fork mode) + node-schedule | Hereda de Horux360; jobs nuevos para métricas pre-calculadas. | | Tunnel BYO-DB | Cloudflare Tunnel (cloudflared) | Broker gratuito hasta 50 tunnels; upgrade path a Cloudflare One Teams o relay propio con Chisel. | | Secrets | KMS / libsodium sealed-box | `Despacho.dbConnectionEnc` + tokens del connector. Llave app-level separada de la de DB. | | CI/CD | Turborepo + Docker + Registry privado | Pipeline idéntico a Horux360. | --- ## 4. Modelo de datos ### 4.1 BD Central (Prisma) ```prisma // =================== IDENTIDAD =================== model User { id String @id @default(uuid()) email String @unique passwordHash String? // null si usuario solo usa magic link nombre String active Boolean @default(true) lastLogin DateTime? tokenVersion Int @default(1) lastDespachoId String? // auto-select al login si tiene solo 1 createdAt DateTime @default(now()) memberships DespachoMembership[] platformRoles UserPlatformRole[] clienteGrants ClienteAccessGrant[] magicLinkTokens MagicLinkToken[] invitationsSent Invitation[] @relation("invitedBy") auditLogsAsAdmin PlatformAuditLog[] @relation("actorLogs") } model DespachoMembership { id String @id @default(uuid()) userId String despachoId String role DespachoRole // OWNER | SUPERVISOR | AUXILIAR | CLIENTE active Boolean @default(true) joinedAt DateTime @default(now()) invitedById String? user User @relation(fields: [userId], references: [id]) despacho Despacho @relation(fields: [despachoId], references: [id]) @@unique([userId, despachoId]) } enum DespachoRole { OWNER SUPERVISOR AUXILIAR CLIENTE } model UserPlatformRole { id String @id @default(uuid()) userId String role PlatformRole // PLATFORM_ADMIN | PLATFORM_TI | PLATFORM_SUPPORT | PLATFORM_SALES | PLATFORM_FINANCE createdAt DateTime @default(now()) user User @relation(fields: [userId], references: [id]) @@unique([userId, role]) } enum PlatformRole { PLATFORM_ADMIN PLATFORM_TI PLATFORM_SUPPORT PLATFORM_SALES PLATFORM_FINANCE } // =================== DESPACHO + VERTICAL =================== model Despacho { id String @id @default(uuid()) rfc String @unique @db.VarChar(13) // RFC del despacho mismo razonSocial String domicilioFiscal Json verticalProfile VerticalProfile @default(CONTABLE) // CONTABLE | JURIDICO | ARQUITECTURA (futuras) active Boolean @default(true) createdAt DateTime @default(now()) trialEndsAt DateTime? // Conexión a BD del despacho dbMode DbMode // BYO | MANAGED dbConnectionEnc String // connection string cifrado (KMS) dbConnectionIv String dbSchemaVersion Int @default(0) // Connector (solo BYO) connectorTokenEnc String? connectorTunnelHostname String? connectorLastSeen DateTime? connectorVersion String? tags String[] // libre, para segmentación del admin global memberships DespachoMembership[] subscription Subscription? timbrePool TimbrePool? invitations Invitation[] clienteGrants ClienteAccessGrant[] auditLogs PlatformAuditLog[] heartbeats ConnectorHeartbeat[] migrationEvents MigrationAppliedEvent[] } enum VerticalProfile { CONTABLE JURIDICO ARQUITECTURA } enum DbMode { BYO MANAGED } // =================== PRICING =================== model Plan { id String @id @default(uuid()) codename String @unique // "starter_contable", "business_contable", ... nombre String verticalProfile VerticalProfile precioBase Decimal frecuencia Frecuencia // MENSUAL | ANUAL limits Json // { maxRfcs, maxUsers, timbresIncluidosMes, features: [...] } active Boolean @default(true) subscriptions Subscription[] } model PlanAddon { id String @id @default(uuid()) codename String @unique nombre String verticalProfile VerticalProfile? // null = aplica a todas precio Decimal frecuencia Frecuencia // MENSUAL | ANUAL | ONE_TIME delta Json // qué modifica: { maxRfcs: +50 } o { timbresIncluidosMes: +1000 } active Boolean @default(true) subscriptionAddons SubscriptionAddon[] } enum Frecuencia { MENSUAL ANUAL ONE_TIME } model Subscription { id String @id @default(uuid()) despachoId String @unique planId String mpPreapprovalId String? // MP preapproval del plan base status SubStatus // TRIAL | ACTIVE | PAST_DUE | CANCELLED currentPeriodStart DateTime currentPeriodEnd DateTime amount Decimal pendingPlanId String? // cambio programado pendingEffectiveAt DateTime? despacho Despacho @relation(fields: [despachoId], references: [id]) plan Plan @relation(fields: [planId], references: [id]) addons SubscriptionAddon[] } enum SubStatus { TRIAL ACTIVE PAST_DUE CANCELLED } model SubscriptionAddon { id String @id @default(uuid()) subscriptionId String planAddonId String mpPreapprovalId String? status SubStatus quantity Int @default(1) currentPeriodStart DateTime currentPeriodEnd DateTime subscription Subscription @relation(fields: [subscriptionId], references: [id]) planAddon PlanAddon @relation(fields: [planAddonId], references: [id]) @@unique([subscriptionId, planAddonId]) } // =================== TIMBRES =================== model TimbrePool { despachoId String @id incluidosMes Int // recalculado en renovación (plan + addons) consumidosMesActual Int @default(0) paquetesVigentes Int @default(0) // suma de TimbrePaquete con saldo > 0 updatedAt DateTime @updatedAt despacho Despacho @relation(fields: [despachoId], references: [id]) } model TimbrePaquete { id String @id @default(uuid()) despachoId String cantidad Int // 100, 1000, 10000 consumidos Int @default(0) precio Decimal mpPaymentId String? expiraEn DateTime // +1 año desde compra createdAt DateTime @default(now()) } model Payment { id String @id @default(uuid()) despachoId String subscriptionId String? addonId String? paqueteId String? mpPaymentId String? amount Decimal status String // pending | approved | rejected kind String // subscription | addon | paquete facturapiInvoiceId String? // CFDI emitido por Horux al despacho createdAt DateTime @default(now()) } model MercadoPagoEventLog { eventId String @id // id único de MP; garantiza idempotencia type String // preapproval | payment | subscription_authorized_payment payload Json processedAt DateTime @default(now()) error String? } // =================== TELEMETRÍA / CONNECTOR =================== model ConnectorHeartbeat { id String @id @default(uuid()) despachoId String timestamp DateTime @default(now()) latencyMs Int version String pgVersion String? status String // OK | DEGRADED | ERROR errorMsg String? despacho Despacho @relation(fields: [despachoId], references: [id]) } model SatSyncStatusEvent { id String @id @default(uuid()) despachoId String contribuyenteId String jobType String emitidos Int recibidos Int errores Int durationSec Int timestamp DateTime @default(now()) } model MigrationAppliedEvent { id String @id @default(uuid()) despachoId String scope String // "core" | "vertical-contable" | ... version Int appliedAt DateTime @default(now()) despacho Despacho @relation(fields: [despachoId], references: [id]) } // =================== AUDIT =================== model PlatformAuditLog { id String @id @default(uuid()) despachoId String actorUserId String accion String // IMPERSONATE_START | IMPERSONATE_END | UPDATE_CFDI | ROTATE_CONNECTOR_TOKEN | ... motivo String? // obligatorio al IMPERSONATE_START objetivo Json? // { entidad, id, diff? } ip String userAgent String timestamp DateTime @default(now()) despacho Despacho @relation(fields: [despachoId], references: [id]) actor User @relation("actorLogs", fields: [actorUserId], references: [id]) } // =================== AUTH / INVITACIONES =================== model Invitation { id String @id @default(uuid()) despachoId String email String role DespachoRole tokenHash String // SHA-256 del token visible invitedById String contribuyenteIds String[] // si role=CLIENTE, los RFCs pre-asignados expiresAt DateTime // 72h acceptedAt DateTime? createdAt DateTime @default(now()) despacho Despacho @relation(fields: [despachoId], references: [id]) invitedBy User @relation("invitedBy", fields: [invitedById], references: [id]) } model MagicLinkToken { id String @id @default(uuid()) userId String tokenHash String expiresAt DateTime // 15 min consumedAt DateTime? user User @relation(fields: [userId], references: [id]) } // =================== CLIENTES-VISORES (índice cross-BD) =================== model ClienteAccessGrant { id String @id @default(uuid()) userId String despachoId String contribuyenteId String // FK lógica a contribuyentes.entidad_id en BD despacho contribuyenteRfc String // denormalizado para UI active Boolean @default(true) grantedAt DateTime @default(now()) user User @relation(fields: [userId], references: [id]) despacho Despacho @relation(fields: [despachoId], references: [id]) @@unique([userId, despachoId, contribuyenteId]) } ``` ### 4.2 BD del Despacho (SQL migrations) Organización de archivos (ver §12 para detalles operacionales): ``` migrations/tenant/ ├── core/ # aplica a TODAS las verticales │ ├── 001_entidades_gestionadas.sql │ ├── 002_carteras.sql │ └── 003_cliente_accesos.sql └── verticales/ ├── contable/ # solo si verticalProfile=CONTABLE │ ├── 001_contribuyentes.sql │ ├── 002_cfdis.sql │ ├── 003_fiel.sql │ ├── ... │ ├── 012_metricas_mensuales.sql │ └── 013_metricas_acumuladas.sql ├── juridica/ # futuro └── arquitectura/ # futuro ``` **Schema — Core (universal):** ```sql CREATE TABLE entidades_gestionadas ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), tipo text NOT NULL, -- 'CONTRIBUYENTE' | 'EXPEDIENTE' | 'PROYECTO' nombre text NOT NULL, identificador text, -- RFC, num expediente (denormalizado para búsqueda) supervisor_user_id uuid, -- FK lógica a User central; NULL = Owner implícito active boolean DEFAULT true, created_at timestamptz DEFAULT now(), updated_at timestamptz DEFAULT now() ); CREATE INDEX ix_entidades_supervisor ON entidades_gestionadas(supervisor_user_id); CREATE INDEX ix_entidades_tipo ON entidades_gestionadas(tipo, active); CREATE TABLE carteras ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), supervisor_user_id uuid NOT NULL, -- FK lógica nombre text NOT NULL, descripcion text, created_at timestamptz DEFAULT now() ); CREATE TABLE cartera_entidades ( cartera_id uuid REFERENCES carteras(id) ON DELETE CASCADE, entidad_id uuid REFERENCES entidades_gestionadas(id) ON DELETE CASCADE, added_at timestamptz DEFAULT now(), PRIMARY KEY (cartera_id, entidad_id) ); CREATE TABLE cartera_auxiliares ( cartera_id uuid REFERENCES carteras(id) ON DELETE CASCADE, auxiliar_user_id uuid NOT NULL, added_at timestamptz DEFAULT now(), PRIMARY KEY (cartera_id, auxiliar_user_id) ); CREATE TABLE cliente_accesos ( user_id uuid NOT NULL, entidad_id uuid REFERENCES entidades_gestionadas(id) ON DELETE CASCADE, granted_at timestamptz DEFAULT now(), PRIMARY KEY (user_id, entidad_id) ); CREATE TABLE tenant_migrations ( scope text NOT NULL, -- 'core' | 'vertical-contable' | ... version int NOT NULL, applied_at timestamptz DEFAULT now(), PRIMARY KEY (scope, version) ); ``` **Schema — Vertical Contable (subtipo de entidades_gestionadas):** ```sql -- Subtipo contribuyente (mismo PK que la entidad base) CREATE TABLE contribuyentes ( entidad_id uuid PRIMARY KEY REFERENCES entidades_gestionadas(id) ON DELETE CASCADE, rfc varchar(13) UNIQUE NOT NULL, regimen_fiscal varchar(3) NOT NULL, codigo_postal varchar(5), domicilio jsonb ); -- FIEL por contribuyente (cifrada) CREATE TABLE fiel_credentials ( contribuyente_id uuid PRIMARY KEY REFERENCES contribuyentes(entidad_id) ON DELETE CASCADE, cert_enc bytea NOT NULL, cert_iv bytea NOT NULL, key_enc bytea NOT NULL, key_iv bytea NOT NULL, pass_enc bytea NOT NULL, pass_iv bytea NOT NULL, uploaded_at timestamptz DEFAULT now(), expires_at date ); -- Facturapi (una org por contribuyente dentro de cuenta maestra Horux) CREATE TABLE facturapi_organizations ( contribuyente_id uuid PRIMARY KEY REFERENCES contribuyentes(entidad_id) ON DELETE CASCADE, facturapi_org_id text NOT NULL UNIQUE, csd_uploaded boolean DEFAULT false, active boolean DEFAULT true, created_at timestamptz DEFAULT now() ); -- CFDIs (100+ columnas heredadas de Horux360, con FK a contribuyente_id NUEVO) CREATE TABLE cfdis ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), contribuyente_id uuid NOT NULL REFERENCES contribuyentes(entidad_id) ON DELETE CASCADE, uuid varchar(36) NOT NULL, tipo varchar(1), -- I | E | P | T tipo_comprobante varchar(1), status varchar(20), fecha_emision timestamptz, rfc_emisor varchar(13), rfc_receptor varchar(13), nombre_emisor text, nombre_receptor text, subtotal numeric(18,2), total numeric(18,2), regimen_fiscal_emisor varchar(3), regimen_fiscal_receptor varchar(3), iva_trasladado numeric(18,2), iva_retenido numeric(18,2), isr_retenido numeric(18,2), ieps_trasladado numeric(18,2), metodo_pago varchar(3), -- PUE | PPD forma_pago varchar(3), moneda varchar(3), tipo_cambio numeric(12,6), conciliado boolean DEFAULT false, id_conciliacion uuid, source varchar(20), -- manual | sat | sat-metadata | facturapi facturapi_id text, xml_original text, cancelled_at timestamptz, motivo_cancelacion varchar(2), created_at timestamptz DEFAULT now(), updated_at timestamptz DEFAULT now(), UNIQUE (contribuyente_id, uuid) ); CREATE INDEX ix_cfdi_contrib_fecha ON cfdis(contribuyente_id, fecha_emision DESC); CREATE INDEX ix_cfdi_status ON cfdis(status, contribuyente_id); CREATE TABLE cfdi_conceptos ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), cfdi_id uuid NOT NULL REFERENCES cfdis(id) ON DELETE CASCADE, contribuyente_id uuid NOT NULL, -- denormalizado para queries directas clave_prod_serv varchar(10), clave_unidad varchar(10), cantidad numeric(18,6), importe numeric(18,2), descripcion text, iva_trasladado numeric(18,2), -- etc. created_at timestamptz DEFAULT now() ); -- Tablas operativas adicionales (heredadas de Horux360 con FK contribuyente_id): -- alertas, conciliaciones, recordatorios, bancos, rfcs (catálogo de contrapartes), -- opinion_cumplimiento, constancias_situacion_fiscal, declaraciones_provisionales, -- sat_sync_jobs, timbre_consumos CREATE TABLE timbre_consumos ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), contribuyente_id uuid NOT NULL REFERENCES contribuyentes(entidad_id), cfdi_id uuid REFERENCES cfdis(id), creditos_usados int DEFAULT 1, timestamp timestamptz DEFAULT now() ); ``` **Schema — Métricas pre-calculadas (vertical contable):** ```sql CREATE TABLE metricas_mensuales ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), contribuyente_id uuid NOT NULL REFERENCES contribuyentes(entidad_id) ON DELETE CASCADE, anio smallint NOT NULL, mes smallint NOT NULL, regimen_fiscal varchar(3), -- NULL = agregado cross-régimen formula_version smallint DEFAULT 1, -- IVA (desglose DIOT-compatible) iva_trasladado_16 numeric(18,2) DEFAULT 0, iva_trasladado_8 numeric(18,2) DEFAULT 0, iva_trasladado_0 numeric(18,2) DEFAULT 0, iva_trasladado_exento numeric(18,2) DEFAULT 0, iva_trasladado_total numeric(18,2) DEFAULT 0, iva_acreditable numeric(18,2) DEFAULT 0, iva_retenido_cobrado numeric(18,2) DEFAULT 0, iva_retenido_pagado numeric(18,2) DEFAULT 0, iva_resultado numeric(18,2) DEFAULT 0, iva_a_favor_mes numeric(18,2) DEFAULT 0, -- ISR isr_ingresos_brutos numeric(18,2) DEFAULT 0, isr_deducciones_autoriz numeric(18,2) DEFAULT 0, isr_base numeric(18,2) DEFAULT 0, isr_causado numeric(18,2) DEFAULT 0, isr_retenido numeric(18,2) DEFAULT 0, isr_a_pagar numeric(18,2) DEFAULT 0, -- IEPS ieps_trasladado numeric(18,2) DEFAULT 0, ieps_acreditable numeric(18,2) DEFAULT 0, -- KPIs operativos cfdis_emitidos_count int DEFAULT 0, cfdis_recibidos_count int DEFAULT 0, cfdis_cancelados_count int DEFAULT 0, -- Estado de resultados (devengado vs realizado) ingresos_devengados numeric(18,2) DEFAULT 0, ingresos_cobrados numeric(18,2) DEFAULT 0, egresos_devengados numeric(18,2) DEFAULT 0, egresos_pagados numeric(18,2) DEFAULT 0, utilidad_devengada numeric(18,2) DEFAULT 0, utilidad_realizada numeric(18,2) DEFAULT 0, -- Flujo de efectivo flujo_entradas numeric(18,2) DEFAULT 0, flujo_salidas numeric(18,2) DEFAULT 0, flujo_neto numeric(18,2) DEFAULT 0, -- CxC / CxP (saldos al cierre del mes) cxc_saldo_final numeric(18,2) DEFAULT 0, cxp_saldo_final numeric(18,2) DEFAULT 0, cxc_cfdis_count int DEFAULT 0, cxp_cfdis_count int DEFAULT 0, -- Metadata cerrado boolean DEFAULT false, computed_at timestamptz DEFAULT now(), source_max_cfdi_at timestamptz, UNIQUE (contribuyente_id, anio, mes, regimen_fiscal) ); CREATE INDEX ix_metricas_contrib_anio ON metricas_mensuales(contribuyente_id, anio DESC, mes DESC); CREATE INDEX ix_metricas_cerrado ON metricas_mensuales(cerrado, computed_at); CREATE INDEX ix_metricas_drift ON metricas_mensuales(contribuyente_id, source_max_cfdi_at); CREATE TABLE metricas_acumuladas_anuales ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), contribuyente_id uuid NOT NULL REFERENCES contribuyentes(entidad_id) ON DELETE CASCADE, anio smallint NOT NULL, regimen_fiscal varchar(3), formula_version smallint DEFAULT 1, iva_a_favor_arrastrado numeric(18,2) DEFAULT 0, iva_a_favor_generado numeric(18,2) DEFAULT 0, iva_a_favor_aplicado numeric(18,2) DEFAULT 0, iva_a_favor_saldo numeric(18,2) DEFAULT 0, ingresos_anuales numeric(18,2) DEFAULT 0, deducciones_anuales numeric(18,2) DEFAULT 0, utilidad_anual numeric(18,2) DEFAULT 0, isr_causado_anual numeric(18,2) DEFAULT 0, isr_retenido_anual numeric(18,2) DEFAULT 0, isr_a_pagar_anual numeric(18,2) DEFAULT 0, cerrado boolean DEFAULT false, computed_at timestamptz DEFAULT now(), UNIQUE (contribuyente_id, anio, regimen_fiscal) ); CREATE TABLE metricas_por_contraparte_anuales ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), contribuyente_id uuid NOT NULL REFERENCES contribuyentes(entidad_id) ON DELETE CASCADE, anio smallint NOT NULL, rfc_contraparte varchar(13) NOT NULL, nombre_contraparte text, tipo char(1), -- 'E' (proveedor) | 'R' (cliente) subtotal numeric(18,2), total numeric(18,2), cfdis_count int, concentracion_pct numeric(5,2), computed_at timestamptz DEFAULT now(), UNIQUE (contribuyente_id, anio, rfc_contraparte, tipo) ); CREATE TABLE metricas_invalidaciones ( contribuyente_id uuid NOT NULL, anio smallint NOT NULL, mes smallint NOT NULL, reason text, marcado_at timestamptz DEFAULT now(), PRIMARY KEY (contribuyente_id, anio, mes) ); ``` ### 4.3 Principios del modelo 1. **`User` es central, no per-despacho**. Un email = un User. `DespachoMembership` N:M permite multi-despacho. 2. **FK cross-BD lógicas** (`supervisor_user_id`, `cartera_auxiliares.auxiliar_user_id`, `cliente_accesos.user_id`). Sin constraint físico; validación a nivel aplicación; job diario de purga de huérfanos. 3. **`ClienteAccessGrant` en BD central duplica `cliente_accesos` de cada BD despacho**. Permite al cliente-visor loguearse y ver sus despachos antes de conectar a N BDs. 4. **`contribuyente_id` FK NOT NULL en todas las tablas verticales contables**. Eje de autorización; omitirlo = fuga de datos intra-despacho. 5. **Patrón de herencia "single-PK inheritance"**: `contribuyentes.entidad_id = entidades_gestionadas.id` (mismo UUID). Queries cross-vertical usan `entidades_gestionadas`; queries específicas usan subtabla. 6. **Métricas cold (años pasados) en tablas dedicadas; hot (año actual) on-the-fly**. Invalidación dirigida via `metricas_invalidaciones` cuando hay cambios retroactivos. --- ## 5. Autenticación y autorización ### 5.1 JWT payload ```typescript interface AccessTokenPayload { userId: string; email: string; despachoId: string | null; // null = user aún no elige despacho role: DespachoRole | null; platformRoles: PlatformRole[]; impersonating: boolean; originalAdminUserId?: string; // si impersonating=true tokenVersion: number; iat: number; exp: number; // 15 min } interface RefreshTokenPayload { userId: string; family: string; // familia de rotación tokenVersion: number; exp: number; // 7 días } ``` ### 5.2 Middleware cascade Orden obligatorio en cada request: ```typescript // 1. authenticate — verifica JWT, carga User, valida tokenVersion req.user = { id, email, platformRoles } // 2. resolveDespacho — carga Despacho + pool desde TenantConnectionManager // Valida que el user tiene DespachoMembership activa O es PlatformAdmin impersonando req.despacho = { id, verticalProfile, dbMode, pool } req.role = 'OWNER' | 'SUPERVISOR' | 'AUXILIAR' | 'CLIENTE' | 'PLATFORM_ADMIN' // 3. authorize(allowedRoles, options) — chequeo de rol + feature flags authorize(['OWNER', 'SUPERVISOR']); requireFeature('MODULO_IA'); ``` **Orden crítico**: nunca asumir acceso antes de verificarlo. Invertir los dos últimos permite forzar `despachoId` arbitrario con JWT fabricado. ### 5.3 Autorización por rol ```typescript async function getEntidadesVisibles(req): Promise { const { user, despacho, role } = req; switch (role) { case 'OWNER': case 'PLATFORM_ADMIN': // impersonando return sql`SELECT id FROM entidades_gestionadas WHERE active = true`; case 'SUPERVISOR': return sql` SELECT id FROM entidades_gestionadas WHERE supervisor_user_id = ${user.id} AND active = true`; case 'AUXILIAR': return sql` SELECT DISTINCT ce.entidad_id FROM cartera_entidades ce JOIN cartera_auxiliares ca USING (cartera_id) JOIN entidades_gestionadas e ON e.id = ce.entidad_id WHERE ca.auxiliar_user_id = ${user.id} AND e.active = true`; case 'CLIENTE': return sql`SELECT entidad_id FROM cliente_accesos WHERE user_id = ${user.id}`; } } ``` **Regla de oro**: toda query a tablas del vertical incluye `WHERE contribuyente_id IN (getEntidadesVisibles())` vía helper `withTenancy(pool, req).query(sql, params)`. Lint rule bloquea queries sin `withTenancy` en CI. RLS (Row-Level Security) es hardening de Fase 2. ### 5.4 Flujo de login **Password o magic link:** ``` POST /auth/login { email, password } → bcrypt compare, emite JWT → si N memberships, emite con despachoId=null y responde { options: [...] } POST /auth/magic-link { email } → envía email con token (15 min) POST /auth/magic { token } → valida, emite JWT (misma lógica de memberships) ``` **Switch de despacho:** ``` POST /auth/switch-despacho { despachoId } → valida membership/grant; revoca refresh token actual; emite JWT nuevo → actualiza User.lastDespachoId ``` ### 5.5 Flujo de impersonación ``` POST /admin/impersonate-despacho { despachoId, motivo } // motivo OBLIGATORIO → valida PlatformRole en [ADMIN, TI, SUPPORT] → PlatformAuditLog: IMPERSONATE_START → JWT con impersonating=true, originalAdminUserId, role=PLATFORM_ADMIN ... admin opera; cada mutación escribe PlatformAuditLog entry ... ... sesión expira automáticamente a 2h sin actividad ... POST /admin/impersonate-stop → PlatformAuditLog: IMPERSONATE_END { duracion_seg, acciones_count } → JWT sin impersonación ``` ### 5.6 Flujo de invitación (Cliente-visor) ``` POST /despachos/:id/invitations { email, role: 'CLIENTE', entidadIds: [...] } → valida caller es OWNER o SUPERVISOR con acceso a las entidades → crea Invitation (hash del token, 72h) → email al cliente POST /auth/accept-invite { token, password? } → valida; si User existe (multi-despacho), solo agrega membership + grants → si no existe, crea User; agrega membership + grants → INSERT cliente_accesos en BD despacho → login automático ``` ### 5.7 Seguridad - Password policy: ≥10 chars + mix alfanumérico + bcrypt rounds=12. - Rate limiting: login 5 req/min/IP; magic link 3 req/min/email; accept-invite 10 req/hora/IP. - 2FA TOTP **obligatorio** para PLATFORM_ADMIN desde MVP; opcional para OWNER en Fase 2. - Refresh token rotation con detección de reuso (kill family). - CORS restrictivo; CSP estricto en panel admin. - Magic link es canal único de recovery (sin SMS en MVP). --- ## 6. Performance: métricas pre-calculadas ### 6.1 Principio hot/cold - **Año actual = hot**: on-the-fly sobre `cfdis`. - **Años pasados = cold**: lectura directa de `metricas_mensuales` y `metricas_acumuladas_anuales`. No se recalculan salvo invalidación dirigida. ### 6.2 Drill-down Las métricas almacenan solo valores agregados. Los CFDIs que las compusieron se reconstruyen on-demand con la MISMA función computadora: ```typescript type MetricComputer = (pool, contribuyenteId, anio, mes, regimen) => Promise<{ value: number; cfdiIds: string[]; rawConceptos?: string[]; }>; ``` Flujo: `GET /metricas/drilldown?metric=iva_acreditable&contribuyenteId=X&anio=2024&mes=1` → invoca el computador → devuelve valor + lista de CFDIs + validación de drift via `source_max_cfdi_at`. **`formula_version`** int en cada fila permite invalidación masiva si la lógica fiscal cambia (ej. resolución miscelánea SAT). Bumping → job nocturno recomputa; NO toca filas con la versión nueva. ### 6.3 Jobs programados (por despacho) | Job | Cron | Qué hace | |-----|------|----------| | Cierre mensual | Día 1 de cada mes, 03:00 | Calcula métricas del mes recién terminado; inserta con `cerrado=false`. | | Cierre anual | 1 enero, 04:00 | Marca meses del año anterior `cerrado=true`; consolida en `metricas_acumuladas_anuales`. | | Invalidación dirigida | Diario, 02:00 | Procesa `metricas_invalidaciones`, recomputa los (contrib, año, mes) listados, borra la entrada. | | Cálculo de contrapartes | Día 2 de cada mes, 03:30 | Recomputa `metricas_por_contraparte_anuales` del año actual. | ### 6.4 Invalidación retroactiva Hook en `CfdiService.insert/update/cancel`: ```typescript async function afterCfdiChange(cfdi, pool) { const anio = new Date(cfdi.fecha_emision).getFullYear(); const anioActual = new Date().getFullYear(); if (anio < anioActual) { await pool.query(` INSERT INTO metricas_invalidaciones (contribuyente_id, anio, mes, reason) VALUES ($1, $2, $3, 'CFDI_CHANGE') ON CONFLICT (contribuyente_id, anio, mes) DO UPDATE SET marcado_at = now() `, [cfdi.contribuyente_id, anio, new Date(cfdi.fecha_emision).getMonth() + 1]); } } ``` ### 6.5 Reportes desde métricas | Reporte | Fuente | |---------|--------| | Estado de Resultados mensual | `metricas_mensuales` (ingresos/egresos/utilidad) | | Flujo de Efectivo mensual | `metricas_mensuales` (flujo_entradas/salidas/neto) | | Comparativo año-contra-año | `metricas_acumuladas_anuales` × N años | | CxP / CxC | `metricas_mensuales` (cxp_saldo_final / cxc_saldo_final) + drill-down on-the-fly | | IVA a favor últimos 5 años | `metricas_acumuladas_anuales.iva_a_favor_saldo` | | Declaración provisional IVA/ISR mensual | `metricas_mensuales` del mes + validaciones SAT | --- ## 7. Facturapi + pool de timbres ### 7.1 Arquitectura - Cuenta maestra Facturapi única (Horux). - 1 organización Facturapi por Contribuyente (dentro de cuenta maestra). - Cada organización: su propio CSD subido, logo, series. - Pool único de timbres por Despacho (en `TimbrePool` de BD central). ### 7.2 Flujo de emisión ```typescript async function emitirCFDI(req, invoiceData) { assertRole(req, ['OWNER', 'SUPERVISOR', 'AUXILIAR']); await assertContribuyenteVisible(req, invoiceData.contribuyenteId); // 1. Decremento atómico del pool (write-ahead) const pool = await centralPool.query(` UPDATE timbre_pool SET consumidos_mes_actual = consumidos_mes_actual + 1 WHERE despacho_id = $1 AND (incluidos_mes - consumidos_mes_actual + paquetes_vigentes) > 0 RETURNING * `, [req.despacho.id]); if (pool.rowCount === 0) throw new Error('SIN_TIMBRES'); try { // 2. Llamada Facturapi const orgId = await getFacturapiOrgId(req.pool, invoiceData.contribuyenteId); const cfdi = await facturapi.emitInvoice(orgId, invoiceData); // 3. Persistir CFDI + timbre_consumos const cfdiId = await req.pool.query(`INSERT INTO cfdis (...) RETURNING id`).then(r => r.rows[0].id); await req.pool.query(`INSERT INTO timbre_consumos (contribuyente_id, cfdi_id, creditos_usados) VALUES ($1, $2, 1)`, [...]); return cfdi; } catch (err) { // Devolver timbre si Facturapi falló await centralPool.query(`UPDATE timbre_pool SET consumidos_mes_actual = consumidos_mes_actual - 1 WHERE despacho_id = $1`, [req.despacho.id]); throw err; } } ``` **Orden crítico**: decremento ANTES de Facturapi; devolución en catch. Invertir → leaks de timbres por webhooks perdidos. ### 7.3 Agotamiento y paquetes Al llegar a `consumidos >= incluidos + paquetes_vigentes`: UI bloquea emisiones; banner "Compra paquete". Paquetes (100 / 1000 / 10000) son one-shot via MercadoPago. Consumo FIFO por `expiraEn`. ### 7.4 Reset mensual Job día 1 a las 00:05: `UPDATE timbre_pool SET consumidos_mes_actual = 0` + recálculo de `incluidos_mes` según plan + addons activos. ### 7.5 Cancelación Roles: OWNER, SUPERVISOR, AUXILIAR (si plan lo permite). Motivos SAT: 01 (con relación), 02 (sin relación), 03 (op. no realizada), 04 (nominativa). El timbre NO se devuelve al pool — regla de Facturapi. UI debe comunicarlo. ### 7.6 Sharding (Fase 2) `FacturapiClient` recibe API key como parámetro (no constante global). Preparado para múltiples cuentas maestras (`FacturapiAccount` tabla central) cuando la cuenta se acerque a 5000 organizaciones. --- ## 8. Suscripciones y pagos ### 8.1 Tres capas de cobro | Capa | Frecuencia | Tabla | |------|-----------|-------| | Plan base | Mensual / Anual | `Subscription` (1 activa por despacho) | | Add-on recurrente | Mensual / Anual | `SubscriptionAddon` (N activos) | | Paquete one-shot | Único | `TimbrePaquete` (N comprados, 1 año vigencia) | Cada una con su propio MercadoPago preapproval_id o payment_id. ### 8.2 Estados de Subscription ``` TRIAL → ACTIVE (primer pago OK) TRIAL → CANCELLED (antes de pagar) ACTIVE → PAST_DUE (cobro falla) PAST_DUE → ACTIVE (reintento OK) PAST_DUE → CANCELLED (7 días sin pago) ACTIVE → (pending upgrade/downgrade) → ACTIVE (nuevo plan) ``` **Reglas**: - Upgrade: inmediato con prorrateo. - Downgrade: efectivo en próximo período. - Cancelación: 30 días read-only de grace; data no se borra automáticamente (requisito legal fiscal). ### 8.3 Webhook MercadoPago ```typescript POST /webhooks/mercadopago 1. Verifica firma HMAC-SHA256 contra MP_WEBHOOK_SECRET 2. Idempotencia: dedupe por event.id en MercadoPagoEventLog 3. Routing por event.type (preapproval | payment | subscription_authorized_payment) 4. Responde 200 rápido; trabajo pesado delegado a worker async 5. Reconciliación nocturna: job compara Subscription.status vs GET /preapproval/:id en MP ``` ### 8.4 Facturación a despachos (tus CFDIs) Cada Payment aprobado dispara emisión de CFDI desde Horux al despacho: - Organización Facturapi "Horux Despachos" (dentro de tu cuenta maestra) con tu RFC HTS240708LJA y tu CSD. - CFDI tipo "I" a nombre del despacho; `Payment.facturapiInvoiceId` lo liga. - UI del despacho permite descargar sus facturas de plan/addons/paquetes. ### 8.5 Trial - 30 días sin tarjeta al signup. - Plan "TRIAL" con limits capados (3 RFCs, 20 timbres, sin reportes premium). - Provisiona BD Managed temporal (sin importar tier final deseado). - Emails: día 7 (si wizard incompleto), día 25, día 28, día 31 (suspensión). - Al convertir a plan pagado, opción de migrar a BYO-DB (§10.6). --- ## 9. Connector BYO-DB ### 9.1 Arquitectura - Imagen Docker `horux/connector:vX.Y.Z` firmada con cosign, ejecuta como non-root. - Incluye `cloudflared` (Cloudflare Tunnel) + script de heartbeat. - Apunta a Postgres local del despacho (típicamente `localhost:5432` o `postgres:5432` de otro container). - Abre tunnel saliente TCP a `.tunnel.horux.mx` (registrado en cuenta Cloudflare Horux). ### 9.2 Onboarding Backend genera tunnel vía Cloudflare API + token del connector: ```typescript async function provisionConnector(despachoId) { const slug = slugify(despacho.razonSocial) + '-' + shortId(); const hostname = `${slug}.tunnel.horux.mx`; const tunnel = await cf.tunnels.create({ name: slug }); await cf.tunnels.configure(tunnel.id, { ingress: [{ hostname, service: 'tcp://postgres:5432' }, { service: 'http_status:404' }] }); const tunnelToken = tunnel.credentials; const horuxToken = await signHoruxToken(despachoId, { expiresIn: '1y' }); await updateDespacho(despachoId, { dbMode: 'BYO', connectorTunnelHostname: hostname, connectorTokenEnc: encrypt({ tunnelToken, horuxToken }), }); return { hostname, dockerRunCommand: `docker run -d --name horux-connector \ -e HORUX_TOKEN="${horuxToken}" \ -e CF_TUNNEL_TOKEN="${tunnelToken}" \ -e POSTGRES_HOST="postgres" \ -e POSTGRES_PORT="5432" \ horux/connector:latest` }; } ``` UI del despacho presenta el comando + inputs para credenciales Postgres (user/pass/db). Backend valida conectividad, aplica migraciones lazy. ### 9.3 Heartbeat Cada 30 seg: ``` POST /connector/heartbeat Authorization: Bearer { "version": "1.4.2", "uptime_seconds": ..., "postgres_ping_ms": ..., "last_migration": "contable_012" } ``` UI despacho: - ✅ Conectado (< 1 min) - ⚠ Lento (1-5 min) - 🔴 Desconectado (> 5 min) → banner obstructivo + email al owner. ### 9.4 Rotación y seguridad - `HORUX_TOKEN` expira cada 12 meses; rotación 1-click desde UI (grace 24h). - Postgres del despacho: pg_hba.conf restrictivo a conexión desde container connector. - Scope mínimo del token: solo heartbeat y descarga de updates. No lee data. - Triple-cifrado: Postgres SSL + tunnel TLS + TLS red Cloudflare. ### 9.5 Auto-update - Patch (1.4.x): silencioso. - Minor (1.x.0): notificación, manual. - Major (2.0.0): manual obligatorio; backend soporta v1.x por 6+ meses. ### 9.6 Migración Managed ↔ BYO Managed → BYO: pg_dump encriptado → despacho instala Postgres → pg_restore → instala connector → backend valida + catch-up migration → switch. Ventana read-only 20 min - 2h. BYO → Managed: inverso (raro; rollback contractual). --- ## 10. Admins globales + audit ### 10.1 Roles de plataforma | Capacidad | ADMIN | TI | SUPPORT | SALES | FINANCE | |-----------|-------|----|---------|-------|---------| | Dashboard cross-despacho | ✅ | ✅ | ✅ | ✅ | ✅ | | Impersonar (lectura) | ✅ | ✅ | ✅ | ❌ | ❌ | | Impersonar (escritura) | ✅ | ✅ | parcial | ❌ | ❌ | | Reiniciar connector / rotar token | ✅ | ✅ | ❌ | ❌ | ❌ | | Suspender despacho | ✅ | ❌ | ❌ | ❌ | ❌ | | Reembolsos / ajustes MP | ✅ | ❌ | ❌ | ❌ | ✅ | | Crear/editar Planes/Addons | ✅ | ❌ | ❌ | ✅ | ❌ | | Ver audit log cross-despacho | ✅ | ✅ | ✅ | ❌ | ❌ | SUPPORT parcial: puede editar tickets y notas internas; NO puede cambiar CFDIs, suscripciones ni permisos. ### 10.2 Dashboard cross-despacho Ruta `/admin/dashboard` (solo PlatformRole). Lee **solo de BD central** (sin tocar BDs de despachos). Contenido: - **KPIs**: despachos (total/activos/trial/cancel), MRR, timbres consumidos vs pool, signups 30d. - **Alertas operacionales**: connectors caídos, SAT sync con errores, timbres cerca de cap, cobros fallidos. - **Actividad reciente**: signups, upgrades, cancelaciones, compras de paquetes. - **Tabla de despachos** con filtros (vertical, plan, estado, tier DB, tags) → click → detalle + botón impersonar. ### 10.3 Eventos BD despacho → BD central | Evento | Cuándo | Destino | |--------|--------|---------| | `connector.heartbeat` | 30 seg | `ConnectorHeartbeat` | | `timbre.consumed` | emitir CFDI | `TimbrePool.consumidos_mes_actual++` | | `sat_sync.completed` | fin job SAT | `SatSyncStatusEvent` | | `migration.applied` | migración tenant | `MigrationAppliedEvent` + `Despacho.dbSchemaVersion` | | `subscription.change` | webhook MP | `Subscription` | Regla: solo eventos de operación del SaaS, nunca data fiscal del despacho. ### 10.4 Impersonación (Ver §5.5 para flujo.) Policies: - Motivo obligatorio (campo libre auditable). - Expiry automático 2h sin actividad. - SUPPORT lock read-only fuera de namespace `tickets`. - Toda mutación escribe `PlatformAuditLog` entry. ### 10.5 Audit log expuesto al despacho Endpoint `GET /despachos/:id/audit-log?from=X&to=Y` (role=OWNER). UI muestra lista de accesos de staff Horux con fecha, admin, rol, motivo, duración, acciones count. Descarga CSV. ### 10.6 Herramientas de soporte (internas) - **Runner de queries controladas** (set finito, no SQL arbitrario). - **Kill switch sesiones** del despacho. - **Rotar credenciales Postgres** del tunnel. - **Force resync SAT** por contribuyente. - **Re-ejecutar migración tenant** manual. - **Export dump cifrado** de BD despacho. Todos se auditan. --- ## 11. Signup y onboarding ### 11.1 Signup ``` POST /signup { despacho: { rfc, razonSocial, regimenFiscal, domicilio, verticalProfile: 'CONTABLE' }, owner: { nombre, email, password }, terms: true } ``` Backend: 1. Valida RFC + email único. 2. Crea User, Despacho (status=TRIAL, trialEndsAt=+30d), DespachoMembership (OWNER). 3. Auto-provisiona BD Managed temporal: `CREATE DATABASE horux_despacho_`. 4. Aplica `migrations/tenant/core/*` + `migrations/tenant/verticales/contable/*`. 5. Auto-asigna `Subscription.status=TRIAL` con un Plan dedicado (codename `trial_contable`, limits: 3 RFCs, 20 timbres, sin reportes premium). Nota: TRIAL es tanto un `SubStatus` como un Plan específico por vertical; el Plan existe para que los limits trial se expresen en el mismo modelo que el resto. 6. Envía email bienvenida + verificación. 7. Emite JWT; redirige a wizard. **Sin tarjeta al signup**. Se pide método de pago solo al convertir. ### 11.2 Wizard de onboarding (checklist persistente) Pasos obligatorios: 1. Cuenta creada (auto). 2. Email verificado. 3. Perfil del despacho (revisar). 4. Agregar primer contribuyente (RFC). 5. Subir FIEL del contribuyente. 6. Subir CSD (Facturapi). Opcionales (no bloquean): - Invitar supervisores/auxiliares. - Configurar BYO-DB. - Elegir plan de pago. ### 11.3 Emails transaccionales | Momento | Email | |---------|-------| | Signup | Bienvenida + verificación | | Verificación OK | Próximos pasos | | Día 7 trial (wizard incompleto) | "Configura tu cuenta" | | Día 25 trial | "Elige plan" | | Día 28 trial | "⚠ En 2 días perderás acceso" | | Día 31 (sin pago) | "Acceso suspendido" | | FIEL cerca de expirar | 60d, 30d, 7d antes (heredado Horux360) | --- ## 12. Updates y migraciones ### 12.1 Versionado ``` Backend apps/api: SemVer 1.2.3 Frontend apps/web: SemVer 1.2.3 (lockstep con backend) Connector: SemVer 1.2.3 (ciclo independiente, más lento) Schema tenant: integer monotónico por scope (core, vertical-*) Schema central: Prisma migrate ``` ### 12.2 Migraciones BD central `prisma migrate deploy` en cada deploy. ### 12.3 Migraciones BD tenant Estructura: ``` migrations/tenant/ ├── core/ │ ├── 001_*.sql │ └── ... └── verticales/ ├── contable/ │ ├── 001_*.sql │ └── ... └── juridica/ (futuro) ``` Cada BD despacho tiene `tenant_migrations(scope, version)` para tracking. Al conectar, backend detecta pendientes por scope, aplica en orden, actualiza `Despacho.dbSchemaVersion`. ### 12.4 Compatibilidad backwards (regla aditiva) **Regla de oro**: backend v1.5 corre contra schema v1.4 y v1.5. - Agregar columnas: OK. - Eliminar columnas: 2 releases. v1.5 deja de usar; v1.7 elimina. - Renombrar: nunca directo. Agregar nueva + backfill + dejar vieja + eliminar en v1.7. - Cambiar tipo: dos-fase. Nueva columna + dual-write + lectura ambas + eliminar vieja. - `CHECK constraint + varchar` en vez de Postgres enum para valores que pueden crecer. ### 12.5 Aplicación de migraciones - **Lazy (default)**: al primer request tras deploy, si `dbSchemaVersion < latest`, aplica pendientes. - **Eager (CLI)**: `pnpm migrate:all-tenants` para releases mayores. - **Online schema migration**: UPDATE en batches de 10k rows con LOCK SKIP (patrón gh-ost). ### 12.6 Rolling deployment ``` 1. Push Docker Registry: horux/api:1.5.0, horux/web:1.5.0 2. Kube rollout (max unavailable = 1) con init container de Prisma migrate central 3. Health check /healthz; auto-rollback si falla 4. Backends nuevos: lazy migrate tenant al primer request por despacho ``` ### 12.7 Rollback - Código: kube rollout undo (trivial). - Schema central: Prisma "down" si existe. - Schema tenant: NO hay rollback automático. Regla aditiva elimina necesidad; recovery de backup en emergencia. ### 12.8 Prohibido en producción - Skip de migraciones ("solo algunos despachos"). - Migraciones destructivas sin 2-fase. - Hot-fixes manuales a BDs específicas (siempre es migration o script auditado). --- ## 13. Refactor preparatorio del monorepo (paso 0) Antes de cualquier feature nueva de despachos, se ejecuta el refactor para extraer lo compartible de Horux360 a `packages/`. ### 13.1 Estructura objetivo ``` apps/ ├── api/ # backend compartido; routers por producto/vertical ├── web/ # frontend compartido; layouts por producto │ ├── horux360/ (producto usuario-final, existente) │ └── despachos/ (producto despachos, nuevo) packages/ ├── core/ # universal: User, Despacho, DespachoRole, auth, carteras, audit ├── vertical-contable/ # CFDI, SAT sync, Facturapi client, motor IVA/ISR, métricas ├── vertical-juridica/ # futuro ├── vertical-arquitectura/ # futuro ├── shared-ui/ # componentes UI reusables (tablas, forms, layouts fiscales) ├── shared/ # tipos Zod + constantes (ya existe) └── sat-catalog/ # catálogos SAT (regimenes, uso_cfdi, forma_pago, etc.) ``` ### 13.2 Criterios de extracción - Va a `packages/` si ambos productos lo necesitan sin modificaciones. - Se queda en `apps/` si es específico del producto/vertical. - Test: si tienes que agregar `if (esDespacho)` dentro del package, extráelo al app. ### 13.3 Candidatos claros a extraer - `packages/core`: User model, JWT helpers, rate limiting, email templates base, KMS helpers. - `packages/vertical-contable`: `FacturapiClient`, parser XML CFDI, `SatSyncService`, motor IVA/ISR, catálogos SAT, `MetricsComputer` de cada métrica. - `packages/shared-ui`: Table, Form, DatePicker, RfcInput, AmountInput, ImpuestosBadge. ### 13.4 Lo que NO se extrae - Middleware de autorización (Horux360 usa `TenantMembership`; Despachos usa `DespachoMembership` + Carteras; son distintos). - Dashboards de primera pantalla (distinto contexto). - Onboarding flows. - Modelo de suscripción (planes distintos). --- ## 14. Riesgos identificados y mitigaciones | # | Riesgo | Severidad | Mitigación | |---|--------|-----------|------------| | 1 | Drift de esquema entre BDs despacho | Alta | Regla aditiva (§12.4) + `Despacho.dbSchemaVersion` tracking + alerta si > N versiones atrás. | | 2 | Fuga de datos entre RFCs del mismo despacho (query sin `WHERE contribuyente_id`) | Alta | Helper `withTenancy` obligatorio + lint rule + tests + RLS Fase 2. | | 3 | FK lógicas cross-BD colgadas (user borrado en central) | Media | Job diario de purga de huérfanos + validación defensiva en login. | | 4 | Leak de timbres por orden incorrecto con Facturapi | Media | Orden write-ahead documentado + reconciliación nocturna Facturapi vs local. | | 5 | Webhook MP perdido → divergencia estado suscripción | Media | Idempotencia por event.id + reconciliación nocturna con `GET /preapproval/:id`. | | 6 | Connector caído → despacho inoperante | Media | Heartbeat + banner UI + email al owner + dashboard admin con alertas. | | 7 | Cloudflare Tunnel cap (50 tunnels gratis) | Baja (temp) | Upgrade a Cloudflare One Teams o migración a Chisel propio cuando > 50 despachos. | | 8 | Cuenta Facturapi maestra cap operativo | Baja (temp) | Arquitectura multi-cuenta ready (`FacturapiClient` recibe key param); implementar cuando acercándose a 5000 orgs. | | 9 | Abstracción vertical prematura (YAGNI) | Baja | Patrón "single-PK inheritance" es canónico (no experimental). Si solo queda en CONTABLE, el costo es marginal. | | 10 | Admin global impersonación olvidada (ghost admin) | Baja | Expiry automático 2h + audit log exhaustivo + banner persistente en UI. | --- ## 15. Roadmap de implementación de alto nivel (Detalle en plan de implementación — siguiente paso vía skill `writing-plans`.) **Fase 0 — Refactor preparatorio** (~1-2 semanas) - Extraer `packages/core`, `packages/vertical-contable`, `packages/shared-ui` desde Horux360 actual. - Verificar que Horux360 sigue funcional tras el refactor. **Fase 1 — Cimientos de Despachos** (~3-4 semanas) - BD Central: Prisma models `Despacho`, `DespachoMembership`, `ClienteAccessGrant`, `PlatformAuditLog`, etc. - BD Tenant: migrations core + vertical contable con `entidades_gestionadas`, `carteras`, `cliente_accesos`, refactor de `cfdis` con FK `contribuyente_id`. - Auth refactor: JWT con `despachoId`, middleware `resolveDespacho`, flujo magic link. - Pass-through con datos mínimos: signup → trial → agregar contribuyente → subir FIEL/CSD → emitir CFDI básico. **Fase 2 — Roles y carteras** (~2 semanas) - Supervisor/Auxiliar + Carteras: UI + endpoints + cascada de autorización. - Cliente-visor: invitaciones, multi-RFC, multi-despacho. **Fase 3 — Pricing y pagos** (~2-3 semanas) - Planes + Add-ons (tiers + codename). - Integración MercadoPago con múltiples preapprovals. - TimbrePool con decremento atómico + paquetes. - Webhook MP + reconciliación nocturna. **Fase 4 — Connector BYO-DB** (~2 semanas) - Docker image `horux/connector`. - Setup via Cloudflare API + token emission. - Heartbeat + UI estado. - Migración Managed → BYO. **Fase 5 — Admin global + dashboard cross-despacho** (~2 semanas) - Platform roles + impersonación con audit log. - Dashboard `/admin/*` con alertas operacionales. - Tooling de soporte (queries controladas, kill switch, etc.). **Fase 6 — Métricas pre-calculadas** (~2 semanas) - Tablas `metricas_mensuales`, `metricas_acumuladas_anuales`, `metricas_por_contraparte_anuales`, `metricas_invalidaciones`. - `MetricComputer` para cada métrica (IVA, ISR, IEPS, ER, Flujo, CxC, CxP, contrapartes). - Jobs cron (cierre mensual, anual, invalidación). - Integración a dashboard y reportes. **Fase 7 — Polish + launch privado** (~1-2 semanas) - Emails transaccionales. - Empty states y wizard. - Documentación del despacho. - Onboarding de 3-5 despachos piloto. **Total estimado**: 15-19 semanas hasta launch privado. --- ## 16. Terminología - **Despacho**: la firma profesional (contable, jurídica, etc.) que es el "tenant" de primer nivel. - **Contribuyente**: un RFC gestionado por el despacho; en vertical contable, un subtipo de `entidades_gestionadas`. - **Entidad Gestionada**: abstracción universal del objeto que gestiona el despacho (contribuyente/expediente/proyecto). - **Cartera**: agrupador creado por un Supervisor, contiene un subset de sus entidades asignadas más uno o más auxiliares con acceso. - **Owner**: dueño del despacho. Rol administrativo máximo. Puede actuar como Supervisor implícito. - **Supervisor**: rol del despacho; antes "contador". Tiene titularidad sobre N entidades y crea carteras para delegar a auxiliares. - **Auxiliar**: rol del despacho; accede solo a entidades incluidas en carteras donde está como miembro. - **Cliente**: usuario externo al despacho (el dueño del RFC); acceso read-only limitado a sus propias entidades. - **Platform Admin / TI / Support / Sales / Finance**: roles de staff Horux con capacidades cross-despacho. - **BYO-DB**: "Bring Your Own Database" — tier donde el despacho hostea Postgres en su infra. - **Managed**: tier donde Horux hostea Postgres en cluster propio. - **Connector**: imagen Docker que el despacho corre para exponer su Postgres vía Cloudflare Tunnel. - **Vertical Profile**: tipo de despacho (CONTABLE en MVP; JURIDICO, ARQUITECTURA futuras).