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

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

```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 `<slug>.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 <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).
