598 lines
25 KiB
Markdown
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.
|