61 KiB
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
-
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).
-
TenantConnectionManagerrefactorizado (ya existe en Horux360): sigue siendo unMap<despachoId, Pool>con cache 5 min. ElconnectionStringse lee descifrado deDespacho.dbConnectionEnc(KMS) en lugar de construirse comohorux_<rfc>. -
Admins globales tienen acceso nativo vía el mismo pool (credenciales conocidas); impersonar es setear
despachoIdActivoen el JWT. -
FK cross-BD lógicas, no físicas: UUIDs globalmente únicos; validación a nivel aplicación; job de purga de huérfanos.
-
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. |
| 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
Useres central, no per-despacho. Un email = un User.DespachoMembershipN:M permite multi-despacho.- 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. ClienteAccessGranten BD central duplicacliente_accesosde cada BD despacho. Permite al cliente-visor loguearse y ver sus despachos antes de conectar a N BDs.contribuyente_idFK NOT NULL en todas las tablas verticales contables. Eje de autorización; omitirlo = fuga de datos intra-despacho.- Patrón de herencia "single-PK inheritance":
contribuyentes.entidad_id = entidades_gestionadas.id(mismo UUID). Queries cross-vertical usanentidades_gestionadas; queries específicas usan subtabla. - Métricas cold (años pasados) en tablas dedicadas; hot (año actual) on-the-fly. Invalidación dirigida via
metricas_invalidacionescuando 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_mensualesymetricas_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
TimbrePoolde 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.facturapiInvoiceIdlo 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.Zfirmada con cosign, ejecuta como non-root. - Incluye
cloudflared(Cloudflare Tunnel) + script de heartbeat. - Apunta a Postgres local del despacho (típicamente
localhost:5432opostgres:5432de 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_TOKENexpira 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
PlatformAuditLogentry.
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:
- Valida RFC + email único.
- Crea User, Despacho (status=TRIAL, trialEndsAt=+30d), DespachoMembership (OWNER).
- Auto-provisiona BD Managed temporal:
CREATE DATABASE horux_despacho_<slug>. - Aplica
migrations/tenant/core/*+migrations/tenant/verticales/contable/*. - Auto-asigna
Subscription.status=TRIALcon un Plan dedicado (codenametrial_contable, limits: 3 RFCs, 20 timbres, sin reportes premium). Nota: TRIAL es tanto unSubStatuscomo un Plan específico por vertical; el Plan existe para que los limits trial se expresen en el mismo modelo que el resto. - Envía email bienvenida + verificación.
- 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:
- Cuenta creada (auto).
- Email verificado.
- Perfil del despacho (revisar).
- Agregar primer contribuyente (RFC).
- Subir FIEL del contribuyente.
- Subir CSD (Facturapi).
Opcionales (no bloquean):
- Invitar supervisores/auxiliares.
- Configurar BYO-DB.
- Elegir plan de pago.
11.3 Emails transaccionales
| Momento | |
|---|---|
| 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 + varcharen 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-tenantspara 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,MetricsComputerde 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 usaDespachoMembership+ 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-uidesde 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 decfdiscon FKcontribuyente_id. - Auth refactor: JWT con
despachoId, middlewareresolveDespacho, 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. MetricComputerpara 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).