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