1463 lines
61 KiB
Markdown
1463 lines
61 KiB
Markdown
# 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).
|