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

25 KiB

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
  2. Catálogo despacho a tabla despacho_plan_prices
  3. Deuda técnica seed.ts
  4. Precios monthly/firstYear/renewal desde BD
  5. Flag permite_sat_incremental en BD
  6. Facturapi Live multi-RFC end-to-end
  7. Migraciones aplicadas
  8. Pendientes derivados
  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. startertrial como default del enum y signup.
  4. Tenant.cfdiLimit y Tenant.usersLimit se eliminan (limits viven en DESPACHO_PLANS).

Cambios

Datos local:

  • 1 tenant enterprisecustom (Horux 360 admin RFC HTS240708LJA)
  • 1 subscription enterprisecustom
  • 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.tscreateTenant/addTenantToOwner/updateTenant tipos de plan migrados a DespachoPlan
  • despacho.service.ts:registerDespachoplan: '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 enterprisebusiness_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:

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:inviteUsuariomaxUsers 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_cloudtrue

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. 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) → generateLiveKeypersistEncryptedKey → 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

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