Files
HoruxDespachos/docs/superpowers/specs/2026-04-16-horux-despachos-design.md
2026-04-27 22:09:36 -06:00

61 KiB
Raw Blame History

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<despachoId, Pool> con cache 5 min. El connectionString se lee descifrado de Despacho.dbConnectionEnc (KMS) en lugar de construirse como horux_<rfc>.

  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)

// =================== 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):

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):

-- 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):

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

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:

// 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

async function getEntidadesVisibles(req): Promise<string[]> {
  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:

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:

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

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

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 <slug>.tunnel.horux.mx (registrado en cuenta Cloudflare Horux).

9.2 Onboarding

Backend genera tunnel vía Cloudflare API + token del connector:

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 <HORUX_TOKEN>
{ "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_<slug>.
  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).