# Sesión 2026-04-30 — Cleanup Horux 360 + Catálogo despacho a tabla + Facturapi Live

Sesión enfocada en eliminar el legacy de Horux 360 (planes, helpers, columnas
huérfanas), mover el catálogo despacho a tabla `despacho_plan_prices` con
edición desde UI admin, agregar flag de SAT incremental gobernado por BD, y
preparar Facturapi para emitir en modo Live multi-RFC con cache cifrada de
las Live Secret Keys.

12 tareas completadas. Typecheck limpio en 5 paquetes (`@horux/shared`,
`@horux/core`, `@horux/shared-ui`, `@horux/vertical-contable`, `@horux/api`).
Smoke test del ciclo completo Facturapi queda para mañana.

---

## Índice

1. [Cleanup planes Horux 360 legacy](#1-cleanup-planes-horux-360-legacy)
2. [Catálogo despacho a tabla `despacho_plan_prices`](#2-catálogo-despacho-a-tabla-despacho_plan_prices)
3. [Deuda técnica seed.ts](#3-deuda-técnica-seedts)
4. [Precios `monthly/firstYear/renewal` desde BD](#4-precios-monthlyfirstyearrenewal-desde-bd)
5. [Flag `permite_sat_incremental` en BD](#5-flag-permite_sat_incremental-en-bd)
6. [Facturapi Live multi-RFC end-to-end](#6-facturapi-live-multi-rfc-end-to-end)
7. [Migraciones aplicadas](#7-migraciones-aplicadas)
8. [Pendientes derivados](#8-pendientes-derivados)
9. [Smoke test plan para mañana](#9-smoke-test-plan-para-mañana)

---

## 1. Cleanup planes Horux 360 legacy

### Problema
Coexistían dos catálogos: `PLANS` (Horux 360: starter/business/business_ia/
custom/enterprise) y `DESPACHO_PLANS` (despacho: trial/mi_empresa/
mi_empresa_plus/business_control/business_cloud/custom). El enum Prisma
`Plan` tenía los 9 mezclados, default `starter`. Tenants legacy en local;
producción ya no los tiene. `feature-gate.middleware.ts` bifurcaba entre
ambos catálogos con un Set incompleto que silenciosamente daba 403 a
`mi_empresa`/`mi_empresa_plus`/`custom` para features que sí incluyen.

### Decisiones
1. No hay tenants legacy en producción → cleanup puro código (con migración
   de datos local del tenant Horux 360 admin).
2. `custom` cambia su semántica: monto mensual variable definido al
   provisionar; si $0 sin Subscription/cobro, si >$0 Subscription con
   preapproval MP mensual.
3. `starter` → `trial` como default del enum y signup.
4. `Tenant.cfdiLimit` y `Tenant.usersLimit` se eliminan (limits viven en
   `DESPACHO_PLANS`).

### Cambios

**Datos local:**
- 1 tenant `enterprise` → `custom` (Horux 360 admin RFC `HTS240708LJA`)
- 1 subscription `enterprise` → `custom`
- 8 filas legacy de `plan_prices` borradas

**Schema Prisma:**
- Enum `Plan` estrechado de 9 → 6 valores: `trial`, `custom`,
  `business_control`, `business_cloud`, `mi_empresa`, `mi_empresa_plus`
- `Tenant.plan @default(trial)`
- `Tenant.cfdiLimit` y `Tenant.usersLimit` eliminadas

**Backend refactor:**
- `auth.service.ts:register` → plan default `trial`
- `tenants.service.ts` → `createTenant`/`addTenantToOwner`/`updateTenant`
  tipos de `plan` migrados a `DespachoPlan`
- `despacho.service.ts:registerDespacho` → `plan: 'trial'`
- `tenants.controller.ts` → schema Zod actualizado, params `cfdiLimit`/
  `usersLimit` removidos
- `usuarios.service.ts:inviteUsuario` → maxUsers desde catálogo
- `plan-limits.middleware.ts:checkCfdiLimit` → no-op (límite real es por
  contribuyente, no por tenant)
- `feature-gate.middleware.ts` → unificado a `hasDespachoFeature` (cierra
  bug latente de `mi_empresa`/`custom` cayendo al hasFeature legacy)
- `subscription.controller.ts:VALID_PLANS` → solo planes despacho
- `subscription.service.ts` → type alias local `Plan` reducido
- `sat-sync.job.ts` → tenant filter `enterprise` → `business_cloud`
  (después se vuelve a refactorizar en sección 5)

**Frontend cleanup:**
- 4 sidebars/topnav → `hasFeature` legacy → `hasDespachoFeature`
- `mis-empresas/page.tsx`, `clientes/page.tsx`, `configuracion/suscripcion/
  page.tsx`, `lib/api/tenants.ts` → tipos y selectores migrados a planes
  despacho

**Borrados:**
- `packages/shared/src/constants/plans.ts` (legacy `PLANS`,
  `getPlanLimits`, `hasFeature`, type `Plan`)
- Bloque `planCatalogoData` del seed
- 8 filas legacy de catálogo Horux 360 en `plan_prices` (modelo
  `PlanPrice` queda huérfano — ver pendientes)

### Migración Prisma
`20260430184123_cleanup_legacy_plans/migration.sql`:
```sql
CREATE TYPE "Plan_new" AS ENUM ('trial','custom','business_control',
  'business_cloud','mi_empresa','mi_empresa_plus');
ALTER TABLE tenants/subscriptions/plan_prices ALTER COLUMN ... USING ... ;
ALTER TYPE "Plan" RENAME TO "Plan_old"; DROP TYPE;
ALTER TABLE tenants DROP COLUMN cfdi_limit, DROP COLUMN users_limit,
  ALTER COLUMN plan SET DEFAULT 'trial';
```

---

## 2. Catálogo despacho a tabla `despacho_plan_prices`

### Problema
El catálogo despacho estaba 100% hardcoded en
`packages/shared/src/constants/despacho-plans.ts`. Existía una tabla
`PlanCatalogo` huérfana con datos viejos sin uso. Existía
`despacho_plan_prices` solo para precios. Admin no podía ajustar
precios/limits sin redeploy.

### Decisión: Opción B
- Limits + precios en BD (`despacho_plan_prices` extendida)
- Features siguen en TS (acopladas a UI/middleware, contrato de código)
- `PlanCatalogo` huérfano se elimina del schema
- Conservar el nombre `despacho_plan_prices` (no renombrar)

### Cambios

**Schema:**
- `DespachoPlanPrice` extendido con: `nombre VARCHAR(50)`,
  `maxRfcs INT`, `maxUsers INT`, `timbresIncluidosMes INT default 0`,
  `dbMode DbMode`, `permiteServidorBackup BOOLEAN default false`
- `firstYear` y `renewal` cambiados a `Decimal?` (nullable para trial/custom)
- `PlanCatalogo` model eliminado (drop table)

**Migración aditiva no destructiva:**
1. ADD COLUMN como nullable
2. UPDATE backfill desde catálogo TS para las 4 filas existentes
3. ALTER COLUMN ... SET NOT NULL
4. ALTER first_year/renewal DROP NOT NULL

**Seed:**
- 6 filas (incluye `trial` y `custom` con precios null) UPSERT idempotente
- Bloque legacy `planCatalogoData` eliminado

**Service nuevo `plan-catalogo.service.ts`:**
- `interface DespachoPlanLimits` (precio + limits)
- `getDespachoPlanLimits(plan)` async con cache 5min en memoria
- `getAllDespachoPlanLimits()` igual
- `invalidateDespachoPlanCache()` para post-edit admin
- `listPlans`/`listAddons`/`getPlanByCodename` reescritos para leer de
  `despacho_plan_prices` (anteriormente leían de `PlanCatalogo` huérfano)

**Backend callers migrados:**
- `usuarios.service.ts:inviteUsuario` → `maxUsers` desde
  `getDespachoPlanLimits` (BD) en vez de objeto TS

**Backend endpoints nuevos:**
- `GET /api/planes/despacho` → listDespachoCatalogo (auth required)
- `PATCH /api/planes/despacho/:plan` → updateDespachoCatalogo (canEditPrices)
  - Schema Zod: `nombre`, `monthly`, `firstYear`, `renewal`,
    `permiteMonthly`, `maxRfcs`, `maxUsers`, `timbresIncluidosMes`,
    `dbMode`, `permiteServidorBackup`, `permiteSatIncremental`
  - Llama `invalidateDespachoPlanCache()` post-update

**UI admin reescrita:**
- `apps/web/app/(dashboard)/configuracion/precios-suscripcion/page.tsx`:
  - Lee de `/api/planes/despacho` (BD) en vez de catálogo TS
  - 9 columnas: Plan, Mensual, Anual 1°, Renovación, RFCs, Usuarios,
    Timbres, DB, Backup + (en sección 5: SAT Inc)
  - Edición inline: lápiz → inputs → ✓ guarda / ✗ cancela
  - useMutation invalida `['despacho-catalogo']` post-edit
  - Botón Save deshabilitado durante mutación

**Documentación split TS/BD:**
- Comentario al inicio de `DESPACHO_PLANS` (TS) explica:
  - `features` → autoritativo en TS (acoplado a UI/middleware)
  - `name`, `maxRfcs`, `maxUsers`, `timbresIncluidosMes`, `dbMode`,
    `permiteServidorBackup` → defaults en TS, autoritativo en BD
  - `maxCfdisPorContribuyente` → solo en TS (no se gating en runtime)

### Estado en BD post-migración

```
plan             | nombre           | monthly | first_year | renewal | maxRfcs | maxUsers | timbres | dbMode  | backup
trial            | Prueba           | -       | -          | -       | 3       | 1        | 20      | MANAGED | -
mi_empresa       | Mi Empresa       | 580     | 5800       | 5800    | 1       | 3        | 50      | MANAGED | -
mi_empresa_plus  | Mi Empresa +     | 900     | 9000       | 9000    | 1       | 3        | 50      | MANAGED | -
business_control | Business Control | -       | 25850      | 25850   | 100     | -1       | 0       | BYO     | ✓
business_cloud   | Enterprise       | -       | 43000      | 43000   | 100     | -1       | 0       | BYO     | ✓
custom           | Custom           | -       | -          | -       | 1       | 3        | 50      | MANAGED | -
```

---

## 3. Deuda técnica `seed.ts`

Bugs encontrados al re-correr seed (ya no funcionaba después de F6 +
cleanup planes):

| Bug | Fix |
|-----|-----|
| `trial_usages.rfc varchar(13)` no admite slugs `DESPACHO_xxx` (~22 chars) del backfill | Filtrar `WHERE LENGTH(rfc) <= 13` (slugs no participan del padrón anti-abuso de trial) |
| Backfill `user_platform_roles` referenciaba `users.tenant_id`/`rol_id` (eliminados en F6) | Migrado a `tenant_memberships` |
| Demo users creados con `tenantId`/`rolId` (eliminados en F6) | Crear User sin tenant + crear membership por separado |
| CFDIs demo `ON CONFLICT (uuid) DO NOTHING` falla — el UNIQUE en `cfdis.uuid` se reemplazó por índice funcional `LOWER(uuid)` (migración 027) que no soporta ON CONFLICT por columna plana | Removido — los UUIDs son `crypto.randomUUID()` y las tablas se dropean cada seed; sin colisión posible |
| Seed `Tenant` demo con `plan: 'business'` + `cfdiLimit/usersLimit` | Cambiado a `plan: 'mi_empresa_plus'`, columnas eliminadas |
| Bloque `planCatalogoData` (4 filas que insertaban en `PlanCatalogo` huérfano) | Eliminado |

`pnpm db:seed` ahora corre end-to-end exitoso.

---

## 4. Precios `monthly/firstYear/renewal` desde BD

### Problema
Después de #2, el admin podía editar precios desde UI pero
`subscription.service.ts:getPlanPrice` seguía leyendo del catálogo TS
hardcoded vía `getPrecioDespacho`. Editar precio en UI no afectaba al
cobro real.

### Cambios

**Service `plan-catalogo.service.ts` extendido con 3 helpers async:**
- `permiteFrecuenciaMensualDb(plan)` — lee BD via cache
- `despachoPlanTieneDualidadDb(plan)` — `firstYear !== renewal` desde BD
- `getPrecioDespachoDb(plan, frequency, phase)` — precio desde BD; throws
  si plan no existe o no permite frecuencia

**`subscription.service.ts:getPlanPrice` simplificado:**
- Removida rama legacy `prisma.planPrice.findUnique` (planes Horux 360 no
  existen)
- Reemplazado `getPrecioDespacho`+`permiteFrecuenciaMensual` con un solo
  `getPrecioDespachoDb(plan, frequency, phase)`
- `custom` sigue throwando (monto variable se fija al provisionar)

**`subscription.service.ts:436`:**
- `despachoPlanTieneDualidad(plan)` → `await despachoPlanTieneDualidadDb(plan)`

**`webhook.controller.ts:174`:**
- Mismo cambio en el handler que ajusta preapproval tras primer pago

**Imports limpiados:**
- Removidos del shared: `DESPACHO_PLAN_PRICES`, `getPrecioDespacho`,
  `permiteFrecuenciaMensual`, `despachoPlanTieneDualidad` (siguen
  exportados por el shared package, pero el backend ya no los usa).

### Resultado
Si admin baja el precio de Mi Empresa de $580 a $400 desde la UI, el
próximo `subscribe` cobra $400. Cache se invalida automáticamente vía
`invalidateDespachoPlanCache()` en el PATCH.

---

## 5. Flag `permite_sat_incremental` en BD

### Problema
La extracción SAT incremental (3 syncs/día adicionales al daily) estaba
hardcoded para plan `business_cloud` solo. El usuario quiere que aplique
también a `mi_empresa_plus` y `business_control`.

### Decisión: Opción B
Flag editable en BD (siguiendo el patrón de `permite_servidor_backup`)
en vez de hardcoded `IN (...)`. Admin gobierna desde UI sin redeploy.

### Cambios

**Schema:**
- `DespachoPlanPrice.permiteSatIncremental Boolean @default(false)`
- Migración aditiva con backfill: `mi_empresa_plus`, `business_control`,
  `business_cloud` → `true`

**Helper service:**
- `interface DespachoPlanLimits` extendido con `permiteSatIncremental`

**Cron `sat-sync.job.ts`:**
- `getEnterpriseTenantsWithFiel()` renombrado a
  `getTenantsConSatIncremental()`
- Query refactorizada: `findMany` en `despachoPlanPrice` filtrando
  `permiteSatIncremental: true` → IN sobre `tenants.plan`
- Lee directo de Prisma (no usa el cache de 5min) para decisiones
  in-instant en cada disparo

**Endpoint admin:**
- Schema Zod del PATCH acepta `permiteSatIncremental`
- Response incluye el campo

**UI admin:**
- Nueva columna "SAT Inc" con check verde / dash
- Checkbox editable inline
- Nota explicativa al pie: "3 syncs SAT extra al día (11:00, 15:00, 19:00)
  además del daily de las 03:00. Ventana de 8h por sync, deduplicado por
  UUID. Latencia ~1-2h en horario laboral vs ~24h con solo el daily."

**Seed:**
- Catálogo despacho actualizado con `permiteSatIncremental` para los 6
  planes

### Estado actual

| Plan | permite_sat_incremental |
|------|-------------------------|
| trial | false |
| mi_empresa | false |
| mi_empresa_plus | **true** ✓ |
| business_control | **true** ✓ |
| business_cloud | **true** ✓ |
| custom | false |

### Latencia esperada

| Plan | Daily 03:00 | Inc 11/15/19 | Worst case |
|------|-------------|--------------|------------|
| trial, mi_empresa, custom | ✓ | ✗ | ~24h |
| mi_empresa_plus, business_control, business_cloud | ✓ | ✓ | ~5h (emit ~22:00); ~1-2h (horario laboral) |

---

## 6. Facturapi Live multi-RFC end-to-end

### Problema
El working dir usaba modo Test/Sandbox (`GET /apikeys/test`). Para
producción Live multi-RFC se necesita:
1. Cada organización Facturapi (1:1 con contribuyente) tiene su propia
   `sk_live_xxx`
2. Endpoint oficial: `PUT /v2/organizations/{id}/apikeys/live` (idempotente)
3. Cada Live Secret Key se debe almacenar (cifrada, decisión del usuario)
4. El primer emit no debe pagar el costo del PUT — eager generation tras
   crear la org

Adicional: en Live, SAT rechaza CFDIs con `relatedDocuments` mal-formados
(ej. UUID inexistente, cancelado, RFC receptor que no coincide). Hay que
validar antes de consumir timbre.

### Decisiones del usuario
1. **Encriptar** la api_key (AES-256-GCM con derivación
   `FIEL_ENCRYPTION_KEY`, mismo patrón que credenciales FIEL)
2. **Eager** generation: tras crear org, PUT `/apikeys/live` + cifrar +
   guardar inmediatamente
3. **Sí** al paquete completo: validaciones controller + estructura
   `relatedDocuments` SAT 4.0

### Cambios

**Migraciones:**
- Tenant: `041_facturapi_orgs_api_key_enc.sql` agrega `api_key_enc/iv/tag
  BYTEA` a `facturapi_orgs`
- Central: `20260430230000_add_facturapi_org_key_enc/migration.sql` agrega
  `Tenant.facturapiOrgKeyEnc/Iv/Tag BYTEA`

**Service `contribuyente-facturapi.service.ts` (multi-RFC):**
- `generateLiveKey(orgId)` — helper PUT idempotente; valida que la
  respuesta empiece con `sk_live_`; throws con error legible si Facturapi
  rechaza
- `persistEncryptedKey(pool, orgId, plaintextKey)` — `encryptString`
  AES-256-GCM + UPDATE
- `getOrgApiKey(pool, orgId)` reescrito:
  1. SELECT `api_key_enc/iv/tag` desde `facturapi_orgs`
  2. Si existe → `decryptToString` y retornar
  3. Si no (org legacy sin key cacheada) → `generateLiveKey` →
     `persistEncryptedKey` → retornar
- `createOrgContribuyente` ahora **eager**:
  1. `client.organizations.create({name})` con User Key
  2. INSERT en `facturapi_orgs`
  3. `ensureLiveKeyCached(pool, org.id)` — PUT live + cifrar + guardar
- `ensureLiveKeyCached` — idempotente: si ya hay key cifrada, no-op; si
  no, genera y persiste

**Service `facturapi.service.ts` (tenant central — Horux 360 emite a
clientes):**
- Mismo patrón: `generateLiveKey`, `getOrgClient(tenantId)` lee BD
  cifrada → fallback PUT live + persist
- `createOrganization(tenantId)` eager: tras crear org, PUT live + cifrar
  + UPDATE `Tenant.facturapiOrgKeyEnc/Iv/Tag`

**Estructura `relatedDocuments` SAT 4.0:**
- Working dir antes: `Array<{ uuid: string; relationship: string }>` —
  una entrada por UUID (Facturapi Live rechaza por validación SAT estricta)
- Ahora: `Array<{ relationship: string; uuids: string[] }>` — una entrada
  por tipo de relación, agrupando N UUIDs
- Backend: `documents: r.uuids` en lugar de `documents: [r.uuid]`
- Service contribuyente acepta ambos formatos para compat (legacy `r.uuid`
  fallback a `[r.uuid]`)

**Validaciones controller `facturacion.controller.ts:emitir`:**
Antes de consumir timbre, valida cada UUID en `relatedDocuments`:
- **Existe** en `cfdis` del tenant (case-insensitive `LOWER(uuid)`)
- **No cancelado** (`status NOT IN ('Cancelado', '0')`)
- **Match RFC** receptor: `cfdis.rfc_receptor` vs `customer.taxId` del
  CFDI nuevo (uppercase + trim)
- Si alguno falla → `AppError(400, ...)` con mensaje legible (sin
  consumir timbre)

**Frontend `facturacion/page.tsx:639`:**
- Manda `{ relationship: relatedRelationship, uuids: [relatedUuid] }`
  (formato SAT 4.0 nuevo)

### Flow nuevo de provisioning

```
createOrgContribuyente(pool, contribId, nombre)
  ↓ User Key
client.organizations.create({name})
  ↓
INSERT INTO facturapi_orgs (contribuyente_id, facturapi_org_id)
  ↓
ensureLiveKeyCached(pool, orgId)
  ↓ User Key
PUT /v2/organizations/{orgId}/apikeys/live   ← idempotente, devuelve sk_live_xxx
  ↓
encryptString(sk_live_xxx)   ← AES-256-GCM con FIEL_ENCRYPTION_KEY
  ↓
UPDATE facturapi_orgs SET api_key_enc, api_key_iv, api_key_tag
```

### Flow nuevo de emisión

```
getOrgClientContribuyente(pool, contribId)
  ↓
SELECT facturapi_org_id FROM facturapi_orgs
  ↓
getOrgApiKey(pool, orgId)
  ↓
SELECT api_key_enc, api_key_iv, api_key_tag FROM facturapi_orgs
  ↓ si existe
decryptToString(...) → sk_live_xxx
  ↓ si NO existe (org legacy)
generateLiveKey(orgId) → persistEncryptedKey → return
  ↓
new Facturapi(sk_live_xxx)
```

### Validación pre-emisión

```
emitir() controller
  ↓
relatedDocs = req.body.relatedDocuments || []
customerRfc = req.body.customer?.taxId?.toUpperCase().trim()
  ↓ por cada uuid en relatedDocs.flatMap(r => r.uuids)
SELECT rfc_receptor, status FROM cfdis WHERE LOWER(uuid) = LOWER($1)
  ↓ si no existe / cancelado / RFC no match
throw AppError(400, mensaje específico)
  ↓ todo OK
consumeTimbre() → createInvoiceContribuyente() → ...
```

### Diferencias con producción

Producción tiene un approach **plain text** (sin cifrar) y **lazy** (solo
genera la live key al primer emit). El working dir ahora es **cifrado** y
**eager** (más seguro y la org queda lista para emitir desde el momento 0
sin un PUT extra).

---

## 7. Migraciones aplicadas

| Fecha | ID | Descripción |
|-------|-----|-------------|
| 2026-04-30 | `20260430184123_cleanup_legacy_plans` | Estrechar enum Plan + drop cfdi_limit/users_limit |
| 2026-04-30 | `20260430195000_extend_despacho_plan_prices_with_limits` | Agregar nombre/maxRfcs/maxUsers/timbres/dbMode/backup a despacho_plan_prices con backfill aditivo |
| 2026-04-30 | `20260430200000_drop_plan_catalogo_orphan` | Drop tabla `plan_catalogo` huérfana |
| 2026-04-30 | `20260430215000_add_permite_sat_incremental` | Agregar `permite_sat_incremental` a despacho_plan_prices con backfill |
| 2026-04-30 | `20260430230000_add_facturapi_org_key_enc` | Agregar `Tenant.facturapiOrgKeyEnc/Iv/Tag` BYTEA |
| 2026-04-30 | `tenant/041_facturapi_orgs_api_key_enc.sql` | Agregar `facturapi_orgs.api_key_enc/iv/tag` BYTEA |

---

## 8. Pendientes derivados

### Bloqueantes para producción
- **Aplicar las 5 migraciones Prisma + 1 tenant en producción** (en
  orden cronológico). La tenant se aplica vía
  `pnpm db:migrate-tenants` (lazy en `getPool` también).
- **Variable de entorno**: confirmar `FIEL_ENCRYPTION_KEY` configurada en
  prod (mismo valor o key rotation strategy si se rota).

### Eliminar deuda restante
- **Modelo `PlanPrice`** queda huérfano (sin filas, sin callers en
  código activo). Drop del schema en próxima release.
- **`DESPACHO_PLAN_PRICES` (TS)** ya no se usa en backend pero sigue
  exportado del shared. Algún caller frontend podría seguir consumiéndolo
  → grep `DESPACHO_PLAN_PRICES` en `apps/web`.

### Mejoras opcionales
- **Smoke test del ciclo Facturapi Live completo** — pendiente para mañana
  (ver sección 9).
- **`Frequency` type en `subscription.service.ts`** y el `'monthly'|
  'annual'` que recibe `getPrecioDespachoDb` son tipos paralelos. Si se
  agrega una frecuencia (e.g. `'quarterly'`), sincronizar ambos lados.
- **Errores typecheck pre-existentes en web** (cfdi/page.tsx, sidebar*,
  usuarios/page.tsx, etc.) — no relacionados a esta sesión, persisten.
- **`bootstrap-horux360-admin.ts`** sigue con nombre legacy. Renombrar a
  `bootstrap-platform-admin.ts` cuando convenga (no urgente).

---

## 9. Smoke test plan para mañana

### Setup
1. `pnpm dev` con dev server arriba (API :4000, web :3000)
2. Login como **admin@demo.com / demo123** (tenant demo `EDE123456AB1`)
3. Verificar `FACTURAPI_USER_KEY` y `FIEL_ENCRYPTION_KEY` en `.env`

### Test 1 — Catálogo BD funciona
1. Ir a `/configuracion/precios-suscripcion` (login como admin global)
2. Esperado: 9 columnas + 6 planes con limits desde BD
3. Editar `mi_empresa.maxUsers` de 3 → 5 → guardar
4. Verificar SQL: `SELECT max_users FROM despacho_plan_prices WHERE plan='mi_empresa'` → 5
5. Cancelar edit en otro plan → no se persiste

### Test 2 — Toggle SAT Inc desde UI
1. Editar `mi_empresa` → marcar checkbox SAT Inc → guardar
2. Verificar SQL: `permite_sat_incremental = t`
3. Revertir (UI o SQL)

### Test 3 — Precio editado aplica al cobro
1. Editar `mi_empresa.firstYear` de 5800 → 5000
2. Cualquier nuevo `subscribe` con plan `mi_empresa annual` debe cobrar 5000
3. Revertir a 5800

### Test 4 — Crear contribuyente con auto-org Facturapi (Live)
1. Ir a `/contribuyentes` (o equivalente)
2. Crear contribuyente con RFC válido + nombre
3. Verificar SQL en BD tenant:
   ```
   PGPASSWORD='Hesoy@m11' psql -h localhost -U postgres -d horux_ede123456ab1 \
     -c "SELECT contribuyente_id, facturapi_org_id, csd_uploaded,
                (api_key_enc IS NOT NULL) AS has_live_key,
                octet_length(api_key_enc) AS enc_size,
                octet_length(api_key_iv) AS iv_size,
                octet_length(api_key_tag) AS tag_size
         FROM facturapi_orgs;"
   ```
4. Esperado: `has_live_key = t`, `iv_size = 12`, `tag_size = 16`,
   `enc_size ~50-60` bytes

### Test 5 — Subir CSD del contribuyente
1. Subir `.cer + .key + password` del CSD
2. Esperado: `csd_uploaded = true` sin errores (validación CSD vs RFC del
   contribuyente)

### Test 6 — Emitir factura tipo I
1. Ir a `/facturacion`, crear factura tipo I (Ingreso) con receptor
   PÚBLICO en general (`XAXX010101000`) o un RFC con datos válidos
2. Esperado: factura emitida sin errores; logs muestran
   `decryptToString(api_key_enc, ...)` (sin PUT live extra)
3. Verificar timbre consumido: `SELECT * FROM timbre_suscripciones WHERE
   tenant_id = ...`

### Test 7 — Emitir tipo E (Egreso/NC) con CFDI relacionado
1. Crear factura tipo E que referencie un UUID de una factura I previa
2. Validar que la validación pre-emit pasa (UUID existe + vigente +
   match RFC receptor)
3. Probar caso negativo: pasar UUID inventado → esperado AppError(400)
   con mensaje "El CFDI relacionado con UUID X no existe"
4. Probar caso negativo: pasar UUID con RFC receptor diferente al
   `customer.taxId` → esperado mensaje específico

### Test 8 — Emitir tipo P (Complemento de Pago)
1. Crear factura tipo P referenciando una factura PPD previa
2. Validar emisión + persistencia con `cfdi_tipo_relacion='04'` y
   `cfdis_relacionados=UUID|UUID|...`

### Test 9 — Idempotencia eager
1. Eliminar contribuyente y recrearlo con mismo RFC
2. Esperado: `client.organizations.create` se llama; `ensureLiveKeyCached`
   genera nueva live key; no errores
3. Verificar que `api_key_enc` se actualizó (timestamp updated_at vs anterior)

### Comandos útiles para debugging

```bash
# Ver logs del backend en tiempo real
tail -f /tmp/claude/.../tasks/<id>.output

# Inspeccionar facturapi_orgs cifrado
PGPASSWORD='Hesoy@m11' psql -h localhost -U postgres -d horux_ede123456ab1 \
  -c "SELECT contribuyente_id, facturapi_org_id, csd_uploaded,
              encode(api_key_enc, 'hex') as enc_hex,
              octet_length(api_key_enc) as enc_size FROM facturapi_orgs;"

# Inspeccionar Tenant central
PGPASSWORD='Hesoy@m11' psql -h localhost -U postgres -d horux_despachos \
  -c "SELECT nombre, rfc, plan, facturapi_org_id,
              (facturapi_org_key_enc IS NOT NULL) AS has_live_key
       FROM tenants WHERE rfc = 'HTS240708LJA';"
```

### Si algo falla
- **Facturapi PUT 4xx**: verificar `FACTURAPI_USER_KEY` válida, org_id
  existe en dashboard de Facturapi, no se hizo `client.organizations.delete`
  desde otro lado
- **decryptToString throws**: el cifrado está corrupto (api_key_enc/iv/tag
  inconsistentes). Borrar la fila y dejar que `getOrgApiKey` regenere
- **AppError "no existe en el sistema"**: el UUID relacionado no se
  sincronizó vía SAT sync — el daily de 03:00 lo pondría disponible. O el
  CFDI nunca existió.
- **"RFC no corresponde"**: el `customer.taxId` que se manda en el body no
  coincide con el `rfc_receptor` del UUID relacionado. Es validación SAT
  correcta, no bug.
