Files
HoruxDespachosNuevo/docs/plans/2026-04-30-session.md

598 lines
25 KiB
Markdown

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