Initial commit - Horux Despachos NL
This commit is contained in:
597
docs/plans/2026-04-30-session.md
Normal file
597
docs/plans/2026-04-30-session.md
Normal file
@@ -0,0 +1,597 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user