Update: nueva version Horux Despachos

This commit is contained in:
consultoria-as
2026-04-27 22:09:36 -06:00
commit 6b36db1403
614 changed files with 125926 additions and 0 deletions

2
.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto

53
.gitignore vendored Normal file
View File

@@ -0,0 +1,53 @@
# Dependencies
node_modules/
.pnp
.pnp.js
# Build
.next/
out/
dist/
build/
# Environment
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Testing
coverage/
.nyc_output/
# Prisma
prisma/*.db
prisma/*.db-journal
# Misc
*.log
*.tsbuildinfo
.turbo/
# Runtime data (XMLs descargados del SAT, nunca subir — datos fiscales reales)
apps/api/data/
# Email template previews (regenerados con `pnpm email:preview`)
email-previews/

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
20

504
CLAUDE.md Normal file
View File

@@ -0,0 +1,504 @@
# Horux360 - Contexto para Claude
## Qué es esto
Plataforma SaaS de análisis financiero y gestión fiscal para empresas mexicanas. Maneja CFDIs (facturas electrónicas del SAT), cálculos de IVA/ISR, reportes financieros, alertas de cumplimiento fiscal, conciliación bancaria, sincronización directa con el SAT y emisión de facturas vía Facturapi.
**Producción:** https://horuxfin.com
**Autor:** Carlos e Ivan (Horux 360) (RFC: HTS240708LJA)
---
## Arquitectura en 30 segundos
```
Monorepo (pnpm + Turborepo)
├── apps/api → Express + TypeScript (tsx, puerto 4000)
├── apps/web → Next.js 14 + App Router (puerto 3000)
└── packages/shared → Tipos, constantes e interfaces compartidas
```
**Multi-tenant database-per-tenant:**
- `horux360` → BD central (Prisma): tenants, users, roles, subscriptions, catálogos fiscales, catálogos SAT, timbres Facturapi
- `horux_<rfc>` → BD por tenant (pg Pool + raw SQL): cfdis, cfdi_conceptos, rfcs, bancos, conciliaciones, alertas, recordatorios, contribuyentes, carteras, obligaciones, tareas, papelería
**Dos capas de acceso a datos** (esto es intencional):
- **Prisma** para la BD central (ORM, migraciones, tipos generados)
- **pg Pool directo** para BDs de tenant (queries SQL complejos de cálculos fiscales)
---
## Archivos clave por área
### Configuración
| Archivo | Qué hace |
|---------|----------|
| `apps/api/src/config/env.ts` | Variables de entorno validadas con Zod (incluye FACTURAPI_USER_KEY) |
| `apps/api/src/config/database.ts` | Prisma client + `TenantConnectionManager` (pools, provisioning, lazy migration) |
| `apps/api/src/config/tenant-migrations.ts` | `migrate()`, `migrateAll()`, `getMigrationFiles()` — sistema de migraciones SQL para BDs tenant |
| `apps/api/src/migrations/tenant/*.sql` | Archivos SQL numerados (`001_initial_schema.sql`, etc.) |
| `apps/api/.env` | Credenciales locales (DB, JWT, SMTP, MercadoPago, FIEL, Facturapi) |
| `apps/api/prisma/schema.prisma` | Schema de la BD central |
### Autenticación y seguridad
| Archivo | Qué hace |
|---------|----------|
| `src/middlewares/auth.middleware.ts` | JWT verify + `authorize(...roles)` |
| `src/middlewares/tenant.middleware.ts` | Resuelve pool de BD del tenant, cache 5min, `X-View-Tenant` para admin global |
| `src/middlewares/plan-limits.middleware.ts` | Verifica suscripción, limita CFDIs, read-only si inactiva |
| `src/middlewares/feature-gate.middleware.ts` | `requireFeature('reportes')` por plan |
| `src/utils/global-admin.ts` | Admin global = tenant con RFC `HTS240708LJA` |
| `packages/shared/src/constants/roles.ts` | `GLOBAL_ADMIN_RFC` + `isGlobalAdminRfc()` compartido frontend/backend |
### Roles (tabla `roles` en BD central)
| id | nombre | Label UI | Acceso |
|----|--------|----------|--------|
| 1 | `owner` | Dueño | Todo + gestión usuarios + configuración + reportes |
| 7 | `cfo` | CFO | Todo (mismo nivel que owner) |
| 2 | `contador` | Contador | Dashboard, CFDI, Impuestos, Calendario, Alertas, Conciliación, Facturación (puede completar alertas y crear recordatorios) |
| 8 | `auxiliar` | Auxiliar | Mismos permisos que contador |
| 3 | `visor` | Visor | CFDI, Impuestos, Calendario, Alertas, Conciliación (solo lectura) |
**"Admin global" (platform staff) — tabla `user_platform_roles`:** staff interno de Horux 360 con acceso transversal. 5 roles en enum `PlatformRole`:
- `platform_admin` — Todo (gestión staff, precios, clientes, facturas)
- `platform_ti` — Mismos permisos que admin (equipo TI, trazabilidad distinta en audit)
- `platform_support` — Ver tenants, tickets; NO facturación/precios
- `platform_sales` — Crear/editar clientes; NO precios
- `platform_finance` — Pagos, facturas manuales, editar precios
`platform_admin` y `platform_ti` son **supersets** (implican todos los demás). Se checa vía helper: `hasPlatformRole(userId, role)`, `canManageTenants()`, `canEditPrices()`, `canEmitInvoicesManual()`, `isPlatformStaff()` en `apps/api/src/utils/platform-admin.ts`. JWT incluye `platformRoles[]` al login.
**`isGlobalAdmin()` compat:** `apps/api/src/utils/global-admin.ts` es shim que re-exporta de `platform-admin.ts` — resuelve "admin global" vía tabla (busca rol superset), fallback a RFC `HTS240708LJA` si tabla vacía. El concepto UX "admin global" se preserva; la implementación migró de hardcode a tabla.
**`isGlobalAdminRfc(rfc, role, platformRoles?)` en shared:** tercer parámetro opcional — prioriza `platformRoles` si existe, fallback al RFC check.
**UI `/admin/staff`:** gestión de roles (solo platform_admin/platform_ti). Protección "último superset": no te puedes quitar si serías el único con acceso transversal. Ver `docs/plans/2026-04-14-platform-admin-roles.md` — sección "Implementación ejecutada".
### Planes (5 planes)
| Plan | CFDIs | Usuarios | Features |
|------|-------|----------|----------|
| starter | 0 | 1 | dashboard, cfdi_basic, iva_isr |
| business | 50 | 3 | + reportes, alertas, calendario, conciliacion, forecasting, xml_sat, documentos |
| business_ia | 50 | 3 | Mismas que business + Lolita (feature `ia_lolita`, agente IA fiscal) |
| custom | 50 | 3 | Mismas que business_ia, precio variable por tenant |
| enterprise | 100 | ilimitado | + api |
Cada empresa requiere su propia suscripción. `multi_empresa` no existe como feature.
### Suscripciones (self-serve con MercadoPago)
**Precios:** tabla `plan_prices` en BD central — 8 filas (4 planes × monthly/annual). Custom no se guarda aquí (el admin fija monto por tenant). Editables desde `/configuracion/suscripcion` (admin global) o SQL directo. Los cambios aplican solo a suscripciones nuevas; vigentes conservan su amount.
**Estados de `Subscription.status`:** `trial`, `trial_converted`, `trial_expired`, `pending`, `authorized`, `paused`, `cancelled`.
**Flujos self-serve (endpoints `/api/subscriptions/me/*`):**
- `trial` — 30 días gratis sin tarjeta. Una sola vez **por RFC** (padrón persistente `trial_usages` — sobrevive borrado/recreación del tenant, bloquea abuso).
- `subscribe` — crea preapproval MP con `auto_recurring` mensual (`months/1`) o anual (`months/12`).
- `change` — downgrades y cambios de frecuencia van a `pendingPlan`/`pendingEffectiveAt = currentPeriodEnd`.
- `upgrade` — plan más caro con misma frecuencia cobra prorateo inmediato vía MP Preference + `updatePreapprovalAmount` al recibir webhook. `external_reference = 'proration:${tenantId}:${subscriptionId}'` es el marcador que el webhook usa.
- `cancel``status=cancelled` + `cancelPreapproval` en MP. Middleware respeta `currentPeriodEnd`.
- `reactivate` — revive suscripción cancelada dentro del período pagado. Crea preapproval nuevo con `start_date=currentPeriodEnd` (sin doble cobro). Limpia pending/upgrade residuales.
**Cron `applyPendingChanges` + `expireTrials`:** 2:30 AM daily (`30 2 * * *`) — aplica cambios programados y transiciona trials vencidos.
**Auth pattern:** endpoints `/subscriptions/:tenantId`, `/:tenantId/payments`, `/:tenantId/generate-link` usan `requireOwnTenantOrGlobalAdmin` (el dueño puede consultar su propia sub, admin global puede ver cualquiera). `mark-paid`, `listar todas`, `editar precios` solo admin global.
**Webhook `external_reference` routing:**
- `proration:*``applyApprovedUpgrade` (upgrade payment confirmado)
- `${tenantId}` UUID → flujo recurrente
**Plan Custom:** sólo lo activa el admin global al provisionar tenant. El self-serve self-serve lo rechaza con error explícito.
**Auto-facturación de pagos (`invoicing.service.ts`):** cada webhook `payment.approved` dispara `emitInvoiceIfApplicable(payment.id)`. Emite CFDI al público en general vía Facturapi (org de Horux 360, RESICO PM, clave prod/serv `81112502`, `use: S01`). **El primer pago aprobado de cada tenant se skip-ea** (lo factura manual el admin para capturar datos fiscales); los subsecuentes van auto. Idempotente por `Payment.facturapiInvoiceId`. Fail-soft (error en Facturapi → log + invoice null + webhook sigue retornando 200). Ver `docs/plans/2026-04-13-auto-invoicing-mp-payments.md`.
Ver `docs/plans/2026-04-13-subscriptions-self-serve.md` para diseño completo y flujos.
**Schema tenant — single source of truth:** las migraciones SQL en `apps/api/src/migrations/tenant/*.sql` son el único lugar donde se declara el schema de las BDs tenant. El seed (`prisma/seed.ts`) ya NO hardcodea CREATE TABLE — llama a `migrate()` del runner. Añadir una migración = crear `NNN_descripcion.sql`. Se aplica eager via `pnpm db:migrate-tenants` y lazy en `getPool()`.
**Audit log (`utils/audit.ts`):** helper `auditLog()`/`auditFromReq()` fire-and-forget que escribe a tabla `audit_log` (BD central). Instrumentado en 12+ eventos críticos (login, subscribe, cancel, plan change, price edit, invoice auto, password reset, etc.). UI admin global en `/admin/audit-log` con filtros + expand JSON metadata. NUNCA debe romper la acción principal — cualquier fallo al escribir se logea en consola pero no re-lanza. Ver `docs/plans/2026-04-14-audit-log.md`.
**Recuperación de contraseña:** `/login` tiene link "¿Olvidaste tu contraseña?" → `/forgot-password` (solicita token por email) → `/reset-password?token=xxx` (nueva password). Backend: tabla `password_reset_tokens` (32 bytes hex, 1h expiry, single-use), endpoints `POST /auth/password-reset/{request,confirm}` con rate limits 3/h y 10/h. Anti-enumeration (respuesta genérica). Al confirmar reset se borran todos los refresh tokens del user. SMTP logea a consola en dev — copia el link del log del API para testing local. Ver `docs/plans/2026-04-14-password-reset.md`.
**Rate limiting por endpoint (`middlewares/rate-limit.middleware.ts`):** 4 tiers con key por `userId` (no IP, para no bloquear usuarios detrás de NAT compartido). Admin global (superset `platform_admin`/`platform_ti`) exento vía `skip`. Tiers: `veryStrictLimit` (2/día) en sync SAT manual y opinión cumplimiento; `strictLimit` (10/h) en emisión/cancelación factura, CFDI bulk, subs subscribe/change/upgrade, password-change; `normalLimit` (100/15min) a router level en dashboard/reportes/impuestos; `relaxedLimit` (500/15min) en catalogos/regimenes. `/auth/login` y `/auth/register` conservan sus propios limiters específicos. Headers `RateLimit-*` estándar. Key generator: usa `ipKeyGenerator(req.ip)` de `express-rate-limit` para el fallback anónimo — normaliza IPv6 correctamente (sin esto el lib emitía warning de potential bypass). Frontend (`lib/api/client.ts`) preserva el `message` del 429 para los try/catch existentes. Ver `docs/plans/2026-04-14-rate-limiting-expansion.md`.
**Input validation con Zod (defense-in-depth):** todos los controllers críticos que reciben `req.body` validan el shape con Zod antes de pasar al service. Prisma ya protege de SQL injection, pero Zod evita que un payload malformado llegue hasta la capa de persistencia y produzca 500s o comportamiento raro. Cobertura: `auth.controller` (register/login/refresh/reset/change/logout-all/switch-tenant), `bancos.controller` (create/update), `alertas.controller` (create/update), `calendario.controller` (create/update recordatorio), `usuarios.controller` (invite/update/updateGlobal), `tenants.controller` (addMyTenant), `subscription.controller` (varios), `platform-staff.controller`. Patrón uniforme: schema arriba del archivo, `.parse(req.body)` en el handler, catch `z.ZodError``next(new AppError(400, error.errors[0].message))`. Los schemas tipo `UserInvite`/`UserUpdate` en `@horux/shared` se mantienen alineados con los Zod enums.
**Helmet configurado explícitamente (`app.ts`):** `helmet({ contentSecurityPolicy: false, crossOriginResourcePolicy: 'cross-origin' })`. CSP se desactiva en el API porque (a) el API solo sirve JSON y binarios (PDF/XML) sin contenido HTML que requiera CSP, y (b) el default chocaba con el iframe del PDF que el frontend embebe en `/terminos`. Los security headers del frontend (Next) siguen siendo la capa de CSP activa: ver `apps/web/next.config.js` con `X-Frame-Options: SAMEORIGIN`, `Content-Security-Policy: frame-ancestors 'self'`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, HSTS.
**Multi-tenant memberships (owner-multi-rfc, en progreso):** Nueva tabla `tenant_memberships (user_id, tenant_id, rol_id, is_owner, active, joined_at)` con `@@unique([userId, tenantId])`. Permite que un mismo user pertenezca a múltiples tenants con rol distinto en cada uno. Backfilled idempotente desde `User.tenantId+rolId` (1 membership por user existente). `User.tenantId` y `User.rolId` se **mantienen** durante la transición como "default tenant" para UX al login. Login y refresh responses incluyen `user.tenants: [{id, nombre, rfc, plan, role, isOwner}]` derivado de memberships activas. Endpoint `POST /auth/switch-tenant { tenantId, refreshToken }` valida membership, revoca el refresh actual, emite nuevo par apuntando al target con el rol de ese tenant (audit event `user.tenant_switched`). Helpers en `apps/api/src/utils/memberships.ts`: `getUserTenants()`, `verifyMembership()`. Frontend: `MembershipSwitcher` en el header (`apps/web/components/membership-switcher.tsx`) visible cuando `tenants.length > 1` y NO es admin global; click llama `switchTenant` + `queryClient.clear()` + reload. Coexiste con `TenantSelector` (admin global usa impersonación X-View-Tenant, modelo distinto). Página `/mis-empresas` (owner-only) lista solo tenants donde `isOwner=true` con su subscripción joined; modal "Agregar empresa" llama `POST /api/tenants/mine` (gateado por `isOwnerSomewhere(userId)` en backend → 403 si no eres owner en ningún lado). Sidebar item "Mis empresas" usa flag `requireOwnerSomewhere` (no `roles[]`) para que un híbrido contador+owner siga viéndolo desde cualquier contexto activo. Helpers: `addTenantToOwner()`, `getMyTenantsDetailed()` en `tenants.service.ts`; `isOwnerSomewhere()`, `getTenantOwnerEmail()` en `utils/memberships.ts`. Trial gate por owner: `startTrial()` acepta `ownerUserId` opcional — si se pasa, busca otro tenant donde el user es owner activo Y tiene `trialEndsAt` set → si encuentra, rechaza con mensaje citando el RFC previo. Combinado con `trial_usages` (gate por RFC), cubre todos los vectores de abuso del trial. **F6 cleanup completo (2026-04-14):** `User.tenantId` y `User.rolId` **eliminados** del schema. `User.lastTenantId String?` agregado para "remember last tenant" UX. `auth.login` resuelve activeMembership desde `tenant_memberships` (prefiere lastTenantId, fallback al primer membership por joinedAt). 5 callsites de subscription.service migrados a `getTenantOwnerEmail()`. usuarios.service todo via memberships. Fases 1-6 ✅ (refactor completo). Ver `docs/plans/2026-04-14-owner-multi-rfc-subscriptions.md`.
**Sistema de emails (`apps/api/src/services/email/`):** transporte SMTP via Nodemailer (config en `.env`: `SMTP_HOST/PORT/USER/PASS`). Si SMTP no está configurado, los emails se logean a consola — útil en dev. 9 templates en `templates/`: `welcome`, `password-reset`, `payment-confirmed`, `payment-failed`, `subscription-cancelled`, `subscription-expiring`, `fiel-notification`, `new-client-admin`, `weekly-update`. Layout común en `base.ts` con identidad visual de horux360.com: header con gradiente azul→morado (`#2563EB → #7C3AED`), tipografía Inter (Google Fonts con fallback a system-ui), tokens de marca exportados como `BRAND_COLORS`. Helpers `primaryButton`, `infoBox`, `heading` para componentes consistentes. **Importante:** las familias de fuentes en `style="..."` deben usar comillas simples (`'Inter', ...`) — comillas dobles rompen el atributo HTML. Para previsualizar sin SMTP: `pnpm email:preview` genera HTMLs estáticos en `apps/api/email-previews/` (gitignored) con datos de ejemplo + index navegable. Ejecutar tras cualquier cambio de template para verificar visualmente.
**Reporte semanal automático (`weekly-update.job.ts`):** cron Lunes 8:00 AM (`0 8 * * 1` America/Mexico_City) que itera todos los tenants activos y envía a cada owner activo el correo "Actualización semanal — {empresa}". Solo arranca en `NODE_ENV=production`. Contenido: KPIs del mes en curso (ingresos/egresos/utilidad/IVA balance/CFDIs emitidos+recibidos vía `getKpis()`), alertas activas (rojo/ámbar/azul por prioridad vía `generarAlertasAutomaticas()`), y breakdown de discrepancias de régimen por mes en los últimos 6 meses (`getDiscrepanciasPorMes(pool, tenantId, 6)`). Recipientes resueltos vía `tenant_memberships.where(isOwner=true, active=true)`. Por-tenant + por-recipient try/catch — un fallo individual no bloquea al resto. `sendWeeklyUpdateForTenant(tenantId)` exportado para disparo manual desde un endpoint admin futuro.
**Revocación de JWT (tokenVersion):** `User.tokenVersion` (entero, default 0) se incluye en cada JWT. `authenticate` middleware compara `payload.tokenVersion` contra el actual en BD (cache 30s + broadcast PM2 via `process.send`). Incrementar el version invalida **todos** los access tokens del user en el siguiente request. Disparadores: (1) cambio de password autenticado (`POST /auth/password-change`), (2) "cerrar todas las sesiones" (`POST /auth/logout-all`), (3) confirmar password reset (`POST /auth/password-reset/confirm`). UI en `/configuracion/seguridad`. Backward compat: JWTs pre-deploy no incluyen el campo → se interpreta como 0 → matchea con default, no hay re-login forzado masivo. Ver `docs/plans/2026-04-14-jwt-revocation.md`.
### Lógica fiscal (lo más complejo)
| Archivo | Qué hace |
|---------|----------|
| `src/services/dashboard.service.ts` | KPIs: ingresos/egresos/IVA/adquisición mercancías por régimen, IVA a favor acumulado. Filtro de conciliación. |
| `src/services/impuestos.service.ts` | IVA mensual, resumen IVA/ISR, coeficiente de utilidad |
| `src/services/cfdi.service.ts` | CRUD CFDIs, filtros, búsqueda, resúmenes, bulk insert |
| `src/services/conciliacion.service.ts` | Conciliación bancaria: vincular CFDIs con pagos |
| `src/services/bancos.service.ts` | CRUD bancos por tenant |
| `src/services/reportes.service.ts` | Estado de resultados, flujo efectivo, comparativo, CxP, CxC |
| `src/services/alertas-auto.service.ts` | Alertas automáticas: lista negra, concentración, discrepancias, cancelación retroactiva, TipoRelacion sospechoso, tareas próximas a vencer |
| `src/services/calendario-fiscal.service.ts` | Genera calendario fiscal desde catálogo + días inhábiles |
| `src/services/regimen.service.ts` | Catálogo de regímenes, configuración activos/ignorados por tenant |
| `src/services/recordatorios.service.ts` | CRUD recordatorios custom por tenant (público/privado) |
| `src/services/tareas.service.ts` | Tareas operativas recurrentes por contribuyente con materialización lazy de periodos |
| `src/services/papeleria.service.ts` | Papelería de trabajo: archivos por contribuyente con flujo opcional de aprobación |
| `src/services/despacho-stats.service.ts` | Métricas del módulo Despacho: contribuyentes, mis-asignados, equipo (jerárquico) |
| `src/services/notification-preferences.service.ts` | Preferencias de email por contribuyente (JSONB en `contribuyentes`) |
### Claves de concepto excluidas de cálculos fiscales
Los conceptos con las siguientes `clave_prod_serv` se excluyen de **todos** los cálculos de ingresos, egresos, IVA e ISR (dashboard + impuestos, base y conciliación):
- `84121603` — Seguros
- `93161608` — Servicios gubernamentales
- `85101501` — Servicios de salud
- `85121800` — Servicios médicos
Implementado con subqueries correlacionados a `cfdi_conceptos` que restan los montos de conceptos excluidos por CFDI. Si un CFDI tiene otros conceptos válidos, solo se excluyen los que coinciden con estas claves.
### Regla clave: régimen del tenant en queries fiscales
- `type = 'EMITIDO'` → el tenant es el emisor → usar `regimen_fiscal_emisor`
- `type = 'RECIBIDO'` → el tenant es el receptor → usar `regimen_fiscal_receptor`
### Filtro de Conciliación (Dashboard + Impuestos)
Toggle global que cambia el comportamiento de todas las métricas:
- **Desactivado (default):** Usa `fecha_emision` y todos los CFDIs vigentes
- **Activado:** Solo CFDIs con `id_conciliacion IS NOT NULL`, usa `fecha_de_pago` de tabla `conciliaciones`
- Implementado con `getFechaRango(conciliacion)` que retorna el SQL fragment correcto
- Afecta: ingresos, egresos, adquisición mercancías, IVA balance, IVA mensual, resumen IVA, resumen ISR, IVA a favor, regímenes del periodo, chart ingresos/egresos
### Métricas del Dashboard
- **Ingresos del Mes** — Por régimen del emisor, filtrable
- **Gastos del Mes** — Por régimen del receptor, filtrable
- **Adquisición de Mercancías** — Egresos con uso_cfdi G01, por régimen, filtrable
- **Utilidad** — Ingresos - Egresos
- **Balance IVA** — Causado - Acreditable, por régimen
- **CFDIs Emitidos/Recibidos** — Contadores
- **IVA a Favor** — Acumulado anual e histórico (5 años)
### Métricas de Impuestos
**IVA:** Trasladado, Acreditable, Retenido, Resultado, Acumulado Anual — todos filtrables por régimen con `trasladadoPorRegimen`, `acreditablePorRegimen`, `retenidoPorRegimen`
**ISR:** Ingresos acumulados, Deducciones, Base gravable, ISR causado, ISR retenido, ISR a pagar — todos filtrables por régimen con `ingresosPorRegimen`, `deduccionesPorRegimen`, `baseGravablePorRegimen`
### Conciliación — reglas importantes
- **Recibidos:** excluye PPD (solo PUE y pagos)
- **Emitidos:** excluye PPD para todos los regímenes excepto 605 y 616
- **Facturas tipo P:** usan `monto_pago_mxn` en vez de `total_mxn`
- **Auto-conciliación PPD:** cuando una factura P con `saldo_pendiente = 0` se concilia, la PPD relacionada (via `uuid_relacionado`) se auto-concilia con los mismos datos.
- **Score cards:** PPD auto-conciliadas tienen `montoMxn = 0` para no duplicar montos
### SAT — Sincronización
| Archivo | Qué hace |
|---------|----------|
| `src/services/sat/sat.service.ts` | Orquestación: sync jobs, chunking inteligente, saveCfdis, saveMetadata |
| `src/services/sat/sat-client.service.ts` | HTTP client SAT: query, verify, download. DocumentStatus 'active' para XMLs |
| `src/services/sat/sat-parser.service.ts` | Parseo de XML CFDI + metadata CSV del SAT |
| `src/services/fiel.service.ts` | FIEL: upload, encriptación AES-256-GCM, validación |
| `src/jobs/sat-sync.job.ts` | Cron 03:00 AM: sync automático para todos los tenants con FIEL |
**Cuatro modos de sincronización:**
| Modo | Cuándo | XMLs | Metadata |
|------|--------|------|----------|
| `initial` | Primera sync del tenant | Sondeo → bloques 3/6 meses | Bloques de 3 años |
| `daily` | Cron 3:00 AM (todos los planes) | Últimos 7 días | Año fiscal completo (ene → hoy) |
| `incremental` | Cron 11:00/15:00/19:00 (solo plan Enterprise) | Últimas 8 horas | Últimas 8 horas |
| `custom` (rango personalizado) | Botón en UI | Directo si ≤6m, bloques de 6m si >6m | Rango completo |
**Incremental Enterprise:** Reduce latencia de CFDIs nuevos a ~4h en horario laboral. Ventana de 8h cubre el gap 03:00→11:00 y solape con disparos siguientes (dedup por UUID). Requiere `initial` completado previamente — el job se omite si el tenant no tiene backfill. Ver `docs/plans/2026-04-13-sat-incremental-enterprise.md`.
**Flujo de sincronización (initial):**
1. Sondeo: metadata del rango completo (2 solicitudes) → determina volumen
2. XMLs vigentes: bloques de 6 meses (≤15k CFDIs) o 3 meses (>15k)
3. Metadata vigentes+cancelados: bloques de 3 años
4. XMLs se guardan en disco (`data/xmls/<rfc>/<tipo>/<packageId>/`) antes de procesar
5. RFCs se upsert en tabla `rfcs` con `rfc_emisor_id`/`rfc_receptor_id` FK
6. Metadata: inserta CFDIs cancelados sin XML, actualiza status de existentes
**Retry automático en timeouts:**
- Poll interval: 60 segundos, máximo 30 intentos (30 min)
- Si timeout: job queda `pending` con `nextRetryAt` en +6 horas
- Cron horario revisa y reintenta (máx 3 veces)
- Tras 3 fallos: "Fallo conexión SAT, vuelve a intentar con un rango de fechas menor."
**Pool management:** `ctx.getPool()` (no estático) para mantener `lastAccess` fresco durante syncs largos.
### Facturapi — Emisión de facturas
| Archivo | Qué hace |
|---------|----------|
| `src/services/facturapi.service.ts` | Facturapi: organizaciones, CSD, clientes, emisión, cancelación, descargas, logo, color, envío email |
| `src/controllers/facturacion.controller.ts` | Endpoints: emitir, cancelar, PDF/XML, timbres, búsqueda RFCs, conceptos previos, datos fiscales, personalización |
| `src/controllers/catalogos.controller.ts` | Catálogos SAT: forma pago, uso CFDI, clave prod/serv (con búsqueda sin acentos), unidades, etc. |
| `src/routes/facturacion.routes.ts` | Rutas de facturación con tenantMiddleware |
| `src/routes/catalogos.routes.ts` | Rutas de catálogos SAT |
**Modelo Facturapi:**
- Una cuenta Horux360 (User Key `sk_user_...`) con organizaciones por tenant
- `tenants.facturapi_org_id` vincula tenant ↔ organización Facturapi
- API key de org se obtiene via HTTP directo (`/v2/organizations/{id}/apikeys/test`) — el SDK tiene bugs con este endpoint
- CSD (Certificado de Sello Digital) se sube por organización (no requerido en modo test)
- Timbres controlados por `timbre_suscripciones` (50/mes o 600/año, pool del plan) + `timbre_paquetes` (compras adicionales, vigencia 1 año). Catálogo de paquetes vendibles en `timbre_paquetes_catalogo` (100/$200, 1000/$1400, 10000/$8600 por default, editable por admin global en `/configuracion/precios-timbres`). Pool mensual se **resetea a 0 al final del periodo** (cron diario 2:30 AM via `resetExpiredMonthlyTimbres`, no acumulable). `consumeTimbre(tenantId)` en `$transaction`: primero pool mensual, luego `TimbrePaquete` con menor `expiraEn` (FIFO anti-desperdicio). Compra self-serve en `/facturacion/timbres` (owner/cfo): `POST /api/facturacion/timbres/paquetes/comprar` crea `Payment(kind=timbres_pack)` + MP Preference con `external_reference=timbres-pack:{paymentId}`. El webhook MP aprueba el Payment, llama `activarPaqueteTrasPago` (crea `TimbrePaquete` con `expiraEn = now + 1 año`), y dispara `invoicingService.emitInvoiceIfApplicable` que ramifica por `Payment.kind` (concepto "{cantidad} timbres adicionales" vs "Suscripción {plan}"). `Payment.kind` enum (`subscription | timbres_pack`) discrimina flujos en un solo modelo.
- Al emitir: guarda en `cfdis` con `source: 'facturapi'` + `facturapi_id`, upsert RFCs con regimen/CP
- Envío automático por email al receptor si tiene email configurado
- **Cancelación:** botón en `/cfdi` para facturas con `source='facturapi'` y status vigente. Modal con los 4 motivos SAT (02 = errores sin relación, default; 01 = errores con relación, requiere UUID sustituto; 03 = no se llevó a cabo; 04 = incluida en factura global). `POST /facturacion/cancelar/:uuid` con `{ motive, substitution? }`. El estatus en BD local pasa a `Cancelado` al confirmar, pero el SAT puede dejarla en "pendiente" si requiere aceptación del receptor (copy en el modal lo advierte). Distinta a `DELETE /cfdi/:id` que solo borra el registro local.
- Personalización por organización: logo (PNG/JPG ≤2MB) y color (HEX)
**Catálogos SAT en BD central (9 tablas):**
- `cat_forma_pago` (22), `cat_metodo_pago` (2), `cat_uso_cfdi` (24), `cat_moneda` (7)
- `cat_clave_unidad` (26), `cat_clave_prod_serv` (52,513 — importado de phpcfdi/resources-sat-catalogs), `cat_objeto_imp` (4)
- `cat_tipo_relacion` (7), `cat_exportacion` (4)
- Búsqueda de `cat_clave_prod_serv` soporta búsqueda sin acentos (regex PostgreSQL)
**Tipos de comprobante soportados:**
| Tipo | Secciones dinámicas |
|------|-------------------|
| I - Ingreso | Conceptos con traslados/retenciones por concepto (IVA, ISR, IEPS, impuestos locales), exportación, serie/folio, condiciones, descuento, objeto de impuesto. Factura global (periodicidad, mes, año). Autocompletar receptor desde tabla RFCs. Búsqueda de conceptos previos en facturas. |
| E - Egreso | Igual que Ingreso + documento relacionado (UUID + tipo relación 01-04) |
| P - Pago | Solo comprobante (tipo+serie/folio) + receptor + complemento de pago. Autocompletar UUID desde CFDIs PPD pendientes del receptor con saldo. Desglose IVA del pago (base, tasa, monto). |
| T - Traslado | Solo comprobante (tipo+serie/folio) + receptor + conceptos sin precio/impuestos. Unidades filtradas (sin servicio). |
**Recomendación automática de retenciones (tipo I):**
- Emisor PF (13 chars RFC) con régimen 612, 626, o 606
- Receptor PM (12 chars RFC) con régimen distinto a 612/626/606
- Unidad de servicio (E48, ACT)
- → Auto-agrega: IVA retenido 10.6667% (2/3) + ISR retenido (1.25% RESICO, 10% otros)
**Cliente extranjero:**
- Detectado al escribir RFC `XEXX010101000`
- Auto-llena: régimen 616, CP del tenant
- Campos adicionales: Tax ID extranjero + País (selector con ~50 países)
- Backend envía `country` en address, Facturapi agrega RFC genérico extranjero
**Factura global:**
- Toggle en tipo I que auto-llena receptor con PUBLICO EN GENERAL (XAXX010101000, régimen 616)
- Campos: periodicidad (day/week/fortnight/month/two_months), mes (01-18), año
- CP se auto-llena desde datos fiscales del tenant
**Datos fiscales del tenant (tabla `tenants`):**
- Columnas: codigo_postal, calle, num_exterior, num_interior, colonia, ciudad, municipio, estado, telefono
- Editable desde Configuración > Domicilio Fiscal
- Se usa para: CP en facturas globales, datos del emisor
### Calendario y Recordatorios
| Archivo | Qué hace |
|---------|----------|
| `src/controllers/calendario.controller.ts` | GET eventos fiscales + recordatorios custom, CRUD recordatorios |
| `src/services/recordatorios.service.ts` | CRUD recordatorios en tabla `recordatorios` del tenant |
**Recordatorios custom:**
- Solo fechas únicas (sin recurrencia)
- Público (visible para todo el equipo) o privado (solo el creador)
- Admin y contador pueden crear/editar/eliminar
- Estilo morado en el calendario
- Se mezclan con eventos fiscales generados, ordenados por fecha
**Recordatorios automáticos de e.firma:**
- Al subir la FIEL, se crean 3 recordatorios públicos: 60, 30 y 7 días antes del vencimiento
- Prefijo `[e.firma]` en el título para identificarlos
- Al re-subir la FIEL, se eliminan los anteriores y se crean nuevos
- Solo se crean si la fecha no ha pasado
### Documentos — Opinión de Cumplimiento
| Archivo | Qué hace |
|---------|----------|
| `src/services/opinion-cumplimiento.service.ts` | Orquestación: decrypt FIEL → Playwright headless → scrape PDF → parse → guardar en BD tenant |
| `src/services/sat/sat-opinion-login.ts` | Playwright: navegación portal SAT → login FIEL → reporte opinión |
| `src/services/sat/sat-opinion-scraper.ts` | 4 estrategias para extraer PDF base64 del DOM Angular del SAT |
| `src/services/sat/sat-opinion-parser.ts` | Parseo de texto del PDF con regex (RFC, razón social, estatus, folio, cadena original) |
| `src/controllers/documentos.controller.ts` | Endpoints: listar opiniones, descargar PDF, trigger manual |
| `src/routes/documentos.routes.ts` | Rutas con tenantMiddleware + requireFeature('documentos') |
**Cron semanal:** Domingos 4:00 AM (`0 4 * * 0`). Descarga opinión para todos los tenants con FIEL. Limpia registros > 6 meses.
**Seguridad FIEL:** Archivos temporales con `0o600` en tmpdir con UUID. Cleanup garantizado en `finally`. Contraseña solo en memoria. Playwright headless. Timeout 3 min por tenant.
**Almacenamiento:** PDF completo en BYTEA en tabla `opiniones_cumplimiento` (BD tenant). 6 meses de retención. Frontend muestra últimas 5.
**Alerta automática:** Si última opinión no es Positiva → alerta alta en alertas automáticas.
**Feature gate:** `documentos` — disponible en planes Business y Enterprise.
### Documentos — Declaraciones Provisionales
| Archivo | Qué hace |
|---------|----------|
| `src/migrations/tenant/003_create_declaraciones_provisionales.sql` | Tabla `declaraciones_provisionales` con 1 normal única por (año, mes) + N complementarias |
| `src/migrations/tenant/004_declaraciones_liga_pago_pdf.sql` | Reemplaza `link_pago TEXT` por `pdf_liga_pago BYTEA` + filename (la liga de pago es PDF, no URL) |
| `src/services/declaraciones.service.ts` | CRUD + auto-resolución de alertas por impuesto/mes + purge 5 años |
Flujo: el contador sube 3 PDFs por mes — **declaración** (obligatorio), **liga de pago** (opcional), **comprobante de pago** (opcional). Al subir la declaración se marcan como resueltos los recordatorios `decl-<impuesto>-<mes>` correspondientes a los impuestos seleccionados (IVA, ISR, IEPS, SUELDOS, DIOT, OTRO). Si es tipo `complementaria`, también se resuelven los recordatorios `pago-*` del mes (la complementaria sustituye a la normal en pago). Al subir comprobante de pago después, se resuelven los `pago-*` del mes. Retención 5 años (CFF Art. 30) purgada en cron lifecycle diario 2:30 AM. El flujo manual de "marcar como realizado" desde /alertas se mantiene para usuarios que no quieran subir documento.
UI: pestaña "Declaraciones Provisionales" en `/documentos` con selector de año, tabla mensual con badges tipo/impuestos y botones descarga/subir-pago/eliminar. Roles con upload: owner, cfo, contador, auxiliar.
### Documentos — Constancia de Situación Fiscal (CSF)
| Archivo | Qué hace |
|---------|----------|
| `src/migrations/tenant/005_create_constancias_situacion_fiscal.sql` | Tabla con PDF BYTEA + datos JSONB (shape completo) + retención 5 años |
| `src/services/sat/sat-csf-login.ts` | Playwright: página pública SAT → popup SERVICIO → login FIEL (con retry `dispatchEvent` si el click sintético se pierde) |
| `src/services/sat/sat-csf-scraper.ts` | Busca "Generar Constancia" en cualquier `frame()` (vive en iframe JSF legacy `rfcampc.siat.sat.gob.mx`). 3 rutas: download event, popup viewer, response interception |
| `src/services/sat/sat-csf-parser.ts` | Parser PF+PM: labels key:value + 3 tablas (actividades/regímenes/obligaciones, agrupadas por "chunk termina en dd/mm/yyyy") + sellos |
| `src/services/constancia.service.ts` | Orquestación + `sincronizarDatosFiscales(tenantId, csf)` — auto-fill domicilio tenant (codigoPostal, calle, numExterior/Interior, colonia, ciudad, municipio, estado) y regímenes activos (matcheados contra catálogo `regimenes` por nombre normalizado) |
**Cron mensual:** Día 1 de cada mes 04:00 AM (`0 4 1 * *`). Descarga CSF para todos los tenants con FIEL. Por-tenant try/catch — un fallo no bloquea al resto.
**Retención:** 5 años purgados junto con declaraciones en cron lifecycle diario 2:30 AM.
**Trigger on first-upload FIEL:** En `fiel.service.ts`, al primer upload exitoso (`existingFiel` nulo o inactivo) se disparan en background Opinión de Cumplimiento + CSF con `import()` fire-and-forget. No bloquea la respuesta al usuario.
**Headless por default:** `chromium.launch({ headless: true })`. El fix clave es en `sat-csf-login.ts`: el click sintético a "e.firma" del portal SAT a veces no dispara el handler, por eso se espera a que aparezca `input[type=file]` (10s) y si no llega, reintenta con `dispatchEvent('click')`. Para debug visual temporal, setear `SAT_HEADLESS=false` en `.env`.
**UI:** pestaña "Constancia de Situación Fiscal" en `/documentos` con último CSF expandido (identificación, domicilio, regímenes activos, obligaciones), historial de 12 con detalle desplegable, descarga PDF, y botón "Consultar ahora" (owner/cfo). La UI refleja `datos: JSONB` de la BD sin re-parsear el PDF.
**Shape `ConstanciaSituacionFiscal`:** rfc, curp?, idCIF, nombre?/primerApellido?/segundoApellido?/razonSocial?, estatusPadron, fechaInicioOperaciones, lugarFechaEmision, domicilio (11 campos), actividadesEconomicas[], regimenes[], obligaciones[], cadenaOriginalSello, selloDigital.
### Frontend
| Archivo | Qué hace |
|---------|----------|
| `apps/web/lib/api/client.ts` | Axios instance con auto-refresh JWT + `X-View-Tenant` |
| `apps/web/stores/auth-store.ts` | Zustand: user, tokens, logout (persist localStorage) |
| `apps/web/stores/tenant-view-store.ts` | Zustand: impersonación de tenant (admin global) |
| `apps/web/stores/theme-store.ts` | Zustand: tema visual (light/dark) |
| `apps/web/lib/export-excel.ts` | Utilidad client-side para export Excel |
| `apps/web/lib/hooks/use-facturacion.ts` | Hooks para facturación + catálogos SAT |
| `apps/web/lib/hooks/use-calendario.ts` | Hooks para calendario + recordatorios |
**Tenant-aware query keys:** Todos los hooks de datos (`use-dashboard`, `use-bancos`, `use-calendario`, `use-facturacion`) incluyen `viewingTenantId` en los query keys de React Query para refetchear al cambiar de empresa.
**Temas:** Solo Light y Dark habilitados. Fondo Light = lavanda sutil (270 50% 98%).
---
## Convenciones importantes
### Cálculos fiscales por régimen
Los ingresos/egresos/IVA se calculan de forma diferente según el grupo de régimen:
- **PF Empresarial (606, 612, 621, 625, 626):** Facturas PUE + Pagos - Notas de crédito PUE
- **Sueldos (605):** Nóminas recibidas PUE
- **PM y otros (601, 603, 607...):** Facturas PUE+PPD - Notas de crédito PUE
Los montos se calculan **sin impuestos** (total - IVA trasladado - IEPS - impuestos locales).
Los montos en MXN se usan siempre para cálculos (campo `_mxn`).
### Convenciones de BD
- BD central: nombres en `snake_case` mapeados por Prisma a `camelCase`
- BD tenant: SQL directo, alias explícitos en queries (`rfc_emisor as "rfcEmisor"`)
- `CFDI_SELECT` constant en `cfdi.service.ts` define todos los campos mapeados
- Status vigente: `WHERE status NOT IN ('Cancelado', '0')`
### Tenant provisioning
Al crear un tenant (`TenantConnectionManager.provisionDatabase(rfc)`):
1. Crea BD `horux_<rfc_lowercase>`
2. Ejecuta `createTables()`: crea `rfcs`, `bancos`, `cfdis`, `cfdi_conceptos`, `conciliaciones`, `alertas`, `recordatorios`
3. Ejecuta `createIndexes()`: índices B-tree + trigram (pg_trgm) + FK diferida para `id_conciliacion`
### Tablas por tenant
| Tabla | Propósito |
|-------|-----------|
| `rfcs` | Catálogo de RFCs (id, rfc, razon_social, regimen_fiscal, codigo_postal) |
| `bancos` | Cuentas bancarias (id, banco, terminacion_cuenta) |
| `cfdis` | Facturas electrónicas (100+ columnas, incluye conciliado, id_conciliacion, facturapi_id, source, cfdi_tipo_relacion, cfdis_relacionados, saldo_pendiente_mxn) |
| `cfdi_conceptos` | Líneas de detalle por CFDI |
| `cfdi_descartados` | CFDIs marcados como ignorados por tipo de alerta (whitelist por contador) |
| `conciliaciones` | Registros de conciliación (id, anio, mes, id_cfdi, fecha_de_pago, id_banco) |
| `alertas` | Alertas manuales persistidas |
| `recordatorios` | Recordatorios custom del calendario (título, fecha, público/privado, creado_por) |
| `entidades_gestionadas` | Entidades del despacho (clientes/contribuyentes) — tipo, nombre, supervisor_user_id |
| `contribuyentes` | Contribuyentes con FK a entidades_gestionadas (rfc, regimen_fiscal CSV, domicilio, email_preferences jsonb) |
| `carteras` | Carteras del despacho con supervisor_user_id, auxiliar_user_id, parent_id (subcarteras) |
| `cartera_entidades` | M:N cartera ↔ contribuyente |
| `cartera_auxiliares` | M:N cartera ↔ auxiliar (legacy, ahora en `auxiliar_user_id` directo) |
| `auxiliar_supervisores` | Override 1:1 auxiliar → supervisor (editado desde `/usuarios`) |
| `obligaciones_contribuyente` | Catálogo de obligaciones por contribuyente |
| `obligacion_periodos` | Instancias mensuales de cada obligación; estado completada |
| `tareas_catalogo` | Tareas operativas recurrentes por contribuyente (semanal a anual) |
| `tarea_periodos` | Instancias materializadas con fecha_limite y estado |
| `papeleria_trabajo` | Archivos del despacho (PDF/Word/Excel ≤5MB) por contribuyente con flujo opcional de aprobación |
| `declaraciones_provisionales` | PDFs de declaraciones por (contribuyente, año, mes, tipo) con liga de pago + comprobante de pago |
| `documentos_extras` | PDFs libres por contribuyente con categoría y descripción |
| `opiniones_cumplimiento` | Cache 6 meses de Opinión SAT (PDF + datos parseados) |
| `constancias_situacion_fiscal` | Cache 5 años de CSF (PDF + datos JSONB) |
| `facturapi_orgs` | Org Facturapi por contribuyente (api_key cacheada, csd_uploaded, last_lco_rejection_at) |
### Campos source en cfdis
- `'manual'` — Cargado por usuario via XML upload
- `'sat'` — Descargado del SAT via sync (tiene xml_original)
- `'sat-metadata'` — Solo metadata del SAT (sin XML, típicamente cancelados)
- `'facturapi'` — Emitido desde Horux360 via Facturapi (tiene facturapi_id)
### Impersonación de tenant (X-View-Tenant)
El admin global puede ver/gestionar datos de otros tenants. Los endpoints que lo soportan usan:
- Backend: `tenantMiddleware` + `effectiveTenantId(req)` = `req.viewingTenantId || req.user!.tenantId`
- Frontend: `useTenantViewStore()` + tenant key en query keys de React Query
Rutas con tenantMiddleware: dashboard, cfdi, impuestos, reportes, conciliación, bancos, calendario, regímenes, fiel, sat, facturación.
---
## Setup local
```bash
# Requisitos: Node 20+, pnpm 9+, PostgreSQL 16+
pnpm install
pnpm db:generate # Genera Prisma client
pnpm dev # Arranca API (4000) + Web (3000) via Turborepo
```
### Bootstrap desde BD vacía (deploy fresco)
```bash
cd apps/api
pnpm prisma migrate deploy # schema central
pnpm db:seed # catálogos + tenant demo + roles
pnpm bootstrap:admin-global # tenant Horux 360 (HTS240708LJA)
pnpm import:lista-negra # opcional
```
`bootstrap:admin-global` usa `tenantsService.createTenant()` con overrides enterprise (`cfdiLimit: -1`, `usersLimit: 10`) + eleva la subscripción a `authorized` por 1 año. Imprime password temporal del admin en consola. Configurable vía `HORUX_ADMIN_EMAIL` / `HORUX_ADMIN_NOMBRE`.
**Variables de entorno:** `apps/api/.env` y `apps/web/.env.local`
**BD de prueba:** tenant `EDE123456AB1` → BD `horux_ede123456ab1`
**Credenciales demo:**
- admin@demo.com / demo123 (admin)
- contador@demo.com / demo123 (contador)
- visor@demo.com / demo123 (visor)
**Credenciales Horux 360:**
- carlos@horuxfin.com / (password en consola del bootstrap) — owner + platform_admin
- ivan@horuxfin.com / (password en consola del bootstrap) — contador + platform_ti (TI superset)
---
## Producción
- **Servidor:** Ubuntu 24.04, 22GB RAM, 8 cores
- **Process manager:** PM2 (fork mode)
- **Proxy:** Nginx con SSL (Let's Encrypt)
- **Cron:** SAT sync 03:00 AM, Backups 01:00 AM
- **Deploy:** `git pull → pnpm install → pnpm build → pnpm db:migrate-tenants → pm2 restart all`
---
## Problemas conocidos / pendientes
**Typecheck limpio.** `pnpm typecheck` pasa 0 errores en `@horux/api` y `@horux/shared` tras el cleanup del 2026-04-13. El proyecto corre con `tsx watch` que no valida tipos en runtime, por eso agregar `pnpm typecheck` a CI/pre-commit es recomendado para no re-acumular deuda. Ver `docs/plans/2026-04-13-typescript-debt-cleanup.md` para el inventario completo y decisiones descartadas (incluye por qué no se downgradeo `@types/express@5` ni se migró a `"type": "module"`).
**Express version mismatch:** `@types/express@^5.0.0` corre sobre `express@^4.21.0`. La diferencia se manifiesta en `req.params.id` tipado como `string | string[]`. Solución aplicada: cast `String(req.params.id)` en los 8 controllers afectados. Cuando migren a Express 5, los casts siguen siendo correctos.
**Facturapi SDK interop:** el paquete `facturapi@4.14.2` no declara `"exports"` en su package.json, solo `main`/`module`. Esto impide migrar el api a `"type": "module"` sin workarounds en cada `import Facturapi from 'facturapi'` (el default export se trata como namespace bajo ESM estricto). `tenant-migrations.ts` usa `__dirname` directo para evitar `import.meta` y mantenerse compatible con el modo CJS efectivo.
1. ~~**Schema drift multi-tenant:**~~ Resuelto. Migraciones SQL numeradas en `apps/api/src/migrations/tenant/`. Se aplican eager (`pnpm db:migrate-tenants`) en deploy y lazy (auto en `getPool()`) como safety net. Para agregar un cambio de schema tenant: crear `NNN_description.sql` en el directorio de migraciones.
**Términos y Condiciones:** fuente legal = `docs/legal/Terminos y condiciones.pdf`. Pipeline: `pnpm legal:sync` (corre `scripts/extract-terminos.mjs` usando `pdf-parse` v2 con `PDFParse.getText()`) copia el PDF a `apps/web/public/legal/terminos-y-condiciones.pdf` y genera `apps/web/content/terminos.ts` con el texto extraído (cheap insurance para indexado/búsqueda futura). Página `/terminos` (pública, fuera de `(auth)`/`(dashboard)`) **embebe el PDF con `<object type="application/pdf">`** — usa el viewer nativo del navegador (PDF.js/QuickLook) y preserva fidelidad total de formato (tipografía, tablas, listas). Header con "Descargar" (download attribute) y "Abrir" (nueva pestaña). Fallback dentro del object para móviles sin inline PDF: botones centrados. Register checkbox obligatorio con link a `/terminos` (target=\_blank). Workflow para actualizar: reemplazar el PDF en `docs/legal/` con el mismo nombre → `pnpm legal:sync` → commit los 3 artefactos (PDF fuente + PDF copia + `terminos.ts`).
**Security headers (`next.config.js`):** aplicados a todas las rutas para proteger contra clickjacking y otros vectores. `X-Frame-Options: SAMEORIGIN` + `Content-Security-Policy: frame-ancestors 'self'` impiden que sitios externos embeban Horux 360 en iframes propios (preservando el `/terminos` que embebe un PDF del mismo origen). `X-Content-Type-Options: nosniff` previene MIME sniffing. `Referrer-Policy: strict-origin-when-cross-origin` evita leak de URLs completas al navegar externo. `Strict-Transport-Security: max-age=31536000; includeSubDomains` fuerza HTTPS (ignorado en dev). Si en el futuro se necesita embeber Horux 360 en otro dominio propio (app móvil híbrida, portal partner), extender `frame-ancestors 'self' https://otro-dominio`.
**BD central**: usa Prisma migrations en `apps/api/prisma/migrations/`. Baseline `20260414152220_initial_schema_v0_9_2/migration.sql` consolida todo el schema acumulado hasta v0.9.2. Para futuros cambios: `pnpm prisma migrate dev --name <descripción>` genera SQL versionado. En prod: `pnpm prisma migrate deploy`. **NO** usar `prisma db push` en prod — se pierde el trail.
2. **Nómina (pendiente):** Tipo de comprobante N no implementado en facturación. Requiere complemento de nómina con datos del empleado, percepciones, deducciones.
3. **Carta Porte (pendiente):** Complemento para facturas tipo T. Facturapi lo soporta en Beta. No implementado.
4. **Notificaciones por email de alertas/recordatorios:** El sistema de email existe (Nodemailer + Gmail) pero no envía alertas/recordatorios automáticos. Solo bienvenida, pagos, notificaciones admin y facturas (via Facturapi).
5. **Catálogo c_ClaveProdServ:** 52,513 registros importados desde phpcfdi/resources-sat-catalogs. Si se necesita actualizar, re-importar desde la BD SQLite del release.
6. **SMTP local:** No configurado en desarrollo. Emails se logean a consola. Configurar `SMTP_USER` y `SMTP_PASS` en `.env` para envío real.

Binary file not shown.

112
README.md Normal file
View File

@@ -0,0 +1,112 @@
# Horux Despachos
Plataforma SaaS para despachos profesionales mexicanos. Gestión fiscal multi-RFC con roles jerárquicos (Owner/Supervisor/Auxiliar/Cliente), carteras de contribuyentes, y arquitectura BYO-DB.
**Autor:** Carlos e Ivan (Horux 360)
---
## Qué es
Horux Despachos permite a despachos contables gestionar múltiples contribuyentes (RFCs) desde una sola cuenta. Cada contribuyente tiene su propia FIEL, CSD, y organización Facturapi. Los supervisores organizan contribuyentes en carteras y delegan trabajo a auxiliares.
## Arquitectura
```
Monorepo (pnpm + Turborepo)
├── apps/api → Express + TypeScript (puerto 4000)
├── apps/web → Next.js 14 + App Router (puerto 3000)
└── packages/
├── core → Auth (JWT), email transport, crypto (AES-256-GCM)
├── shared → Tipos, constantes, interfaces compartidas
├── shared-ui → Componentes UI (Button, Card, Dialog, selectors, hooks)
└── vertical-contable → (scaffold) Lógica fiscal compartida
```
## Funcionalidades implementadas
### Gestión de despachos
- Signup multi-paso (formulario → vertical → plan)
- Onboarding wizard (6 pasos)
- Planes: Trial (30 días), Business Control (BYO-DB), Business Cloud (Managed)
### Contribuyentes (RFCs)
- CRUD de contribuyentes por despacho
- FIEL per contribuyente (almacenada en BD tenant, cifrada AES-256-GCM)
- Facturapi org per contribuyente (CSD independiente)
- Emisión de CFDI con contribuyente_id
### Roles y autorización
- **Owner**: acceso total, actúa como supervisor implícito
- **Supervisor**: titular de RFCs, crea carteras, gestiona auxiliares
- **Auxiliar**: accede solo a RFCs en carteras asignadas
- **Cliente**: visor externo read-only de sus RFCs
- `getEntidadesVisibles()`: cascada de permisos automática
### Carteras
- CRUD completo (crear, editar, eliminar)
- Asignar/remover contribuyentes
- Asignar/remover auxiliares
- Cascada: si supervisor pierde RFC → auxiliares pierden acceso
### Pricing
- Catálogo de planes (Business Control $21,000/año, Business Cloud $15,000/año + $45/RFC/mes)
- Add-ons recurrentes con multi-preapproval MercadoPago
- Paquetes de timbres one-shot
### Connector BYO-DB
- Provisioning de tunnel (Cloudflare Tunnel ready)
- Heartbeat cada 30s con status en UI
- getPool() refactorizado para decrypt de conexiones BYO
### Admin global
- Dashboard cross-despacho (métricas, lista despachos, actividad)
- Impersonación con motivo obligatorio + audit log
- Audit log expuesto al owner del despacho
### Métricas pre-calculadas
- Hot/cold: año actual on-the-fly, años pasados pre-calculados
- Invalidación dirigida por cambios retroactivos en CFDIs
- Tablas: metricas_mensuales, acumuladas_anuales, contraparte, invalidaciones
## Stack técnico
| Capa | Tecnología |
|------|------------|
| Frontend | Next.js 14, React 18, Tailwind, shadcn/ui, Zustand, React Query |
| Backend | Node.js 20+, Express 4, TypeScript 5, Prisma 5.22 |
| BD Central | PostgreSQL 16 (Prisma ORM) |
| BD Tenant | PostgreSQL 16 (pg Pool + SQL raw + 17 migraciones numeradas) |
| Auth | JWT (15min) + refresh (7d) + bcrypt + magic link ready |
| Pagos | MercadoPago (preapproval + webhooks) |
| Email | Nodemailer + SMTP |
| Facturación | Facturapi (cuenta maestra broker) |
## Setup local
```bash
# Requisitos: Node 20+, pnpm 9+, PostgreSQL 16+
pnpm install
cd apps/api && npx prisma generate
cd apps/api && npx prisma migrate deploy
cd apps/api && pnpm db:seed
pnpm dev # API :4000 + Web :3000
```
## Estructura de BD
### BD Central (Prisma)
Tenant, User, TenantMembership, Rol, Subscription, SubscriptionAddon, Payment, PlanCatalogo, PlanAddonCatalogo, FielCredential, ConnectorHeartbeat, AuditLog, TimbreSuscripcion, TimbrePaquete, catálogos SAT.
### BD Tenant (SQL migrations 001-017)
001-005: Schema base (rfcs, cfdis, conciliaciones, alertas, opiniones, declaraciones, constancias)
006: tenant_migrations tracking
007-009: Core (entidades_gestionadas, carteras, cliente_accesos)
010-013: Vertical contable (contribuyentes, fiel_contribuyente, facturapi_orgs, cfdi contribuyente_id)
014-017: Métricas (mensuales, acumuladas, contraparte, invalidaciones)
## Autor
**Carlos e Ivan (Horux 360)**
---

7
apps/api/.env.example Normal file
View File

@@ -0,0 +1,7 @@
NODE_ENV=development
PORT=4000
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/horux360?schema=public"
JWT_SECRET=your-super-secret-jwt-key-min-32-chars-long-for-development
JWT_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
CORS_ORIGIN=http://localhost:3000

65
apps/api/package.json Normal file
View File

@@ -0,0 +1,65 @@
{
"name": "@horux/api",
"version": "0.0.1",
"private": true,
"author": "Carlos e Ivan (Horux 360)",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"lint": "eslint src/",
"typecheck": "tsc --noEmit",
"db:generate": "prisma generate",
"db:push": "prisma db push",
"db:migrate": "prisma migrate dev",
"db:seed": "tsx prisma/seed.ts",
"import:lista-negra": "tsx scripts/import-lista-negra.ts",
"db:migrate-tenants": "tsx scripts/migrate-tenants.ts",
"bootstrap:admin-global": "tsx scripts/bootstrap-horux360-admin.ts",
"legal:sync": "node scripts/extract-terminos.mjs",
"email:preview": "tsx scripts/preview-emails.mjs"
},
"dependencies": {
"@horux/core": "workspace:*",
"@horux/shared": "workspace:*",
"@nodecfdi/cfdi-core": "^1.0.1",
"@nodecfdi/credentials": "^3.2.0",
"@nodecfdi/sat-ws-descarga-masiva": "^2.0.0",
"@prisma/client": "^5.22.0",
"adm-zip": "^0.5.16",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"exceljs": "^4.4.0",
"express": "^4.21.0",
"facturapi": "^4.14.2",
"fast-xml-parser": "^5.3.3",
"helmet": "^8.0.0",
"jsonwebtoken": "^9.0.2",
"mercadopago": "^2.12.0",
"node-cron": "^4.2.1",
"node-forge": "^1.3.3",
"nodemailer": "^8.0.2",
"pdf-parse": "^2.4.5",
"pg": "^8.18.0",
"playwright": "^1.59.1",
"zod": "^3.23.0"
},
"devDependencies": {
"@types/adm-zip": "^0.5.7",
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.7",
"@types/node": "^22.0.0",
"@types/node-cron": "^3.0.11",
"@types/node-forge": "^1.3.14",
"@types/nodemailer": "^7.0.11",
"@types/pg": "^8.18.0",
"express-rate-limit": "^8.3.1",
"prisma": "^5.22.0",
"sql.js": "^1.14.1",
"tsx": "^4.19.0",
"typescript": "^5.3.0"
}
}

View File

@@ -0,0 +1,121 @@
// Catálogos SAT CFDI 4.0 para facturación
export const FORMAS_PAGO = [
{ clave: '01', descripcion: 'Efectivo' },
{ clave: '02', descripcion: 'Cheque nominativo' },
{ clave: '03', descripcion: 'Transferencia electrónica de fondos' },
{ clave: '04', descripcion: 'Tarjeta de crédito' },
{ clave: '05', descripcion: 'Monedero electrónico' },
{ clave: '06', descripcion: 'Dinero electrónico' },
{ clave: '08', descripcion: 'Vales de despensa' },
{ clave: '12', descripcion: 'Dación en pago' },
{ clave: '13', descripcion: 'Pago por subrogación' },
{ clave: '14', descripcion: 'Pago por consignación' },
{ clave: '15', descripcion: 'Condonación' },
{ clave: '17', descripcion: 'Compensación' },
{ clave: '23', descripcion: 'Novación' },
{ clave: '24', descripcion: 'Confusión' },
{ clave: '25', descripcion: 'Remisión de deuda' },
{ clave: '26', descripcion: 'Prescripción o caducidad' },
{ clave: '27', descripcion: 'A satisfacción del acreedor' },
{ clave: '28', descripcion: 'Tarjeta de débito' },
{ clave: '29', descripcion: 'Tarjeta de servicios' },
{ clave: '30', descripcion: 'Aplicación de anticipos' },
{ clave: '31', descripcion: 'Intermediario pagos' },
{ clave: '99', descripcion: 'Por definir' },
];
export const METODOS_PAGO = [
{ clave: 'PUE', descripcion: 'Pago en una sola exhibición' },
{ clave: 'PPD', descripcion: 'Pago en parcialidades o diferido' },
];
export const USOS_CFDI = [
{ clave: 'G01', descripcion: 'Adquisición de mercancías', personaFisica: true, personaMoral: true },
{ clave: 'G02', descripcion: 'Devoluciones, descuentos o bonificaciones', personaFisica: true, personaMoral: true },
{ clave: 'G03', descripcion: 'Gastos en general', personaFisica: true, personaMoral: true },
{ clave: 'I01', descripcion: 'Construcciones', personaFisica: true, personaMoral: true },
{ clave: 'I02', descripcion: 'Mobiliario y equipo de oficina por inversiones', personaFisica: true, personaMoral: true },
{ clave: 'I03', descripcion: 'Equipo de transporte', personaFisica: true, personaMoral: true },
{ clave: 'I04', descripcion: 'Equipo de cómputo y accesorios', personaFisica: true, personaMoral: true },
{ clave: 'I05', descripcion: 'Dados, troqueles, moldes, matrices y herramental', personaFisica: true, personaMoral: true },
{ clave: 'I06', descripcion: 'Comunicaciones telefónicas', personaFisica: true, personaMoral: true },
{ clave: 'I07', descripcion: 'Comunicaciones satelitales', personaFisica: true, personaMoral: true },
{ clave: 'I08', descripcion: 'Otra maquinaria y equipo', personaFisica: true, personaMoral: true },
{ clave: 'D01', descripcion: 'Honorarios médicos, dentales y gastos hospitalarios', personaFisica: true, personaMoral: false },
{ clave: 'D02', descripcion: 'Gastos médicos por incapacidad o discapacidad', personaFisica: true, personaMoral: false },
{ clave: 'D03', descripcion: 'Gastos funerales', personaFisica: true, personaMoral: false },
{ clave: 'D04', descripcion: 'Donativos', personaFisica: true, personaMoral: true },
{ clave: 'D05', descripcion: 'Intereses reales efectivamente pagados por créditos hipotecarios', personaFisica: true, personaMoral: false },
{ clave: 'D06', descripcion: 'Aportaciones voluntarias al SAR', personaFisica: true, personaMoral: false },
{ clave: 'D07', descripcion: 'Primas por seguros de gastos médicos', personaFisica: true, personaMoral: false },
{ clave: 'D08', descripcion: 'Gastos de transportación escolar obligatoria', personaFisica: true, personaMoral: false },
{ clave: 'D09', descripcion: 'Depósitos en cuentas para el ahorro, primas de pensiones', personaFisica: true, personaMoral: false },
{ clave: 'D10', descripcion: 'Pagos por servicios educativos (colegiaturas)', personaFisica: true, personaMoral: false },
{ clave: 'S01', descripcion: 'Sin efectos fiscales', personaFisica: true, personaMoral: true },
{ clave: 'CP01', descripcion: 'Pagos', personaFisica: true, personaMoral: true },
{ clave: 'CN01', descripcion: 'Nómina', personaFisica: true, personaMoral: false },
];
export const MONEDAS = [
{ clave: 'MXN', descripcion: 'Peso Mexicano', decimales: 2 },
{ clave: 'USD', descripcion: 'Dólar Americano', decimales: 2 },
{ clave: 'EUR', descripcion: 'Euro', decimales: 2 },
{ clave: 'GBP', descripcion: 'Libra Esterlina', decimales: 2 },
{ clave: 'CAD', descripcion: 'Dólar Canadiense', decimales: 2 },
{ clave: 'JPY', descripcion: 'Yen Japonés', decimales: 0 },
{ clave: 'XXX', descripcion: 'Los códigos asignados para transacciones en que intervenga ninguna moneda', decimales: 0 },
];
export const CLAVES_UNIDAD = [
{ clave: 'H87', descripcion: 'Pieza' },
{ clave: 'E48', descripcion: 'Unidad de servicio' },
{ clave: 'KGM', descripcion: 'Kilogramo' },
{ clave: 'LTR', descripcion: 'Litro' },
{ clave: 'MTR', descripcion: 'Metro' },
{ clave: 'MTK', descripcion: 'Metro cuadrado' },
{ clave: 'MTQ', descripcion: 'Metro cúbico' },
{ clave: 'KWH', descripcion: 'Kilovatio hora' },
{ clave: 'TNE', descripcion: 'Tonelada' },
{ clave: 'GRM', descripcion: 'Gramo' },
{ clave: 'HUR', descripcion: 'Hora' },
{ clave: 'DAY', descripcion: 'Día' },
{ clave: 'MON', descripcion: 'Mes' },
{ clave: 'ANN', descripcion: 'Año' },
{ clave: 'XBX', descripcion: 'Caja' },
{ clave: 'XPK', descripcion: 'Paquete' },
{ clave: 'XKI', descripcion: 'Kit' },
{ clave: 'SET', descripcion: 'Conjunto' },
{ clave: 'XLT', descripcion: 'Lote' },
{ clave: 'ACT', descripcion: 'Actividad' },
{ clave: 'XUN', descripcion: 'Unidad' },
{ clave: 'DPC', descripcion: 'Docena de piezas' },
{ clave: 'XRO', descripcion: 'Rollo' },
{ clave: 'GLL', descripcion: 'Galón' },
{ clave: 'MLT', descripcion: 'Mililitro' },
{ clave: 'CMT', descripcion: 'Centímetro' },
];
export const OBJETOS_IMP = [
{ clave: '01', descripcion: 'No objeto de impuesto' },
{ clave: '02', descripcion: 'Sí objeto de impuesto' },
{ clave: '03', descripcion: 'Sí objeto del impuesto y no obligado al desglose' },
{ clave: '04', descripcion: 'Sí objeto del impuesto y no causa impuesto' },
];
export const TIPOS_RELACION = [
{ clave: '01', descripcion: 'Nota de crédito de los documentos relacionados' },
{ clave: '02', descripcion: 'Nota de débito de los documentos relacionados' },
{ clave: '03', descripcion: 'Devolución de mercancía sobre facturas o traslados previos' },
{ clave: '04', descripcion: 'Sustitución de los CFDI previos' },
{ clave: '05', descripcion: 'Traslados de mercancías facturados previamente' },
{ clave: '06', descripcion: 'Factura generada por los traslados previos' },
{ clave: '07', descripcion: 'CFDI por aplicación de anticipo' },
];
export const EXPORTACIONES = [
{ clave: '01', descripcion: 'No aplica' },
{ clave: '02', descripcion: 'Definitiva' },
{ clave: '03', descripcion: 'Temporal' },
{ clave: '04', descripcion: 'Definitiva con clave distinta a A1 o cuando no existe enajenación en términos del CFF' },
];

View File

@@ -0,0 +1,185 @@
// Catálogo de eventos fiscales
export const EVENTOS_FISCALES = [
{
titulo: 'Declaración mensual ISR',
tipo: 'declaracion',
diaBase: 17,
mesRelativo: 1,
recurrencia: 'mensual',
usaExtensionRfc: false,
regimenes: 'todos',
condicion: null,
},
{
titulo: 'Declaración mensual IVA',
tipo: 'declaracion',
diaBase: 17,
mesRelativo: 1,
recurrencia: 'mensual',
usaExtensionRfc: false,
regimenes: 'todos',
condicion: null,
},
{
titulo: 'Declaración mensual IEPS',
tipo: 'declaracion',
diaBase: 17,
mesRelativo: 1,
recurrencia: 'mensual',
usaExtensionRfc: false,
regimenes: 'todos',
condicion: null,
},
{
titulo: 'Declaración de sueldos y salarios',
tipo: 'declaracion',
diaBase: 17,
mesRelativo: 1,
recurrencia: 'mensual',
usaExtensionRfc: false,
regimenes: 'todos',
condicion: 'tiene_nomina',
},
{
titulo: 'Pago provisional ISR',
tipo: 'pago',
diaBase: 17,
mesRelativo: 1,
recurrencia: 'mensual',
usaExtensionRfc: true,
regimenes: 'todos',
condicion: null,
},
{
titulo: 'Pago provisional IVA',
tipo: 'pago',
diaBase: 17,
mesRelativo: 1,
recurrencia: 'mensual',
usaExtensionRfc: true,
regimenes: 'todos',
condicion: null,
},
{
titulo: 'Pago provisional IEPS',
tipo: 'pago',
diaBase: 17,
mesRelativo: 1,
recurrencia: 'mensual',
usaExtensionRfc: true,
regimenes: 'todos',
condicion: null,
},
{
titulo: 'DIOT',
tipo: 'obligacion',
diaBase: 17,
mesRelativo: 1,
recurrencia: 'mensual',
usaExtensionRfc: false,
regimenes: '601,603,607,608,610,611,612,614,615,620,622,623,624',
condicion: null, // 612 aplica condición ingresos_4m, se valida en runtime
},
{
titulo: 'Contabilidad electrónica',
tipo: 'obligacion',
diaBase: 3,
mesRelativo: 2,
recurrencia: 'mensual',
usaExtensionRfc: false,
regimenes: '601,603,607,608,610,611,612,614,615,620,622,623,624',
condicion: null,
},
{
titulo: 'Declaración anual PM',
tipo: 'declaracion',
diaBase: 31,
mesRelativo: 0,
mesFijo: 3,
recurrencia: 'anual',
usaExtensionRfc: false,
regimenes: '601,603,620,622,623,624',
condicion: null,
},
{
titulo: 'Declaración anual PF',
tipo: 'declaracion',
diaBase: 30,
mesRelativo: 0,
mesFijo: 4,
recurrencia: 'anual',
usaExtensionRfc: false,
regimenes: '605,606,607,608,611,612,614,615,621,625,626',
condicion: null,
},
{
titulo: 'Informativa Sueldos y Salarios',
tipo: 'informativa',
diaBase: 15,
mesRelativo: 0,
mesFijo: 2,
recurrencia: 'anual',
usaExtensionRfc: false,
regimenes: 'todos',
condicion: 'tiene_nomina',
},
];
// Días festivos oficiales de México (2020-2027)
// Incluye: 1 ene, 5 feb, 21 mar, 1 may, 16 sep, 1 oct (cambio poder), 20 nov, 25 dic
// + cambios de poder cada 6 años, semana santa variable
export const DIAS_INHABILES: { fecha: string; nombre: string }[] = [];
function addFestivos(año: number) {
const fijos = [
{ mes: 1, dia: 1, nombre: 'Año Nuevo' },
{ mes: 5, dia: 1, nombre: 'Día del Trabajo' },
{ mes: 9, dia: 16, nombre: 'Independencia de México' },
{ mes: 12, dia: 25, nombre: 'Navidad' },
];
// Primer lunes de febrero (Constitución)
const feb1 = new Date(año, 1, 1);
const primerLunesFeb = new Date(año, 1, 1 + ((8 - feb1.getDay()) % 7));
DIAS_INHABILES.push({
fecha: primerLunesFeb.toISOString().split('T')[0],
nombre: 'Día de la Constitución',
});
// Tercer lunes de marzo (Benito Juárez)
const mar1 = new Date(año, 2, 1);
const primerLunesMar = new Date(año, 2, 1 + ((8 - mar1.getDay()) % 7));
const tercerLunesMar = new Date(primerLunesMar);
tercerLunesMar.setDate(tercerLunesMar.getDate() + 14);
DIAS_INHABILES.push({
fecha: tercerLunesMar.toISOString().split('T')[0],
nombre: 'Natalicio de Benito Juárez',
});
// Tercer lunes de noviembre (Revolución)
const nov1 = new Date(año, 10, 1);
const primerLunesNov = new Date(año, 10, 1 + ((8 - nov1.getDay()) % 7));
const tercerLunesNov = new Date(primerLunesNov);
tercerLunesNov.setDate(tercerLunesNov.getDate() + 14);
DIAS_INHABILES.push({
fecha: tercerLunesNov.toISOString().split('T')[0],
nombre: 'Día de la Revolución',
});
for (const f of fijos) {
DIAS_INHABILES.push({
fecha: `${año}-${String(f.mes).padStart(2, '0')}-${String(f.dia).padStart(2, '0')}`,
nombre: f.nombre,
});
}
// Cambio de poder (1 oct cada 6 años: 2024, 2030...)
if (año % 6 === 0 || (año - 2024) % 6 === 0) {
DIAS_INHABILES.push({
fecha: `${año}-10-01`,
nombre: 'Transmisión del Poder Ejecutivo Federal',
});
}
}
for (let y = 2020; y <= 2027; y++) addFestivos(y);

103
apps/api/prisma/isr-data.ts Normal file
View File

@@ -0,0 +1,103 @@
// Tasas RESICO (Art. 113-E) - iguales 2022-2026
export const RESICO_TASAS = [
{ montoMaximo: 25000.00, porcentaje: 1.00 },
{ montoMaximo: 50000.00, porcentaje: 1.10 },
{ montoMaximo: 83888.33, porcentaje: 1.50 },
{ montoMaximo: 208333.33, porcentaje: 2.00 },
{ montoMaximo: 291666.66, porcentaje: 2.50 },
];
// Tarifas ISR mensuales (Art. 96) por año
export const ISR_TARIFAS: Record<number, { li: number; ls: number | null; cf: number; pe: number }[]> = {
2020: [
{ li: 0.01, ls: 578.52, cf: 0, pe: 1.92 },
{ li: 578.53, ls: 4910.18, cf: 11.11, pe: 6.40 },
{ li: 4910.19, ls: 8629.20, cf: 288.33, pe: 10.88 },
{ li: 8629.21, ls: 10031.07, cf: 692.96, pe: 16.00 },
{ li: 10031.08, ls: 12009.94, cf: 917.26, pe: 17.92 },
{ li: 12009.95, ls: 24222.31, cf: 1271.87, pe: 21.36 },
{ li: 24222.32, ls: 38177.69, cf: 3880.44, pe: 23.52 },
{ li: 38177.70, ls: 72887.50, cf: 7162.74, pe: 30.00 },
{ li: 72887.51, ls: 97183.33, cf: 17575.69, pe: 32.00 },
{ li: 97183.34, ls: 291550.00, cf: 25350.35, pe: 34.00 },
{ li: 291550.01, ls: null, cf: 91435.02, pe: 35.00 },
],
2021: [
{ li: 0.01, ls: 644.58, cf: 0, pe: 1.92 },
{ li: 644.59, ls: 5470.92, cf: 12.38, pe: 6.40 },
{ li: 5470.93, ls: 9614.66, cf: 321.26, pe: 10.88 },
{ li: 9614.67, ls: 11176.62, cf: 772.10, pe: 16.00 },
{ li: 11176.63, ls: 13381.47, cf: 1022.01, pe: 17.92 },
{ li: 13381.48, ls: 26988.50, cf: 1417.12, pe: 21.36 },
{ li: 26988.51, ls: 42537.58, cf: 4323.58, pe: 23.52 },
{ li: 42537.59, ls: 81211.25, cf: 7980.73, pe: 30.00 },
{ li: 81211.26, ls: 108281.67, cf: 19582.83, pe: 32.00 },
{ li: 108281.68, ls: 324845.01, cf: 28245.36, pe: 34.00 },
{ li: 324845.02, ls: null, cf: 101876.90, pe: 35.00 },
],
2022: [
{ li: 0.01, ls: 644.58, cf: 0, pe: 1.92 },
{ li: 644.59, ls: 5470.92, cf: 12.38, pe: 6.40 },
{ li: 5470.93, ls: 9614.66, cf: 321.26, pe: 10.88 },
{ li: 9614.67, ls: 11176.62, cf: 772.10, pe: 16.00 },
{ li: 11176.63, ls: 13381.47, cf: 1022.01, pe: 17.92 },
{ li: 13381.48, ls: 26988.50, cf: 1417.12, pe: 21.36 },
{ li: 26988.51, ls: 42537.58, cf: 4323.58, pe: 23.52 },
{ li: 42537.59, ls: 81211.25, cf: 7980.73, pe: 30.00 },
{ li: 81211.26, ls: 108281.67, cf: 19582.83, pe: 32.00 },
{ li: 108281.68, ls: 324845.01, cf: 28245.36, pe: 34.00 },
{ li: 324845.02, ls: null, cf: 101876.90, pe: 35.00 },
],
2023: [
{ li: 0.01, ls: 746.04, cf: 0, pe: 1.92 },
{ li: 746.05, ls: 6332.05, cf: 14.32, pe: 6.40 },
{ li: 6332.06, ls: 11128.01, cf: 371.83, pe: 10.88 },
{ li: 11128.02, ls: 12935.82, cf: 893.63, pe: 16.00 },
{ li: 12935.83, ls: 15487.71, cf: 1182.88, pe: 17.92 },
{ li: 15487.72, ls: 31236.49, cf: 1640.18, pe: 21.36 },
{ li: 31236.50, ls: 49233.00, cf: 5004.12, pe: 23.52 },
{ li: 49233.01, ls: 93993.90, cf: 9236.89, pe: 30.00 },
{ li: 93993.91, ls: 125325.20, cf: 22665.17, pe: 32.00 },
{ li: 125325.21, ls: 375975.61, cf: 32691.18, pe: 34.00 },
{ li: 375975.62, ls: null, cf: 117912.32, pe: 35.00 },
],
2024: [
{ li: 0.01, ls: 746.04, cf: 0, pe: 1.92 },
{ li: 746.05, ls: 6332.05, cf: 14.32, pe: 6.40 },
{ li: 6332.06, ls: 11128.01, cf: 371.83, pe: 10.88 },
{ li: 11128.02, ls: 12935.82, cf: 893.63, pe: 16.00 },
{ li: 12935.83, ls: 15487.71, cf: 1182.88, pe: 17.92 },
{ li: 15487.72, ls: 31236.49, cf: 1640.18, pe: 21.36 },
{ li: 31236.50, ls: 49233.00, cf: 5004.12, pe: 23.52 },
{ li: 49233.01, ls: 93993.90, cf: 9236.89, pe: 30.00 },
{ li: 93993.91, ls: 125325.20, cf: 22665.17, pe: 32.00 },
{ li: 125325.21, ls: 375975.61, cf: 32691.18, pe: 34.00 },
{ li: 375975.62, ls: null, cf: 117912.32, pe: 35.00 },
],
2025: [
{ li: 0.01, ls: 746.04, cf: 0, pe: 1.92 },
{ li: 746.05, ls: 6332.05, cf: 14.32, pe: 6.40 },
{ li: 6332.06, ls: 11128.01, cf: 371.83, pe: 10.88 },
{ li: 11128.02, ls: 12935.82, cf: 893.63, pe: 16.00 },
{ li: 12935.83, ls: 15487.71, cf: 1182.88, pe: 17.92 },
{ li: 15487.72, ls: 31236.49, cf: 1640.18, pe: 21.36 },
{ li: 31236.50, ls: 49233.00, cf: 5004.12, pe: 23.52 },
{ li: 49233.01, ls: 93993.90, cf: 9236.89, pe: 30.00 },
{ li: 93993.91, ls: 125325.20, cf: 22665.17, pe: 32.00 },
{ li: 125325.21, ls: 375975.61, cf: 32691.18, pe: 34.00 },
{ li: 375975.62, ls: null, cf: 117912.32, pe: 35.00 },
],
2026: [
{ li: 0.01, ls: 844.59, cf: 0, pe: 1.92 },
{ li: 844.60, ls: 7168.51, cf: 16.22, pe: 6.40 },
{ li: 7168.52, ls: 12598.02, cf: 420.95, pe: 10.88 },
{ li: 12598.03, ls: 14644.64, cf: 1011.68, pe: 16.00 },
{ li: 14644.65, ls: 17533.64, cf: 1339.14, pe: 17.92 },
{ li: 17533.65, ls: 35362.83, cf: 1856.84, pe: 21.36 },
{ li: 35362.84, ls: 55736.68, cf: 5665.16, pe: 23.52 },
{ li: 55736.69, ls: 106410.50, cf: 10457.09, pe: 30.00 },
{ li: 106410.51, ls: 141880.66, cf: 25659.23, pe: 32.00 },
{ li: 141880.67, ls: 425641.99, cf: 37009.69, pe: 34.00 },
{ li: 425642.00, ls: null, cf: 133488.54, pe: 35.00 },
],
};

View File

@@ -0,0 +1,634 @@
-- CreateEnum
CREATE TYPE "Plan" AS ENUM ('starter', 'business', 'business_ia', 'custom', 'enterprise');
-- CreateEnum
CREATE TYPE "PlatformRole" AS ENUM ('platform_admin', 'platform_ti', 'platform_support', 'platform_sales', 'platform_finance');
-- CreateEnum
CREATE TYPE "SatSyncType" AS ENUM ('initial', 'daily', 'incremental');
-- CreateEnum
CREATE TYPE "SatSyncStatus" AS ENUM ('pending', 'running', 'completed', 'failed');
-- CreateEnum
CREATE TYPE "CfdiSyncType" AS ENUM ('emitidos', 'recibidos');
-- CreateTable
CREATE TABLE "tenants" (
"id" TEXT NOT NULL,
"nombre" TEXT NOT NULL,
"rfc" TEXT NOT NULL,
"plan" "Plan" NOT NULL DEFAULT 'starter',
"database_name" TEXT NOT NULL,
"cfdi_limit" INTEGER NOT NULL DEFAULT 100,
"users_limit" INTEGER NOT NULL DEFAULT 1,
"active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expires_at" TIMESTAMP(3),
"trial_ends_at" TIMESTAMP(3),
"facturapi_org_id" TEXT,
"codigo_postal" VARCHAR(5),
"calle" VARCHAR(255),
"num_exterior" VARCHAR(20),
"num_interior" VARCHAR(20),
"colonia" VARCHAR(255),
"ciudad" VARCHAR(100),
"municipio" VARCHAR(100),
"estado" VARCHAR(100),
"telefono" VARCHAR(20),
CONSTRAINT "tenants_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password_hash" TEXT NOT NULL,
"nombre" TEXT NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
"last_login" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"token_version" INTEGER NOT NULL DEFAULT 0,
"last_tenant_id" TEXT,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "tenant_memberships" (
"id" SERIAL NOT NULL,
"user_id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"rol_id" INTEGER NOT NULL,
"is_owner" BOOLEAN NOT NULL DEFAULT false,
"active" BOOLEAN NOT NULL DEFAULT true,
"joined_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "tenant_memberships_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "roles" (
"id" SERIAL NOT NULL,
"nombre" VARCHAR(20) NOT NULL,
"descripcion" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "roles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "refresh_tokens" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expires_at" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "refresh_tokens_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "password_reset_tokens" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expires_at" TIMESTAMP(3) NOT NULL,
"used_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "password_reset_tokens_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "regimenes" (
"id" SERIAL NOT NULL,
"clave" VARCHAR(3) NOT NULL,
"descripcion" TEXT NOT NULL,
"tipo_persona" VARCHAR(20) NOT NULL,
"activo" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "regimenes_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "tenant_regimenes_ignorados" (
"id" SERIAL NOT NULL,
"tenant_id" TEXT NOT NULL,
"regimen_id" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "tenant_regimenes_ignorados_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "tenant_regimenes_activos" (
"id" SERIAL NOT NULL,
"tenant_id" TEXT NOT NULL,
"regimen_id" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "tenant_regimenes_activos_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "eventos_fiscales_catalogo" (
"id" SERIAL NOT NULL,
"titulo" TEXT NOT NULL,
"descripcion" TEXT,
"tipo" VARCHAR(20) NOT NULL,
"dia_base" INTEGER NOT NULL,
"mes_relativo" INTEGER NOT NULL DEFAULT 1,
"mes_fijo" INTEGER,
"recurrencia" VARCHAR(20) NOT NULL DEFAULT 'mensual',
"usa_extension_rfc" BOOLEAN NOT NULL DEFAULT false,
"regimenes" TEXT NOT NULL DEFAULT 'todos',
"condicion" VARCHAR(50),
"activo" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "eventos_fiscales_catalogo_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "lista_negra" (
"id" SERIAL NOT NULL,
"rfc" VARCHAR(13) NOT NULL,
"nombre" TEXT NOT NULL,
"situacion" VARCHAR(30) NOT NULL,
"updated_at" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "lista_negra_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "dias_inhabiles" (
"id" SERIAL NOT NULL,
"fecha" DATE NOT NULL,
"nombre" TEXT NOT NULL,
CONSTRAINT "dias_inhabiles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "isr_resico_tasas" (
"id" SERIAL NOT NULL,
"anio" INTEGER NOT NULL,
"monto_maximo" DECIMAL(18,2) NOT NULL,
"porcentaje" DECIMAL(5,2) NOT NULL,
CONSTRAINT "isr_resico_tasas_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "isr_tarifas" (
"id" SERIAL NOT NULL,
"anio" INTEGER NOT NULL,
"limite_inferior" DECIMAL(18,2) NOT NULL,
"limite_superior" DECIMAL(18,2),
"cuota_fija" DECIMAL(18,2) NOT NULL,
"porcentaje_excedente" DECIMAL(5,2) NOT NULL,
CONSTRAINT "isr_tarifas_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "coeficiente_utilidad" (
"id" SERIAL NOT NULL,
"tenant_id" TEXT NOT NULL,
"anio" INTEGER NOT NULL,
"coeficiente" DECIMAL(10,4) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "coeficiente_utilidad_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "fiel_credentials" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"rfc" VARCHAR(13) NOT NULL,
"cer_data" BYTEA NOT NULL,
"key_data" BYTEA NOT NULL,
"key_password_encrypted" BYTEA NOT NULL,
"cer_iv" BYTEA NOT NULL,
"cer_tag" BYTEA NOT NULL,
"key_iv" BYTEA NOT NULL,
"key_tag" BYTEA NOT NULL,
"password_iv" BYTEA NOT NULL,
"password_tag" BYTEA NOT NULL,
"serial_number" VARCHAR(50),
"valid_from" TIMESTAMP(3) NOT NULL,
"valid_until" TIMESTAMP(3) NOT NULL,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "fiel_credentials_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "subscriptions" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"plan" "Plan" NOT NULL,
"mp_preapproval_id" TEXT,
"status" TEXT NOT NULL DEFAULT 'pending',
"amount" DECIMAL(10,2) NOT NULL,
"frequency" TEXT NOT NULL DEFAULT 'monthly',
"current_period_start" TIMESTAMP(3),
"current_period_end" TIMESTAMP(3),
"pending_plan" "Plan",
"pending_frequency" TEXT,
"pending_effective_at" TIMESTAMP(3),
"upgrade_preference_id" TEXT,
"upgrade_target_plan" "Plan",
"upgrade_target_amount" DECIMAL(10,2),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "subscriptions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "user_platform_roles" (
"id" SERIAL NOT NULL,
"user_id" TEXT NOT NULL,
"role" "PlatformRole" NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" TEXT,
CONSTRAINT "user_platform_roles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "audit_log" (
"id" TEXT NOT NULL,
"user_id" TEXT,
"tenant_id" TEXT,
"action" VARCHAR(64) NOT NULL,
"entity_type" VARCHAR(32),
"entity_id" TEXT,
"metadata" JSONB,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "audit_log_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "trial_usages" (
"id" SERIAL NOT NULL,
"rfc" VARCHAR(13) NOT NULL,
"tenant_id" TEXT,
"started_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "trial_usages_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "plan_prices" (
"id" SERIAL NOT NULL,
"plan" "Plan" NOT NULL,
"frequency" TEXT NOT NULL,
"amount" DECIMAL(10,2) NOT NULL,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "plan_prices_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "payments" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"subscription_id" TEXT,
"mp_payment_id" TEXT,
"amount" DECIMAL(10,2) NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"payment_method" TEXT,
"paid_at" TIMESTAMP(3),
"facturapi_invoice_id" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "payments_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "sat_sync_jobs" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"type" "SatSyncType" NOT NULL,
"status" "SatSyncStatus" NOT NULL DEFAULT 'pending',
"date_from" DATE NOT NULL,
"date_to" DATE NOT NULL,
"cfdi_type" "CfdiSyncType",
"sat_request_id" VARCHAR(50),
"sat_package_ids" TEXT[],
"cfdis_found" INTEGER NOT NULL DEFAULT 0,
"cfdis_downloaded" INTEGER NOT NULL DEFAULT 0,
"cfdis_inserted" INTEGER NOT NULL DEFAULT 0,
"cfdis_updated" INTEGER NOT NULL DEFAULT 0,
"progress_percent" INTEGER NOT NULL DEFAULT 0,
"error_message" TEXT,
"started_at" TIMESTAMP(3),
"completed_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"retry_count" INTEGER NOT NULL DEFAULT 0,
"next_retry_at" TIMESTAMP(3),
CONSTRAINT "sat_sync_jobs_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "cat_forma_pago" (
"id" SERIAL NOT NULL,
"clave" VARCHAR(2) NOT NULL,
"descripcion" TEXT NOT NULL,
CONSTRAINT "cat_forma_pago_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "cat_metodo_pago" (
"id" SERIAL NOT NULL,
"clave" VARCHAR(3) NOT NULL,
"descripcion" TEXT NOT NULL,
CONSTRAINT "cat_metodo_pago_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "cat_uso_cfdi" (
"id" SERIAL NOT NULL,
"clave" VARCHAR(4) NOT NULL,
"descripcion" TEXT NOT NULL,
"persona_fisica" BOOLEAN NOT NULL DEFAULT true,
"persona_moral" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "cat_uso_cfdi_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "cat_moneda" (
"id" SERIAL NOT NULL,
"clave" VARCHAR(3) NOT NULL,
"descripcion" TEXT NOT NULL,
"decimales" INTEGER NOT NULL DEFAULT 2,
CONSTRAINT "cat_moneda_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "cat_clave_unidad" (
"id" SERIAL NOT NULL,
"clave" VARCHAR(10) NOT NULL,
"descripcion" TEXT NOT NULL,
CONSTRAINT "cat_clave_unidad_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "cat_clave_prod_serv" (
"id" SERIAL NOT NULL,
"clave" VARCHAR(8) NOT NULL,
"descripcion" TEXT NOT NULL,
CONSTRAINT "cat_clave_prod_serv_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "cat_objeto_imp" (
"id" SERIAL NOT NULL,
"clave" VARCHAR(2) NOT NULL,
"descripcion" TEXT NOT NULL,
CONSTRAINT "cat_objeto_imp_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "cat_tipo_relacion" (
"id" SERIAL NOT NULL,
"clave" VARCHAR(2) NOT NULL,
"descripcion" TEXT NOT NULL,
CONSTRAINT "cat_tipo_relacion_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "cat_exportacion" (
"id" SERIAL NOT NULL,
"clave" VARCHAR(2) NOT NULL,
"descripcion" TEXT NOT NULL,
CONSTRAINT "cat_exportacion_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "timbre_suscripciones" (
"id" SERIAL NOT NULL,
"tenant_id" TEXT NOT NULL,
"tipo" VARCHAR(10) NOT NULL,
"timbres_limite" INTEGER NOT NULL,
"timbres_usados" INTEGER NOT NULL DEFAULT 0,
"periodo_inicio" DATE NOT NULL,
"periodo_fin" DATE NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "timbre_suscripciones_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "tenants_rfc_key" ON "tenants"("rfc");
-- CreateIndex
CREATE UNIQUE INDEX "tenants_database_name_key" ON "tenants"("database_name");
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE INDEX "tenant_memberships_user_id_active_idx" ON "tenant_memberships"("user_id", "active");
-- CreateIndex
CREATE INDEX "tenant_memberships_tenant_id_active_idx" ON "tenant_memberships"("tenant_id", "active");
-- CreateIndex
CREATE UNIQUE INDEX "tenant_memberships_user_id_tenant_id_key" ON "tenant_memberships"("user_id", "tenant_id");
-- CreateIndex
CREATE UNIQUE INDEX "roles_nombre_key" ON "roles"("nombre");
-- CreateIndex
CREATE UNIQUE INDEX "refresh_tokens_token_key" ON "refresh_tokens"("token");
-- CreateIndex
CREATE UNIQUE INDEX "password_reset_tokens_token_key" ON "password_reset_tokens"("token");
-- CreateIndex
CREATE INDEX "password_reset_tokens_user_id_idx" ON "password_reset_tokens"("user_id");
-- CreateIndex
CREATE INDEX "password_reset_tokens_expires_at_idx" ON "password_reset_tokens"("expires_at");
-- CreateIndex
CREATE UNIQUE INDEX "regimenes_clave_key" ON "regimenes"("clave");
-- CreateIndex
CREATE UNIQUE INDEX "tenant_regimenes_ignorados_tenant_id_regimen_id_key" ON "tenant_regimenes_ignorados"("tenant_id", "regimen_id");
-- CreateIndex
CREATE UNIQUE INDEX "tenant_regimenes_activos_tenant_id_regimen_id_key" ON "tenant_regimenes_activos"("tenant_id", "regimen_id");
-- CreateIndex
CREATE UNIQUE INDEX "lista_negra_rfc_key" ON "lista_negra"("rfc");
-- CreateIndex
CREATE INDEX "lista_negra_rfc_idx" ON "lista_negra"("rfc");
-- CreateIndex
CREATE UNIQUE INDEX "dias_inhabiles_fecha_key" ON "dias_inhabiles"("fecha");
-- CreateIndex
CREATE UNIQUE INDEX "isr_resico_tasas_anio_monto_maximo_key" ON "isr_resico_tasas"("anio", "monto_maximo");
-- CreateIndex
CREATE UNIQUE INDEX "isr_tarifas_anio_limite_inferior_key" ON "isr_tarifas"("anio", "limite_inferior");
-- CreateIndex
CREATE UNIQUE INDEX "coeficiente_utilidad_tenant_id_anio_key" ON "coeficiente_utilidad"("tenant_id", "anio");
-- CreateIndex
CREATE UNIQUE INDEX "fiel_credentials_tenant_id_key" ON "fiel_credentials"("tenant_id");
-- CreateIndex
CREATE INDEX "subscriptions_tenant_id_idx" ON "subscriptions"("tenant_id");
-- CreateIndex
CREATE INDEX "subscriptions_status_idx" ON "subscriptions"("status");
-- CreateIndex
CREATE INDEX "subscriptions_pending_effective_at_idx" ON "subscriptions"("pending_effective_at");
-- CreateIndex
CREATE INDEX "user_platform_roles_role_idx" ON "user_platform_roles"("role");
-- CreateIndex
CREATE UNIQUE INDEX "user_platform_roles_user_id_role_key" ON "user_platform_roles"("user_id", "role");
-- CreateIndex
CREATE INDEX "audit_log_user_id_created_at_idx" ON "audit_log"("user_id", "created_at");
-- CreateIndex
CREATE INDEX "audit_log_tenant_id_created_at_idx" ON "audit_log"("tenant_id", "created_at");
-- CreateIndex
CREATE INDEX "audit_log_action_created_at_idx" ON "audit_log"("action", "created_at");
-- CreateIndex
CREATE INDEX "audit_log_entity_type_entity_id_idx" ON "audit_log"("entity_type", "entity_id");
-- CreateIndex
CREATE UNIQUE INDEX "trial_usages_rfc_key" ON "trial_usages"("rfc");
-- CreateIndex
CREATE UNIQUE INDEX "plan_prices_plan_frequency_key" ON "plan_prices"("plan", "frequency");
-- CreateIndex
CREATE INDEX "payments_tenant_id_idx" ON "payments"("tenant_id");
-- CreateIndex
CREATE INDEX "payments_subscription_id_idx" ON "payments"("subscription_id");
-- CreateIndex
CREATE INDEX "sat_sync_jobs_tenant_id_idx" ON "sat_sync_jobs"("tenant_id");
-- CreateIndex
CREATE INDEX "sat_sync_jobs_status_idx" ON "sat_sync_jobs"("status");
-- CreateIndex
CREATE INDEX "sat_sync_jobs_status_next_retry_at_idx" ON "sat_sync_jobs"("status", "next_retry_at");
-- CreateIndex
CREATE UNIQUE INDEX "cat_forma_pago_clave_key" ON "cat_forma_pago"("clave");
-- CreateIndex
CREATE UNIQUE INDEX "cat_metodo_pago_clave_key" ON "cat_metodo_pago"("clave");
-- CreateIndex
CREATE UNIQUE INDEX "cat_uso_cfdi_clave_key" ON "cat_uso_cfdi"("clave");
-- CreateIndex
CREATE UNIQUE INDEX "cat_moneda_clave_key" ON "cat_moneda"("clave");
-- CreateIndex
CREATE UNIQUE INDEX "cat_clave_unidad_clave_key" ON "cat_clave_unidad"("clave");
-- CreateIndex
CREATE UNIQUE INDEX "cat_clave_prod_serv_clave_key" ON "cat_clave_prod_serv"("clave");
-- CreateIndex
CREATE INDEX "cat_clave_prod_serv_descripcion_idx" ON "cat_clave_prod_serv"("descripcion");
-- CreateIndex
CREATE UNIQUE INDEX "cat_objeto_imp_clave_key" ON "cat_objeto_imp"("clave");
-- CreateIndex
CREATE UNIQUE INDEX "cat_tipo_relacion_clave_key" ON "cat_tipo_relacion"("clave");
-- CreateIndex
CREATE UNIQUE INDEX "cat_exportacion_clave_key" ON "cat_exportacion"("clave");
-- CreateIndex
CREATE UNIQUE INDEX "timbre_suscripciones_tenant_id_key" ON "timbre_suscripciones"("tenant_id");
-- AddForeignKey
ALTER TABLE "tenant_memberships" ADD CONSTRAINT "tenant_memberships_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tenant_memberships" ADD CONSTRAINT "tenant_memberships_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tenant_memberships" ADD CONSTRAINT "tenant_memberships_rol_id_fkey" FOREIGN KEY ("rol_id") REFERENCES "roles"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "password_reset_tokens" ADD CONSTRAINT "password_reset_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tenant_regimenes_ignorados" ADD CONSTRAINT "tenant_regimenes_ignorados_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tenant_regimenes_ignorados" ADD CONSTRAINT "tenant_regimenes_ignorados_regimen_id_fkey" FOREIGN KEY ("regimen_id") REFERENCES "regimenes"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tenant_regimenes_activos" ADD CONSTRAINT "tenant_regimenes_activos_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tenant_regimenes_activos" ADD CONSTRAINT "tenant_regimenes_activos_regimen_id_fkey" FOREIGN KEY ("regimen_id") REFERENCES "regimenes"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "coeficiente_utilidad" ADD CONSTRAINT "coeficiente_utilidad_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "fiel_credentials" ADD CONSTRAINT "fiel_credentials_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_platform_roles" ADD CONSTRAINT "user_platform_roles_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "payments" ADD CONSTRAINT "payments_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "payments" ADD CONSTRAINT "payments_subscription_id_fkey" FOREIGN KEY ("subscription_id") REFERENCES "subscriptions"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sat_sync_jobs" ADD CONSTRAINT "sat_sync_jobs_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "timbre_suscripciones" ADD CONSTRAINT "timbre_suscripciones_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,47 @@
-- CreateEnum
CREATE TYPE "PaymentKind" AS ENUM ('subscription', 'timbres_pack');
-- AlterTable
ALTER TABLE "payments" ADD COLUMN "kind" "PaymentKind" NOT NULL DEFAULT 'subscription';
-- CreateTable
CREATE TABLE "timbre_paquetes_catalogo" (
"id" SERIAL NOT NULL,
"cantidad" INTEGER NOT NULL,
"precio" DECIMAL(10,2) NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "timbre_paquetes_catalogo_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "timbre_paquetes" (
"id" SERIAL NOT NULL,
"tenant_id" TEXT NOT NULL,
"payment_id" TEXT,
"cantidad" INTEGER NOT NULL,
"usados" INTEGER NOT NULL DEFAULT 0,
"precio" DECIMAL(10,2) NOT NULL,
"adquirido_en" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expira_en" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "timbre_paquetes_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "timbre_paquetes_catalogo_cantidad_key" ON "timbre_paquetes_catalogo"("cantidad");
-- CreateIndex
CREATE UNIQUE INDEX "timbre_paquetes_payment_id_key" ON "timbre_paquetes"("payment_id");
-- CreateIndex
CREATE INDEX "timbre_paquetes_tenant_id_expira_en_idx" ON "timbre_paquetes"("tenant_id", "expira_en");
-- AddForeignKey
ALTER TABLE "timbre_paquetes" ADD CONSTRAINT "timbre_paquetes_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "timbre_paquetes" ADD CONSTRAINT "timbre_paquetes_payment_id_fkey" FOREIGN KEY ("payment_id") REFERENCES "payments"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,16 @@
-- CreateEnum
CREATE TYPE "VerticalProfile" AS ENUM ('CONTABLE', 'JURIDICO', 'ARQUITECTURA');
-- CreateEnum
CREATE TYPE "DbMode" AS ENUM ('BYO', 'MANAGED');
-- AlterTable
ALTER TABLE "tenants" ADD COLUMN "connector_last_seen" TIMESTAMP(3),
ADD COLUMN "connector_token_enc" TEXT,
ADD COLUMN "connector_tunnel_hostname" TEXT,
ADD COLUMN "connector_version" VARCHAR(20),
ADD COLUMN "db_connection_enc" TEXT,
ADD COLUMN "db_connection_iv" TEXT,
ADD COLUMN "db_mode" "DbMode",
ADD COLUMN "db_schema_version" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "vertical_profile" "VerticalProfile";

View File

@@ -0,0 +1,35 @@
-- CreateTable
CREATE TABLE "plan_catalogo" (
"id" TEXT NOT NULL,
"codename" VARCHAR(50) NOT NULL,
"nombre" TEXT NOT NULL,
"verticalProfile" "VerticalProfile" NOT NULL,
"precio_base" DECIMAL(10,2) NOT NULL,
"frecuencia" VARCHAR(10) NOT NULL,
"limits" JSONB NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "plan_catalogo_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "plan_addon_catalogo" (
"id" TEXT NOT NULL,
"codename" VARCHAR(50) NOT NULL,
"nombre" TEXT NOT NULL,
"verticalProfile" "VerticalProfile",
"precio" DECIMAL(10,2) NOT NULL,
"frecuencia" VARCHAR(10) NOT NULL,
"delta" JSONB NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "plan_addon_catalogo_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "plan_catalogo_codename_key" ON "plan_catalogo"("codename");
-- CreateIndex
CREATE UNIQUE INDEX "plan_addon_catalogo_codename_key" ON "plan_addon_catalogo"("codename");

View File

@@ -0,0 +1,28 @@
-- CreateTable
CREATE TABLE "subscription_addons" (
"id" TEXT NOT NULL,
"subscription_id" TEXT NOT NULL,
"plan_addon_catalogo_id" TEXT NOT NULL,
"mp_preapproval_id" TEXT,
"status" TEXT NOT NULL DEFAULT 'pending',
"quantity" INTEGER NOT NULL DEFAULT 1,
"amount" DECIMAL(10,2) NOT NULL,
"current_period_start" TIMESTAMP(3),
"current_period_end" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "subscription_addons_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "subscription_addons_subscription_id_idx" ON "subscription_addons"("subscription_id");
-- CreateIndex
CREATE UNIQUE INDEX "subscription_addons_subscription_id_plan_addon_catalogo_id_key" ON "subscription_addons"("subscription_id", "plan_addon_catalogo_id");
-- AddForeignKey
ALTER TABLE "subscription_addons" ADD CONSTRAINT "subscription_addons_subscription_id_fkey" FOREIGN KEY ("subscription_id") REFERENCES "subscriptions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "subscription_addons" ADD CONSTRAINT "subscription_addons_plan_addon_catalogo_id_fkey" FOREIGN KEY ("plan_addon_catalogo_id") REFERENCES "plan_addon_catalogo"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,19 @@
-- CreateTable
CREATE TABLE "connector_heartbeats" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"latency_ms" INTEGER NOT NULL,
"version" VARCHAR(20) NOT NULL,
"pg_version" VARCHAR(50),
"status" VARCHAR(20) NOT NULL,
"error_msg" TEXT,
CONSTRAINT "connector_heartbeats_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "connector_heartbeats_tenant_id_timestamp_idx" ON "connector_heartbeats"("tenant_id", "timestamp");
-- AddForeignKey
ALTER TABLE "connector_heartbeats" ADD CONSTRAINT "connector_heartbeats_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "sat_sync_jobs" ADD COLUMN "contribuyente_id" TEXT;

View File

@@ -0,0 +1,10 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "Plan" ADD VALUE 'business_control';
ALTER TYPE "Plan" ADD VALUE 'business_cloud';

View File

@@ -0,0 +1,18 @@
-- Add-ons por contribuyente: permite que SubscriptionAddon se asocie a un
-- contribuyente específico (ej. Lolita IA $250/mes activable por RFC) además
-- de los add-ons a nivel tenant (modulos, +RFCs, +timbres) que tienen
-- contribuyente_id = NULL.
ALTER TABLE "subscription_addons"
ADD COLUMN "contribuyente_id" TEXT;
-- Eliminar el UNIQUE (subscription_id, plan_addon_catalogo_id). Ahora el
-- mismo add-on (p. ej. lolita_ia_contribuyente) puede tener N filas por
-- subscription, una por cada contribuyente que lo contrate.
ALTER TABLE "subscription_addons"
DROP CONSTRAINT IF EXISTS "subscription_addons_subscription_id_plan_addon_catalogo_id_key";
-- Índice por (subscription_id, contribuyente_id) para lookups rápidos
-- "qué add-ons tiene este contribuyente"
CREATE INDEX IF NOT EXISTS "subscription_addons_subscription_id_contribuyente_id_idx"
ON "subscription_addons"("subscription_id", "contribuyente_id");

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "Plan" ADD VALUE 'mi_empresa';

View File

@@ -0,0 +1,18 @@
-- CreateTable
CREATE TABLE "despacho_plan_prices" (
"plan" TEXT NOT NULL,
"monthly" DECIMAL(10,2),
"first_year" DECIMAL(10,2) NOT NULL,
"renewal" DECIMAL(10,2) NOT NULL,
"permite_monthly" BOOLEAN NOT NULL DEFAULT false,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "despacho_plan_prices_pkey" PRIMARY KEY ("plan")
);
-- Seed inicial con valores actuales del catálogo `DESPACHO_PLAN_PRICES`.
INSERT INTO "despacho_plan_prices" ("plan", "monthly", "first_year", "renewal", "permite_monthly", "updated_at") VALUES
('mi_empresa', 580, 5800, 5800, true, NOW()),
('mi_empresa_plus', 900, 9000, 9000, true, NOW()),
('business_control', NULL, 25850, 25850, false, NOW()),
('business_cloud', NULL, 43000, 43000, false, NOW());

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

View File

@@ -0,0 +1,744 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Tenant {
id String @id @default(uuid())
nombre String
rfc String @unique
plan Plan @default(starter)
databaseName String @unique @map("database_name")
cfdiLimit Int @default(100) @map("cfdi_limit")
usersLimit Int @default(1) @map("users_limit")
active Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
expiresAt DateTime? @map("expires_at")
// Prueba gratuita: si está set y en el futuro, el tenant está en trial.
// Se consume una sola vez por tenant (al activarla, nunca se regenera).
trialEndsAt DateTime? @map("trial_ends_at")
facturapiOrgId String? @map("facturapi_org_id")
// Domicilio fiscal
codigoPostal String? @map("codigo_postal") @db.VarChar(5)
calle String? @db.VarChar(255)
numExterior String? @map("num_exterior") @db.VarChar(20)
numInterior String? @map("num_interior") @db.VarChar(20)
colonia String? @db.VarChar(255)
ciudad String? @db.VarChar(100)
municipio String? @db.VarChar(100)
estado String? @db.VarChar(100)
telefono String? @db.VarChar(20)
// === Despacho fields ===
verticalProfile VerticalProfile? @map("vertical_profile")
dbMode DbMode? @map("db_mode")
dbConnectionEnc String? @map("db_connection_enc")
dbConnectionIv String? @map("db_connection_iv")
dbSchemaVersion Int @default(0) @map("db_schema_version")
connectorTokenEnc String? @map("connector_token_enc")
connectorTunnelHostname String? @map("connector_tunnel_hostname")
connectorLastSeen DateTime? @map("connector_last_seen")
connectorVersion String? @map("connector_version") @db.VarChar(20)
memberships TenantMembership[]
fielCredential FielCredential?
satSyncJobs SatSyncJob[]
subscriptions Subscription[]
payments Payment[]
regimenesIgnorados TenantRegimenIgnorado[]
regimenesActivos TenantRegimenActivo[]
coeficientes CoeficienteUtilidad[]
timbreSuscripcion TimbreSuscripcion?
timbrePaquetes TimbrePaquete[]
connectorHeartbeats ConnectorHeartbeat[]
@@map("tenants")
}
model User {
id String @id @default(uuid())
email String @unique
passwordHash String @map("password_hash")
nombre String
active Boolean @default(true)
lastLogin DateTime? @map("last_login")
createdAt DateTime @default(now()) @map("created_at")
// Contador para invalidar sesiones masivamente. Al incrementar, todos los
// JWT emitidos antes (con tokenVersion menor) quedan rechazados en el
// siguiente request. Se incrementa en: password change, password reset,
// logout-all. Default 0 para compat con users pre-rollout.
tokenVersion Int @default(0) @map("token_version")
// Último tenant que el user activó (via switch-tenant). Se usa para resolver
// el "tenant activo al login". Si es null, el login cae al primer membership
// por joinedAt. Se actualiza en cada switch.
lastTenantId String? @map("last_tenant_id")
memberships TenantMembership[]
platformRoles UserPlatformRole[]
passwordResetTokens PasswordResetToken[]
@@map("users")
}
/// Relación many-to-many entre User y Tenant. Permite que un mismo user (p.ej.
/// un dueño/contador) pertenezca a varios tenants con distintos roles. Esta
/// tabla es la fuente de verdad del "¿a qué tenants tiene acceso este user?".
///
/// Durante la transición, `User.tenantId` y `User.rolId` se mantienen como
/// "default tenant" para login UX. El backfill inicial crea 1 membership por
/// user basado en esos campos. Cuando se agregue la UI de multi-tenant, los
/// nuevos accesos solo tocarán esta tabla.
model TenantMembership {
id Int @id @default(autoincrement())
userId String @map("user_id")
tenantId String @map("tenant_id")
rolId Int @map("rol_id")
isOwner Boolean @default(false) @map("is_owner")
active Boolean @default(true)
joinedAt DateTime @default(now()) @map("joined_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
rol Rol @relation(fields: [rolId], references: [id])
@@unique([userId, tenantId])
@@index([userId, active])
@@index([tenantId, active])
@@map("tenant_memberships")
}
model Rol {
id Int @id @default(autoincrement())
nombre String @unique @db.VarChar(20)
descripcion String?
createdAt DateTime @default(now()) @map("created_at")
memberships TenantMembership[]
@@map("roles")
}
model RefreshToken {
id String @id @default(uuid())
userId String @map("user_id")
token String @unique
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
@@map("refresh_tokens")
}
/// Tokens para recuperación de contraseña. Expiran en 1 hora, son single-use
/// (se marca `usedAt` al consumir). Al completar reset se invalidan todos los
/// refresh tokens del user — cierra todas sus sesiones forzando re-login.
model PasswordResetToken {
id String @id @default(uuid())
userId String @map("user_id")
token String @unique
expiresAt DateTime @map("expires_at")
usedAt DateTime? @map("used_at")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([expiresAt])
@@map("password_reset_tokens")
}
enum Plan {
starter
business
business_ia
custom
enterprise
business_control
business_cloud
mi_empresa
mi_empresa_plus
}
enum VerticalProfile {
CONTABLE
JURIDICO
ARQUITECTURA
}
enum DbMode {
BYO
MANAGED
}
// ============================================
// Catálogo de Regímenes Fiscales SAT
// ============================================
model Regimen {
id Int @id @default(autoincrement())
clave String @unique @db.VarChar(3)
descripcion String
tipoPersona String @map("tipo_persona") @db.VarChar(20) // fisica, moral, ambos
activo Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
tenantIgnorados TenantRegimenIgnorado[]
tenantActivos TenantRegimenActivo[]
@@map("regimenes")
}
model TenantRegimenIgnorado {
id Int @id @default(autoincrement())
tenantId String @map("tenant_id")
regimenId Int @map("regimen_id")
createdAt DateTime @default(now()) @map("created_at")
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
regimen Regimen @relation(fields: [regimenId], references: [id], onDelete: Cascade)
@@unique([tenantId, regimenId])
@@map("tenant_regimenes_ignorados")
}
model TenantRegimenActivo {
id Int @id @default(autoincrement())
tenantId String @map("tenant_id")
regimenId Int @map("regimen_id")
createdAt DateTime @default(now()) @map("created_at")
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
regimen Regimen @relation(fields: [regimenId], references: [id], onDelete: Cascade)
@@unique([tenantId, regimenId])
@@map("tenant_regimenes_activos")
}
// ============================================
// Catálogo de Eventos Fiscales
// ============================================
model EventoFiscalCatalogo {
id Int @id @default(autoincrement())
titulo String
descripcion String?
tipo String @db.VarChar(20) // declaracion, pago, obligacion, informativa
diaBase Int @map("dia_base") // día del mes (17, 3, 31, etc.)
mesRelativo Int @default(1) @map("mes_relativo") // 1=mes posterior, 2=segundo mes posterior, 0=mes fijo
mesFijo Int? @map("mes_fijo") // para anuales: 2=feb, 3=mar, 4=abr
recurrencia String @default("mensual") @db.VarChar(20) // mensual, anual
usaExtensionRfc Boolean @default(false) @map("usa_extension_rfc")
regimenes String @default("todos") // 'todos' o CSV de claves: '601,603,612'
condicion String? @db.VarChar(50) // null, 'tiene_nomina', 'ingresos_4m'
activo Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
@@map("eventos_fiscales_catalogo")
}
/// Lista negra SAT (Art. 69-B CFF)
model ListaNegra {
id Int @id @default(autoincrement())
rfc String @unique @db.VarChar(13)
nombre String
situacion String @db.VarChar(30) // Definitivo, Presunto, Desvirtuado, Sentencia Favorable
updatedAt DateTime @updatedAt @map("updated_at")
createdAt DateTime @default(now()) @map("created_at")
@@index([rfc])
@@map("lista_negra")
}
/// Días inhábiles fiscales (festivos oficiales de México)
model DiaInhabil {
id Int @id @default(autoincrement())
fecha DateTime @unique @db.Date
nombre String
@@map("dias_inhabiles")
}
// ============================================
// ISR Tables
// ============================================
/// Tasas RESICO (Art. 113-E) - tasa plana por bracket mensual
model IsrResicoTasa {
id Int @id @default(autoincrement())
anio Int @map("anio")
montoMaximo Decimal @map("monto_maximo") @db.Decimal(18, 2)
porcentaje Decimal @db.Decimal(5, 2)
@@unique([anio, montoMaximo])
@@map("isr_resico_tasas")
}
/// Tarifa ISR progresiva (Art. 96) - mensual
model IsrTarifa {
id Int @id @default(autoincrement())
anio Int @map("anio")
limiteInferior Decimal @map("limite_inferior") @db.Decimal(18, 2)
limiteSuperior Decimal? @map("limite_superior") @db.Decimal(18, 2)
cuotaFija Decimal @map("cuota_fija") @db.Decimal(18, 2)
porcentajeExcedente Decimal @map("porcentaje_excedente") @db.Decimal(5, 2)
@@unique([anio, limiteInferior])
@@map("isr_tarifas")
}
/// Coeficiente de utilidad por tenant/año (no se sobrescribe)
model CoeficienteUtilidad {
id Int @id @default(autoincrement())
tenantId String @map("tenant_id")
anio Int @map("anio")
coeficiente Decimal @db.Decimal(10, 4)
createdAt DateTime @default(now()) @map("created_at")
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@unique([tenantId, anio])
@@map("coeficiente_utilidad")
}
// ============================================
// SAT Sync Models
// ============================================
model FielCredential {
id String @id @default(uuid())
tenantId String @unique @map("tenant_id")
rfc String @db.VarChar(13)
cerData Bytes @map("cer_data")
keyData Bytes @map("key_data")
keyPasswordEncrypted Bytes @map("key_password_encrypted")
cerIv Bytes @map("cer_iv")
cerTag Bytes @map("cer_tag")
keyIv Bytes @map("key_iv")
keyTag Bytes @map("key_tag")
passwordIv Bytes @map("password_iv")
passwordTag Bytes @map("password_tag")
serialNumber String? @map("serial_number") @db.VarChar(50)
validFrom DateTime @map("valid_from")
validUntil DateTime @map("valid_until")
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@map("fiel_credentials")
}
model Subscription {
id String @id @default(uuid())
tenantId String @map("tenant_id")
plan Plan
mpPreapprovalId String? @map("mp_preapproval_id")
status String @default("pending")
amount Decimal @db.Decimal(10, 2)
frequency String @default("monthly")
currentPeriodStart DateTime? @map("current_period_start")
currentPeriodEnd DateTime? @map("current_period_end")
// Cambio programado al próximo período (downgrades y cambios de frecuencia)
pendingPlan Plan? @map("pending_plan")
pendingFrequency String? @map("pending_frequency")
pendingEffectiveAt DateTime? @map("pending_effective_at")
// Upgrade inmediato en curso: preference MP esperando cobro prorateado.
// Cuando el webhook confirma el pago, se aplica el plan nuevo y se limpian estos campos.
upgradePreferenceId String? @map("upgrade_preference_id")
upgradeTargetPlan Plan? @map("upgrade_target_plan")
upgradeTargetAmount Decimal? @db.Decimal(10, 2) @map("upgrade_target_amount")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
payments Payment[]
addons SubscriptionAddon[]
@@index([tenantId])
@@index([status])
@@index([pendingEffectiveAt])
@@map("subscriptions")
}
model SubscriptionAddon {
id String @id @default(uuid())
subscriptionId String @map("subscription_id")
planAddonCatalogoId String @map("plan_addon_catalogo_id")
/// UUID del contribuyente (entidad_id en tenant BD) cuando el add-on
/// aplica a un RFC específico. NULL para add-ons a nivel tenant (módulos
/// globales, +RFCs, +timbres). Sin FK porque contribuyente vive en BD tenant.
contribuyenteId String? @map("contribuyente_id")
mpPreapprovalId String? @map("mp_preapproval_id")
status String @default("pending")
quantity Int @default(1)
amount Decimal @db.Decimal(10, 2)
currentPeriodStart DateTime? @map("current_period_start")
currentPeriodEnd DateTime? @map("current_period_end")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
subscription Subscription @relation(fields: [subscriptionId], references: [id])
planAddonCatalogo PlanAddonCatalogo @relation(fields: [planAddonCatalogoId], references: [id])
/// Sin UNIQUE compuesto: la validación de "un solo add-on activo por
/// (subscription, addon, contribuyente?)" queda a nivel aplicación
/// (findFirst en subscribeAddon), porque Postgres trata NULL!=NULL y no
/// hay forma trivial de enforcar unicidad con contribuyenteId opcional.
@@index([subscriptionId])
@@index([subscriptionId, contribuyenteId])
@@map("subscription_addons")
}
/// Roles de plataforma (staff interno de Horux 360) — ortogonales al rol per-tenant.
/// Un user puede tener 0, 1 o varios roles. `platform_admin` es el superrol.
/// Ver `docs/plans/2026-04-14-platform-admin-roles.md`.
enum PlatformRole {
platform_admin // Todo: precios, clientes, facturas, suscripciones, gestión de staff
platform_ti // Mismos permisos que admin (equipo de TI / tech ops). Diferencia solo en trazabilidad.
platform_support // Ver todos los tenants, resolver tickets, NO facturación/precios
platform_sales // Crear/editar tenants (onboarding), ver suscripciones, NO precios
platform_finance // Ver payments, emitir facturas manuales, editar precios, reportes fiscales
}
model UserPlatformRole {
id Int @id @default(autoincrement())
userId String @map("user_id")
role PlatformRole
createdAt DateTime @default(now()) @map("created_at")
createdBy String? @map("created_by") // User.id de quien asignó (audit trail)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, role])
@@index([role])
@@map("user_platform_roles")
}
/// Registro de acciones críticas para auditoría (SAT compliance, forense, disputas).
/// Se instrumenta vía `utils/audit.ts` con helper fire-and-forget — un fallo al
/// escribir aquí NUNCA debe romper la acción principal.
model AuditLog {
id String @id @default(uuid())
userId String? @map("user_id")
tenantId String? @map("tenant_id")
action String @db.VarChar(64) // "price.updated", "subscription.cancelled", etc.
entityType String? @map("entity_type") @db.VarChar(32)
entityId String? @map("entity_id")
metadata Json? // before/after, ip, userAgent, contexto
createdAt DateTime @default(now()) @map("created_at")
@@index([userId, createdAt])
@@index([tenantId, createdAt])
@@index([action, createdAt])
@@index([entityType, entityId])
@@map("audit_log")
}
/// Padrón persistente de RFCs que ya consumieron su prueba gratuita de 30 días.
/// Sobrevive al ciclo de vida del Tenant (si se borra/recrea, el RFC sigue aquí),
/// bloqueando el abuso de "registro nuevo con el mismo RFC para otro trial".
model TrialUsage {
id Int @id @default(autoincrement())
rfc String @unique @db.VarChar(13)
tenantId String? @map("tenant_id") // Tenant que consumió (null si el tenant se borró después)
startedAt DateTime @default(now()) @map("started_at")
@@map("trial_usages")
}
/// Precios editables de los planes (self-serve). Custom no se guarda aquí
/// porque cada cliente tiene su monto propio (lo fija el admin al crear tenant).
model PlanPrice {
id Int @id @default(autoincrement())
plan Plan
frequency String // "monthly" | "annual"
amount Decimal @db.Decimal(10, 2)
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([plan, frequency])
@@map("plan_prices")
}
/// Precios editables por admin global de los planes despacho.
/// Antes vivía en `DESPACHO_PLAN_PRICES` (catálogo estático en `@horux/shared`);
/// movido a BD para permitir actualización desde `/configuracion/precios-suscripcion`.
/// Si una fila no existe, `getPlanPrice` cae al catálogo estático como fallback.
model DespachoPlanPrice {
plan String @id // mi_empresa | mi_empresa_plus | business_control | business_cloud
monthly Decimal? @db.Decimal(10, 2)
firstYear Decimal @db.Decimal(10, 2) @map("first_year")
renewal Decimal @db.Decimal(10, 2)
permiteMonthly Boolean @default(false) @map("permite_monthly")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("despacho_plan_prices")
}
model PlanCatalogo {
id String @id @default(uuid())
codename String @unique @db.VarChar(50)
nombre String
verticalProfile VerticalProfile
precioBase Decimal @db.Decimal(10, 2) @map("precio_base")
frecuencia String @db.VarChar(10)
limits Json
active Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
@@map("plan_catalogo")
}
model PlanAddonCatalogo {
id String @id @default(uuid())
codename String @unique @db.VarChar(50)
nombre String
verticalProfile VerticalProfile?
precio Decimal @db.Decimal(10, 2)
frecuencia String @db.VarChar(10)
delta Json
active Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
subscriptionAddons SubscriptionAddon[]
@@map("plan_addon_catalogo")
}
model ConnectorHeartbeat {
id String @id @default(uuid())
tenantId String @map("tenant_id")
timestamp DateTime @default(now())
latencyMs Int @map("latency_ms")
version String @db.VarChar(20)
pgVersion String? @map("pg_version") @db.VarChar(50)
status String @db.VarChar(20)
errorMsg String? @map("error_msg")
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@index([tenantId, timestamp])
@@map("connector_heartbeats")
}
enum PaymentKind {
subscription
timbres_pack
}
model Payment {
id String @id @default(uuid())
tenantId String @map("tenant_id")
subscriptionId String? @map("subscription_id")
mpPaymentId String? @map("mp_payment_id")
amount Decimal @db.Decimal(10, 2)
status String @default("pending")
paymentMethod String? @map("payment_method")
paidAt DateTime? @map("paid_at")
// Tipo de pago. subscription = cobro mensual/anual del plan.
// timbres_pack = compra de paquete de timbres adicionales.
kind PaymentKind @default(subscription)
// ID de la factura emitida auto por Facturapi. Null si no se facturó:
// primer pago (manual), trial sin monto, o fallo al emitir.
facturapiInvoiceId String? @map("facturapi_invoice_id")
createdAt DateTime @default(now()) @map("created_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
subscription Subscription? @relation(fields: [subscriptionId], references: [id])
timbrePaquete TimbrePaquete?
@@index([tenantId])
@@index([subscriptionId])
@@map("payments")
}
/// Catálogo de paquetes de timbres adicionales vendibles. Precios editables
/// desde panel admin. Los 3 defaults (100/$200, 1000/$1400, 10000/$8600) se
/// insertan en seed idempotente.
model TimbrePaqueteCatalogo {
id Int @id @default(autoincrement())
cantidad Int @unique // 100, 1000, 10000
precio Decimal @db.Decimal(10, 2)
active Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("timbre_paquetes_catalogo")
}
/// Compra individual de timbres adicionales. Los timbres del plan (mensuales)
/// se rastrean en TimbreSuscripcion — esto es SOLO para los extras pagados.
/// Vigencia 1 año desde `adquiridoEn`. El orden de consumo es FIFO por
/// `expiraEn` (menor primero) para no desperdiciar paquetes próximos a vencer.
model TimbrePaquete {
id Int @id @default(autoincrement())
tenantId String @map("tenant_id")
paymentId String? @unique @map("payment_id") // Payment que lo compró; null si admin grant manual
cantidad Int // cuántos timbres tenía originalmente
usados Int @default(0)
precio Decimal @db.Decimal(10, 2) // precio pagado (historial, no cambia si el catálogo cambia)
adquiridoEn DateTime @default(now()) @map("adquirido_en")
expiraEn DateTime @map("expira_en") // adquiridoEn + 1 año
createdAt DateTime @default(now()) @map("created_at")
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
payment Payment? @relation(fields: [paymentId], references: [id])
@@index([tenantId, expiraEn])
@@map("timbre_paquetes")
}
model SatSyncJob {
id String @id @default(uuid())
tenantId String @map("tenant_id")
contribuyenteId String? @map("contribuyente_id")
type SatSyncType
status SatSyncStatus @default(pending)
dateFrom DateTime @map("date_from") @db.Date
dateTo DateTime @map("date_to") @db.Date
cfdiType CfdiSyncType? @map("cfdi_type")
satRequestId String? @map("sat_request_id") @db.VarChar(50)
satPackageIds String[] @map("sat_package_ids")
cfdisFound Int @default(0) @map("cfdis_found")
cfdisDownloaded Int @default(0) @map("cfdis_downloaded")
cfdisInserted Int @default(0) @map("cfdis_inserted")
cfdisUpdated Int @default(0) @map("cfdis_updated")
progressPercent Int @default(0) @map("progress_percent")
errorMessage String? @map("error_message")
startedAt DateTime? @map("started_at")
completedAt DateTime? @map("completed_at")
createdAt DateTime @default(now()) @map("created_at")
retryCount Int @default(0) @map("retry_count")
nextRetryAt DateTime? @map("next_retry_at")
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@index([tenantId])
@@index([status])
@@index([status, nextRetryAt])
@@map("sat_sync_jobs")
}
enum SatSyncType {
initial
daily
incremental
}
enum SatSyncStatus {
pending
running
completed
failed
}
enum CfdiSyncType {
emitidos
recibidos
}
// ============================================
// Catálogos SAT para Facturación (CFDI 4.0)
// ============================================
model CatFormaPago {
id Int @id @default(autoincrement())
clave String @unique @db.VarChar(2)
descripcion String
@@map("cat_forma_pago")
}
model CatMetodoPago {
id Int @id @default(autoincrement())
clave String @unique @db.VarChar(3)
descripcion String
@@map("cat_metodo_pago")
}
model CatUsoCfdi {
id Int @id @default(autoincrement())
clave String @unique @db.VarChar(4)
descripcion String
personaFisica Boolean @default(true) @map("persona_fisica")
personaMoral Boolean @default(true) @map("persona_moral")
@@map("cat_uso_cfdi")
}
model CatMoneda {
id Int @id @default(autoincrement())
clave String @unique @db.VarChar(3)
descripcion String
decimales Int @default(2)
@@map("cat_moneda")
}
model CatClaveUnidad {
id Int @id @default(autoincrement())
clave String @unique @db.VarChar(10)
descripcion String
@@map("cat_clave_unidad")
}
model CatClaveProdServ {
id Int @id @default(autoincrement())
clave String @unique @db.VarChar(8)
descripcion String
@@index([descripcion])
@@map("cat_clave_prod_serv")
}
model CatObjetoImp {
id Int @id @default(autoincrement())
clave String @unique @db.VarChar(2)
descripcion String
@@map("cat_objeto_imp")
}
model CatTipoRelacion {
id Int @id @default(autoincrement())
clave String @unique @db.VarChar(2)
descripcion String
@@map("cat_tipo_relacion")
}
model CatExportacion {
id Int @id @default(autoincrement())
clave String @unique @db.VarChar(2)
descripcion String
@@map("cat_exportacion")
}
// ============================================
// Gestión de Timbres Facturapi
// ============================================
model TimbreSuscripcion {
id Int @id @default(autoincrement())
tenantId String @unique @map("tenant_id")
tipo String @db.VarChar(10) // mensual, anual
timbresLimite Int @map("timbres_limite") // 50 o 600
timbresUsados Int @default(0) @map("timbres_usados")
periodoInicio DateTime @map("periodo_inicio") @db.Date
periodoFin DateTime @map("periodo_fin") @db.Date
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@map("timbre_suscripciones")
}

559
apps/api/prisma/seed.ts Normal file
View File

@@ -0,0 +1,559 @@
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
import { Pool } from 'pg';
import { migrate } from '../src/config/tenant-migrations.js';
import { RESICO_TASAS, ISR_TARIFAS } from './isr-data.js';
import { EVENTOS_FISCALES, DIAS_INHABILES } from './eventos-fiscales-data.js';
import {
FORMAS_PAGO, METODOS_PAGO, USOS_CFDI, MONEDAS, CLAVES_UNIDAD,
OBJETOS_IMP, TIPOS_RELACION, EXPORTACIONES,
} from './catalogos-sat-data.js';
const prisma = new PrismaClient();
function parseDatabaseUrl(url: string) {
const parsed = new URL(url);
return {
host: parsed.hostname,
port: parseInt(parsed.port || '5432'),
user: decodeURIComponent(parsed.username),
password: decodeURIComponent(parsed.password),
};
}
const REGIMENES_SAT = [
{ clave: '601', descripcion: 'General de Ley Personas Morales', tipoPersona: 'moral' },
{ clave: '603', descripcion: 'Personas Morales con Fines no Lucrativos', tipoPersona: 'moral' },
{ clave: '605', descripcion: 'Sueldos y Salarios e Ingresos Asimilados a Salarios', tipoPersona: 'fisica' },
{ clave: '606', descripcion: 'Arrendamiento', tipoPersona: 'fisica' },
{ clave: '607', descripcion: 'Régimen de Enajenación o Adquisición de Bienes', tipoPersona: 'fisica' },
{ clave: '608', descripcion: 'Demás ingresos', tipoPersona: 'fisica' },
{ clave: '610', descripcion: 'Residentes en el Extranjero sin Establecimiento Permanente en México', tipoPersona: 'ambos' },
{ clave: '611', descripcion: 'Ingresos por Dividendos (socios y accionistas)', tipoPersona: 'fisica' },
{ clave: '612', descripcion: 'Personas Físicas con Actividades Empresariales y Profesionales', tipoPersona: 'fisica' },
{ clave: '614', descripcion: 'Ingresos por intereses', tipoPersona: 'fisica' },
{ clave: '615', descripcion: 'Régimen de los ingresos por obtención de premios', tipoPersona: 'fisica' },
{ clave: '616', descripcion: 'Sin obligaciones fiscales', tipoPersona: 'ambos' },
{ clave: '620', descripcion: 'Sociedades Cooperativas de Producción que optan por diferir sus ingresos', tipoPersona: 'moral' },
{ clave: '621', descripcion: 'Incorporación Fiscal', tipoPersona: 'fisica' },
{ clave: '622', descripcion: 'Actividades Agrícolas, Ganaderas, Silvícolas y Pesqueras', tipoPersona: 'ambos' },
{ clave: '623', descripcion: 'Opcional para Grupos de Sociedades', tipoPersona: 'moral' },
{ clave: '624', descripcion: 'Coordinados', tipoPersona: 'moral' },
{ clave: '625', descripcion: 'Régimen de las Actividades Empresariales con ingresos a través de Plataformas Tecnológicas', tipoPersona: 'fisica' },
{ clave: '626', descripcion: 'Régimen Simplificado de Confianza', tipoPersona: 'ambos' },
];
async function main() {
console.log('🌱 Seeding database...');
// Seed regimenes catalog
for (const r of REGIMENES_SAT) {
await prisma.regimen.upsert({
where: { clave: r.clave },
update: { descripcion: r.descripcion, tipoPersona: r.tipoPersona },
create: r,
});
}
console.log(`${REGIMENES_SAT.length} regímenes fiscales SAT cargados`);
// Seed ISR tables — limpiar y recrear
await prisma.isrResicoTasa.deleteMany();
await prisma.isrTarifa.deleteMany();
for (const anio of [2020, 2021, 2022, 2023, 2024, 2025, 2026]) {
if (anio >= 2022) {
await prisma.isrResicoTasa.createMany({
data: RESICO_TASAS.map(t => ({ anio, montoMaximo: t.montoMaximo, porcentaje: t.porcentaje })),
});
}
const tarifas = ISR_TARIFAS[anio];
if (tarifas) {
await prisma.isrTarifa.createMany({
data: tarifas.map(t => ({
anio,
limiteInferior: t.li,
limiteSuperior: t.ls,
cuotaFija: t.cf,
porcentajeExcedente: t.pe,
})),
});
}
}
console.log('✅ Tablas ISR 2020-2026 cargadas');
// Seed eventos fiscales catálogo
await prisma.eventoFiscalCatalogo.deleteMany();
await prisma.eventoFiscalCatalogo.createMany({
data: EVENTOS_FISCALES.map(e => ({
titulo: e.titulo,
tipo: e.tipo,
diaBase: e.diaBase,
mesRelativo: e.mesRelativo,
mesFijo: (e as any).mesFijo || null,
recurrencia: e.recurrencia,
usaExtensionRfc: e.usaExtensionRfc,
regimenes: e.regimenes,
condicion: e.condicion || null,
})),
});
console.log(`${EVENTOS_FISCALES.length} eventos fiscales cargados`);
// Seed días inhábiles
await prisma.diaInhabil.deleteMany();
await prisma.diaInhabil.createMany({
data: DIAS_INHABILES.map(d => ({ fecha: new Date(d.fecha), nombre: d.nombre })),
skipDuplicates: true,
});
console.log(`${DIAS_INHABILES.length} días inhábiles cargados (2020-2027)`);
// Seed catálogos SAT para facturación
for (const fp of FORMAS_PAGO) {
await prisma.catFormaPago.upsert({ where: { clave: fp.clave }, update: { descripcion: fp.descripcion }, create: fp });
}
console.log(`${FORMAS_PAGO.length} formas de pago cargadas`);
for (const mp of METODOS_PAGO) {
await prisma.catMetodoPago.upsert({ where: { clave: mp.clave }, update: { descripcion: mp.descripcion }, create: mp });
}
console.log(`${METODOS_PAGO.length} métodos de pago cargados`);
for (const u of USOS_CFDI) {
await prisma.catUsoCfdi.upsert({ where: { clave: u.clave }, update: { descripcion: u.descripcion, personaFisica: u.personaFisica, personaMoral: u.personaMoral }, create: u });
}
console.log(`${USOS_CFDI.length} usos CFDI cargados`);
for (const m of MONEDAS) {
await prisma.catMoneda.upsert({ where: { clave: m.clave }, update: { descripcion: m.descripcion, decimales: m.decimales }, create: m });
}
console.log(`${MONEDAS.length} monedas cargadas`);
for (const cu of CLAVES_UNIDAD) {
await prisma.catClaveUnidad.upsert({ where: { clave: cu.clave }, update: { descripcion: cu.descripcion }, create: cu });
}
console.log(`${CLAVES_UNIDAD.length} claves de unidad cargadas`);
for (const oi of OBJETOS_IMP) {
await prisma.catObjetoImp.upsert({ where: { clave: oi.clave }, update: { descripcion: oi.descripcion }, create: oi });
}
console.log(`${OBJETOS_IMP.length} objetos de impuesto cargados`);
for (const tr of TIPOS_RELACION) {
await prisma.catTipoRelacion.upsert({ where: { clave: tr.clave }, update: { descripcion: tr.descripcion }, create: tr });
}
console.log(`${TIPOS_RELACION.length} tipos de relación cargados`);
for (const ex of EXPORTACIONES) {
await prisma.catExportacion.upsert({ where: { clave: ex.clave }, update: { descripcion: ex.descripcion }, create: ex });
}
console.log(`${EXPORTACIONES.length} exportaciones cargadas`);
// Seed precios de planes (editables vía BD — custom no se incluye, se fija por tenant)
const PLAN_PRICES = [
{ plan: 'starter' as const, frequency: 'monthly', amount: 199 },
{ plan: 'starter' as const, frequency: 'annual', amount: 1990 },
{ plan: 'business' as const, frequency: 'monthly', amount: 480 },
{ plan: 'business' as const, frequency: 'annual', amount: 4800 },
{ plan: 'business_ia' as const, frequency: 'monthly', amount: 780 },
{ plan: 'business_ia' as const, frequency: 'annual', amount: 7800 },
{ plan: 'enterprise' as const, frequency: 'monthly', amount: 900 },
{ plan: 'enterprise' as const, frequency: 'annual', amount: 9000 },
];
for (const p of PLAN_PRICES) {
await prisma.planPrice.upsert({
where: { plan_frequency: { plan: p.plan, frequency: p.frequency } },
update: { amount: p.amount },
create: p,
});
}
console.log(`${PLAN_PRICES.length} precios de planes cargados`);
// Catálogo de paquetes de timbres adicionales. Editables desde panel admin.
// Se crean con upsert por `cantidad` (unique) — permite reejecutar seed sin
// sobrescribir precios ya ajustados manualmente: si el row existe, update
// NO toca el precio (solo active + updatedAt si hace falta), sólo lo crea
// si no existía. Si se quiere forzar reset de precios, borrar las filas.
const TIMBRE_PAQUETES = [
{ cantidad: 100, precio: 200 },
{ cantidad: 1000, precio: 1400 },
{ cantidad: 10000, precio: 8600 },
];
for (const p of TIMBRE_PAQUETES) {
await prisma.timbrePaqueteCatalogo.upsert({
where: { cantidad: p.cantidad },
update: {}, // No tocamos `precio` si ya existe (admin pudo editarlo)
create: { cantidad: p.cantidad, precio: p.precio, active: true },
});
}
console.log(`${TIMBRE_PAQUETES.length} paquetes de timbres en catálogo`);
const databaseName = 'horux_ede123456ab1';
// Create demo tenant
const tenant = await prisma.tenant.upsert({
where: { rfc: 'EDE123456AB1' },
update: {},
create: {
nombre: 'Empresa Demo SA de CV',
rfc: 'EDE123456AB1',
plan: 'business',
databaseName,
cfdiLimit: 500,
usersLimit: 3,
},
});
console.log('✅ Tenant created:', tenant.nombre);
// Migración: renombra el rol legacy 'admin' a 'owner' si sobrevive de un seed viejo.
// Idempotente (no-op si ya se renombró o nunca existió).
await prisma.$executeRawUnsafe(`UPDATE roles SET nombre = 'owner' WHERE nombre = 'admin'`);
// Backfill de trial_usages para tenants que ya consumieron su trial antes de que
// existiera esta tabla. Idempotente: ON CONFLICT DO NOTHING.
await prisma.$executeRawUnsafe(`
INSERT INTO trial_usages (rfc, tenant_id, started_at)
SELECT UPPER(rfc), id, COALESCE(created_at, NOW())
FROM tenants
WHERE trial_ends_at IS NOT NULL
ON CONFLICT (rfc) DO NOTHING
`);
// Backfill de user_platform_roles: los owners del tenant HTS240708LJA se
// convierten automáticamente en platform_admin. Esto preserva el comportamiento
// anterior (admin global por RFC) al mismo tiempo que abre la puerta al modelo
// de roles granulares. Idempotente.
await prisma.$executeRawUnsafe(`
INSERT INTO user_platform_roles (user_id, role, created_at)
SELECT u.id, 'platform_admin'::"PlatformRole", NOW()
FROM users u
JOIN tenants t ON u.tenant_id = t.id
JOIN roles r ON u.rol_id = r.id
WHERE t.rfc = 'HTS240708LJA' AND r.nombre = 'owner'
ON CONFLICT (user_id, role) DO NOTHING
`);
// Backfill de tenant_memberships: cada user existente genera una membership
// con su tenant y rol actuales. isOwner = true si su rol es 'owner' (u 'cfo',
// que es equivalente en permisos). Esto es el fundamento del modelo multi-tenant
// — durante la transición, User.tenantId sigue siendo el "default tenant" para
// login UX, pero las autorizaciones verdaderas vienen de esta tabla.
// Idempotente: ON CONFLICT evita duplicados al re-correr seed.
await prisma.$executeRawUnsafe(`
INSERT INTO tenant_memberships (user_id, tenant_id, rol_id, is_owner, active, joined_at)
SELECT u.id, u.tenant_id, u.rol_id, (r.nombre IN ('owner', 'cfo')), u.active, u.created_at
FROM users u
JOIN roles r ON u.rol_id = r.id
ON CONFLICT (user_id, tenant_id) DO NOTHING
`);
// Seed roles
const rolesData = [
{ nombre: 'owner', descripcion: 'Dueño - acceso completo' },
{ nombre: 'cfo', descripcion: 'CFO - acceso completo (mismo nivel que el dueño)' },
{ nombre: 'contador', descripcion: 'Contador - dashboard, CFDI, impuestos, calendario, alertas, facturación' },
{ nombre: 'auxiliar', descripcion: 'Auxiliar - mismos permisos que contador' },
{ nombre: 'visor', descripcion: 'Visor - solo lectura de CFDI, impuestos, calendario, alertas' },
];
for (const r of rolesData) {
await prisma.rol.upsert({
where: { nombre: r.nombre },
update: { descripcion: r.descripcion },
create: r,
});
}
// Seed despacho roles
await prisma.rol.upsert({
where: { nombre: 'supervisor' },
update: {},
create: { id: 9, nombre: 'supervisor', descripcion: 'Supervisor de despacho — titular de RFCs, crea carteras' },
});
await prisma.rol.upsert({
where: { nombre: 'cliente' },
update: {},
create: { id: 10, nombre: 'cliente', descripcion: 'Cliente visor externo — acceso read-only a sus RFCs' },
});
const roles = await prisma.rol.findMany();
const rolMap = new Map(roles.map(r => [r.nombre, r.id]));
console.log(`${roles.length} roles cargados`);
// Create demo users
const passwordHash = await bcrypt.hash('demo123', 12);
const users = [
{ email: 'admin@demo.com', nombre: 'Dueño Demo', rolNombre: 'owner' },
{ email: 'contador@demo.com', nombre: 'Contador Demo', rolNombre: 'contador' },
{ email: 'visor@demo.com', nombre: 'Visor Demo', rolNombre: 'visor' },
];
for (const userData of users) {
const user = await prisma.user.upsert({
where: { email: userData.email },
update: {},
create: {
tenantId: tenant.id,
email: userData.email,
passwordHash,
nombre: userData.nombre,
rolId: rolMap.get(userData.rolNombre)!,
},
include: { rol: true },
});
console.log(`✅ User created: ${user.email} (${user.rol.nombre})`);
}
// Create tenant database
const dbConfig = parseDatabaseUrl(process.env.DATABASE_URL!);
const adminPool = new Pool({ ...dbConfig, database: 'postgres', max: 1 });
try {
const exists = await adminPool.query(
`SELECT 1 FROM pg_database WHERE datname = $1`,
[databaseName]
);
if (exists.rows.length === 0) {
await adminPool.query(`CREATE DATABASE "${databaseName}"`);
console.log(`✅ Tenant database created: ${databaseName}`);
} else {
console.log(` Tenant database already exists: ${databaseName}`);
}
} finally {
await adminPool.end();
}
// Create tables in tenant database
const tenantPool = new Pool({ ...dbConfig, database: databaseName, max: 1 });
try {
// Reset tenant tables so the re-seed parte de cero. Luego corremos las
// migraciones (fuente única de verdad del schema tenant) para garantizar
// que queden todas las tablas y columnas actuales.
await tenantPool.query(`
DROP TABLE IF EXISTS cfdi_conceptos CASCADE;
DROP TABLE IF EXISTS cfdis CASCADE;
DROP TABLE IF EXISTS conciliaciones CASCADE;
DROP TABLE IF EXISTS bancos CASCADE;
DROP TABLE IF EXISTS recordatorios CASCADE;
DROP TABLE IF EXISTS alertas CASCADE;
DROP TABLE IF EXISTS rfcs CASCADE;
DROP TABLE IF EXISTS opiniones_cumplimiento CASCADE;
DROP TABLE IF EXISTS schema_migrations;
`);
await migrate(tenantPool, tenant.rfc);
console.log('✅ Tenant schema aplicado vía migraciones');
// Bloque legacy de CREATE TABLE / CREATE INDEX retirado: vive ahora en
// `apps/api/src/migrations/tenant/*.sql` (fuente única de verdad).
// Insert demo CFDIs with new structure
const cfdiTypes = ['EMITIDO', 'RECIBIDO'];
const tipoComprobantes: Record<string, string> = { EMITIDO: 'I', RECIBIDO: 'I' };
const rfcs = ['XAXX010101000', 'MEXX020202000', 'AAXX030303000', 'BBXX040404000'];
const nombres = ['Cliente Demo SA', 'Proveedor ABC', 'Servicios XYZ', 'Materiales 123'];
for (let i = 0; i < 50; i++) {
const tipo = cfdiTypes[i % 2];
const rfcIndex = i % 4;
const subtotal = Math.floor(Math.random() * 50000) + 1000;
const iva = subtotal * 0.16;
const total = subtotal + iva;
const daysAgo = Math.floor(Math.random() * 180);
const fecha = new Date();
fecha.setDate(fecha.getDate() - daysAgo);
const year = String(fecha.getFullYear());
const month = String(fecha.getMonth() + 1).padStart(2, '0');
await tenantPool.query(`
INSERT INTO cfdis (
year, month, type, uuid, serie, folio, status, fecha_emision,
rfc_emisor, nombre_emisor, rfc_receptor, nombre_receptor,
subtotal, subtotal_mxn, descuento, descuento_mxn,
total, total_mxn, moneda, tipo_cambio, tipo_comprobante,
metodo_pago, iva_traslado, iva_traslado_mxn,
regimen_fiscal_emisor, regimen_fiscal_receptor
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26)
ON CONFLICT (uuid) DO NOTHING
`, [
year, month, tipo, crypto.randomUUID(), 'A', String(1000 + i),
'Vigente', fecha,
tipo === 'EMITIDO' ? 'EDE123456AB1' : rfcs[rfcIndex],
tipo === 'EMITIDO' ? 'Empresa Demo SA de CV' : nombres[rfcIndex],
tipo === 'RECIBIDO' ? 'EDE123456AB1' : rfcs[rfcIndex],
tipo === 'RECIBIDO' ? 'Empresa Demo SA de CV' : nombres[rfcIndex],
subtotal, subtotal, 0, 0,
total, total, 'MXN', 1, tipoComprobantes[tipo],
'PUE', iva, iva,
'601', '601',
]);
}
console.log('✅ Demo CFDIs created (50)');
// Insert demo conceptos for each CFDI
const { rows: allCfdis } = await tenantPool.query(`SELECT id FROM cfdis`);
const productos = ['Servicio de consultoría', 'Licencia de software', 'Soporte técnico', 'Desarrollo web', 'Capacitación'];
for (const c of allCfdis) {
const numConceptos = Math.floor(Math.random() * 3) + 1;
for (let j = 0; j < numConceptos; j++) {
const cantidad = Math.floor(Math.random() * 5) + 1;
const valorUnitario = Math.floor(Math.random() * 5000) + 500;
const importe = cantidad * valorUnitario;
const iva = importe * 0.16;
await tenantPool.query(`
INSERT INTO cfdi_conceptos (
cfdi_id, clave_prod_serv, descripcion, cantidad, clave_unidad, unidad,
valor_unitario, valor_unitario_mxn,
importe, importe_mxn, descuento, descuento_mxn,
iva_traslado, iva_traslado_mxn
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
`, [
c.id,
'84111506', productos[j % productos.length], cantidad, 'E48', 'Servicio',
valorUnitario, valorUnitario,
importe, importe, 0, 0,
iva, iva,
]);
}
}
console.log('✅ Demo conceptos created');
} finally {
await tenantPool.end();
}
// Seed plan catalog for CONTABLE vertical
const planCatalogoData = [
{
codename: 'trial_contable',
nombre: 'Trial Contable',
verticalProfile: 'CONTABLE' as const,
precioBase: 0,
frecuencia: 'mensual',
limits: { maxRfcs: 3, maxUsers: 1, timbresIncluidosMes: 20, features: ['dashboard', 'cfdi_basic', 'iva_isr'] },
},
{
codename: 'starter_contable',
nombre: 'Starter Contable',
verticalProfile: 'CONTABLE' as const,
precioBase: 490,
frecuencia: 'mensual',
limits: { maxRfcs: 10, maxUsers: 3, timbresIncluidosMes: 50, features: ['dashboard', 'cfdi_basic', 'iva_isr', 'alertas', 'calendario'] },
},
{
codename: 'business_contable',
nombre: 'Business Contable',
verticalProfile: 'CONTABLE' as const,
precioBase: 1290,
frecuencia: 'mensual',
limits: { maxRfcs: 50, maxUsers: 10, timbresIncluidosMes: 200, features: ['dashboard', 'cfdi_basic', 'iva_isr', 'alertas', 'calendario', 'reportes', 'conciliacion', 'documentos', 'facturacion'] },
},
{
codename: 'enterprise_contable',
nombre: 'Enterprise Contable',
verticalProfile: 'CONTABLE' as const,
precioBase: 2990,
frecuencia: 'mensual',
limits: { maxRfcs: -1, maxUsers: -1, timbresIncluidosMes: 600, features: ['dashboard', 'cfdi_basic', 'iva_isr', 'alertas', 'calendario', 'reportes', 'conciliacion', 'documentos', 'facturacion', 'api'] },
},
];
for (const p of planCatalogoData) {
await prisma.planCatalogo.upsert({
where: { codename: p.codename },
update: { nombre: p.nombre, precioBase: p.precioBase, limits: p.limits },
create: p,
});
}
console.log('✓ Plan catalog seeded (4 plans CONTABLE)');
// Seed addon catalog
const addonCatalogoData = [
{
codename: 'rfcs_extra_10',
nombre: '+10 RFCs adicionales',
verticalProfile: 'CONTABLE' as const,
precio: 190,
frecuencia: 'mensual',
delta: { maxRfcs: 10 },
},
{
codename: 'rfcs_extra_50',
nombre: '+50 RFCs adicionales',
verticalProfile: 'CONTABLE' as const,
precio: 690,
frecuencia: 'mensual',
delta: { maxRfcs: 50 },
},
{
codename: 'timbres_extra_500',
nombre: '+500 timbres mensuales',
precio: 490,
frecuencia: 'mensual',
delta: { timbresIncluidosMes: 500 },
},
{
codename: 'modulo_ia',
nombre: 'Módulo IA Fiscal',
precio: 390,
frecuencia: 'mensual',
delta: { features: ['ia_lolita'] },
},
{
// Lolita IA activable por contribuyente específico del despacho.
// SubscriptionAddon.contribuyenteId apunta al RFC que lo contrata.
// Cobro mensual en preapproval propio (la licencia del despacho es anual;
// el add-on va en ciclo independiente).
codename: 'lolita_ia_contribuyente',
nombre: 'Lolita IA (por contribuyente)',
verticalProfile: 'CONTABLE' as const,
precio: 250,
frecuencia: 'mensual',
delta: { features: ['ia_lolita'] },
},
{
// Contribuyente adicional para planes Business Control y Enterprise
// (ambos incluyen 100 base). Se cobra automáticamente según overage; no
// requiere opt-in, pero se modela como add-on para que el preapproval MP
// lo cubra. El codename mantiene el sufijo "business_cloud" por compat
// con suscripciones existentes; el nombre display ya es genérico.
codename: 'contribuyente_extra_business_cloud',
nombre: 'Contribuyente adicional (RFC extra)',
verticalProfile: 'CONTABLE' as const,
precio: 45,
frecuencia: 'mensual',
delta: { maxRfcs: 1 },
},
];
for (const a of addonCatalogoData) {
await prisma.planAddonCatalogo.upsert({
where: { codename: a.codename },
update: { nombre: a.nombre, precio: a.precio, delta: a.delta },
create: { ...a, verticalProfile: a.verticalProfile ?? null },
});
}
console.log('✓ Addon catalog seeded (6 addons)');
console.log('\n🎉 Seed completed successfully!');
console.log('\n📝 Demo credentials:');
console.log(' Admin: admin@demo.com / demo123');
console.log(' Contador: contador@demo.com / demo123');
console.log(' Visor: visor@demo.com / demo123');
}
main()
.catch((e) => {
console.error('Error seeding database:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -0,0 +1,158 @@
/**
* Backfill de cfdis.contribuyente_id para los despachos.
*
* Asocia CFDIs huérfanos (contribuyente_id NULL) con el contribuyente cuyo RFC
* coincide con rfc_emisor (si type='EMITIDO') o rfc_receptor (si type='RECIBIDO').
*
* Causa raíz: retry path de sat.service.ts construía SyncContext sin
* contribuyenteId (bug fixed 2026-04-20).
*
* Idempotente: solo actualiza filas con contribuyente_id IS NULL y match único
* por RFC. Si no hay contribuyentes en el tenant (Horux360 clásico), no-op.
*
* Uso:
* pnpm --filter @horux/api exec tsx scripts/backfill-cfdi-contribuyente.ts # ejecuta
* pnpm --filter @horux/api exec tsx scripts/backfill-cfdi-contribuyente.ts --dry # reporta sin escribir
*/
import { prisma, tenantDb } from '../src/config/database.js';
const DRY_RUN = process.argv.includes('--dry') || process.argv.includes('--dry-run');
interface PerTenantResult {
tenantId: string;
rfc: string;
databaseName: string;
contribuyentesCount: number;
updated: number;
perContribuyente: Array<{ rfc: string; entidadId: string; rows: number }>;
error?: string;
}
async function backfillTenant(
tenantId: string,
rfc: string,
databaseName: string,
): Promise<PerTenantResult> {
const result: PerTenantResult = {
tenantId,
rfc,
databaseName,
contribuyentesCount: 0,
updated: 0,
perContribuyente: [],
};
const pool = await tenantDb.getPool(tenantId, databaseName);
const { rows: contribs } = await pool.query<{ entidad_id: string; rfc: string }>(
`SELECT entidad_id, rfc FROM contribuyentes`,
);
result.contribuyentesCount = contribs.length;
if (contribs.length === 0) return result;
const client = await pool.connect();
try {
await client.query('BEGIN');
const sql = `
UPDATE cfdis c
SET contribuyente_id = cnt.entidad_id
FROM contribuyentes cnt
WHERE c.contribuyente_id IS NULL
AND (
(c.type = 'EMITIDO' AND cnt.rfc = c.rfc_emisor) OR
(c.type = 'RECIBIDO' AND cnt.rfc = c.rfc_receptor)
)
RETURNING cnt.entidad_id as "entidadId", cnt.rfc as "rfcContrib"
`;
const { rows: updated } = await client.query<{ entidadId: string; rfcContrib: string }>(sql);
result.updated = updated.length;
const byContrib = new Map<string, { rfc: string; rows: number }>();
for (const row of updated) {
const cur = byContrib.get(row.entidadId);
if (cur) cur.rows += 1;
else byContrib.set(row.entidadId, { rfc: row.rfcContrib, rows: 1 });
}
result.perContribuyente = Array.from(byContrib.entries()).map(([entidadId, v]) => ({
entidadId,
rfc: v.rfc,
rows: v.rows,
}));
if (DRY_RUN) {
await client.query('ROLLBACK');
} else {
await client.query('COMMIT');
}
} catch (err: any) {
await client.query('ROLLBACK').catch(() => {});
result.error = err?.message || String(err);
} finally {
client.release();
}
return result;
}
async function main() {
console.log(`=== Backfill cfdis.contribuyente_id ${DRY_RUN ? '(DRY RUN — no writes)' : ''} ===\n`);
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true, rfc: true, databaseName: true },
orderBy: { rfc: 'asc' },
});
console.log(`Tenants activos: ${tenants.length}\n`);
const results: PerTenantResult[] = [];
for (const t of tenants) {
process.stdout.write(`[${t.rfc}] (${t.databaseName}) ... `);
try {
const r = await backfillTenant(t.id, t.rfc, t.databaseName);
results.push(r);
if (r.error) {
console.log(`ERROR: ${r.error}`);
} else if (r.contribuyentesCount === 0) {
console.log(`sin contribuyentes (skip)`);
} else {
console.log(`${r.contribuyentesCount} contribs, ${r.updated} CFDIs backfill`);
for (const pc of r.perContribuyente) {
console.log(` ${pc.rfc}: ${pc.rows}`);
}
}
} catch (err: any) {
console.log(`FATAL: ${err?.message || err}`);
results.push({
tenantId: t.id,
rfc: t.rfc,
databaseName: t.databaseName,
contribuyentesCount: 0,
updated: 0,
perContribuyente: [],
error: err?.message || String(err),
});
}
}
const totalUpdated = results.reduce((s, r) => s + r.updated, 0);
const tenantsTouched = results.filter(r => r.updated > 0).length;
const tenantsFailed = results.filter(r => r.error).length;
console.log(`\n=== Resumen ===`);
console.log(` Tenants procesados: ${results.length}`);
console.log(` Tenants con backfill: ${tenantsTouched}`);
console.log(` CFDIs actualizados: ${totalUpdated}${DRY_RUN ? ' (rolled back)' : ''}`);
if (tenantsFailed > 0) console.log(` Tenants con error: ${tenantsFailed}`);
await prisma.$disconnect();
process.exit(tenantsFailed > 0 ? 1 : 0);
}
main().catch(async (err) => {
console.error('Fatal:', err);
await prisma.$disconnect().catch(() => {});
process.exit(1);
});

View File

@@ -0,0 +1,209 @@
/**
* Backfill de cfdis.cfdi_tipo_relacion + cfdis.cfdis_relacionados desde
* xml_original para CFDIs pre-migración 032.
*
* Criterio: WHERE xml_original IS NOT NULL AND cfdi_tipo_relacion IS NULL.
* Re-usa `parseXml()` para mantener la lógica de extracción idéntica al sync.
* Solo escribe si el parser extrae `cfdiTipoRelacion` no-nulo — los CFDIs sin
* CfdiRelacionados se siguen dejando con NULL (distinguible de "no procesado"
* via el filtro `cfdi_tipo_relacion IS NULL` porque el WHERE al final del run
* ya no los va a volver a tocar — pero cada invocación empieza desde el mismo
* filtro, por eso es idempotente: los sin-relación se re-parsean cada vez pero
* no se escribe nada).
*
* Uso:
* pnpm --filter @horux/api exec tsx scripts/backfill-cfdis-relaciones.ts # ejecuta
* pnpm --filter @horux/api exec tsx scripts/backfill-cfdis-relaciones.ts --dry # reporta sin escribir
*/
import { prisma, tenantDb } from '../src/config/database.js';
import { parseXml } from '../src/services/sat/sat-parser.service.js';
const DRY_RUN = process.argv.includes('--dry') || process.argv.includes('--dry-run');
interface PerTenantResult {
tenantId: string;
rfc: string;
databaseName: string;
scanned: number;
parsedOk: number;
parseFailed: number;
withRelation: number;
updated: number;
byTipoRelacion: Record<string, number>;
error?: string;
}
async function backfillTenant(
tenantId: string,
rfc: string,
databaseName: string,
): Promise<PerTenantResult> {
const result: PerTenantResult = {
tenantId,
rfc,
databaseName,
scanned: 0,
parsedOk: 0,
parseFailed: 0,
withRelation: 0,
updated: 0,
byTipoRelacion: {},
};
const pool = await tenantDb.getPool(tenantId, databaseName);
const { rows } = await pool.query<{
id: number;
uuid: string;
type: string;
xml_original: string | null;
}>(
`SELECT id, uuid, type, xml_original
FROM cfdis
WHERE xml_original IS NOT NULL
AND cfdi_tipo_relacion IS NULL
ORDER BY id`,
);
result.scanned = rows.length;
if (rows.length === 0) return result;
const client = await pool.connect();
try {
await client.query('BEGIN');
for (const row of rows) {
if (!row.xml_original) continue;
const downloadType = row.type === 'EMITIDO' ? 'emitidos' : 'recibidos';
let parsed;
try {
parsed = parseXml(row.xml_original, downloadType);
} catch {
result.parseFailed++;
continue;
}
if (!parsed) {
result.parseFailed++;
continue;
}
result.parsedOk++;
if (!parsed.cfdiTipoRelacion) continue;
result.withRelation++;
const tr = parsed.cfdiTipoRelacion;
result.byTipoRelacion[tr] = (result.byTipoRelacion[tr] || 0) + 1;
await client.query(
`UPDATE cfdis
SET cfdi_tipo_relacion = $2,
cfdis_relacionados = $3,
actualizado_en = NOW()
WHERE id = $1`,
[row.id, parsed.cfdiTipoRelacion, parsed.cfdisRelacionados],
);
result.updated++;
}
if (DRY_RUN) {
await client.query('ROLLBACK');
} else {
await client.query('COMMIT');
}
} catch (err: any) {
await client.query('ROLLBACK').catch(() => {});
result.error = err?.message || String(err);
} finally {
client.release();
}
return result;
}
async function main() {
console.log(`=== Backfill cfdis CfdiRelacionados ${DRY_RUN ? '(DRY RUN — no writes)' : ''} ===\n`);
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true, rfc: true, databaseName: true },
orderBy: { rfc: 'asc' },
});
console.log(`Tenants activos: ${tenants.length}\n`);
const results: PerTenantResult[] = [];
for (const t of tenants) {
process.stdout.write(`[${t.rfc}] (${t.databaseName}) ... `);
try {
const r = await backfillTenant(t.id, t.rfc, t.databaseName);
results.push(r);
if (r.error) {
console.log(`ERROR: ${r.error}`);
} else if (r.scanned === 0) {
console.log(`sin CFDIs candidatos (skip)`);
} else {
const tiposStr = Object.entries(r.byTipoRelacion)
.sort((a, b) => b[1] - a[1])
.map(([tr, n]) => `${tr}:${n}`)
.join(', ');
console.log(
`scan=${r.scanned} parsed=${r.parsedOk} fail=${r.parseFailed} rel=${r.withRelation} upd=${r.updated}${
tiposStr ? ` [${tiposStr}]` : ''
}`,
);
}
} catch (err: any) {
console.log(`FATAL: ${err?.message || err}`);
results.push({
tenantId: t.id,
rfc: t.rfc,
databaseName: t.databaseName,
scanned: 0,
parsedOk: 0,
parseFailed: 0,
withRelation: 0,
updated: 0,
byTipoRelacion: {},
error: err?.message || String(err),
});
}
}
const totalScanned = results.reduce((s, r) => s + r.scanned, 0);
const totalUpdated = results.reduce((s, r) => s + r.updated, 0);
const totalParseFailed = results.reduce((s, r) => s + r.parseFailed, 0);
const tenantsTouched = results.filter(r => r.updated > 0).length;
const tenantsFailed = results.filter(r => r.error).length;
const tiposGlobales: Record<string, number> = {};
for (const r of results) {
for (const [tr, n] of Object.entries(r.byTipoRelacion)) {
tiposGlobales[tr] = (tiposGlobales[tr] || 0) + n;
}
}
console.log(`\n=== Resumen ===`);
console.log(` Tenants procesados: ${results.length}`);
console.log(` Tenants con backfill: ${tenantsTouched}`);
console.log(` CFDIs escaneados: ${totalScanned}`);
console.log(` CFDIs actualizados: ${totalUpdated}${DRY_RUN ? ' (rolled back)' : ''}`);
if (totalParseFailed > 0) console.log(` CFDIs parse falló: ${totalParseFailed}`);
if (tenantsFailed > 0) console.log(` Tenants con error: ${tenantsFailed}`);
if (Object.keys(tiposGlobales).length > 0) {
console.log(` Desglose TipoRelacion:`);
for (const [tr, n] of Object.entries(tiposGlobales).sort((a, b) => b[1] - a[1])) {
console.log(` ${tr}: ${n}`);
}
}
await prisma.$disconnect();
process.exit(tenantsFailed > 0 ? 1 : 0);
}
main().catch(async (err) => {
console.error('Fatal:', err);
await prisma.$disconnect().catch(() => {});
process.exit(1);
});

View File

@@ -0,0 +1,126 @@
/**
* Backfill one-shot: completa los campos de emisor/subtotal/IVA/XML en las
* filas de `cfdis` con `source='facturapi'` que fueron insertadas por la
* versión buggy del controller (previo al fix 2026-04-24).
*
* Descarga el XML real de Facturapi, lo parsea con el mismo parser SAT,
* upsertea la fila de `rfcs` del emisor, y actualiza la fila de `cfdis`.
*/
import { prisma, tenantDb } from '../src/config/database.js';
import { downloadXmlContribuyente } from '../src/services/contribuyente-facturapi.service.js';
import * as facturapiService from '../src/services/facturapi.service.js';
import { parseXml } from '../src/services/sat/sat-parser.service.js';
const TENANT_RFC = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG';
async function main() {
const tenant = await prisma.tenant.findFirst({
where: { rfc: TENANT_RFC },
select: { id: true, databaseName: true },
});
if (!tenant) {
console.log(`Tenant ${TENANT_RFC} no encontrado`);
return;
}
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
const { rows: pendientes } = await pool.query<{
id: number;
uuid: string;
facturapi_id: string;
contribuyente_id: string | null;
rfc_emisor: string | null;
}>(
`SELECT id, uuid, facturapi_id, contribuyente_id, rfc_emisor
FROM cfdis
WHERE source = 'facturapi'
AND (COALESCE(rfc_emisor, '') = '' OR xml_original IS NULL OR subtotal = 0)
ORDER BY fecha_emision ASC`,
);
console.log(`Encontradas ${pendientes.length} CFDIs Facturapi a backfillear en ${TENANT_RFC}\n`);
let ok = 0;
let fail = 0;
for (const row of pendientes) {
try {
console.log(`\n[${row.uuid}] facturapi_id=${row.facturapi_id} contrib=${row.contribuyente_id}`);
const xmlBuffer = row.contribuyente_id
? await downloadXmlContribuyente(pool, row.contribuyente_id, row.facturapi_id)
: await facturapiService.downloadXml(tenant.id, row.facturapi_id);
const xmlString = xmlBuffer.toString('utf-8');
const parsed = parseXml(xmlString, 'emitidos');
if (!parsed) {
console.log(` ⚠️ Parser retornó null — skip`);
fail++;
continue;
}
console.log(` emisor=${parsed.rfcEmisor} (${parsed.nombreEmisor}, régimen ${parsed.regimenFiscalEmisor})`);
console.log(` receptor=${parsed.rfcReceptor} (${parsed.nombreReceptor}, régimen ${parsed.regimenFiscalReceptor})`);
console.log(` subtotal=${parsed.subtotal} total=${parsed.total} iva_traslado=${parsed.ivaTraslado}`);
// Upsert rfcs emisor
const { rows: [emisorRow] } = await pool.query(
`INSERT INTO rfcs (rfc, razon_social, regimen_fiscal) VALUES ($1, $2, $3)
ON CONFLICT (rfc) DO UPDATE SET
razon_social = COALESCE(NULLIF($2, ''), rfcs.razon_social),
regimen_fiscal = CASE WHEN $3 IS NOT NULL AND $3 != '' THEN $3 ELSE rfcs.regimen_fiscal END
RETURNING id`,
[parsed.rfcEmisor, parsed.nombreEmisor || null, parsed.regimenFiscalEmisor || null],
);
// Upsert rfcs receptor
const { rows: [receptorRow] } = await pool.query(
`INSERT INTO rfcs (rfc, razon_social, regimen_fiscal) VALUES ($1, $2, $3)
ON CONFLICT (rfc) DO UPDATE SET
razon_social = COALESCE(NULLIF($2, ''), rfcs.razon_social),
regimen_fiscal = CASE WHEN $3 IS NOT NULL AND $3 != '' THEN $3 ELSE rfcs.regimen_fiscal END
RETURNING id`,
[parsed.rfcReceptor, parsed.nombreReceptor || null, parsed.regimenFiscalReceptor || null],
);
await pool.query(
`UPDATE cfdis SET
fecha_cert_sat = $2,
rfc_emisor_id = $3, rfc_emisor = $4, nombre_emisor = $5,
regimen_fiscal_emisor = $6,
rfc_receptor_id = $7, rfc_receptor = $8, nombre_receptor = $9,
regimen_fiscal_receptor = $10,
subtotal = $11, subtotal_mxn = $11,
total = $12, total_mxn = $12,
iva_traslado = $13, iva_traslado_mxn = $13,
iva_retencion = $14, iva_retencion_mxn = $14,
xml_original = $15,
serie = COALESCE($16, serie), folio = COALESCE($17, folio)
WHERE id = $1`,
[
row.id,
parsed.fechaCertSat,
emisorRow.id, parsed.rfcEmisor, parsed.nombreEmisor,
parsed.regimenFiscalEmisor,
receptorRow.id, parsed.rfcReceptor, parsed.nombreReceptor,
parsed.regimenFiscalReceptor,
parsed.subtotal,
parsed.total,
parsed.ivaTraslado,
parsed.ivaRetencion,
xmlString,
parsed.serie, parsed.folio,
],
);
console.log(` ✅ actualizada fila id=${row.id}`);
ok++;
} catch (e: any) {
console.log(` ❌ error: ${e?.message || String(e)}`);
fail++;
}
}
console.log(`\n=== Resumen: ${ok} actualizadas, ${fail} fallidas ===`);
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,174 @@
/**
* Backfill de `fecha_emision` (y opcionalmente `fecha_cert_sat`) para CFDIs
* sincronizados antes del fix de zona horaria. El parser convertía la fecha
* del XML ("2025-12-31T18:37:51") asumiéndola como hora local de la máquina
* y la guardaba en UTC ("2026-01-01T00:37:51Z"), corriendo 6 horas y a veces
* sacando el CFDI de su mes/año correcto.
*
* Re-parsea la fecha literal del XML (atributo `Fecha=""` del Comprobante y
* `FechaTimbrado=""` del TimbreFiscalDigital) y lo guarda como UTC-literal
* (forzando 'Z' al string del XML).
*
* Solo aplica a CFDIs con `xml_original IS NOT NULL`. Idempotente.
*
* Uso:
* pnpm --filter @horux/api exec tsx scripts/backfill-fechas-tz.ts # ejecuta
* pnpm --filter @horux/api exec tsx scripts/backfill-fechas-tz.ts --dry # reporta
*/
import { prisma, tenantDb } from '../src/config/database.js';
const DRY_RUN = process.argv.includes('--dry') || process.argv.includes('--dry-run');
function parseLiteral(str: string | null | undefined): Date | null {
if (!str) return null;
const s = String(str).trim();
if (!s) return null;
const hasTz = /[Zz]|[+-]\d{2}:?\d{2}$/.test(s);
return new Date(hasTz ? s : s + 'Z');
}
function extractFechaFromXml(xml: string): string | null {
// Atributo Fecha del root <cfdi:Comprobante Fecha="...">
const m = xml.match(/<cfdi:Comprobante\b[^>]*\bFecha="([^"]+)"/);
return m ? m[1] : null;
}
function extractFechaTimbradoFromXml(xml: string): string | null {
const m = xml.match(/<tfd:TimbreFiscalDigital\b[^>]*\bFechaTimbrado="([^"]+)"/);
return m ? m[1] : null;
}
interface PerTenantResult {
tenantId: string;
rfc: string;
databaseName: string;
scanned: number;
updatedFechaEmision: number;
updatedFechaCert: number;
noChange: number;
noXmlMatch: number;
error?: string;
}
async function backfillTenant(tenantId: string, rfc: string, databaseName: string): Promise<PerTenantResult> {
const result: PerTenantResult = {
tenantId, rfc, databaseName,
scanned: 0, updatedFechaEmision: 0, updatedFechaCert: 0, noChange: 0, noXmlMatch: 0,
};
const pool = await tenantDb.getPool(tenantId, databaseName);
const { rows } = await pool.query<{
id: number;
uuid: string;
fecha_emision: Date;
fecha_cert_sat: Date | null;
xml_original: string;
}>(
`SELECT id, uuid, fecha_emision, fecha_cert_sat, xml_original
FROM cfdis
WHERE xml_original IS NOT NULL
ORDER BY id`,
);
result.scanned = rows.length;
if (rows.length === 0) return result;
const client = await pool.connect();
try {
await client.query('BEGIN');
for (const row of rows) {
const fechaXml = extractFechaFromXml(row.xml_original);
const fechaTimbradoXml = extractFechaTimbradoFromXml(row.xml_original);
if (!fechaXml) {
result.noXmlMatch++;
continue;
}
const nuevaFecha = parseLiteral(fechaXml);
const nuevaFechaCert = fechaTimbradoXml ? parseLiteral(fechaTimbradoXml) : null;
if (!nuevaFecha) {
result.noXmlMatch++;
continue;
}
const fechaEmisionActual = row.fecha_emision?.toISOString();
const fechaCertActual = row.fecha_cert_sat?.toISOString();
const fechaEmisionNueva = nuevaFecha.toISOString();
const fechaCertNueva = nuevaFechaCert?.toISOString();
let updatedThis = false;
if (fechaEmisionActual !== fechaEmisionNueva) {
await client.query(
`UPDATE cfdis SET fecha_emision = $2 WHERE id = $1`,
[row.id, nuevaFecha],
);
result.updatedFechaEmision++;
updatedThis = true;
}
if (nuevaFechaCert && fechaCertActual !== fechaCertNueva) {
await client.query(
`UPDATE cfdis SET fecha_cert_sat = $2 WHERE id = $1`,
[row.id, nuevaFechaCert],
);
result.updatedFechaCert++;
updatedThis = true;
}
if (!updatedThis) result.noChange++;
}
if (DRY_RUN) await client.query('ROLLBACK');
else await client.query('COMMIT');
} catch (err: any) {
await client.query('ROLLBACK').catch(() => {});
result.error = err?.message || String(err);
} finally {
client.release();
}
return result;
}
async function main() {
console.log(`=== Backfill fechas (fecha_emision + fecha_cert_sat) ${DRY_RUN ? '(DRY RUN)' : ''} ===\n`);
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true, rfc: true, databaseName: true },
orderBy: { rfc: 'asc' },
});
console.log(`Tenants activos: ${tenants.length}\n`);
const results: PerTenantResult[] = [];
for (const t of tenants) {
process.stdout.write(`[${t.rfc}] ... `);
try {
const r = await backfillTenant(t.id, t.rfc, t.databaseName);
results.push(r);
if (r.error) console.log(`ERROR: ${r.error}`);
else if (r.scanned === 0) console.log(`sin XMLs (skip)`);
else console.log(
`scan=${r.scanned} upd_emision=${r.updatedFechaEmision} upd_cert=${r.updatedFechaCert} ` +
`sin_cambio=${r.noChange} sin_match=${r.noXmlMatch}${DRY_RUN ? ' (rolled back)' : ''}`,
);
} catch (err: any) {
console.log(`FATAL: ${err?.message || err}`);
}
}
const totalScan = results.reduce((s, r) => s + r.scanned, 0);
const totalUpdEm = results.reduce((s, r) => s + r.updatedFechaEmision, 0);
const totalUpdCert = results.reduce((s, r) => s + r.updatedFechaCert, 0);
const tFail = results.filter(r => r.error).length;
console.log(`\n=== Resumen ===`);
console.log(` Tenants procesados: ${results.length}`);
console.log(` CFDIs escaneados: ${totalScan}`);
console.log(` fecha_emision actualizada: ${totalUpdEm}`);
console.log(` fecha_cert_sat actualizada: ${totalUpdCert}`);
if (tFail > 0) console.log(` Tenants con error: ${tFail}`);
await prisma.$disconnect();
process.exit(tFail > 0 ? 1 : 0);
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,101 @@
/**
* Backfill de métricas mensuales pre-calculadas (Tanda A hot/cold).
*
* Itera todos los tenants activos, sus contribuyentes, y popula la tabla
* `metricas_mensuales` con los agregados de años pasados (desde el CFDI más
* antiguo hasta el año actual - 1). El año actual queda on-the-fly.
*
* Idempotente: usa upsert — re-correrlo no duplica filas, recalcula valores.
*
* Uso:
* pnpm --filter @horux/api exec tsx scripts/backfill-metricas.ts # ejecuta
* pnpm --filter @horux/api exec tsx scripts/backfill-metricas.ts --dry # dry-run
*
* Opciones via env:
* BACKFILL_DESDE_ANIO=2023 # limita el rango inferior
* BACKFILL_HASTA_ANIO=2024 # default: año actual - 1
* BACKFILL_TENANT=<uuid> # procesa solo un tenant
*/
import { prisma } from '../src/config/database.js';
import { backfillTenant } from '../src/services/metricas-compute.service.js';
const DRY_RUN = process.argv.includes('--dry') || process.argv.includes('--dry-run');
const TENANT_FILTER = process.env.BACKFILL_TENANT || null;
const DESDE_ANIO = process.env.BACKFILL_DESDE_ANIO ? parseInt(process.env.BACKFILL_DESDE_ANIO, 10) : undefined;
const HASTA_ANIO = process.env.BACKFILL_HASTA_ANIO ? parseInt(process.env.BACKFILL_HASTA_ANIO, 10) : undefined;
async function main() {
console.log(`=== Backfill metricas_mensuales ${DRY_RUN ? '(DRY RUN)' : ''} ===\n`);
if (DESDE_ANIO) console.log(`Desde año: ${DESDE_ANIO}`);
if (HASTA_ANIO) console.log(`Hasta año: ${HASTA_ANIO}`);
if (TENANT_FILTER) console.log(`Tenant filtro: ${TENANT_FILTER}`);
console.log();
const tenants = await prisma.tenant.findMany({
where: {
active: true,
...(TENANT_FILTER ? { id: TENANT_FILTER } : {}),
},
select: { id: true, rfc: true, nombre: true },
orderBy: { rfc: 'asc' },
});
console.log(`Tenants activos: ${tenants.length}\n`);
let totalContribs = 0;
let totalMeses = 0;
let totalFilas = 0;
let totalErrores = 0;
for (const t of tenants) {
process.stdout.write(`[${t.rfc}] ${t.nombre} ... `);
try {
const r = await backfillTenant(t.id, {
dryRun: DRY_RUN,
desdeAnio: DESDE_ANIO,
hastaAnio: HASTA_ANIO,
});
if (r.contribuyentesProcesados === 0) {
console.log('sin contribuyentes (skip)');
} else {
console.log(
`${r.contribuyentesProcesados} contribs, ${r.mesesProcesados} meses, ` +
`${r.filasEscritas} filas${r.errores.length > 0 ? `, ${r.errores.length} errores` : ''}`,
);
if (r.errores.length > 0 && r.errores.length <= 5) {
for (const e of r.errores) {
console.log(` ERR (${e.anio}-${String(e.mes).padStart(2, '0')}): ${e.error}`);
}
} else if (r.errores.length > 5) {
console.log(` (${r.errores.length} errores — los primeros 3):`);
for (const e of r.errores.slice(0, 3)) {
console.log(` ERR (${e.anio}-${String(e.mes).padStart(2, '0')}): ${e.error}`);
}
}
}
totalContribs += r.contribuyentesProcesados;
totalMeses += r.mesesProcesados;
totalFilas += r.filasEscritas;
totalErrores += r.errores.length;
} catch (err: any) {
console.log(`FATAL: ${err?.message || err}`);
totalErrores++;
}
}
console.log(`\n=== Resumen ===`);
console.log(` Tenants procesados: ${tenants.length}`);
console.log(` Contribuyentes: ${totalContribs}`);
console.log(` (Contribuyente, mes): ${totalMeses}`);
console.log(` Filas metricas_mensuales: ${totalFilas}${DRY_RUN ? ' (NO escritas)' : ''}`);
if (totalErrores > 0) console.log(` Errores: ${totalErrores}`);
await prisma.$disconnect();
process.exit(totalErrores > 0 ? 1 : 0);
}
main().catch(async (err) => {
console.error('Fatal:', err);
await prisma.$disconnect().catch(() => {});
process.exit(1);
});

View File

@@ -0,0 +1,163 @@
/**
* Backfill de `saldo_pendiente_mxn` para CFDIs I PPD vigentes. Computa el
* saldo con la fórmula centralizada en `utils/saldo.ts` (pagos P + NC no-07
* + anticipo aplicado si es I/07) y lo persiste.
*
* Idempotente: corrido varias veces produce el mismo resultado. Safe para
* repetir después de un sync SAT masivo o si se sospecha drift.
*
* Uso:
* pnpm --filter @horux/api exec tsx scripts/backfill-saldo-pendiente.ts # ejecuta
* pnpm --filter @horux/api exec tsx scripts/backfill-saldo-pendiente.ts --dry # reporta sin escribir
*/
import { prisma, tenantDb } from '../src/config/database.js';
import { saldoComputadoExpr } from '../src/utils/saldo.js';
const DRY_RUN = process.argv.includes('--dry') || process.argv.includes('--dry-run');
interface PerTenantResult {
tenantId: string;
rfc: string;
databaseName: string;
iPpdsVigentes: number;
actualizadas: number;
saldoTotalAntes: number;
saldoTotalDespues: number;
error?: string;
}
async function backfillTenant(
tenantId: string,
rfc: string,
databaseName: string,
): Promise<PerTenantResult> {
const result: PerTenantResult = {
tenantId,
rfc,
databaseName,
iPpdsVigentes: 0,
actualizadas: 0,
saldoTotalAntes: 0,
saldoTotalDespues: 0,
};
const pool = await tenantDb.getPool(tenantId, databaseName);
const { rows: count } = await pool.query<{ n: number; suma: string }>(
`SELECT COUNT(*)::int AS n, COALESCE(SUM(COALESCE(saldo_pendiente_mxn, total_mxn)), 0) AS suma
FROM cfdis
WHERE tipo_comprobante = 'I' AND metodo_pago = 'PPD'
AND status NOT IN ('Cancelado', '0')`,
);
result.iPpdsVigentes = count[0]?.n || 0;
result.saldoTotalAntes = Number(count[0]?.suma || 0);
if (result.iPpdsVigentes === 0) return result;
const client = await pool.connect();
try {
await client.query('BEGIN');
// UPDATE masivo con la fórmula centralizada (misma que hooks y reporte).
const expr = saldoComputadoExpr('c');
const { rowCount } = await client.query(
`UPDATE cfdis c
SET saldo_pendiente_mxn = ${expr}
WHERE c.tipo_comprobante = 'I'
AND c.metodo_pago = 'PPD'
AND c.status NOT IN ('Cancelado', '0')`,
);
result.actualizadas = rowCount ?? 0;
const { rows: cntDespues } = await client.query<{ suma: string }>(
`SELECT COALESCE(SUM(COALESCE(saldo_pendiente_mxn, total_mxn)), 0) AS suma
FROM cfdis
WHERE tipo_comprobante = 'I' AND metodo_pago = 'PPD'
AND status NOT IN ('Cancelado', '0')`,
);
result.saldoTotalDespues = Number(cntDespues[0]?.suma || 0);
if (DRY_RUN) {
await client.query('ROLLBACK');
} else {
await client.query('COMMIT');
}
} catch (err: any) {
await client.query('ROLLBACK').catch(() => {});
result.error = err?.message || String(err);
} finally {
client.release();
}
return result;
}
function fmt(n: number): string {
return n.toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
async function main() {
console.log(`=== Backfill saldo_pendiente_mxn ${DRY_RUN ? '(DRY RUN — no writes)' : ''} ===\n`);
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true, rfc: true, databaseName: true },
orderBy: { rfc: 'asc' },
});
console.log(`Tenants activos: ${tenants.length}\n`);
const results: PerTenantResult[] = [];
for (const t of tenants) {
process.stdout.write(`[${t.rfc}] ... `);
try {
const r = await backfillTenant(t.id, t.rfc, t.databaseName);
results.push(r);
if (r.error) {
console.log(`ERROR: ${r.error}`);
} else if (r.iPpdsVigentes === 0) {
console.log(`sin I PPD vigentes (skip)`);
} else {
const delta = r.saldoTotalDespues - r.saldoTotalAntes;
console.log(
`I_PPD=${r.iPpdsVigentes} upd=${r.actualizadas} ` +
`antes=${fmt(r.saldoTotalAntes)} despues=${fmt(r.saldoTotalDespues)} ` +
`Δ=${delta >= 0 ? '+' : ''}${fmt(delta)}${DRY_RUN ? ' (rolled back)' : ''}`,
);
}
} catch (err: any) {
console.log(`FATAL: ${err?.message || err}`);
results.push({
tenantId: t.id,
rfc: t.rfc,
databaseName: t.databaseName,
iPpdsVigentes: 0,
actualizadas: 0,
saldoTotalAntes: 0,
saldoTotalDespues: 0,
error: err?.message || String(err),
});
}
}
const totalI = results.reduce((s, r) => s + r.iPpdsVigentes, 0);
const totalAntes = results.reduce((s, r) => s + r.saldoTotalAntes, 0);
const totalDespues = results.reduce((s, r) => s + r.saldoTotalDespues, 0);
const tenantsFailed = results.filter(r => r.error).length;
console.log(`\n=== Resumen ===`);
console.log(` Tenants procesados: ${results.length}`);
console.log(` I PPD vigentes total: ${totalI}`);
console.log(` Saldo total antes: ${fmt(totalAntes)}`);
console.log(` Saldo total después: ${fmt(totalDespues)}${DRY_RUN ? ' (rolled back)' : ''}`);
console.log(` Delta (recuperado): ${fmt(totalAntes - totalDespues)} (saldo que ya no está pendiente)`);
if (tenantsFailed > 0) console.log(` Tenants con error: ${tenantsFailed}`);
await prisma.$disconnect();
process.exit(tenantsFailed > 0 ? 1 : 0);
}
main().catch(async (err) => {
console.error('Fatal:', err);
await prisma.$disconnect().catch(() => {});
process.exit(1);
});

View File

@@ -0,0 +1,135 @@
/**
* Bootstrap del tenant admin global (Horux 360 — HTS240708LJA) + usuarios staff.
*
* Crea:
* 1. Tenant Horux 360 (RFC HTS240708LJA, plan enterprise)
* 2. Carlos como owner del tenant + rol platform_admin
* 3. Ivan como contador del tenant + rol platform_ti (TI superset)
* 4. Suscripción authorized por 1 año
*
* Uso: `pnpm bootstrap:admin-global`
*
* Idempotente-ish: falla limpio si el tenant ya existe (RFC unique).
* Para re-ejecutar, borra el tenant y su BD manualmente antes.
*
* Requisitos previos:
* 1. `pnpm prisma migrate deploy` (schema central)
* 2. `pnpm db:seed` (catálogos SAT, regímenes, ISR, eventos fiscales, roles)
*
* Env vars opcionales (con defaults):
* HORUX_ADMIN_EMAIL (default: carlos@horuxfin.com)
* HORUX_ADMIN_NOMBRE (default: Carlos)
* HORUX_TI_EMAIL (default: ivan@horuxfin.com)
* HORUX_TI_NOMBRE (default: Ivan)
*/
import { prisma } from '../src/config/database.js';
import * as tenantsService from '../src/services/tenants.service.js';
import * as usuariosService from '../src/services/usuarios.service.js';
const RFC = 'HTS240708LJA';
const TENANT_NAME = 'Horux 360';
const PLAN = 'enterprise' as const;
const CFDI_LIMIT = -1; // ilimitado
const USERS_LIMIT = 10;
const SUBSCRIPTION_YEARS = 1;
async function main() {
const adminEmail = process.env.HORUX_ADMIN_EMAIL || 'carlos@horuxfin.com';
const adminNombre = process.env.HORUX_ADMIN_NOMBRE || 'Carlos';
const tiEmail = process.env.HORUX_TI_EMAIL || 'ivan@horuxfin.com';
const tiNombre = process.env.HORUX_TI_NOMBRE || 'Ivan';
console.log(`Bootstrap del tenant admin global`);
console.log(` RFC: ${RFC}`);
console.log(` Nombre: ${TENANT_NAME}`);
console.log(` Admin: ${adminNombre} <${adminEmail}> (platform_admin)`);
console.log(` TI: ${tiNombre} <${tiEmail}> (platform_ti)`);
console.log(` Plan: ${PLAN} (cfdi: ${CFDI_LIMIT}, users: ${USERS_LIMIT})`);
console.log('');
// 1. Crea tenant + BD provisionada + Carlos como owner + subscription pending
const { tenant, user: carlosUser, tempPassword: carlosPassword } = await tenantsService.createTenant({
nombre: TENANT_NAME,
rfc: RFC,
plan: PLAN,
cfdiLimit: CFDI_LIMIT,
usersLimit: USERS_LIMIT,
adminEmail,
adminNombre,
amount: 0,
});
console.log(`✓ Tenant creado: ${tenant.id}`);
console.log(`✓ BD provisionada: ${tenant.databaseName}`);
console.log(`✓ Carlos creado (owner): ${carlosUser.email}`);
// 2. Asigna platform_admin a Carlos (no se hace automáticamente desde tenants.service)
const carlosFull = await prisma.user.findUnique({ where: { email: adminEmail } });
if (carlosFull) {
await prisma.userPlatformRole.upsert({
where: { userId_role: { userId: carlosFull.id, role: 'platform_admin' } },
update: {},
create: { userId: carlosFull.id, role: 'platform_admin' },
});
console.log(`✓ Carlos: rol platform_admin asignado`);
}
// 3. Crea Ivan como contador del tenant (membership) y le asigna platform_ti
const ivan = await usuariosService.inviteUsuario(tenant.id, {
email: tiEmail,
nombre: tiNombre,
role: 'contador',
});
console.log(`✓ Ivan creado: ${ivan.email} (membership contador)`);
await prisma.userPlatformRole.upsert({
where: { userId_role: { userId: ivan.id, role: 'platform_ti' } },
update: {},
create: { userId: ivan.id, role: 'platform_ti' },
});
console.log(`✓ Ivan: rol platform_ti asignado (superset, mismos permisos que admin)`);
// 4. Sube la subscription a 'authorized' con vigencia de 1 año
const existing = await prisma.subscription.findFirst({
where: { tenantId: tenant.id },
orderBy: { createdAt: 'desc' },
});
if (existing) {
const now = new Date();
const end = new Date(now);
end.setFullYear(end.getFullYear() + SUBSCRIPTION_YEARS);
await prisma.subscription.update({
where: { id: existing.id },
data: {
status: 'authorized',
currentPeriodStart: now,
currentPeriodEnd: end,
},
});
console.log(`✓ Suscripción marcada 'authorized' hasta ${end.toISOString().slice(0, 10)}`);
}
console.log('');
console.log('=== DONE ===');
console.log(`Credenciales temporales para primer login:`);
console.log(` Carlos (admin): ${adminEmail}`);
console.log(` Password: ${carlosPassword}`);
console.log('');
console.log(` Ivan (TI): ${tiEmail}`);
console.log(` Password: revisa el correo de bienvenida (inviteUsuario lo envía por email)`);
console.log('');
console.log('Próximos pasos manuales:');
console.log(` 1. Carlos login en /login con las credenciales de arriba`);
console.log(` 2. Cambiar el password desde /configuracion/seguridad`);
console.log(` 3. Verificar que Ivan recibió su correo de invitación`);
console.log(` 4. Subir FIEL en /configuracion/sat para habilitar sincronización`);
console.log(` 5. (Opcional) Configurar organización Facturapi en /configuracion`);
}
main()
.catch((err) => {
console.error('✗ Bootstrap falló:', err.message || err);
process.exit(1);
})
.finally(() => prisma.$disconnect());

View File

@@ -0,0 +1,75 @@
import { prisma, tenantDb } from '../src/config/database.js';
const yearMonth = '2025-02';
const contribuyenteId = 'd745a915-6a23-4818-944b-a7e1e18e536a';
const tenantRfc = 'DESPACHO_MO3NI6U8_B9VGG';
async function main() {
const tenant = await prisma.tenant.findFirst({ where: { rfc: tenantRfc }, select: { id: true, databaseName: true } });
if (!tenant) return;
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
const [anio, mes] = yearMonth.split('-').map(Number);
const lastDay = new Date(anio, mes, 0).getDate();
const fi = `${yearMonth}-01`;
const ff = `${yearMonth}-${String(lastDay).padStart(2, '0')}`;
const IMP_TRAS = `COALESCE(iva_traslado_mxn,0) + COALESCE(ieps_traslado_mxn,0) + COALESCE(impuestos_locales_trasladado_mxn,0)`;
const IMP_TRAS_PAGO = `COALESCE(iva_traslado_pago_mxn,0) + COALESCE(ieps_traslado_pago_mxn,0)`;
const EXCL_MONTO = `COALESCE((SELECT SUM(COALESCE(cc.importe_mxn,0) - COALESCE(cc.descuento_mxn,0)) FROM cfdi_conceptos cc WHERE cc.cfdi_id = cfdis.id AND cc.clave_prod_serv IN ('84121603','93161608','85101501','85121800')), 0)`;
// Drill desglosado por régimen del receptor
const { rows } = await pool.query(
`SELECT
COALESCE(regimen_fiscal_receptor, 'null') AS regimen_rec,
type, tipo_comprobante, metodo_pago,
COALESCE(cfdi_tipo_relacion, '') AS tipo_rel,
COUNT(*)::int AS n,
SUM(total_mxn) AS total_bruto,
SUM(COALESCE(total_mxn,0) - (${IMP_TRAS}) - (${EXCL_MONTO})) AS total_neto,
SUM(COALESCE(monto_pago_mxn,0) - (${IMP_TRAS_PAGO})) AS pago_neto
FROM cfdis
WHERE (
(type='RECIBIDO' AND tipo_comprobante='I' AND metodo_pago='PUE')
OR (type='RECIBIDO' AND tipo_comprobante='P')
OR (type='RECIBIDO' AND tipo_comprobante='E' AND metodo_pago='PUE' AND COALESCE(cfdi_tipo_relacion,'')<>'07')
)
AND status NOT IN ('Cancelado','0')
AND ((tipo_comprobante='P' AND fecha_pago_p >= $1::date AND fecha_pago_p < ($2::date + interval '1 day'))
OR (tipo_comprobante!='P' AND fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')))
AND contribuyente_id = $3
GROUP BY regimen_rec, type, tipo_comprobante, metodo_pago, tipo_rel
ORDER BY regimen_rec, tipo_comprobante, metodo_pago`,
[fi, ff, contribuyenteId],
);
const byReg: Record<string, { fact: number; pago: number; nc: number; detalle: any[] }> = {};
for (const r of rows) {
const reg = r.regimen_rec;
if (!byReg[reg]) byReg[reg] = { fact: 0, pago: 0, nc: 0, detalle: [] };
const v = r.tipo_comprobante === 'P' ? Number(r.pago_neto) : Number(r.total_neto);
byReg[reg].detalle.push({ tc: r.tipo_comprobante, mp: r.metodo_pago, rel: r.tipo_rel, n: r.n, valor: v, bruto: Number(r.total_bruto) });
if (r.tipo_comprobante === 'I') byReg[reg].fact += v;
else if (r.tipo_comprobante === 'P') byReg[reg].pago += v;
else if (r.tipo_comprobante === 'E') byReg[reg].nc += v;
}
console.log(`\n=== DRILL-DOWN por régimen del receptor — ${fi} a ${ff} ===\n`);
let totalAll = 0;
const TODOS_REGS = new Set(['605','606','612','621','625','626','601','603','607','608','610','611','614','615','620','622','623','624']);
for (const [reg, v] of Object.entries(byReg).sort()) {
const subtot = v.fact + v.pago - v.nc;
totalAll += subtot;
const inTodos = TODOS_REGS.has(reg) ? '✓' : '✗ (excluido de TODOS_REGIMENES)';
console.log(`Régimen ${reg} ${inTodos}`);
console.log(` fact=${v.fact.toFixed(2)} pago=${v.pago.toFixed(2)} NC=${v.nc.toFixed(2)} → subtotal=${subtot.toFixed(2)}`);
for (const d of v.detalle) {
console.log(` ${d.tc} ${d.mp || '-'} rel=${d.rel || '-'} n=${d.n} bruto=${d.bruto.toFixed(2)} neto=${d.valor.toFixed(2)}`);
}
}
console.log(`\nTotal todos regímenes: ${totalAll.toFixed(2)}`);
const inTodos = Object.entries(byReg).filter(([r]) => TODOS_REGS.has(r)).reduce((s, [, v]) => s + (v.fact + v.pago - v.nc), 0);
console.log(`Total solo en TODOS_REGIMENES: ${inTodos.toFixed(2)}`);
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,67 @@
/**
* Breakdown ingresos por grupo + filas que el drill-down mostraría,
* para un contribuyente + mes. Identifica discrepancias entre el
* dashboard y el drill.
*/
import { prisma, tenantDb } from '../src/config/database.js';
import { resolveContribuyenteContext } from '../src/utils/contribuyente-context.js';
const tenantRfc = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG';
const contribuyenteId = process.argv[3] || 'b3761db6-0b8d-4251-8078-4ddc31e9c75b';
const yearMonth = process.argv[4] || '2025-05';
async function main() {
const tenant = await prisma.tenant.findFirst({ where: { rfc: tenantRfc }, select: { id: true, databaseName: true } });
if (!tenant) return;
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
const [anio, mes] = yearMonth.split('-').map(Number);
const lastDay = new Date(anio, mes, 0).getDate();
const fi = `${yearMonth}-01`;
const ff = `${yearMonth}-${String(lastDay).padStart(2, '0')}`;
const ctx = await resolveContribuyenteContext(pool, tenant.id, contribuyenteId);
console.log(`\n=== ${yearMonth} ${contribuyenteId} RFC=${ctx.rfc} ===\n`);
console.log(`esEmisor: ${ctx.esEmisor}`);
console.log(`esReceptor: ${ctx.esReceptor}\n`);
// Todos los CFDIs donde el contribuyente es emisor en el mes (ingresos potenciales)
const { rows: emitidos } = await pool.query(
`SELECT uuid, fecha_emision, tipo_comprobante, metodo_pago,
cfdi_tipo_relacion, regimen_fiscal_emisor, regimen_fiscal_receptor,
total_mxn, monto_pago_mxn
FROM cfdis
WHERE ${ctx.esEmisor}
AND status NOT IN ('Cancelado', '0')
AND ((tipo_comprobante='P' AND fecha_pago_p >= $1::date AND fecha_pago_p < ($2::date + interval '1 day'))
OR (tipo_comprobante<>'P' AND fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')))
ORDER BY fecha_emision, uuid`,
[fi, ff],
);
console.log(`EMITIDOS por el contribuyente en el mes: ${emitidos.length}`);
let sumaTotal = 0, sumaPagos = 0;
const porRegimen: Record<string, { n: number; total: number; pago: number; types: Record<string, number> }> = {};
for (const r of emitidos) {
const reg = r.regimen_fiscal_emisor || 'NULL';
const tcKey = `${r.tipo_comprobante}${r.metodo_pago ? '/' + r.metodo_pago : ''}${r.cfdi_tipo_relacion ? '/rel=' + r.cfdi_tipo_relacion : ''}`;
if (!porRegimen[reg]) porRegimen[reg] = { n: 0, total: 0, pago: 0, types: {} };
porRegimen[reg].n++;
porRegimen[reg].total += Number(r.total_mxn || 0);
porRegimen[reg].pago += Number(r.monto_pago_mxn || 0);
porRegimen[reg].types[tcKey] = (porRegimen[reg].types[tcKey] || 0) + 1;
sumaTotal += Number(r.total_mxn || 0);
sumaPagos += Number(r.monto_pago_mxn || 0);
}
console.log(`Suma total_mxn: ${sumaTotal.toFixed(2)} | Suma monto_pago_mxn: ${sumaPagos.toFixed(2)}\n`);
for (const [reg, v] of Object.entries(porRegimen)) {
console.log(` Régimen ${reg}: n=${v.n} total=${v.total.toFixed(2)} pago=${v.pago.toFixed(2)}`);
for (const [tc, n] of Object.entries(v.types)) {
console.log(` ${tc}: ${n}`);
}
}
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,24 @@
import { prisma, tenantDb } from '../src/config/database.js';
const tenantRfc = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG';
const contribuyenteId = process.argv[3];
const year = process.argv[4] || '2025';
const month = process.argv[5];
async function main() {
const tenant = await prisma.tenant.findFirst({ where: { rfc: tenantRfc }, select: { id: true, databaseName: true } });
if (!tenant) return;
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
const monthFilter = month ? `AND mes = ${Number(month)}` : '';
const { rows } = await pool.query(
`SELECT anio, mes, regimen_fiscal, ingresos_cobrados, egresos_pagados,
iva_trasladado_total, iva_acreditable, computed_at
FROM metricas_mensuales
WHERE contribuyente_id = $1 AND anio = $2 ${monthFilter}
ORDER BY mes, regimen_fiscal`,
[contribuyenteId, Number(year)],
);
for (const r of rows) console.log(r);
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,26 @@
import { prisma, tenantDb } from '../src/config/database.js';
async function main() {
const tenant = await prisma.tenant.findFirst({ where: { rfc: 'DESPACHO_MO3NI6U8_B9VGG' }, select: { id: true, databaseName: true } });
if (!tenant) return;
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
const { rows } = await pool.query(
`SELECT anio, mes, regimen_fiscal,
ingresos_cobrados, egresos_pagados,
iva_trasladado_total, iva_acreditable,
computed_at
FROM metricas_mensuales
WHERE contribuyente_id = $1 AND anio = 2025 AND mes = 2
ORDER BY regimen_fiscal`,
['d745a915-6a23-4818-944b-a7e1e18e536a'],
);
console.log(`Cache rows para Feb 2025:`);
for (const r of rows) console.log(r);
// Also force on-the-fly by setting BYPASS
process.env.METRICAS_BYPASS_CACHE = '1';
console.log(`\n(cache bypassed below is N/A here; the dashboard service reads planCache directly)`);
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,85 @@
import { prisma, tenantDb } from '../src/config/database.js';
const RFC_CARLOS = 'TORC9611214CA';
async function main() {
const tenants = await prisma.tenant.findMany({
select: { id: true, rfc: true, databaseName: true },
});
let found = false;
for (const t of tenants) {
let pool;
try {
pool = await tenantDb.getPool(t.id, t.databaseName);
} catch {
continue;
}
const { rows: contribs } = await pool.query(
`SELECT c.entidad_id, c.rfc, c.regimen_fiscal, e.nombre, fo.facturapi_org_id, fo.csd_uploaded, fo.active AS org_active
FROM contribuyentes c
JOIN entidades_gestionadas e ON e.id = c.entidad_id
LEFT JOIN facturapi_orgs fo ON fo.contribuyente_id = c.entidad_id
WHERE UPPER(c.rfc) = $1`,
[RFC_CARLOS],
);
if (contribs.length === 0) continue;
found = true;
console.log(`\n=== Tenant ${t.rfc} — BD ${t.databaseName} ===`);
for (const c of contribs) {
console.log(`Contribuyente Carlos: ${c.entidad_id}`);
console.log(` nombre=${c.nombre}`);
console.log(` regimen_fiscal (CSV)=${c.regimen_fiscal}`);
console.log(` facturapi_org_id=${c.facturapi_org_id || 'NULL (sin org)'}`);
console.log(` csd_uploaded=${c.csd_uploaded} org_active=${c.org_active}`);
}
const { rows: cfdis } = await pool.query(
`SELECT uuid, type, tipo_comprobante, metodo_pago, total, total_mxn,
rfc_emisor, rfc_receptor, nombre_receptor, status, fecha_emision,
source, facturapi_id
FROM cfdis
WHERE UPPER(rfc_emisor) = $1
AND (source = 'facturapi' OR facturapi_id IS NOT NULL OR fecha_emision >= NOW() - interval '2 days')
ORDER BY fecha_emision DESC
LIMIT 10`,
[RFC_CARLOS],
);
console.log(`\nÚltimas ${cfdis.length} facturas (facturapi o recientes) emitidas por ${RFC_CARLOS}:`);
for (const c of cfdis) {
console.log(` UUID=${c.uuid}`);
console.log(` tipo=${c.tipo_comprobante} mp=${c.metodo_pago} status=${c.status} source=${c.source}`);
console.log(` receptor=${c.rfc_receptor} (${c.nombre_receptor})`);
console.log(` total=${c.total} total_mxn=${c.total_mxn}`);
console.log(` fecha_emision=${c.fecha_emision?.toISOString?.() || c.fecha_emision}`);
console.log(` facturapi_id=${c.facturapi_id}`);
}
const { rows: [anyEmitido] } = await pool.query(
`SELECT COUNT(*)::int AS total,
SUM(CASE WHEN source='facturapi' THEN 1 ELSE 0 END)::int AS via_facturapi,
SUM(CASE WHEN source='facturapi' AND status NOT IN ('Cancelado','0') THEN 1 ELSE 0 END)::int AS vigentes
FROM cfdis
WHERE UPPER(rfc_emisor) = $1`,
[RFC_CARLOS],
);
console.log(`\nResumen total CFDIs con rfc_emisor=${RFC_CARLOS}:`);
console.log(` total=${anyEmitido.total} via_facturapi=${anyEmitido.via_facturapi} vigentes_facturapi=${anyEmitido.vigentes}`);
}
if (!found) {
console.log(`\nNo se encontró contribuyente con RFC ${RFC_CARLOS} en ningún tenant.`);
}
await prisma.$disconnect();
}
main().catch(async e => {
console.error(e);
await prisma.$disconnect().catch(() => {});
process.exit(1);
});

View File

@@ -0,0 +1,72 @@
import { prisma, tenantDb } from '../src/config/database.js';
import { env } from '../src/config/env.js';
async function main() {
const tenant = await prisma.tenant.findFirst({ where: { rfc: 'DESPACHO_MO3NI6U8_B9VGG' }, select: { id: true, databaseName: true } });
if (!tenant) return;
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
// 1. Last CSF stored for Carlos (source of truth on what SAT sees)
const { rows: csfs } = await pool.query(
`SELECT rfc, created_at, datos->'regimenes' AS regimenes, datos->'obligaciones' AS obligaciones,
datos->>'estatusPadron' AS estatus, datos->>'fechaInicioOperaciones' AS fecha_inicio,
datos->'domicilio' AS domicilio
FROM constancias_situacion_fiscal
WHERE UPPER(rfc) = 'TORC9611214CA'
ORDER BY created_at DESC LIMIT 1`,
);
console.log(`\n=== CSF más reciente de Carlos ===`);
if (csfs.length === 0) {
console.log('NO HAY CSF descargada para este RFC. Eso explica el error de LCO si el contribuyente no ha sincronizado con SAT.');
} else {
const c = csfs[0];
console.log(`created_at: ${c.created_at}`);
console.log(`estatusPadron: ${c.estatus}`);
console.log(`fechaInicioOper: ${c.fecha_inicio}`);
console.log(`Regímenes (CSF):`);
if (Array.isArray(c.regimenes)) for (const r of c.regimenes) console.log(' ', r);
console.log(`Obligaciones (CSF):`);
if (Array.isArray(c.obligaciones)) for (const o of c.obligaciones) console.log(' ', o);
}
// 2. Contribuyente data en BD (lo que estamos usando para llenar la org)
const { rows: contrib } = await pool.query(
`SELECT c.entidad_id, c.rfc, r.razon_social, c.regimen_fiscal, c.codigo_postal, c.domicilio
FROM contribuyentes c
LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc)
WHERE UPPER(c.rfc) = 'TORC9611214CA'`,
);
console.log(`\n=== Contribuyente en BD ===`);
console.log(contrib[0]);
// 3. Facturapi org actual (lo que Facturapi está enviando al SAT)
const { rows: org } = await pool.query(
`SELECT facturapi_org_id FROM facturapi_orgs WHERE contribuyente_id = $1 AND active = true`,
[contrib[0]?.entidad_id],
);
if (org.length > 0 && env.FACTURAPI_USER_KEY) {
const res = await fetch(`https://www.facturapi.io/v2/organizations/${org[0].facturapi_org_id}`, {
headers: { 'Authorization': `Bearer ${env.FACTURAPI_USER_KEY}` },
});
if (res.ok) {
const o = await res.json() as any;
console.log(`\n=== Facturapi Organization ===`);
console.log(`orgId: ${o.id}`);
console.log(`name: ${o.name}`);
console.log(`legal:`);
console.log(` legal_name: ${o.legal?.legal_name}`);
console.log(` tax_system: ${o.legal?.tax_system}`);
console.log(` name: ${o.legal?.name}`);
console.log(` address: ${JSON.stringify(o.legal?.address)}`);
console.log(`certificate:`);
console.log(` has_certificate: ${o.certificate?.has_certificate}`);
console.log(` serial_number: ${o.certificate?.serial_number}`);
console.log(` valid_until: ${o.certificate?.valid_until}`);
} else {
console.log(`Facturapi GET failed: ${res.status}`);
}
}
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,112 @@
/**
* Detecta complementos P cuya ieps_traslado_pago_mxn parece inflada
* respecto al monto pagado y respecto a la factura referenciada.
*
* Heurísticas:
* 1. IEPS del P > monto_pago × 1.6 (tasa máxima teórica SAT para bebidas
* con alto contenido alcohólico; cualquier cosa arriba es sospechoso).
* 2. IEPS del P > IEPS de la factura original a la que se refiere
* (imposible — un pago parcial no puede transferir más IEPS que el total).
* 3. Ratio IEPS / monto_pago vs IEPS_original / total_original, donde la
* proporción del P excede la del original por >5pp (señal de error
* del proveedor).
*/
import { prisma, tenantDb } from '../src/config/database.js';
async function main() {
const tenants = await prisma.tenant.findMany({
select: { id: true, rfc: true, databaseName: true },
});
for (const t of tenants) {
let pool;
try {
pool = await tenantDb.getPool(t.id, t.databaseName);
} catch {
continue;
}
console.log(`\n=== Tenant ${t.rfc} (${t.databaseName}) ===`);
// Heurística 1: IEPS > 160% del monto
const { rows: h1 } = await pool.query(`
SELECT uuid, rfc_emisor, rfc_receptor, monto_pago_mxn, ieps_traslado_pago_mxn,
(ieps_traslado_pago_mxn / NULLIF(monto_pago_mxn, 0))::numeric(10,4) AS ratio
FROM cfdis
WHERE tipo_comprobante = 'P'
AND status NOT IN ('Cancelado', '0')
AND COALESCE(ieps_traslado_pago_mxn, 0) > 0
AND COALESCE(monto_pago_mxn, 0) > 0
AND ieps_traslado_pago_mxn > monto_pago_mxn * 1.6
ORDER BY ieps_traslado_pago_mxn DESC
LIMIT 10
`);
console.log(`\n-- H1: IEPS > monto_pago × 1.6 (${h1.length}) --`);
for (const r of h1) {
console.log(` ${r.uuid.substring(0, 8)} ${r.rfc_emisor}${r.rfc_receptor} pago=${Number(r.monto_pago_mxn).toFixed(2)} IEPS=${Number(r.ieps_traslado_pago_mxn).toFixed(2)} ratio=${r.ratio}`);
}
// Heurística 2: IEPS del P > IEPS de la factura referenciada (imposible)
// uuid_relacionado es pipe-separated; normalizar
const { rows: h2 } = await pool.query(`
SELECT p.uuid AS p_uuid, p.rfc_emisor, p.monto_pago_mxn, p.ieps_traslado_pago_mxn,
i.uuid AS i_uuid, i.total_mxn AS i_total, i.ieps_traslado_mxn AS i_ieps
FROM cfdis p
JOIN cfdis i
ON LOWER(i.uuid) = ANY(string_to_array(LOWER(COALESCE(p.uuid_relacionado, '')), '|'))
AND i.status NOT IN ('Cancelado', '0')
WHERE p.tipo_comprobante = 'P'
AND p.status NOT IN ('Cancelado', '0')
AND COALESCE(p.ieps_traslado_pago_mxn, 0) > 0
AND COALESCE(p.ieps_traslado_pago_mxn, 0) > COALESCE(i.ieps_traslado_mxn, 0)
ORDER BY p.ieps_traslado_pago_mxn DESC
LIMIT 10
`);
console.log(`\n-- H2: IEPS del P > IEPS de la factura referenciada (${h2.length}) --`);
for (const r of h2) {
const ratio = r.i_ieps > 0 ? Number(r.ieps_traslado_pago_mxn) / Number(r.i_ieps) : 0;
console.log(` P=${r.p_uuid.substring(0, 8)} IEPS_P=${Number(r.ieps_traslado_pago_mxn).toFixed(2)} I=${r.i_uuid.substring(0, 8)} IEPS_I=${Number(r.i_ieps || 0).toFixed(2)} ratio=${ratio.toFixed(2)}x`);
}
// Heurística 3: ratio IEPS/pago del P muy distinto del ratio IEPS/total del I
const { rows: h3 } = await pool.query(`
SELECT p.uuid AS p_uuid, p.monto_pago_mxn, p.ieps_traslado_pago_mxn,
i.uuid AS i_uuid, i.total_mxn AS i_total, i.ieps_traslado_mxn AS i_ieps,
(p.ieps_traslado_pago_mxn / NULLIF(p.monto_pago_mxn, 0))::numeric(6,4) AS ratio_p,
(i.ieps_traslado_mxn / NULLIF(i.total_mxn, 0))::numeric(6,4) AS ratio_i
FROM cfdis p
JOIN cfdis i
ON LOWER(i.uuid) = ANY(string_to_array(LOWER(COALESCE(p.uuid_relacionado, '')), '|'))
AND i.status NOT IN ('Cancelado', '0')
WHERE p.tipo_comprobante = 'P'
AND p.status NOT IN ('Cancelado', '0')
AND COALESCE(p.ieps_traslado_pago_mxn, 0) > 0
AND COALESCE(i.ieps_traslado_mxn, 0) > 0
AND COALESCE(p.monto_pago_mxn, 0) > 0
AND COALESCE(i.total_mxn, 0) > 0
AND ABS(
(p.ieps_traslado_pago_mxn / p.monto_pago_mxn)
- (i.ieps_traslado_mxn / i.total_mxn)
) > 0.05
ORDER BY p.ieps_traslado_pago_mxn DESC
LIMIT 10
`);
console.log(`\n-- H3: ratio_P ratio_I > 5pp (${h3.length}) --`);
for (const r of h3) {
console.log(` P=${r.p_uuid.substring(0, 8)} ratio_P=${r.ratio_p} I=${r.i_uuid.substring(0, 8)} ratio_I=${r.ratio_i} delta=${(Number(r.ratio_p) - Number(r.ratio_i)).toFixed(4)}`);
}
// Resumen: total de P con IEPS > 0
const { rows: [summary] } = await pool.query(`
SELECT COUNT(*) FILTER (WHERE COALESCE(ieps_traslado_pago_mxn, 0) > 0)::int AS p_con_ieps,
COUNT(*) FILTER (WHERE tipo_comprobante = 'P')::int AS p_total
FROM cfdis
WHERE status NOT IN ('Cancelado', '0')
`);
console.log(`\nResumen: ${summary.p_con_ieps} P con IEPS > 0 (de ${summary.p_total} P totales)`);
}
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,76 @@
import { prisma, tenantDb } from '../src/config/database.js';
const TENANT_RFC = 'DESPACHO_MO3NI6U8_B9VGG';
async function main() {
const tenant = await prisma.tenant.findFirst({
where: { rfc: TENANT_RFC },
select: { id: true, databaseName: true },
});
if (!tenant) {
console.log('Tenant no encontrado');
return;
}
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
console.log(`\n=== Tenant ${TENANT_RFC} ===\n`);
// 1) CFDIs emitidos via Facturapi (cualquier emisor) últimos 7 días
console.log(`>> CFDIs con source='facturapi' o facturapi_id no nulo, últimos 7 días:`);
const { rows: recientes } = await pool.query(
`SELECT uuid, rfc_emisor, rfc_receptor, nombre_receptor, tipo_comprobante, metodo_pago,
total, total_mxn, status, fecha_emision, source, facturapi_id
FROM cfdis
WHERE (source = 'facturapi' OR facturapi_id IS NOT NULL)
AND fecha_emision >= NOW() - interval '7 days'
ORDER BY fecha_emision DESC
LIMIT 20`,
);
if (recientes.length === 0) console.log(' (ninguno)');
for (const r of recientes) {
const emisor = r.rfc_emisor || '<NULL>';
const receptor = r.rfc_receptor || '<NULL>';
console.log(` ${r.uuid}`);
console.log(` EMISOR=${emisor} RECEPTOR=${receptor} (${r.nombre_receptor})`);
console.log(` tipo=${r.tipo_comprobante}/${r.metodo_pago} total=${r.total} status=${r.status} source=${r.source}`);
console.log(` fecha_emision=${r.fecha_emision?.toISOString?.() || r.fecha_emision}`);
console.log(` facturapi_id=${r.facturapi_id}`);
}
// 2) CFDIs totales en últimas 2 horas (cualquier emisor, cualquier source)
console.log(`\n>> CFDIs insertados en últimas 2 horas (cualquier source):`);
const { rows: ultimas } = await pool.query(
`SELECT uuid, rfc_emisor, rfc_receptor, tipo_comprobante, total,
status, fecha_emision, source, facturapi_id
FROM cfdis
WHERE fecha_emision >= NOW() - interval '2 hours'
ORDER BY fecha_emision DESC
LIMIT 20`,
);
if (ultimas.length === 0) console.log(' (ninguno)');
for (const r of ultimas) {
console.log(` ${r.uuid} | ${r.rfc_emisor}${r.rfc_receptor}`);
console.log(` tipo=${r.tipo_comprobante} total=${r.total} status=${r.status} source=${r.source}`);
console.log(` facturapi_id=${r.facturapi_id || 'null'}`);
}
// 3) Distribución de source en toda la BD
console.log(`\n>> Distribución de 'source' en cfdis:`);
const { rows: dist } = await pool.query(
`SELECT source, COUNT(*)::int AS cnt
FROM cfdis
GROUP BY source
ORDER BY cnt DESC`,
);
for (const r of dist) {
console.log(` source=${r.source || 'NULL'}${r.cnt}`);
}
await prisma.$disconnect();
}
main().catch(async e => {
console.error(e);
await prisma.$disconnect().catch(() => {});
process.exit(1);
});

View File

@@ -0,0 +1,36 @@
import { prisma, tenantDb } from '../src/config/database.js';
async function main() {
const tenant = await prisma.tenant.findFirst({
where: { rfc: 'DESPACHO_MO3NI6U8_B9VGG' },
select: { id: true, databaseName: true },
});
if (!tenant) return;
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
const { rows } = await pool.query(
`SELECT * FROM rfcs WHERE id IN (23709, 1) ORDER BY id`,
);
for (const r of rows) {
console.log(`\nrfcs id=${r.id}:`);
for (const k of Object.keys(r).sort()) {
console.log(` ${k} = ${r[k]}`);
}
}
// Also look at all 4 Facturapi CFDIs' emisor fields
const { rows: all4 } = await pool.query(
`SELECT uuid, rfc_emisor, nombre_emisor, rfc_emisor_id, regimen_fiscal_emisor,
rfc_receptor, nombre_receptor, subtotal, total, xml_original IS NULL AS no_xml
FROM cfdis WHERE source='facturapi' ORDER BY fecha_emision DESC`,
);
console.log(`\n=== Todas las CFDIs source=facturapi (${all4.length}) ===`);
for (const r of all4) {
console.log(` ${r.uuid} | emisor='${r.rfc_emisor}' (id=${r.rfc_emisor_id}, nombre='${r.nombre_emisor}', regimen=${r.regimen_fiscal_emisor})`);
console.log(` receptor='${r.rfc_receptor}' (${r.nombre_receptor}) subtotal=${r.subtotal} total=${r.total} xml_missing=${r.no_xml}`);
}
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,63 @@
import { prisma, tenantDb } from '../src/config/database.js';
const uuid = (process.argv[2] || '5c874749-748f-11f0-96b1-2b9310891836').toLowerCase();
async function main() {
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true, rfc: true, databaseName: true },
});
for (const t of tenants) {
const pool = await tenantDb.getPool(t.id, t.databaseName);
const { rows } = await pool.query(
`SELECT
c.uuid, c.total_mxn,
COALESCE((
SELECT SUM(COALESCE(p.monto_pago_mxn, 0))
FROM cfdis p
WHERE p.tipo_comprobante = 'P'
AND LOWER(COALESCE(p.uuid_relacionado, '')) LIKE '%' || LOWER(c.uuid) || '%'
AND p.status NOT IN ('Cancelado', '0')
), 0) AS pagos_p,
COALESCE((
SELECT SUM(COALESCE(e.total_mxn, 0))
FROM cfdis e
WHERE e.tipo_comprobante = 'E'
AND COALESCE(e.cfdi_tipo_relacion, '') <> '07'
AND e.cfdis_relacionados IS NOT NULL
AND LOWER(c.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
AND e.status NOT IN ('Cancelado', '0')
), 0) AS ncs,
CASE WHEN c.cfdi_tipo_relacion = '07' AND c.cfdis_relacionados IS NOT NULL THEN
COALESCE((
SELECT SUM(COALESCE(a.total_mxn, 0))
FROM cfdis a
WHERE LOWER(a.uuid) = ANY(string_to_array(LOWER(c.cfdis_relacionados), '|'))
AND a.status NOT IN ('Cancelado', '0')
), 0) ELSE 0 END AS anticipo_aplicado,
(
COALESCE(c.total_mxn, 0)
- COALESCE((SELECT SUM(COALESCE(p.monto_pago_mxn, 0)) FROM cfdis p
WHERE p.tipo_comprobante = 'P'
AND LOWER(COALESCE(p.uuid_relacionado, '')) LIKE '%' || LOWER(c.uuid) || '%'
AND p.status NOT IN ('Cancelado', '0')), 0)
- COALESCE((SELECT SUM(COALESCE(e.total_mxn, 0)) FROM cfdis e
WHERE e.tipo_comprobante = 'E' AND COALESCE(e.cfdi_tipo_relacion,'') <> '07'
AND e.cfdis_relacionados IS NOT NULL
AND LOWER(c.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
AND e.status NOT IN ('Cancelado','0')), 0)
- CASE WHEN c.cfdi_tipo_relacion = '07' AND c.cfdis_relacionados IS NOT NULL THEN
COALESCE((SELECT SUM(COALESCE(a.total_mxn,0)) FROM cfdis a
WHERE LOWER(a.uuid) = ANY(string_to_array(LOWER(c.cfdis_relacionados),'|'))
AND a.status NOT IN ('Cancelado','0')), 0)
ELSE 0 END
) AS saldo_computado
FROM cfdis c WHERE LOWER(c.uuid) = $1`,
[uuid],
);
if (rows.length === 0) continue;
console.log(`[${t.rfc}]`, rows[0]);
}
await prisma.$disconnect();
}
main().catch(async (e) => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,37 @@
process.env.METRICAS_BYPASS_CACHE = '1';
import { prisma, tenantDb } from '../src/config/database.js';
import { calcularIngresosPorRegimen, calcularEgresosPorRegimen } from '../src/services/dashboard.service.js';
import { getResumenIva } from '../src/services/impuestos.service.js';
const tenantRfc = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG';
const contribuyenteId = process.argv[3] || 'd745a915-6a23-4818-944b-a7e1e18e536a';
const año = Number(process.argv[4] || '2025');
async function main() {
const tenant = await prisma.tenant.findFirst({ where: { rfc: tenantRfc }, select: { id: true, databaseName: true } });
if (!tenant) return;
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
console.log(`\n=== IVA trasladado/acreditable vs ingresos/gastos — ${año} contrib=${contribuyenteId} ===\n`);
console.log('Mes | Ingresos | IVA tras | Ratio | Gastos | IVA acred | Ratio ');
for (let m = 1; m <= 12; m++) {
const lastDay = new Date(año, m, 0).getDate();
const mm = String(m).padStart(2, '0');
const fi = `${año}-${mm}-01`;
const ff = `${año}-${mm}-${String(lastDay).padStart(2, '0')}`;
const [ing, gas, iva] = await Promise.all([
calcularIngresosPorRegimen(pool, tenant.id, fi, ff, undefined, undefined, false, contribuyenteId),
calcularEgresosPorRegimen(pool, tenant.id, fi, ff, undefined, undefined, false, contribuyenteId),
getResumenIva(pool, fi, ff, tenant.id, false, contribuyenteId),
]);
const rTras = ing.total > 0 ? (iva.trasladado / ing.total) * 100 : 0;
const rAcr = gas.total > 0 ? (iva.acreditable / gas.total) * 100 : 0;
const flagT = Math.abs(rTras - 16) > 3 && ing.total > 0 ? '⚠️' : '';
const flagA = Math.abs(rAcr - 16) > 3 && gas.total > 0 ? '⚠️' : '';
console.log(`${mm} | ${ing.total.toFixed(2).padStart(12)} | ${iva.trasladado.toFixed(2).padStart(13)} | ${rTras.toFixed(1).padStart(5)}%${flagT} | ${gas.total.toFixed(2).padStart(12)} | ${iva.acreditable.toFixed(2).padStart(13)} | ${rAcr.toFixed(1).padStart(5)}%${flagA}`);
}
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,36 @@
process.env.METRICAS_BYPASS_CACHE = '1';
import { prisma, tenantDb } from '../src/config/database.js';
import { calcularEgresosPorRegimen } from '../src/services/dashboard.service.js';
import { getResumenIva } from '../src/services/impuestos.service.js';
const tenantRfc = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG';
const contribuyenteId = process.argv[3] || 'd745a915-6a23-4818-944b-a7e1e18e536a';
const año = Number(process.argv[4] || '2025');
async function main() {
const tenant = await prisma.tenant.findFirst({ where: { rfc: tenantRfc }, select: { id: true, databaseName: true } });
if (!tenant) return;
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
console.log(`\n=== IVA acreditable vs Gastos por mes — ${año} contrib=${contribuyenteId} ===\n`);
console.log('Mes | Gastos | IVA acreditable | Ratio | Esperado (16%) | Diff');
for (let m = 1; m <= 12; m++) {
const lastDay = new Date(año, m, 0).getDate();
const mm = String(m).padStart(2, '0');
const fi = `${año}-${mm}-01`;
const ff = `${año}-${mm}-${String(lastDay).padStart(2, '0')}`;
const [gastos, iva] = await Promise.all([
calcularEgresosPorRegimen(pool, tenant.id, fi, ff, undefined, undefined, false, contribuyenteId),
getResumenIva(pool, fi, ff, tenant.id, false, contribuyenteId),
]);
const ratio = gastos.total > 0 ? (iva.acreditable / gastos.total) * 100 : 0;
const esperado = gastos.total * 0.16;
const diff = iva.acreditable - esperado;
const flag = Math.abs(ratio - 16) > 3 && gastos.total > 0 ? ' ⚠️' : '';
console.log(`${mm} | ${gastos.total.toFixed(2).padStart(13)} | ${iva.acreditable.toFixed(2).padStart(15)} | ${ratio.toFixed(2)}% | ${esperado.toFixed(2).padStart(13)} | ${diff.toFixed(2)}${flag}`);
}
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,22 @@
import { prisma, tenantDb } from '../src/config/database.js';
async function main() {
const tenants = await prisma.tenant.findMany({ select: { id: true, rfc: true, databaseName: true } });
for (const t of tenants) {
let pool;
try { pool = await tenantDb.getPool(t.id, t.databaseName); } catch { continue; }
console.log(`\n=== ${t.rfc} ===`);
const { rows } = await pool.query(`
SELECT tipo_comprobante, metodo_pago, COUNT(*)::int AS cnt
FROM cfdis
WHERE cfdi_tipo_relacion = '07' AND status NOT IN ('Cancelado','0')
GROUP BY tipo_comprobante, metodo_pago
ORDER BY cnt DESC
`);
for (const r of rows) {
console.log(` ${r.tipo_comprobante}/${r.metodo_pago || 'null'}: ${r.cnt}`);
}
}
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,27 @@
import { prisma, tenantDb } from '../src/config/database.js';
const RFC = 'TOAH680201RA2';
async function main() {
const tenants = await prisma.tenant.findMany({ select: { id: true, rfc: true, databaseName: true } });
for (const t of tenants) {
let pool;
try { pool = await tenantDb.getPool(t.id, t.databaseName); } catch { continue; }
const { rows } = await pool.query(`
SELECT tipo_comprobante, metodo_pago, cfdi_tipo_relacion, COUNT(*)::int AS cnt
FROM cfdis
WHERE (UPPER(rfc_emisor) = $1 OR UPPER(rfc_receptor) = $1)
AND status NOT IN ('Cancelado','0')
AND cfdi_tipo_relacion IS NOT NULL
GROUP BY tipo_comprobante, metodo_pago, cfdi_tipo_relacion
ORDER BY cnt DESC`,
[RFC]);
if (rows.length === 0) continue;
console.log(`\n=== ${t.rfc} (${RFC}) ===`);
for (const r of rows) {
console.log(` ${r.tipo_comprobante}/${r.metodo_pago || '?'}/rel=${r.cfdi_tipo_relacion}: ${r.cnt}`);
}
}
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,26 @@
import { prisma } from '../src/config/database.js';
import { hashPassword } from '../src/utils/password.js';
async function main() {
const ivan = await prisma.user.findUnique({ where: { email: 'ivan@horuxfin.com' }, include: { tenant: true } });
if (!ivan) { console.error('Ivan not found'); process.exit(1); }
console.log('Tenant:', ivan.tenant.nombre, '(', ivan.tenant.id, ')');
const existing = await prisma.user.findUnique({ where: { email: 'carlos@horuxfin.com' } });
if (existing) { console.log('Carlos already exists:', existing.id); process.exit(0); }
const hash = await hashPassword('Aasi940812');
const carlos = await prisma.user.create({
data: {
tenantId: ivan.tenantId,
email: 'carlos@horuxfin.com',
passwordHash: hash,
nombre: 'Carlos Horux',
role: 'admin',
}
});
console.log('Carlos created:', carlos.id, carlos.email, carlos.role);
}
main().then(() => process.exit(0)).catch(e => { console.error(e); process.exit(1); });

View File

@@ -0,0 +1,88 @@
/**
* Desglosa cada I/07 recibida de un contribuyente en un rango, mostrando:
* - NETO_CUSTOM(I/07)
* - UUIDs en cfdis_relacionados
* - NETO_CUSTOM de cada relacionada vigente
* - Contribución neta de la I/07 al gasto
*
* Útil para detectar:
* - Múltiples I/07 que referencian el mismo anticipo (doble-resta)
* - Anticipos fuera del periodo que dominan la compensación
* - UUIDs relacionados incorrectos (apuntan a CFDIs enormes no-anticipo)
*/
import { prisma, tenantDb } from '../src/config/database.js';
const tenantRfc = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG';
const contribuyenteId = process.argv[3] || 'd745a915-6a23-4818-944b-a7e1e18e536a';
const yearMonth = process.argv[4] || '2025-07';
async function main() {
const tenant = await prisma.tenant.findFirst({ where: { rfc: tenantRfc }, select: { id: true, databaseName: true } });
if (!tenant) return;
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
const [anio, mes] = yearMonth.split('-').map(Number);
const lastDay = new Date(anio, mes, 0).getDate();
const fi = `${yearMonth}-01`;
const ff = `${yearMonth}-${String(lastDay).padStart(2, '0')}`;
const NETO = (a: string) => `(
COALESCE(${a}.total_mxn,0) - COALESCE(${a}.iva_traslado_mxn,0) + COALESCE(${a}.iva_retencion_mxn,0)
+ COALESCE(${a}.isr_retencion_mxn,0)
- COALESCE(${a}.ieps_traslado_mxn,0) + COALESCE(${a}.ieps_retencion_mxn,0)
- COALESCE(${a}.impuestos_locales_trasladado_mxn,0) + COALESCE(${a}.impuestos_locales_retenidos_mxn,0)
)`;
const { rows } = await pool.query(
`SELECT c.uuid, c.fecha_emision, c.total_mxn, c.rfc_emisor, c.cfdis_relacionados,
${NETO('c')} AS neto_i07
FROM cfdis c
WHERE c.type='RECIBIDO' AND c.tipo_comprobante='I' AND c.metodo_pago='PUE'
AND c.cfdi_tipo_relacion='07'
AND c.status NOT IN ('Cancelado','0')
AND c.fecha_emision >= $1::date AND c.fecha_emision < ($2::date + interval '1 day')
AND c.contribuyente_id = $3
ORDER BY c.fecha_emision`,
[fi, ff, contribuyenteId],
);
console.log(`\n=== I/07 RECIBIDAS en ${fi} a ${ff} ===`);
console.log(`Total I/07: ${rows.length}`);
let sumContrib = 0;
for (const r of rows) {
const relsUuids = (r.cfdis_relacionados || '').split('|').filter(Boolean).map((u: string) => u.toLowerCase());
console.log(`\n I/07 ${r.uuid.substring(0,8)} — fecha=${r.fecha_emision.toISOString().slice(0,10)} — emisor=${r.rfc_emisor}`);
console.log(` total_mxn: ${Number(r.total_mxn).toFixed(2)}`);
console.log(` NETO(I/07): ${Number(r.neto_i07).toFixed(2)}`);
console.log(` relacionados (${relsUuids.length}):`);
let sumRel = 0;
if (relsUuids.length > 0) {
const { rows: rels } = await pool.query(
`SELECT uuid, fecha_emision, total_mxn, tipo_comprobante, metodo_pago, status, ${NETO('a')} AS neto_rel
FROM cfdis a
WHERE LOWER(a.uuid) = ANY($1::text[])`,
[relsUuids],
);
for (const rel of rels) {
const vig = rel.status === 'Vigente' ? '✓' : '✗';
console.log(` ${vig} ${rel.uuid.substring(0,8)} ${rel.tipo_comprobante} ${rel.metodo_pago || '-'} fecha=${rel.fecha_emision?.toISOString?.().slice(0,10) || '-'} total=${Number(rel.total_mxn).toFixed(2)} NETO=${Number(rel.neto_rel).toFixed(2)}`);
if (rel.status === 'Vigente') sumRel += Number(rel.neto_rel);
}
const missing = relsUuids.filter((u: string) => !rels.find((x: any) => x.uuid.toLowerCase() === u));
if (missing.length > 0) {
console.log(` ⚠️ ${missing.length} UUID(s) relacionados NO están en BD:`);
for (const m of missing) console.log(` ${m}`);
}
}
const contrib = Number(r.neto_i07) - sumRel;
sumContrib += contrib;
console.log(` Σ NETO(rel vigentes): ${sumRel.toFixed(2)}`);
console.log(` CONTRIB: ${contrib.toFixed(2)} ${contrib < 0 ? '⚠️ NEGATIVA' : ''}`);
}
console.log(`\nSuma total contribuciones I/07: ${sumContrib.toFixed(2)}`);
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,104 @@
/**
* Amplía la inspección: lista TODOS los CFDIs de mayo-2025 donde Horux 360
* aparece como emisor o receptor, marcando cuáles entran al bucket ingresos
* y cuáles no + por qué.
*/
import { prisma, tenantDb } from '../src/config/database.js';
import { resolveContribuyenteContext } from '../src/utils/contribuyente-context.js';
const TENANT_RFC = 'DESPACHO_MO3NI6U8_B9VGG';
const CONTRIB_ID = 'b3761db6-0b8d-4251-8078-4ddc31e9c75b';
const FI = '2025-05-01';
const FF = '2025-05-31';
async function main() {
const tenant = await prisma.tenant.findFirst({
where: { rfc: TENANT_RFC }, select: { id: true, databaseName: true },
});
if (!tenant) return;
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
const ctx = await resolveContribuyenteContext(pool, tenant.id, CONTRIB_ID);
console.log(`\n=== TODOS los CFDIs de Horux 360 en mayo-2025 (como emisor o receptor) ===\n`);
const { rows } = await pool.query(
`SELECT uuid, type, tipo_comprobante, metodo_pago, status,
regimen_fiscal_emisor, regimen_fiscal_receptor,
rfc_emisor, rfc_receptor, nombre_receptor, nombre_emisor,
total_mxn, monto_pago_mxn, cfdi_tipo_relacion, fecha_emision, source
FROM cfdis
WHERE ((${ctx.esEmisor}) OR (${ctx.esReceptor}))
AND fecha_emision >= $1::date
AND fecha_emision < ($2::date + interval '1 day')
ORDER BY fecha_emision, tipo_comprobante, total_mxn DESC`,
[FI, FF],
);
console.log(`Total CFDIs encontrados: ${rows.length}\n`);
const buckets: Record<string, any[]> = {
ingresosG1: [],
ingresosG3: [],
ingresosSueldos: [],
noIncluye_canceladoOinvalido: [],
noIncluye_regimenFuera: [],
noIncluye_comoReceptor: [],
noIncluye_otroMotivo: [],
};
const G1 = ['606', '612', '621', '625', '626'];
const G3 = ['601', '603', '607', '608', '610', '611', '614', '615', '620', '622', '623', '624'];
for (const r of rows) {
const cancel = ['Cancelado', '0'].includes(r.status);
const esEmisorRow = String(r.rfc_emisor).toUpperCase() === 'HTS240708LJA';
const regE = r.regimen_fiscal_emisor;
const regR = r.regimen_fiscal_receptor;
if (cancel) { buckets.noIncluye_canceladoOinvalido.push(r); continue; }
if (esEmisorRow) {
if (G1.includes(regE)) {
if ((r.tipo_comprobante === 'I' && r.metodo_pago === 'PUE') ||
(r.tipo_comprobante === 'P') ||
(r.tipo_comprobante === 'E' && r.metodo_pago === 'PUE')) {
buckets.ingresosG1.push(r); continue;
}
}
if (G3.includes(regE)) {
if ((r.tipo_comprobante === 'I' && ['PUE', 'PPD'].includes(r.metodo_pago)) ||
(r.tipo_comprobante === 'E' && r.metodo_pago === 'PUE')) {
buckets.ingresosG3.push(r); continue;
}
}
if (!G1.includes(regE) && !G3.includes(regE)) {
buckets.noIncluye_regimenFuera.push({ ...r, reason: `emisor régimen ${regE} fuera de grupo` });
continue;
}
buckets.noIncluye_otroMotivo.push({ ...r, reason: `emisor tipo=${r.tipo_comprobante}/${r.metodo_pago} no matchea` });
continue;
}
// No emisor → receptor
if (r.tipo_comprobante === 'N' && r.metodo_pago === 'PUE' && regR === '605') {
buckets.ingresosSueldos.push(r); continue;
}
buckets.noIncluye_comoReceptor.push({ ...r, reason: 'es receptor, no cuenta como ingreso (salvo N/605)' });
}
const fmt = (n: any) => Number(n || 0).toFixed(2);
for (const [name, list] of Object.entries(buckets)) {
if (list.length === 0) continue;
console.log(`\n--- ${name} (${list.length}) ---`);
for (const r of list) {
const fe = r.fecha_emision?.toISOString?.()?.slice(0, 10) || r.fecha_emision;
const reason = r.reason ? ` | ${r.reason}` : '';
console.log(` ${fe} ${r.tipo_comprobante}/${r.metodo_pago || '-'} status=${r.status} regE=${r.regimen_fiscal_emisor} regR=${r.regimen_fiscal_receptor} ${r.rfc_emisor}${r.rfc_receptor} total=${fmt(r.total_mxn)} mp=${fmt(r.monto_pago_mxn)} ${r.uuid.substring(0,8)}${reason}`);
}
}
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,111 @@
/**
* Debug ingresos Horux 360 mayo-2025 post-Método A:
* - Llama al KPI (calcularIngresosPorRegimen)
* - Lista los CFDIs que entran al drill-down (mismos filtros del controller)
* - Suma manualmente para ver dónde está la discrepancia
*/
process.env.METRICAS_BYPASS_CACHE = '1';
import { prisma, tenantDb } from '../src/config/database.js';
import { calcularIngresosPorRegimen, GRUPO_PF_EMPRESARIAL, GRUPO_PM_OTROS } from '../src/services/dashboard.service.js';
import { resolveContribuyenteContext } from '../src/utils/contribuyente-context.js';
const TENANT_RFC = 'DESPACHO_MO3NI6U8_B9VGG';
const CONTRIB_ID = 'b3761db6-0b8d-4251-8078-4ddc31e9c75b'; // Horux 360
const FI = '2025-05-01';
const FF = '2025-05-31';
async function main() {
const tenant = await prisma.tenant.findFirst({
where: { rfc: TENANT_RFC },
select: { id: true, databaseName: true },
});
if (!tenant) return;
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
const ctx = await resolveContribuyenteContext(pool, tenant.id, CONTRIB_ID);
console.log(`\n=== KPI calcularIngresosPorRegimen ===`);
const kpi = await calcularIngresosPorRegimen(
pool, tenant.id, FI, FF, undefined, undefined, false, CONTRIB_ID,
);
console.log(`Total KPI: ${kpi.total.toFixed(2)}`);
for (const r of kpi.porRegimen) {
console.log(` ${r.regimenClave} ${r.regimenDescripcion.substring(0, 40).padEnd(40)} ${r.monto.toFixed(2)}`);
}
// Replica de los filtros del drill-down bucket 'ingresos' (cfdi.controller.ts:163-187)
const IMP_TRAS = `COALESCE(iva_traslado_mxn,0) + COALESCE(ieps_traslado_mxn,0) + COALESCE(impuestos_locales_trasladado_mxn,0)`;
const IMP_TRAS_PAGO = `COALESCE(iva_traslado_pago_mxn,0) + COALESCE(ieps_traslado_pago_mxn,0)`;
const VIGENTE = `status NOT IN ('Cancelado', '0')`;
const CLAVES = `('84121603','93161608','85101501','85121800')`;
const EXCL_MONTO = `COALESCE((SELECT SUM(COALESCE(cc.importe_mxn,0)-COALESCE(cc.descuento_mxn,0)) FROM cfdi_conceptos cc WHERE cc.cfdi_id = cfdis.id AND cc.clave_prod_serv IN ${CLAVES}),0)`;
const g1 = GRUPO_PF_EMPRESARIAL.map(r => `'${r}'`).join(',');
const g3 = GRUPO_PM_OTROS.map(r => `'${r}'`).join(',');
const drillSql = `
SELECT id, uuid, type, tipo_comprobante, metodo_pago, regimen_fiscal_emisor,
regimen_fiscal_receptor, rfc_emisor, rfc_receptor, nombre_receptor,
total_mxn, iva_traslado_mxn, ieps_traslado_mxn, impuestos_locales_trasladado_mxn,
monto_pago_mxn, iva_traslado_pago_mxn, ieps_traslado_pago_mxn,
cfdi_tipo_relacion, fecha_emision, fecha_pago_p, source,
-- neto (lo que "contribuye" a ingresos según grupo)
CASE
WHEN tipo_comprobante='I' THEN (COALESCE(total_mxn,0) - (${IMP_TRAS}) - (${EXCL_MONTO}))
WHEN tipo_comprobante='E' THEN -(COALESCE(total_mxn,0) - (${IMP_TRAS}) - (${EXCL_MONTO}))
WHEN tipo_comprobante='P' THEN (COALESCE(monto_pago_mxn,0) - (${IMP_TRAS_PAGO}))
WHEN tipo_comprobante='N' THEN COALESCE(total_mxn,0)
ELSE 0
END AS aporte
FROM cfdis
WHERE ${VIGENTE}
AND fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')
AND (
(${ctx.esEmisor} AND regimen_fiscal_emisor IN (${g1}) AND (
(tipo_comprobante='I' AND metodo_pago='PUE')
OR (tipo_comprobante='P')
OR (tipo_comprobante='E' AND metodo_pago='PUE')
))
OR (${ctx.esReceptor} AND tipo_comprobante='N' AND metodo_pago='PUE' AND regimen_fiscal_receptor='605')
OR (${ctx.esEmisor} AND regimen_fiscal_emisor IN (${g3}) AND (
(tipo_comprobante='I' AND metodo_pago IN ('PUE','PPD'))
OR (tipo_comprobante='E' AND metodo_pago='PUE')
))
)
ORDER BY fecha_emision, tipo_comprobante, total_mxn DESC
`;
const { rows } = await pool.query(drillSql, [FI, FF]);
console.log(`\n=== Drill-down (${rows.length} CFDIs) ===`);
let sumDrill = 0;
const perRegimen: Record<string, number> = {};
for (const r of rows) {
const aporte = Number(r.aporte || 0);
sumDrill += aporte;
const reg = r.regimen_fiscal_emisor || r.regimen_fiscal_receptor || '?';
perRegimen[reg] = (perRegimen[reg] || 0) + aporte;
const fe = r.fecha_emision?.toISOString?.()?.slice(0, 10) || r.fecha_emision;
const rel07 = r.cfdi_tipo_relacion === '07' ? ' [07]' : '';
const src = r.source === 'facturapi' ? ' [facturapi]' : '';
console.log(
` ${fe} ${r.tipo_comprobante}/${r.metodo_pago}${rel07}${src} ` +
`reg=${reg} ${String(r.rfc_emisor).padEnd(14)}${String(r.rfc_receptor).padEnd(14)} ` +
`total=${Number(r.total_mxn || 0).toFixed(2).padStart(10)} ` +
`aporte=${aporte.toFixed(2).padStart(10)} ${r.uuid.substring(0,8)}`
);
}
console.log(`\n=== Suma de aportes del drill-down: ${sumDrill.toFixed(2)} ===`);
console.log(`Por régimen (drill-down):`);
for (const [reg, monto] of Object.entries(perRegimen).sort()) {
console.log(` ${reg}: ${monto.toFixed(2)}`);
}
console.log(`\n=== Diferencia KPI drill: ${(kpi.total - sumDrill).toFixed(2)} ===`);
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,82 @@
/**
* CLI script to decrypt FIEL credentials from filesystem backup.
* Usage: FIEL_ENCRYPTION_KEY=<key> npx tsx scripts/decrypt-fiel.ts <RFC>
*
* Decrypted files are written to /tmp/horux-fiel-<RFC>/ and auto-deleted after 30 minutes.
*/
import { readFile, writeFile, mkdir, rm } from 'fs/promises';
import { join } from 'path';
import { createDecipheriv, createHash } from 'crypto';
const FIEL_PATH = process.env.FIEL_STORAGE_PATH || '/var/horux/fiel';
const FIEL_KEY = process.env.FIEL_ENCRYPTION_KEY;
const rfc = process.argv[2];
if (!rfc) {
console.error('Usage: FIEL_ENCRYPTION_KEY=<key> npx tsx scripts/decrypt-fiel.ts <RFC>');
process.exit(1);
}
if (!FIEL_KEY) {
console.error('Error: FIEL_ENCRYPTION_KEY environment variable is required');
process.exit(1);
}
function deriveKey(): Buffer {
return createHash('sha256').update(FIEL_KEY!).digest();
}
function decryptBuffer(encrypted: Buffer, iv: Buffer, tag: Buffer): Buffer {
const key = deriveKey();
const decipher = createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(encrypted), decipher.final()]);
}
async function main() {
const fielDir = join(FIEL_PATH, rfc.toUpperCase());
const outputDir = `/tmp/horux-fiel-${rfc.toUpperCase()}`;
console.log(`Reading encrypted FIEL from: ${fielDir}`);
// Read encrypted certificate
const cerEnc = await readFile(join(fielDir, 'certificate.cer.enc'));
const cerIv = await readFile(join(fielDir, 'certificate.cer.iv'));
const cerTag = await readFile(join(fielDir, 'certificate.cer.tag'));
// Read encrypted private key
const keyEnc = await readFile(join(fielDir, 'private_key.key.enc'));
const keyIv = await readFile(join(fielDir, 'private_key.key.iv'));
const keyTag = await readFile(join(fielDir, 'private_key.key.tag'));
// Read and decrypt metadata
const metaEnc = await readFile(join(fielDir, 'metadata.json.enc'));
const metaIv = await readFile(join(fielDir, 'metadata.json.iv'));
const metaTag = await readFile(join(fielDir, 'metadata.json.tag'));
// Decrypt all
const cerData = decryptBuffer(cerEnc, cerIv, cerTag);
const keyData = decryptBuffer(keyEnc, keyIv, keyTag);
const metadata = JSON.parse(decryptBuffer(metaEnc, metaIv, metaTag).toString('utf-8'));
// Write decrypted files
await mkdir(outputDir, { recursive: true, mode: 0o700 });
await writeFile(join(outputDir, 'certificate.cer'), cerData, { mode: 0o600 });
await writeFile(join(outputDir, 'private_key.key'), keyData, { mode: 0o600 });
await writeFile(join(outputDir, 'metadata.json'), JSON.stringify(metadata, null, 2), { mode: 0o600 });
console.log(`\nDecrypted files written to: ${outputDir}`);
console.log('Metadata:', metadata);
console.log('\nFiles will be auto-deleted in 30 minutes.');
// Auto-delete after 30 minutes
setTimeout(async () => {
await rm(outputDir, { recursive: true, force: true });
console.log(`Cleaned up ${outputDir}`);
process.exit(0);
}, 30 * 60 * 1000);
}
main().catch((err) => {
console.error('Failed to decrypt FIEL:', err.message);
process.exit(1);
});

View File

@@ -0,0 +1,101 @@
/**
* Compara paso a paso los 3 componentes del cálculo de egresos 612 en Feb 2025:
* 1) Query exacto que usa calcularEgresosPorRegimen (con FECHA_RANGO / FECHA_PAGO_RANGO)
* 2) Vs el drill-down usando fecha efectiva por fila
* Detalle al CFDI para encontrar discrepancias.
*/
import { prisma, tenantDb } from '../src/config/database.js';
async function main() {
const tenant = await prisma.tenant.findFirst({ where: { rfc: 'DESPACHO_MO3NI6U8_B9VGG' }, select: { id: true, databaseName: true } });
if (!tenant) return;
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
const fi = '2025-02-01';
const ff = '2025-02-28';
const contrib = 'd745a915-6a23-4818-944b-a7e1e18e536a';
const reg = '612';
const IMP_TRAS = `COALESCE(iva_traslado_mxn,0) + COALESCE(ieps_traslado_mxn,0) + COALESCE(impuestos_locales_trasladado_mxn,0)`;
const IMP_TRAS_PAGO = `COALESCE(iva_traslado_pago_mxn,0) + COALESCE(ieps_traslado_pago_mxn,0)`;
const EXCL = `COALESCE((SELECT SUM(COALESCE(cc.importe_mxn,0) - COALESCE(cc.descuento_mxn,0)) FROM cfdi_conceptos cc WHERE cc.cfdi_id = cfdis.id AND cc.clave_prod_serv IN ('84121603','93161608','85101501','85121800')), 0)`;
// QUERY 1 FACTURAS (idéntico a calcularEgresosPorRegimen)
const f = await pool.query(
`SELECT uuid, total_mxn, (${IMP_TRAS}) AS imp, (${EXCL}) AS excl,
COALESCE(total_mxn,0) - (${IMP_TRAS}) - (${EXCL}) AS neto,
cfdi_tipo_relacion AS rel
FROM cfdis
WHERE type='RECIBIDO' AND tipo_comprobante='I' AND metodo_pago='PUE'
AND status NOT IN ('Cancelado','0')
AND fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')
AND regimen_fiscal_receptor = $3
AND contribuyente_id = $4
ORDER BY fecha_emision`,
[fi, ff, reg, contrib],
);
const sumF = f.rows.reduce((s, r) => s + Number(r.neto), 0);
console.log(`FACTURAS I PUE reg=${reg}: n=${f.rows.length} sum_neto=${sumF.toFixed(2)}`);
// QUERY 2 PAGOS P
const p = await pool.query(
`SELECT uuid, monto_pago_mxn, (${IMP_TRAS_PAGO}) AS imp,
COALESCE(monto_pago_mxn,0) - (${IMP_TRAS_PAGO}) AS neto,
fecha_pago_p, fecha_emision
FROM cfdis
WHERE type='RECIBIDO' AND tipo_comprobante='P'
AND status NOT IN ('Cancelado','0')
AND fecha_pago_p >= $1::date AND fecha_pago_p < ($2::date + interval '1 day')
AND regimen_fiscal_receptor = $3
AND contribuyente_id = $4
ORDER BY fecha_pago_p`,
[fi, ff, reg, contrib],
);
const sumP = p.rows.reduce((s, r) => s + Number(r.neto), 0);
console.log(`PAGOS P reg=${reg} (fecha_pago_p): n=${p.rows.length} sum_neto=${sumP.toFixed(2)}`);
// También probar con fecha_emision del P (alternativo)
const pEmis = await pool.query(
`SELECT uuid, COALESCE(monto_pago_mxn,0) - (${IMP_TRAS_PAGO}) AS neto,
fecha_pago_p, fecha_emision
FROM cfdis
WHERE type='RECIBIDO' AND tipo_comprobante='P'
AND status NOT IN ('Cancelado','0')
AND fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')
AND regimen_fiscal_receptor = $3
AND contribuyente_id = $4
ORDER BY fecha_emision`,
[fi, ff, reg, contrib],
);
const sumPe = pEmis.rows.reduce((s, r) => s + Number(r.neto), 0);
console.log(` (alt) PAGOS P filtrados por fecha_emision: n=${pEmis.rows.length} sum_neto=${sumPe.toFixed(2)}`);
// QUERY 3 NC
const n = await pool.query(
`SELECT uuid, total_mxn, (${IMP_TRAS}) AS imp, (${EXCL}) AS excl,
COALESCE(total_mxn,0) - (${IMP_TRAS}) - (${EXCL}) AS neto,
cfdi_tipo_relacion AS rel
FROM cfdis
WHERE type='RECIBIDO' AND tipo_comprobante='E' AND metodo_pago='PUE'
AND COALESCE(cfdi_tipo_relacion,'') <> '07'
AND status NOT IN ('Cancelado','0')
AND fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')
AND regimen_fiscal_receptor = $3
AND contribuyente_id = $4`,
[fi, ff, reg, contrib],
);
const sumN = n.rows.reduce((s, r) => s + Number(r.neto), 0);
console.log(`NC E PUE excl 07 reg=${reg}: n=${n.rows.length} sum_neto=${sumN.toFixed(2)}`);
console.log(`\nTotal ON-THE-FLY (reg 612): ${(sumF + sumP - sumN).toFixed(2)}`);
console.log(`Cache dice: 446180.10`);
console.log(`Delta: ${((sumF + sumP - sumN) - 446180.10).toFixed(2)}`);
// Detalle de los P para investigar — fecha_emision vs fecha_pago_p
console.log(`\nDetalle PAGOS P (filtrados por fecha_pago_p):`);
for (const r of p.rows) {
console.log(` ${r.uuid.substring(0,8)} monto=${Number(r.monto_pago_mxn).toFixed(2)} neto=${Number(r.neto).toFixed(2)} fecha_pago_p=${r.fecha_pago_p?.toISOString?.()?.slice(0,10)} fecha_emision=${r.fecha_emision?.toISOString?.()?.slice(0,10)}`);
}
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,55 @@
/** Detalle neto de cada CFDI del dashboard para Horux 360 mayo 2025. */
import { prisma, tenantDb } from '../src/config/database.js';
import { resolveContribuyenteContext } from '../src/utils/contribuyente-context.js';
async function main() {
const tenant = await prisma.tenant.findFirst({ where: { rfc: 'DESPACHO_MO3NI6U8_B9VGG' }, select: { id: true, databaseName: true } });
if (!tenant) return;
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
const ctx = await resolveContribuyenteContext(pool, tenant.id, 'b3761db6-0b8d-4251-8078-4ddc31e9c75b');
// Facturas I PUE (rendición con la misma lógica de g1Facturas)
const { rows: fact } = await pool.query(
`SELECT uuid, total_mxn,
iva_traslado_mxn, ieps_traslado_mxn, impuestos_locales_trasladado_mxn,
iva_retencion_mxn, isr_retencion_mxn, ieps_retencion_mxn, impuestos_locales_retenidos_mxn,
cfdi_tipo_relacion,
(COALESCE(total_mxn,0) - COALESCE(iva_traslado_mxn,0) - COALESCE(ieps_traslado_mxn,0) - COALESCE(impuestos_locales_trasladado_mxn,0)) AS neto_normal
FROM cfdis
WHERE ${ctx.esEmisor} AND tipo_comprobante='I' AND metodo_pago='PUE'
AND status NOT IN ('Cancelado','0')
AND fecha_emision >= '2025-05-01'::date AND fecha_emision < '2025-05-31'::date + interval '1 day'
AND regimen_fiscal_emisor = '626'
ORDER BY fecha_emision`,
);
console.log(`\nI PUE régimen 626:`);
for (const r of fact) {
console.log(` ${r.uuid.substring(0,8)} total=${Number(r.total_mxn).toFixed(2)} iva_tras=${Number(r.iva_traslado_mxn).toFixed(2)} iva_ret=${Number(r.iva_retencion_mxn).toFixed(2)} isr_ret=${Number(r.isr_retencion_mxn).toFixed(2)} neto=${Number(r.neto_normal).toFixed(2)} rel=${r.cfdi_tipo_relacion || '-'}`);
}
const factNeto = fact.reduce((s, r) => s + Number(r.neto_normal), 0);
console.log(` Suma neto facturas: ${factNeto.toFixed(2)}`);
// Pagos P
const { rows: pagos } = await pool.query(
`SELECT uuid, fecha_pago_p, monto_pago_mxn,
iva_traslado_pago_mxn, ieps_traslado_pago_mxn,
iva_retencion_pago_mxn, isr_retencion_pago_mxn, ieps_retencion_pago_mxn,
(COALESCE(monto_pago_mxn,0) - COALESCE(iva_traslado_pago_mxn,0) - COALESCE(ieps_traslado_pago_mxn,0)) AS neto_normal
FROM cfdis
WHERE ${ctx.esEmisor} AND tipo_comprobante='P'
AND status NOT IN ('Cancelado','0')
AND fecha_pago_p >= '2025-05-01'::date AND fecha_pago_p < '2025-05-31'::date + interval '1 day'
AND regimen_fiscal_emisor = '626'
ORDER BY fecha_pago_p`,
);
console.log(`\nPagos P régimen 626:`);
for (const r of pagos) {
console.log(` ${r.uuid.substring(0,8)} monto_pago=${Number(r.monto_pago_mxn).toFixed(2)} iva_tras_pago=${Number(r.iva_traslado_pago_mxn).toFixed(2)} iva_ret_pago=${Number(r.iva_retencion_pago_mxn).toFixed(2)} neto=${Number(r.neto_normal).toFixed(2)}`);
}
const pagosNeto = pagos.reduce((s, r) => s + Number(r.neto_normal), 0);
console.log(` Suma neto pagos: ${pagosNeto.toFixed(2)}`);
console.log(`\nTOTAL facturas + pagos: ${(factNeto + pagosNeto).toFixed(2)}`);
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,68 @@
/** Breakdown: qué CFDIs contribuyen al IVA acreditable vs al gasto. */
import { prisma, tenantDb } from '../src/config/database.js';
import { resolveContribuyenteContext } from '../src/utils/contribuyente-context.js';
const tenantRfc = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG';
const contribuyenteId = process.argv[3] || 'd745a915-6a23-4818-944b-a7e1e18e536a';
const yearMonth = process.argv[4] || '2025-12';
async function main() {
const tenant = await prisma.tenant.findFirst({ where: { rfc: tenantRfc }, select: { id: true, databaseName: true } });
if (!tenant) return;
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
const ctx = await resolveContribuyenteContext(pool, tenant.id, contribuyenteId);
const [anio, mes] = yearMonth.split('-').map(Number);
const lastDay = new Date(anio, mes, 0).getDate();
const fi = `${yearMonth}-01`;
const ff = `${yearMonth}-${String(lastDay).padStart(2, '0')}`;
const IMP_TRAS = `COALESCE(iva_traslado_mxn,0) + COALESCE(ieps_traslado_mxn,0) + COALESCE(impuestos_locales_trasladado_mxn,0)`;
// I PUE recibidas
const { rows: facturas } = await pool.query(
`SELECT uuid, total_mxn, iva_traslado_mxn, cfdi_tipo_relacion, cfdis_relacionados,
(COALESCE(total_mxn,0) - (${IMP_TRAS})) AS neto_normal
FROM cfdis
WHERE ${ctx.esReceptor} AND tipo_comprobante='I' AND metodo_pago='PUE'
AND status NOT IN ('Cancelado','0')
AND fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')
ORDER BY total_mxn DESC`,
[fi, ff],
);
console.log(`\n=== I PUE recibidas ${yearMonth} ===`);
console.log(`# | UUID | total | IVA | neto_normal | rel | cfdis_relacionados`);
for (const r of facturas) {
const rel = r.cfdi_tipo_relacion || '-';
const cr = r.cfdis_relacionados ? `${r.cfdis_relacionados.substring(0,36)}` : '';
console.log(` ${r.uuid.substring(0,8)} total=${Number(r.total_mxn).toFixed(2).padStart(12)} IVA=${Number(r.iva_traslado_mxn).toFixed(2).padStart(10)} neto=${Number(r.neto_normal).toFixed(2).padStart(12)} rel=${rel.padEnd(3)}${cr}`);
}
// I PUE recibidas con relación 07 — verificar si el anticipo está en otro mes
const i07 = facturas.filter((r: any) => r.cfdi_tipo_relacion === '07');
if (i07.length > 0) {
console.log(`\nI/07 recibidas en ${yearMonth}: ${i07.length}`);
for (const r of i07) {
const relsUuids = (r.cfdis_relacionados || '').split('|').filter(Boolean).map((u: string) => u.toLowerCase());
if (relsUuids.length > 0) {
const { rows: rels } = await pool.query(
`SELECT uuid, fecha_emision, total_mxn, iva_traslado_mxn
FROM cfdis a
WHERE LOWER(a.uuid) = ANY($1::text[])
AND a.status NOT IN ('Cancelado','0')`,
[relsUuids],
);
console.log(`\n I/07 ${r.uuid.substring(0,8)} total=${Number(r.total_mxn).toFixed(2)} IVA=${Number(r.iva_traslado_mxn).toFixed(2)}`);
for (const a of rels) {
const fecha = a.fecha_emision.toISOString().slice(0,10);
const fuera = fecha.substring(0,7) !== yearMonth ? ' ← FUERA DEL MES' : '';
console.log(` anticipo ${a.uuid.substring(0,8)} fecha=${fecha} total=${Number(a.total_mxn).toFixed(2)} IVA=${Number(a.iva_traslado_mxn).toFixed(2)}${fuera}`);
}
}
}
}
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,88 @@
/**
* Simula el drill-down bucket=ingresos para un contribuyente/mes y muestra
* cada CFDI que aparecería en el drill. Permite comparar con el total del
* dashboard.
*/
import { prisma, tenantDb } from '../src/config/database.js';
import { resolveContribuyenteContext } from '../src/utils/contribuyente-context.js';
const tenantRfc = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG';
const contribuyenteId = process.argv[3] || 'b3761db6-0b8d-4251-8078-4ddc31e9c75b';
const yearMonth = process.argv[4] || '2025-05';
const GRUPO_PF_EMPRESARIAL = ['606', '612', '621', '625', '626'];
const GRUPO_PM_OTROS = ['601', '603', '607', '608', '610', '611', '614', '615', '620', '622', '623', '624'];
async function main() {
const tenant = await prisma.tenant.findFirst({ where: { rfc: tenantRfc }, select: { id: true, databaseName: true } });
if (!tenant) return;
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
const [anio, mes] = yearMonth.split('-').map(Number);
const lastDay = new Date(anio, mes, 0).getDate();
const fi = `${yearMonth}-01`;
const ff = `${yearMonth}-${String(lastDay).padStart(2, '0')}`;
const ctx = await resolveContribuyenteContext(pool, tenant.id, contribuyenteId);
const esEmisor = ctx.esEmisor;
const esReceptor = ctx.esReceptor;
const g1 = GRUPO_PF_EMPRESARIAL.map(r => `'${r}'`).join(',');
const g3 = GRUPO_PM_OTROS.map(r => `'${r}'`).join(',');
const FECHA_EFECTIVA = `CASE WHEN tipo_comprobante = 'P' THEN fecha_pago_p ELSE fecha_emision END`;
// Query idéntico al drill-down bucket=ingresos
const { rows } = await pool.query(
`SELECT uuid, tipo_comprobante, metodo_pago,
regimen_fiscal_emisor, regimen_fiscal_receptor,
cfdi_tipo_relacion,
total_mxn, monto_pago_mxn,
fecha_emision, fecha_pago_p
FROM cfdis
WHERE 1=1
AND (
(
${esEmisor}
AND regimen_fiscal_emisor IN (${g1})
AND (
(tipo_comprobante = 'I' AND metodo_pago = 'PUE')
OR tipo_comprobante = 'P'
OR (tipo_comprobante = 'E' AND metodo_pago = 'PUE' AND COALESCE(cfdi_tipo_relacion, '') <> '07')
)
)
OR (
${esReceptor}
AND tipo_comprobante = 'N' AND metodo_pago = 'PUE'
AND regimen_fiscal_receptor = '605'
)
OR (
${esEmisor}
AND regimen_fiscal_emisor IN (${g3})
AND (
(tipo_comprobante = 'I' AND metodo_pago IN ('PUE','PPD'))
OR (tipo_comprobante = 'E' AND metodo_pago = 'PUE')
)
)
)
AND status NOT IN ('Cancelado','0')
AND ${FECHA_EFECTIVA} >= $1::date
AND ${FECHA_EFECTIVA} < ($2::date + interval '1 day')
ORDER BY ${FECHA_EFECTIVA}`,
[fi, ff],
);
console.log(`\n=== Drill bucket=ingresos ${yearMonth} contrib=${ctx.rfc} ===`);
console.log(`Filas: ${rows.length}\n`);
let sumTotal = 0, sumPago = 0;
for (const r of rows) {
console.log(` ${r.uuid.substring(0,8)} ${r.tipo_comprobante}${r.metodo_pago ? '/' + r.metodo_pago : ''}${r.cfdi_tipo_relacion ? ' rel=' + r.cfdi_tipo_relacion : ''} reg=${r.regimen_fiscal_emisor || r.regimen_fiscal_receptor} total=${Number(r.total_mxn || 0).toFixed(2)} pago=${Number(r.monto_pago_mxn || 0).toFixed(2)}`);
sumTotal += Number(r.total_mxn || 0);
sumPago += Number(r.monto_pago_mxn || 0);
}
console.log(`\nSuma total_mxn (bruto drill): ${sumTotal.toFixed(2)}`);
console.log(`Suma monto_pago_mxn: ${sumPago.toFixed(2)}`);
console.log(`(Total bruto cuenta I + E a total, y P a monto_pago)`);
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,79 @@
#!/usr/bin/env node
/**
* Extrae el texto del PDF de términos y condiciones y lo convierte en un
* módulo TypeScript para que el frontend lo renderice sin tener que parsear
* el PDF en runtime.
*
* Además copia el PDF original a `apps/web/public/legal/` para servirlo como
* descarga.
*
* Uso:
* pnpm legal:sync
*
* Cuando se actualiza el documento legal:
* 1. Reemplazar `docs/legal/Terminos y condiciones.pdf` por la nueva versión
* (mismo nombre de archivo).
* 2. Correr `pnpm legal:sync`.
* 3. Commit de los cambios (PDF, terminos.ts, PDF copy).
*/
import { readFileSync, writeFileSync, copyFileSync, mkdirSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { PDFParse } from 'pdf-parse';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '../../../');
const SRC_PDF = resolve(ROOT, 'docs/legal/Terminos y condiciones.pdf');
const DEST_PDF = resolve(ROOT, 'apps/web/public/legal/terminos-y-condiciones.pdf');
const DEST_TS = resolve(ROOT, 'apps/web/content/terminos.ts');
async function main() {
console.log('[legal:sync] Leyendo:', SRC_PDF);
const buf = readFileSync(SRC_PDF);
const parser = new PDFParse({ data: buf });
const textResult = await parser.getText();
await parser.destroy();
const rawText = (textResult.text ?? '').trim();
const pages = textResult.total ?? textResult.pages?.length ?? 0;
if (!rawText) {
console.error('[legal:sync] ERROR: el PDF no contiene texto extraíble (¿escaneado sin OCR?).');
process.exit(1);
}
// Copia el PDF a public/ para que sea descargable
mkdirSync(dirname(DEST_PDF), { recursive: true });
copyFileSync(SRC_PDF, DEST_PDF);
// Escribe el texto como módulo TypeScript. Escapa backticks para que el
// template literal no rompa si el PDF los contiene.
const escaped = rawText.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
const extractedAt = new Date().toISOString();
const content = `// AUTO-GENERADO por \`pnpm legal:sync\`. NO editar a mano.
// Fuente: docs/legal/Terminos y condiciones.pdf
// Regenerar tras actualizar el PDF.
export const TERMINOS_TEXT = \`${escaped}\`;
export const TERMINOS_META = {
extractedAt: '${extractedAt}',
pages: ${pages},
chars: ${rawText.length},
} as const;
`;
mkdirSync(dirname(DEST_TS), { recursive: true });
writeFileSync(DEST_TS, content, 'utf8');
console.log(`[legal:sync] OK: ${rawText.length} chars extraídos, ${pages} páginas.`);
console.log(`[legal:sync] → ${DEST_PDF}`);
console.log(`[legal:sync] → ${DEST_TS}`);
}
main().catch(err => {
console.error('[legal:sync] FAIL:', err);
process.exit(1);
});

View File

@@ -0,0 +1,28 @@
import { prisma, tenantDb } from '../src/config/database.js';
const term = (process.argv[2] || '').toLowerCase();
if (!term) { console.error('Usage: tsx scripts/find-contribuyente.ts <texto>'); process.exit(1); }
async function main() {
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true, rfc: true, databaseName: true },
});
for (const t of tenants) {
const pool = await tenantDb.getPool(t.id, t.databaseName);
const { rows: cols } = await pool.query(
`SELECT column_name FROM information_schema.columns WHERE table_name='contribuyentes'`,
);
const colNames = cols.map((c: any) => c.column_name);
const nameCols = colNames.filter(n => n.includes('nombre') || n.includes('razon'));
const filterSql = nameCols.map(c => `LOWER(${c}) LIKE '%${term}%'`).join(' OR ');
if (!filterSql) continue;
const { rows } = await pool.query(`SELECT entidad_id, rfc, ${nameCols.join(',')} FROM contribuyentes WHERE ${filterSql}`);
if (rows.length > 0) {
console.log(`\n[${t.rfc}]`);
for (const r of rows) console.log(r);
}
}
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,75 @@
/**
* Encuentra E que referencien directamente a una I/07 PPD vía
* `cfdis_relacionados`. Patrón real observado: la E "ajusta" la I/07 PPD,
* no al anticipo original. La I/07 PPD apunta al anticipo, la E apunta a
* la I/07 PPD.
*/
import { prisma, tenantDb } from '../src/config/database.js';
const TARGET_RFC = process.argv[2];
async function main() {
const tenants = await prisma.tenant.findMany({ select: { id: true, rfc: true, databaseName: true } });
for (const t of tenants) {
let pool;
try { pool = await tenantDb.getPool(t.id, t.databaseName); } catch { continue; }
console.log(`\n=== ${t.rfc}${TARGET_RFC ? ` (RFC=${TARGET_RFC})` : ''} ===`);
const rfcFilter = TARGET_RFC
? `AND (UPPER(i.rfc_emisor) = UPPER('${TARGET_RFC}') OR UPPER(i.rfc_receptor) = UPPER('${TARGET_RFC}'))`
: '';
const { rows } = await pool.query(`
SELECT
i.uuid AS i_uuid, i.fecha_emision AS i_fecha, i.total_mxn AS i_total,
i.iva_traslado_mxn AS i_iva, i.rfc_emisor AS i_emisor, i.rfc_receptor AS i_receptor,
i.type AS i_type,
e.uuid AS e_uuid, e.cfdi_tipo_relacion AS e_rel, e.metodo_pago AS e_mp,
e.fecha_emision AS e_fecha, e.total_mxn AS e_total, e.iva_traslado_mxn AS e_iva,
ABS(EXTRACT(EPOCH FROM (e.fecha_emision - i.fecha_emision)) / 86400)::int AS diff_dias,
EXTRACT(YEAR FROM i.fecha_emision)::int * 12 + EXTRACT(MONTH FROM i.fecha_emision)::int AS i_periodo,
EXTRACT(YEAR FROM e.fecha_emision)::int * 12 + EXTRACT(MONTH FROM e.fecha_emision)::int AS e_periodo
FROM cfdis i
JOIN cfdis e
ON LOWER(i.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
WHERE i.cfdi_tipo_relacion = '07'
AND i.tipo_comprobante = 'I' AND i.metodo_pago = 'PPD'
AND i.status NOT IN ('Cancelado','0')
AND e.tipo_comprobante = 'E'
AND e.status NOT IN ('Cancelado','0')
${rfcFilter}
ORDER BY i.fecha_emision DESC
`);
console.log(`Total pares: ${rows.length}`);
const buckets = { mismoMes: 0, eDespues1: 0, eDespuesMas: 0, eAntes: 0 };
for (const r of rows) {
const diff = Number(r.e_periodo) - Number(r.i_periodo);
if (diff < 0) buckets.eAntes++;
else if (diff === 0) buckets.mismoMes++;
else if (diff === 1) buckets.eDespues1++;
else buckets.eDespuesMas++;
}
console.log(` Mismo mes: ${buckets.mismoMes}`);
console.log(` E 1 mes después: ${buckets.eDespues1}`);
console.log(` E ≥2 meses después: ${buckets.eDespuesMas}`);
console.log(` E antes: ${buckets.eAntes}`);
if (rows.length > 0) {
console.log(`\n Detalle (top ${Math.min(rows.length, 10)}):`);
for (const r of rows.slice(0, 10)) {
const fi = new Date(r.i_fecha).toISOString().slice(0, 10);
const fe = new Date(r.e_fecha).toISOString().slice(0, 10);
const i_base = Number(r.i_total) - Number(r.i_iva || 0);
const e_base = Number(r.e_total) - Number(r.e_iva || 0);
const diff = Number(r.e_periodo) - Number(r.i_periodo);
console.log(` I/07 PPD ${r.i_uuid.substring(0,8)} ${fi} base=${i_base.toFixed(2)} ${r.i_emisor}${r.i_receptor} (${r.i_type})`);
console.log(` E/${r.e_rel ?? 'null'}/${r.e_mp || '?'} ${r.e_uuid.substring(0,8)} ${fe} base=${e_base.toFixed(2)} diffMeses=${diff} (${r.diff_dias}d)`);
}
}
}
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,12 @@
import { prisma, tenantDb } from '../src/config/database.js';
const prefix = process.argv[2];
async function main() {
const ts = await prisma.tenant.findMany({ where: { active: true }, select: { id: true, rfc: true, databaseName: true } });
for (const t of ts) {
const pool = await tenantDb.getPool(t.id, t.databaseName);
const { rows } = await pool.query(`SELECT uuid FROM cfdis WHERE uuid LIKE $1 || '%'`, [prefix]);
for (const r of rows) console.log(t.rfc, r.uuid);
}
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,104 @@
import { PrismaClient } from '@prisma/client';
import { readFileSync } from 'fs';
import { resolve } from 'path';
const prisma = new PrismaClient();
const SITUACIONES_VALIDAS = ['Definitivo', 'Presunto', 'Desvirtuado', 'Sentencia Favorable'];
function parseCsvLine(line: string): string[] {
const fields: string[] = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const c = line[i];
if (c === '"') {
if (inQuotes && line[i + 1] === '"') {
current += '"';
i++;
} else {
inQuotes = !inQuotes;
}
} else if (c === ',' && !inQuotes) {
fields.push(current.trim());
current = '';
} else {
current += c;
}
}
fields.push(current.trim());
return fields;
}
async function main() {
const filePath = resolve(__dirname, '..', '..', '..', 'lista_negra', 'Listado_completo_69-B.csv');
console.log('📂 Leyendo:', filePath);
const data = readFileSync(filePath, 'latin1');
const lines = data.split('\n');
console.log(`📄 ${lines.length} líneas en el archivo`);
// Parsear registros (saltar headers: líneas 0, 1, 2)
const registros: { rfc: string; nombre: string; situacion: string }[] = [];
for (let i = 3; i < lines.length; i++) {
const line = lines[i].replace(/\r/g, '').trim();
if (!line) continue;
const fields = parseCsvLine(line);
if (fields.length < 4) continue;
const rfc = fields[1]?.trim();
const nombre = fields[2]?.trim();
const situacion = fields[3]?.trim();
if (!rfc || !rfc.match(/^[A-Z0-9&]{10,13}$/)) continue;
if (!SITUACIONES_VALIDAS.includes(situacion)) continue;
registros.push({ rfc, nombre, situacion });
}
console.log(`${registros.length} registros válidos parseados`);
// Contar por situación
const counts: Record<string, number> = {};
for (const r of registros) {
counts[r.situacion] = (counts[r.situacion] || 0) + 1;
}
console.log(' Situaciones:', counts);
// Sincronizar: limpiar y reinsertar todo
console.log('🔄 Sincronizando con base de datos...');
await prisma.listaNegra.deleteMany();
// Insertar en batches de 500
const BATCH = 500;
let inserted = 0;
for (let i = 0; i < registros.length; i += BATCH) {
const batch = registros.slice(i, i + BATCH);
// Deduplicar por RFC (quedarse con el último)
const unique = new Map<string, typeof batch[0]>();
for (const r of batch) unique.set(r.rfc, r);
await prisma.listaNegra.createMany({
data: Array.from(unique.values()),
skipDuplicates: true,
});
inserted += unique.size;
if ((i + BATCH) % 5000 === 0 || i + BATCH >= registros.length) {
console.log(` ${Math.min(i + BATCH, registros.length)}/${registros.length}...`);
}
}
const total = await prisma.listaNegra.count();
console.log(`\n🎉 Lista negra actualizada: ${total} registros en la base de datos`);
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect());

View File

@@ -0,0 +1,26 @@
import { prisma, tenantDb } from '../src/config/database.js';
const rawUuid = process.argv[2];
if (!rawUuid) { console.error('Usage: tsx scripts/inspect-cfdi-full.ts <uuid>'); process.exit(1); }
const uuid = rawUuid.toLowerCase();
async function main() {
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true, rfc: true, databaseName: true },
});
for (const t of tenants) {
const pool = await tenantDb.getPool(t.id, t.databaseName);
const { rows } = await pool.query(
`SELECT * FROM cfdis WHERE LOWER(uuid) = $1`,
[uuid],
);
if (rows.length === 0) continue;
console.log(`\n[${t.rfc}] CFDI:`);
console.log(rows[0]);
}
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,90 @@
/**
* Inspecciona el estado de un CFDI y sus relacionados (pagos + E/07) en todos
* los tenants. Útil para debug de saldos pendientes.
*
* Uso:
* pnpm --filter @horux/api exec tsx scripts/inspect-cfdi.ts <uuid>
*/
import { prisma, tenantDb } from '../src/config/database.js';
const rawUuid = process.argv[2];
if (!rawUuid) {
console.error('Usage: tsx scripts/inspect-cfdi.ts <uuid>');
process.exit(1);
}
const uuid = rawUuid.toLowerCase();
async function main() {
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true, rfc: true, databaseName: true },
orderBy: { rfc: 'asc' },
});
for (const t of tenants) {
const pool = await tenantDb.getPool(t.id, t.databaseName);
const { rows: base } = await pool.query(
`SELECT id, uuid, type, tipo_comprobante, metodo_pago, status, fecha_emision,
total, total_mxn, monto_pago, monto_pago_mxn,
saldo_insoluto, saldo_pendiente, saldo_pendiente_mxn,
uuid_relacionado, cfdi_tipo_relacion, cfdis_relacionados,
rfc_emisor, rfc_receptor, conciliado, id_conciliacion,
source, facturapi_id
FROM cfdis WHERE LOWER(uuid) = $1`,
[uuid],
);
if (base.length === 0) continue;
console.log(`\n=== Tenant ${t.rfc} (${t.databaseName}) ===`);
console.log('CFDI base:');
console.log(base[0]);
// P complements que apuntan a este UUID via uuid_relacionado (DoctoRelacionado)
const { rows: pagosP } = await pool.query(
`SELECT id, uuid, type, tipo_comprobante, fecha_emision, fecha_pago_p,
monto_pago, monto_pago_mxn, num_parcialidad,
uuid_relacionado, status
FROM cfdis
WHERE tipo_comprobante = 'P' AND LOWER(uuid_relacionado) = $1
ORDER BY fecha_pago_p NULLS LAST, id`,
[uuid],
);
console.log(`\nComplementos P que referencian este UUID (DoctoRelacionado): ${pagosP.length}`);
for (const r of pagosP) console.log(' ', r);
// E CFDIs con cfdis_relacionados que contengan este UUID (TipoRelacion=07 típicamente)
const { rows: ecfdis } = await pool.query(
`SELECT id, uuid, type, tipo_comprobante, metodo_pago, fecha_emision,
total, total_mxn, cfdi_tipo_relacion, cfdis_relacionados,
status
FROM cfdis
WHERE tipo_comprobante = 'E'
AND cfdis_relacionados IS NOT NULL
AND LOWER(cfdis_relacionados) LIKE $1
ORDER BY fecha_emision, id`,
[`%${uuid}%`],
);
console.log(`\nCFDIs tipo E con este UUID en cfdis_relacionados: ${ecfdis.length}`);
for (const r of ecfdis) console.log(' ', r);
// Si el base está conciliado, traer la fila
if (base[0].id_conciliacion) {
const { rows: conc } = await pool.query(
`SELECT * FROM conciliaciones WHERE id = $1`,
[base[0].id_conciliacion],
);
console.log(`\nConciliación vinculada:`);
for (const r of conc) console.log(' ', r);
}
}
await prisma.$disconnect();
}
main().catch(async (err) => {
console.error('Fatal:', err);
await prisma.$disconnect().catch(() => {});
process.exit(1);
});

View File

@@ -0,0 +1,66 @@
/**
* Inspect the shape of the response from Facturapi invoices.retrieve
* for a recent emission, to know what fields are actually populated.
*/
import { prisma, tenantDb } from '../src/config/database.js';
import { env } from '../src/config/env.js';
const CONTRIB_ID = '414b22a8-c6e2-4f39-be0f-7537a848107e';
const TENANT_RFC = 'DESPACHO_MO3NI6U8_B9VGG';
const INVOICE_ID = '69ebc61f87f122486514c3b4'; // latest
async function main() {
const tenant = await prisma.tenant.findFirst({
where: { rfc: TENANT_RFC },
select: { id: true, databaseName: true },
});
if (!tenant) return;
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
// Fetch org API key
const { rows } = await pool.query<{ facturapi_org_id: string }>(
`SELECT facturapi_org_id FROM facturapi_orgs WHERE contribuyente_id=$1 AND active=true`,
[CONTRIB_ID],
);
if (rows.length === 0) {
console.log('No facturapi_org_id found');
return;
}
const orgId = rows[0].facturapi_org_id;
// Get the org's API key (HTTP direct because SDK has issues)
const userKey = env.FACTURAPI_USER_KEY;
const keyRes = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}/apikeys/test`, {
headers: { Authorization: `Bearer ${userKey}` },
});
const keyData = await keyRes.json();
const apiKey = typeof keyData === 'string' ? keyData : keyData.apikey || keyData.key;
// Retrieve the invoice
const invRes = await fetch(`https://www.facturapi.io/v2/invoices/${INVOICE_ID}`, {
headers: { Authorization: `Bearer ${apiKey}` },
});
const invoice = await invRes.json();
console.log('=== FACTURAPI INVOICE RESPONSE ===');
console.log('Top-level keys:', Object.keys(invoice).sort().join(', '));
console.log('');
console.log('invoice.id =', invoice.id);
console.log('invoice.uuid =', invoice.uuid);
console.log('invoice.date =', invoice.date);
console.log('invoice.subtotal =', invoice.subtotal);
console.log('invoice.total =', invoice.total);
console.log('invoice.series =', invoice.series);
console.log('invoice.folio_number =', invoice.folio_number);
console.log('invoice.issuer =', JSON.stringify(invoice.issuer, null, 2));
console.log('invoice.issuer_info =', JSON.stringify(invoice.issuer_info, null, 2));
console.log('invoice.issuer_type =', invoice.issuer_type);
console.log('invoice.organization =', JSON.stringify(invoice.organization, null, 2));
console.log('invoice.customer =', JSON.stringify(invoice.customer, null, 2));
console.log('invoice.taxes =', JSON.stringify(invoice.taxes, null, 2));
console.log('invoice.items =', JSON.stringify(invoice.items?.slice(0, 2), null, 2));
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,41 @@
import { prisma, tenantDb } from '../src/config/database.js';
const TENANT_RFC = 'DESPACHO_MO3NI6U8_B9VGG';
async function main() {
const tenant = await prisma.tenant.findFirst({
where: { rfc: TENANT_RFC },
select: { id: true, databaseName: true },
});
if (!tenant) return;
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
// Get the full latest Facturapi CFDI with ALL fields
const { rows } = await pool.query(
`SELECT * FROM cfdis
WHERE source = 'facturapi'
ORDER BY fecha_emision DESC
LIMIT 1`,
);
if (rows.length === 0) {
console.log('No hay CFDIs Facturapi');
return;
}
const r = rows[0];
console.log('UUID:', r.uuid);
console.log('');
console.log('Campos relevantes de emisor/receptor:');
const keys = Object.keys(r).sort();
for (const k of keys) {
if (/emisor|receptor|regimen|contribuyente|type|tipo|facturapi|uso_cfdi|forma|metodo|total|iva|lugar|fecha|status|version|uuid|id|source|serie|folio|xml_original/i.test(k)) {
const v = r[k];
const val = typeof v === 'string' && v.length > 200 ? v.substring(0, 200) + '…' : v;
console.log(` ${k} = ${val instanceof Date ? val.toISOString() : String(val).substring(0, 200)}`);
}
}
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,52 @@
import { prisma, tenantDb } from '../src/config/database.js';
const I_UUID = '5c874749-748f-11f0-96b1-2b9310891836';
const E_UUID = '7163da3b-748f-11f0-9853-e97a8e1dedd9';
async function main() {
const tenants = await prisma.tenant.findMany({ select: { id: true, rfc: true, databaseName: true } });
for (const t of tenants) {
let pool;
try { pool = await tenantDb.getPool(t.id, t.databaseName); } catch { continue; }
const { rows } = await pool.query(
`SELECT uuid, tipo_comprobante, metodo_pago, cfdi_tipo_relacion, cfdis_relacionados,
status, fecha_emision, total_mxn, iva_traslado_mxn,
rfc_emisor, rfc_receptor, contribuyente_id, type
FROM cfdis WHERE LOWER(uuid) IN (LOWER($1), LOWER($2))`,
[I_UUID, E_UUID],
);
if (rows.length === 0) continue;
console.log(`\n=== ${t.rfc} ===`);
for (const r of rows) {
const fe = new Date(r.fecha_emision).toISOString().slice(0, 10);
console.log(`\n UUID: ${r.uuid}`);
console.log(` tipo: ${r.tipo_comprobante}/${r.metodo_pago || '?'} rel=${r.cfdi_tipo_relacion ?? 'null'} status=${r.status} type=${r.type}`);
console.log(` fecha: ${fe} total=${r.total_mxn} IVA=${r.iva_traslado_mxn}`);
console.log(` ${r.rfc_emisor}${r.rfc_receptor} contrib_id=${r.contribuyente_id}`);
console.log(` cfdis_relacionados: ${r.cfdis_relacionados ?? 'NULL'}`);
}
// Si están ambos, verificar match de cfdis_relacionados
if (rows.length === 2) {
const i = rows.find((x: any) => x.uuid.toLowerCase() === I_UUID.toLowerCase());
const e = rows.find((x: any) => x.uuid.toLowerCase() === E_UUID.toLowerCase());
if (i && e) {
const iRels = (i.cfdis_relacionados || '').split('|').map((u: string) => u.trim().toLowerCase()).filter(Boolean);
const eRels = (e.cfdis_relacionados || '').split('|').map((u: string) => u.trim().toLowerCase()).filter(Boolean);
const overlap = iRels.filter((u: string) => eRels.includes(u));
console.log(`\n I refs (${iRels.length}): ${iRels.join(', ').substring(0, 200)}`);
console.log(` E refs (${eRels.length}): ${eRels.join(', ').substring(0, 200)}`);
console.log(` Overlap (${overlap.length}): ${overlap.join(', ')}`);
// Cruz: ¿la E referencia a la I directamente, o viceversa?
if (eRels.includes(I_UUID.toLowerCase())) console.log(` → E.cfdis_relacionados INCLUYE el UUID de I/07 PPD`);
if (iRels.includes(E_UUID.toLowerCase())) console.log(` → I.cfdis_relacionados INCLUYE el UUID de E`);
}
}
}
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,48 @@
import { prisma, tenantDb } from '../src/config/database.js';
const rawRfc = process.argv[2];
if (!rawRfc) {
console.error('Usage: tsx scripts/inspect-rfc.ts <rfc>');
process.exit(1);
}
const rfc = rawRfc.toUpperCase();
async function main() {
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true, rfc: true, databaseName: true },
});
for (const t of tenants) {
const pool = await tenantDb.getPool(t.id, t.databaseName);
const { rows: contrib } = await pool.query(
`SELECT * FROM contribuyentes WHERE UPPER(rfc) = $1`,
[rfc],
);
if (contrib.length > 0) {
console.log(`\n[${t.rfc}] Contribuyente ${rfc}:`);
console.log(contrib[0]);
}
const { rows: rfcEntry } = await pool.query(
`SELECT id, rfc, razon_social, regimen_fiscal, codigo_postal FROM rfcs WHERE UPPER(rfc) = $1`,
[rfc],
);
if (rfcEntry.length > 0) {
console.log(`[${t.rfc}] rfcs table:`, rfcEntry[0]);
}
if (contrib.length > 0) {
const { rows: org } = await pool.query(
`SELECT facturapi_org_id, csd_uploaded, active FROM facturapi_orgs WHERE contribuyente_id = $1`,
[contrib[0].entidad_id],
);
if (org.length > 0) console.log(`[${t.rfc}] facturapi_orgs:`, org[0]);
}
}
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,159 @@
/**
* Invalida TODAS las entradas en `metricas_mensuales` — marca para recompute
* cada (contribuyente_id, anio, mes) que tenga datos cacheados. Diseñado para
* usarse después de un cambio de fórmula que afecta resultados históricos
* (ej. 2026-04-23: NC tipo E con TipoRelacion=07 dejan de restar en Grupo 1).
*
* El cron `metricas-invalidations.job` (cada 15min) procesa el backlog.
* Para acelerar: `pnpm --filter @horux/api exec tsx -e "import { runProcessInvalidations } from './src/jobs/metricas-invalidations.job.js'; runProcessInvalidations().then(()=>process.exit(0))"`
*
* Uso:
* pnpm --filter @horux/api exec tsx scripts/invalidate-metricas-all.ts # ejecuta
* pnpm --filter @horux/api exec tsx scripts/invalidate-metricas-all.ts --dry # reporta sin escribir
*/
import { prisma, tenantDb } from '../src/config/database.js';
const DRY_RUN = process.argv.includes('--dry') || process.argv.includes('--dry-run');
const REASON = process.argv.find(a => a.startsWith('--reason='))?.slice(9) || 'FORMULA_CHANGE_E07_GRUPO1';
interface PerTenantResult {
tenantId: string;
rfc: string;
databaseName: string;
metricasRows: number;
marcadasNuevas: number;
marcadasUpdate: number;
error?: string;
}
async function invalidateTenant(
tenantId: string,
rfc: string,
databaseName: string,
): Promise<PerTenantResult> {
const result: PerTenantResult = {
tenantId,
rfc,
databaseName,
metricasRows: 0,
marcadasNuevas: 0,
marcadasUpdate: 0,
};
const pool = await tenantDb.getPool(tenantId, databaseName);
// Cuenta filas existentes en metricas_mensuales para reportar
const { rows: cnt } = await pool.query<{ n: number }>(
`SELECT COUNT(DISTINCT (contribuyente_id, anio, mes))::int AS n FROM metricas_mensuales`,
);
result.metricasRows = cnt[0]?.n || 0;
if (result.metricasRows === 0) return result;
const client = await pool.connect();
try {
await client.query('BEGIN');
// Insert-or-update: si ya estaba marcada, sobrescribe reason y marcado_at
// para que el cron la re-procese con el motivo correcto.
const { rows: inserted } = await client.query<{
contribuyente_id: string;
anio: number;
mes: number;
was_new: boolean;
}>(
`
INSERT INTO metricas_invalidaciones (contribuyente_id, anio, mes, reason)
SELECT DISTINCT contribuyente_id, anio, mes, $1 AS reason
FROM metricas_mensuales
ON CONFLICT (contribuyente_id, anio, mes) DO UPDATE
SET reason = EXCLUDED.reason, marcado_at = now()
RETURNING contribuyente_id, anio, mes, (xmax = 0) AS was_new
`,
[REASON],
);
result.marcadasNuevas = inserted.filter(r => r.was_new).length;
result.marcadasUpdate = inserted.length - result.marcadasNuevas;
if (DRY_RUN) {
await client.query('ROLLBACK');
} else {
await client.query('COMMIT');
}
} catch (err: any) {
await client.query('ROLLBACK').catch(() => {});
result.error = err?.message || String(err);
} finally {
client.release();
}
return result;
}
async function main() {
console.log(`=== Invalidate metricas_mensuales ${DRY_RUN ? '(DRY RUN — no writes)' : ''} ===`);
console.log(`Reason: ${REASON}\n`);
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true, rfc: true, databaseName: true },
orderBy: { rfc: 'asc' },
});
console.log(`Tenants activos: ${tenants.length}\n`);
const results: PerTenantResult[] = [];
for (const t of tenants) {
process.stdout.write(`[${t.rfc}] (${t.databaseName}) ... `);
try {
const r = await invalidateTenant(t.id, t.rfc, t.databaseName);
results.push(r);
if (r.error) {
console.log(`ERROR: ${r.error}`);
} else if (r.metricasRows === 0) {
console.log(`sin cache (skip)`);
} else {
console.log(
`cache=${r.metricasRows} (contrib,año,mes), marcadas=${r.marcadasNuevas + r.marcadasUpdate} (nuevas=${r.marcadasNuevas}, re-marcadas=${r.marcadasUpdate})`,
);
}
} catch (err: any) {
console.log(`FATAL: ${err?.message || err}`);
results.push({
tenantId: t.id,
rfc: t.rfc,
databaseName: t.databaseName,
metricasRows: 0,
marcadasNuevas: 0,
marcadasUpdate: 0,
error: err?.message || String(err),
});
}
}
const totalMetricas = results.reduce((s, r) => s + r.metricasRows, 0);
const totalMarcadas = results.reduce((s, r) => s + r.marcadasNuevas + r.marcadasUpdate, 0);
const tenantsTouched = results.filter(r => r.marcadasNuevas + r.marcadasUpdate > 0).length;
const tenantsFailed = results.filter(r => r.error).length;
console.log(`\n=== Resumen ===`);
console.log(` Tenants procesados: ${results.length}`);
console.log(` Tenants con cache: ${tenantsTouched}`);
console.log(` Filas cache total: ${totalMetricas}`);
console.log(` Invalidaciones: ${totalMarcadas}${DRY_RUN ? ' (rolled back)' : ''}`);
if (tenantsFailed > 0) console.log(` Tenants con error: ${tenantsFailed}`);
if (!DRY_RUN && totalMarcadas > 0) {
console.log(`\nCron metricas-invalidations procesará el backlog en <=15 min.`);
console.log(`Para disparar manual: runProcessInvalidations() desde un tsx -e ad-hoc.`);
}
await prisma.$disconnect();
process.exit(tenantsFailed > 0 ? 1 : 0);
}
main().catch(async (err) => {
console.error('Fatal:', err);
await prisma.$disconnect().catch(() => {});
process.exit(1);
});

View File

@@ -0,0 +1,26 @@
import { prisma, tenantDb } from '../src/config/database.js';
async function main() {
const tenants = await prisma.tenant.findMany({ where: { active: true }, select: { id: true, rfc: true, databaseName: true } });
for (const t of tenants) {
const pool = await tenantDb.getPool(t.id, t.databaseName);
// descubrir tablas con 'entidad' o 'contribuyente' en el nombre
const { rows: tbls } = await pool.query(`SELECT table_name FROM information_schema.tables WHERE table_schema='public' AND (table_name LIKE '%entidad%' OR table_name LIKE '%contribuyente%') ORDER BY table_name`);
console.log(`\n[${t.rfc}] tablas:`, tbls.map((r: any) => r.table_name).join(', '));
// Join con rfcs si existe
try {
const { rows } = await pool.query(
`SELECT c.entidad_id, c.rfc, r.razon_social, c.regimen_fiscal, c.codigo_postal
FROM contribuyentes c
LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc)
ORDER BY r.razon_social NULLS LAST, c.rfc`,
);
for (const r of rows) console.log(' ', r);
} catch (e: any) {
console.log(' ERR:', e.message);
}
}
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,33 @@
/**
* Eager tenant migration script.
* Run: pnpm --filter @horux/api db:migrate-tenants
* Or: pnpm db:migrate-tenants (from monorepo root via Turborepo)
*
* Applies pending SQL migrations to all active tenant databases.
*/
import { migrateAll } from '../src/config/tenant-migrations.js';
async function main() {
console.log('=== Tenant Schema Migration (Eager) ===\n');
const start = Date.now();
const result = await migrateAll();
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
console.log(`\n=== Done in ${elapsed}s ===`);
console.log(` Migrated: ${result.success}`);
console.log(` Up-to-date: ${result.skipped}`);
console.log(` Failed: ${result.failed}`);
if (result.failed > 0) {
console.error('\nSome tenants failed migration. Check logs above.');
process.exit(1);
}
process.exit(0);
}
main().catch((err) => {
console.error('Fatal error:', err);
process.exit(1);
});

View File

@@ -0,0 +1,27 @@
process.env.METRICAS_BYPASS_CACHE = '1';
import { prisma, tenantDb } from '../src/config/database.js';
import { calcularIngresosPorRegimen } from '../src/services/dashboard.service.js';
const tenantRfc = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG';
const contribuyenteId = process.argv[3] || 'b3761db6-0b8d-4251-8078-4ddc31e9c75b';
const yearMonth = process.argv[4] || '2025-05';
async function main() {
const tenant = await prisma.tenant.findFirst({ where: { rfc: tenantRfc }, select: { id: true, databaseName: true } });
if (!tenant) return;
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
const [anio, mes] = yearMonth.split('-').map(Number);
const lastDay = new Date(anio, mes, 0).getDate();
const fi = `${yearMonth}-01`;
const ff = `${yearMonth}-${String(lastDay).padStart(2, '0')}`;
const r = await calcularIngresosPorRegimen(pool, tenant.id, fi, ff, undefined, undefined, false, contribuyenteId);
console.log(`\n=== Ingresos ${yearMonth} contrib=${contribuyenteId} (BYPASS_CACHE=1) ===`);
console.log(`Total: ${r.total.toFixed(2)}`);
for (const p of r.porRegimen) {
console.log(` ${p.regimenClave} (${p.regimenDescripcion}): ${p.monto.toFixed(2)}`);
}
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,164 @@
#!/usr/bin/env node
/**
* Genera los 8 templates de email como archivos HTML estáticos en
* `apps/api/email-previews/` para revisar el diseño en el navegador
* sin necesidad de SMTP configurado.
*
* Uso:
* pnpm email:preview
*
* Tras correr, abre `apps/api/email-previews/index.html` para ver
* el listado con links a cada template.
*/
import { writeFileSync, mkdirSync, rmSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..');
const OUT_DIR = resolve(ROOT, 'email-previews');
// Datos de ejemplo realistas para cada template
const SAMPLES = {
'welcome.html': {
label: 'Bienvenida',
fixture: { nombre: 'Carlos Hernández', email: 'carlos@empresa.com', tempPassword: 'a3f2c891' },
importPath: '../src/services/email/templates/welcome.ts',
fnName: 'welcomeEmail',
},
'password-reset.html': {
label: 'Recuperación de contraseña',
fixture: { nombre: 'Carlos Hernández', resetUrl: 'https://horuxfin.com/reset-password?token=a8e4f...' },
importPath: '../src/services/email/templates/password-reset.ts',
fnName: 'passwordResetEmail',
},
'payment-confirmed.html': {
label: 'Pago confirmado',
fixture: { nombre: 'Carlos Hernández', amount: 780, plan: 'Business + IA', date: new Date().toLocaleDateString('es-MX') },
importPath: '../src/services/email/templates/payment-confirmed.ts',
fnName: 'paymentConfirmedEmail',
},
'payment-failed.html': {
label: 'Pago rechazado',
fixture: { nombre: 'Carlos Hernández', amount: 780, plan: 'Business + IA' },
importPath: '../src/services/email/templates/payment-failed.ts',
fnName: 'paymentFailedEmail',
},
'subscription-cancelled.html': {
label: 'Suscripción cancelada',
fixture: { nombre: 'Carlos Hernández', plan: 'Business + IA' },
importPath: '../src/services/email/templates/subscription-cancelled.ts',
fnName: 'subscriptionCancelledEmail',
},
'subscription-expiring.html': {
label: 'Suscripción por vencer',
fixture: { nombre: 'Carlos Hernández', plan: 'Business + IA', expiresAt: '15 de mayo, 2026' },
importPath: '../src/services/email/templates/subscription-expiring.ts',
fnName: 'subscriptionExpiringEmail',
},
'fiel-notification.html': {
label: 'e.firma cargada (admin)',
fixture: { clienteNombre: 'Empresa Demo SA de CV', clienteRfc: 'EDE123456AB1' },
importPath: '../src/services/email/templates/fiel-notification.ts',
fnName: 'fielNotificationEmail',
},
'weekly-update.html': {
label: 'Actualización semanal',
fixture: {
nombre: 'Carlos Hernández',
empresa: 'Empresa Demo SA de CV',
periodoLabel: 'Abril 2026',
kpis: {
ingresos: 285430.50,
egresos: 142900.00,
utilidad: 142530.50,
margen: 49.9,
ivaBalance: 18420.00,
ivaAFavorAcumulado: 32100.00,
cfdisEmitidos: 47,
cfdisRecibidos: 23,
},
alertas: [
{ titulo: 'Cliente en lista negra', mensaje: '1 cliente con situación SAT "Definitivo".', prioridad: 'alta' },
{ titulo: 'Concentración alta de proveedores', mensaje: 'IHH = 6,840. Más del 50% del gasto en 1 proveedor.', prioridad: 'media' },
{ titulo: 'Pago en efectivo', mensaje: '3 facturas recibidas con forma de pago "01-Efectivo" este mes.', prioridad: 'baja' },
],
discrepanciasPorMes: [
{ label: 'Abril 2026', count: 2 },
{ label: 'Marzo 2026', count: 5 },
{ label: 'Febrero 2026', count: 0 },
{ label: 'Enero 2026', count: 1 },
],
fechaGeneracion: new Date().toLocaleString('es-MX', { dateStyle: 'long', timeStyle: 'short' }),
},
importPath: '../src/services/email/templates/weekly-update.ts',
fnName: 'weeklyUpdateEmail',
},
'new-client-admin.html': {
label: 'Nuevo cliente registrado (admin)',
fixture: {
clienteNombre: 'Empresa Demo SA de CV',
clienteRfc: 'EDE123456AB1',
adminEmail: 'admin@empresademo.com',
adminNombre: 'Carlos Hernández',
tempPassword: 'a3f2c891',
databaseName: 'horux_ede123456ab1',
plan: 'business_ia',
},
importPath: '../src/services/email/templates/new-client-admin.ts',
fnName: 'newClientAdminEmail',
},
};
async function main() {
// Limpia output previo y recrea
try { rmSync(OUT_DIR, { recursive: true, force: true }); } catch {}
mkdirSync(OUT_DIR, { recursive: true });
const generated = [];
for (const [filename, sample] of Object.entries(SAMPLES)) {
const modPath = resolve(__dirname, sample.importPath);
const mod = await import(pathToFileURL(modPath).href);
const fn = mod[sample.fnName];
if (typeof fn !== 'function') {
console.error(`[email:preview] FAIL: ${sample.fnName} no exportada en ${modPath}`);
continue;
}
const html = fn(sample.fixture);
const outPath = resolve(OUT_DIR, filename);
writeFileSync(outPath, html, 'utf8');
generated.push({ filename, label: sample.label });
console.log(`[email:preview] ✓ ${filename}`);
}
// Index navegable
const indexHtml = `<!DOCTYPE html>
<html lang="es"><head><meta charset="utf-8"><title>Email previews — Horux 360</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 720px; margin: 40px auto; padding: 0 24px; color: #1E293B; }
h1 { font-size: 24px; margin-bottom: 8px; }
p.muted { color: #64748B; margin-top: 0; }
ul { list-style: none; padding: 0; }
li { margin: 8px 0; padding: 14px 18px; border: 1px solid #E2E8F0; border-radius: 8px; }
li:hover { background: #F8FAFC; }
a { color: #2563EB; text-decoration: none; font-weight: 500; }
a:hover { text-decoration: underline; }
small { color: #94A3B8; font-size: 12px; margin-left: 8px; }
</style></head><body>
<h1>Email previews — Horux 360</h1>
<p class="muted">Generados desde los templates en <code>apps/api/src/services/email/templates/</code> con datos de ejemplo. Cada link abre el HTML renderizado tal como llegaría al inbox del cliente.</p>
<ul>
${generated.map(g => `<li><a href="${g.filename}">${g.label}</a> <small>(${g.filename})</small></li>`).join('\n ')}
</ul>
<p class="muted" style="margin-top:32px;font-size:13px;">Si modificas un template, vuelve a correr <code>pnpm email:preview</code> para regenerar.</p>
</body></html>`;
writeFileSync(resolve(OUT_DIR, 'index.html'), indexHtml, 'utf8');
console.log(`\n[email:preview] ${generated.length} templates generados.`);
console.log(`[email:preview] Abre: ${resolve(OUT_DIR, 'index.html')}`);
}
main().catch(err => {
console.error('[email:preview] FAIL:', err);
process.exit(1);
});

View File

@@ -0,0 +1,32 @@
/**
* Dispara manualmente el procesamiento de `metricas_invalidaciones` para todos
* los tenants. Útil tras un `invalidate-metricas-all.ts` para no esperar al
* cron (cada 15 min).
*
* Uso:
* pnpm --filter @horux/api exec tsx scripts/process-metricas-now.ts
*/
import { prisma } from '../src/config/database.js';
import { processAllTenantsInvalidations } from '../src/services/metricas-compute.service.js';
async function main() {
console.log('=== Procesar metricas_invalidaciones (all tenants) ===\n');
const start = Date.now();
const r = await processAllTenantsInvalidations();
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
console.log(
`\nTenants revisados: ${r.tenantsRevisados}\n` +
`Invalidaciones procesadas: ${r.totalProcesadas}\n` +
`Filas metricas_mensuales escritas: ${r.totalFilasEscritas}\n` +
`Errores: ${r.totalErrores}\n` +
`Tiempo: ${elapsed}s`,
);
await prisma.$disconnect();
process.exit(r.totalErrores > 0 ? 1 : 0);
}
main().catch(async (err) => {
console.error('Fatal:', err);
await prisma.$disconnect().catch(() => {});
process.exit(1);
});

View File

@@ -0,0 +1,71 @@
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
const prisma = new PrismaClient();
async function main() {
console.log('Setting up horux_despachos database...');
// Create admin user
const hash = await bcrypt.hash('Admin12345!', 12);
const user = await prisma.user.upsert({
where: { email: 'ivan@horuxfin.com' },
update: {},
create: {
email: 'ivan@horuxfin.com',
passwordHash: hash,
nombre: 'Ivan Admin',
},
});
console.log('✅ User created:', user.email);
// Find or create tenant
let tenant = await prisma.tenant.findFirst();
if (!tenant) {
tenant = await prisma.tenant.create({
data: {
nombre: 'Despacho Demo',
rfc: 'DDE250101AAA',
plan: 'business',
databaseName: 'horux_dde250101aaa',
verticalProfile: 'CONTABLE',
dbMode: 'MANAGED',
trialEndsAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
},
});
console.log('✅ Tenant created:', tenant.nombre);
} else {
console.log('✅ Tenant exists:', tenant.nombre);
}
// Create membership
await prisma.tenantMembership.upsert({
where: { userId_tenantId: { userId: user.id, tenantId: tenant.id } },
update: {},
create: {
userId: user.id,
tenantId: tenant.id,
rolId: 1,
isOwner: true,
},
});
console.log('✅ Membership created (owner)');
// Set lastTenantId
await prisma.user.update({
where: { id: user.id },
data: { lastTenantId: tenant.id },
});
console.log('\n🎉 Setup complete!');
console.log('Login: ivan@horuxfin.com / Admin12345!');
console.log('Tenant:', tenant.nombre, `(${tenant.rfc})`);
}
main()
.catch((e) => {
console.error('Setup failed:', e);
process.exit(1);
})
.finally(() => prisma.$disconnect());

View File

@@ -0,0 +1,47 @@
/**
* CLI wrapper del watchdog. La lógica vive en
* `src/services/sat/sweep-stale-jobs.service.ts` para que también se pueda
* correr desde un cron (`sat-sync.job.ts`) sin duplicar código.
*
* Uso:
* pnpm --filter @horux/api exec tsx scripts/sweep-stale-sat-jobs.ts # dry-run
* pnpm --filter @horux/api exec tsx scripts/sweep-stale-sat-jobs.ts --apply # ejecuta
* STALE_RUNNING_HOURS=2 pnpm --filter @horux/api exec tsx scripts/sweep-stale-sat-jobs.ts
*/
import { prisma } from '../src/config/database.js';
import { sweepStaleSatJobs } from '../src/services/sat/sweep-stale-jobs.service.js';
async function main() {
const apply = process.argv.includes('--apply');
const pendingHours = Number(process.env.STALE_PENDING_HOURS || 12);
const runningHours = Number(process.env.STALE_RUNNING_HOURS || 4);
const mode = apply ? 'APPLY' : 'DRY-RUN';
console.log(`=== SAT stale-jobs watchdog [${mode}] ===`);
console.log(` pending: nextRetryAt < now ${pendingHours}h`);
console.log(` running: startedAt < now ${runningHours}h`);
console.log();
const result = await sweepStaleSatJobs({ apply, pendingHours, runningHours });
console.log(`Encontrados:`);
console.log(` pending stale: ${result.pendingFound}`);
console.log(` running stale: ${result.runningFound}`);
for (const e of result.entries) {
console.log(`${e.id} tenant=${e.tenantId} kind=${e.kind} edad=${e.ageHours}h`);
}
if (!apply) {
console.log(`\n[DRY-RUN] No se aplicaron cambios. Pasa --apply para marcar como failed.`);
} else {
console.log(`\nMarcados como failed: pending=${result.pendingMarked} running=${result.runningMarked}`);
}
await prisma.$disconnect();
}
main().catch(async (err) => {
console.error('Fatal:', err);
await prisma.$disconnect().catch(() => {});
process.exit(1);
});

View File

@@ -0,0 +1,96 @@
import { emailService } from '../src/services/email/email.service.js';
const recipients = ['ivan@horuxfin.com', 'carlos@horuxfin.com'];
async function sendAllSamples() {
for (const to of recipients) {
console.log(`\n=== Enviando a ${to} ===`);
// 1. Welcome
console.log('1/6 Bienvenida...');
await emailService.sendWelcome(to, {
nombre: 'Ivan Alcaraz',
email: 'ivan@horuxfin.com',
tempPassword: 'TempPass123!',
});
// 2. FIEL notification (goes to ADMIN_EMAIL, but we override for test)
console.log('2/6 Notificación FIEL...');
// Send directly since sendFielNotification goes to admin
const { fielNotificationEmail } = await import('../src/services/email/templates/fiel-notification.js');
const { createTransport } = await import('nodemailer');
const { env } = await import('../src/config/env.js');
const transport = createTransport({
host: env.SMTP_HOST,
port: parseInt(env.SMTP_PORT),
secure: false,
auth: { user: env.SMTP_USER, pass: env.SMTP_PASS },
});
const fielHtml = fielNotificationEmail({
clienteNombre: 'Horux 360',
clienteRfc: 'CAS200101XXX',
});
await transport.sendMail({
from: env.SMTP_FROM,
to,
subject: '[Horux 360] subió su FIEL (MUESTRA)',
html: fielHtml,
});
// 3. Payment confirmed
console.log('3/6 Pago confirmado...');
await emailService.sendPaymentConfirmed(to, {
nombre: 'Ivan Alcaraz',
amount: 1499,
plan: 'Enterprise',
date: '16 de marzo de 2026',
});
// 4. Payment failed
console.log('4/6 Pago fallido...');
const { paymentFailedEmail } = await import('../src/services/email/templates/payment-failed.js');
const failedHtml = paymentFailedEmail({
nombre: 'Ivan Alcaraz',
amount: 1499,
plan: 'Enterprise',
});
await transport.sendMail({
from: env.SMTP_FROM,
to,
subject: 'Problema con tu pago - Horux360 (MUESTRA)',
html: failedHtml,
});
// 5. Subscription expiring
console.log('5/6 Suscripción por vencer...');
await emailService.sendSubscriptionExpiring(to, {
nombre: 'Ivan Alcaraz',
plan: 'Enterprise',
expiresAt: '21 de marzo de 2026',
});
// 6. Subscription cancelled
console.log('6/6 Suscripción cancelada...');
const { subscriptionCancelledEmail } = await import('../src/services/email/templates/subscription-cancelled.js');
const cancelledHtml = subscriptionCancelledEmail({
nombre: 'Ivan Alcaraz',
plan: 'Enterprise',
});
await transport.sendMail({
from: env.SMTP_FROM,
to,
subject: 'Suscripción cancelada - Horux360 (MUESTRA)',
html: cancelledHtml,
});
console.log(`Listo: 6 correos enviados a ${to}`);
}
console.log('\n=== Todos los correos enviados ===');
process.exit(0);
}
sendAllSamples().catch((err) => {
console.error('Error:', err);
process.exit(1);
});

View File

@@ -0,0 +1,97 @@
/**
* Valida la alineación dashboard ≡ impuestos tras refactor de getResumenIva.
* Para 5 muestras aleatorias por contribuyente, compara:
* dashboard.calcularIvaBalancePorRegimen().total vs
* impuestos.getResumenIva().resultado
*
* Deben coincidir céntimo por céntimo (Resultado = Trasladado Acreditable Retenido,
* usando los mismos 6 buckets del dashboard).
*
* Uso:
* pnpm --filter @horux/api exec tsx scripts/validate-dashboard-impuestos.ts
* METRICAS_BYPASS_CACHE=1 pnpm --filter @horux/api exec tsx scripts/validate-dashboard-impuestos.ts
*/
import { prisma, tenantDb } from '../src/config/database.js';
import * as dashboard from '../src/services/dashboard.service.js';
import { getResumenIva } from '../src/services/impuestos.service.js';
const TOL = 0.01;
function cmp(a: number, b: number): boolean { return Math.abs(a - b) <= TOL; }
function fmt(n: number): string {
return n.toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
async function main() {
console.log('=== Validación dashboard.balance ≡ impuestos.resultado ===');
console.log(` BYPASS_CACHE=${process.env.METRICAS_BYPASS_CACHE === '1' ? 'YES' : 'no'}\n`);
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true, rfc: true, databaseName: true },
});
let total = 0;
let pass = 0;
let fail = 0;
for (const t of tenants) {
const pool = await tenantDb.getPool(t.id, t.databaseName);
const { rows: contribs } = await pool.query<{ entidad_id: string; nombre: string }>(
`SELECT c.entidad_id, eg.nombre
FROM contribuyentes c
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
WHERE EXISTS (SELECT 1 FROM metricas_mensuales m WHERE m.contribuyente_id = c.entidad_id)`,
);
if (contribs.length === 0) continue;
console.log(`[${t.rfc}] ${contribs.length} contribuyentes`);
for (const c of contribs) {
const { rows: samples } = await pool.query<{ anio: number; mes: number }>(
`SELECT anio, mes FROM (
SELECT DISTINCT anio, mes FROM metricas_mensuales WHERE contribuyente_id = $1
) t
ORDER BY random() LIMIT 5`,
[c.entidad_id],
);
console.log(` ${c.nombre}:`);
for (const s of samples) {
total++;
const fi = `${s.anio}-${String(s.mes).padStart(2, '0')}-01`;
const lastDay = new Date(s.anio, s.mes, 0).getDate();
const ff = `${s.anio}-${String(s.mes).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
const bal = await dashboard.calcularIvaBalancePorRegimen(
pool, t.id, fi, ff, [], undefined, false, c.entidad_id,
);
const resumen = await getResumenIva(pool, fi, ff, t.id, false, c.entidad_id);
const mesLabel = `${s.anio}-${String(s.mes).padStart(2, '0')}`;
if (cmp(bal.total, resumen.resultado)) {
pass++;
console.log(`${mesLabel} balance=$${fmt(bal.total)} resultado=$${fmt(resumen.resultado)}`);
} else {
fail++;
const delta = bal.total - resumen.resultado;
console.log(`${mesLabel} balance=$${fmt(bal.total)} resultado=$${fmt(resumen.resultado)} Δ=$${fmt(delta)}`);
console.log(` T=$${fmt(resumen.trasladado)} A=$${fmt(resumen.acreditable)} R=$${fmt(resumen.retenido)}`);
}
}
}
}
console.log(`\n=== Resumen ===`);
console.log(` Muestras: ${total}`);
console.log(` PASS: ${pass}`);
console.log(` FAIL: ${fail}`);
await prisma.$disconnect();
process.exit(fail > 0 ? 1 : 0);
}
main().catch(async (err) => {
console.error('Fatal:', err);
await prisma.$disconnect().catch(() => {});
process.exit(1);
});

View File

@@ -0,0 +1,115 @@
/**
* Compara Gastos del Dashboard vs Drill-down para un mes/contribuyente.
* Identifica discrepancias y rompe el detalle por lado (factura/pago/NC).
*
* Uso: tsx scripts/validate-gastos.ts <tenantRfc> <entidadId> <añoMes>
*/
import { prisma, tenantDb } from '../src/config/database.js';
import { calcularEgresosPorRegimen } from '../src/services/dashboard.service.js';
const tenantRfcArg = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG';
const contribuyenteId = process.argv[3] || 'd745a915-6a23-4818-944b-a7e1e18e536a';
const yearMonth = process.argv[4] || '2025-02';
async function main() {
const tenant = await prisma.tenant.findFirst({
where: { rfc: tenantRfcArg, active: true },
select: { id: true, rfc: true, databaseName: true },
});
if (!tenant) { console.error('Tenant not found'); process.exit(1); }
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
const [anio, mes] = yearMonth.split('-').map(Number);
const lastDay = new Date(anio, mes, 0).getDate();
const fi = `${yearMonth}-01`;
const ff = `${yearMonth}-${String(lastDay).padStart(2, '0')}`;
console.log(`\n=== Contribuyente ${contribuyenteId}${fi} a ${ff} ===\n`);
// 1. Dashboard (calcularEgresosPorRegimen)
const dashboard = await calcularEgresosPorRegimen(
pool, tenant.id, fi, ff, undefined, undefined, false, contribuyenteId,
);
console.log('DASHBOARD calcularEgresosPorRegimen:');
console.log(` total: ${dashboard.total.toFixed(2)}`);
for (const r of dashboard.porRegimen) {
console.log(` ${r.regimenClave} (${r.regimenDescripcion}): ${r.monto.toFixed(2)}`);
}
// 2. Drill-down query (simulated — bucket=gastos uniforme)
const IMP_TRAS = `COALESCE(iva_traslado_mxn,0) + COALESCE(ieps_traslado_mxn,0) + COALESCE(impuestos_locales_trasladado_mxn,0)`;
const IMP_TRAS_PAGO = `COALESCE(iva_traslado_pago_mxn,0) + COALESCE(ieps_traslado_pago_mxn,0)`;
const EXCL_MONTO = `COALESCE((SELECT SUM(COALESCE(cc.importe_mxn,0) - COALESCE(cc.descuento_mxn,0)) FROM cfdi_conceptos cc WHERE cc.cfdi_id = cfdis.id AND cc.clave_prod_serv IN ('84121603','93161608','85101501','85121800')), 0)`;
// bucket=gastos: RECIBIDO I PUE + RECIBIDO P + RECIBIDO E PUE (excl 07)
// Sumamos tomando en cuenta el signo (E resta)
const { rows: drillRows } = await pool.query(
`SELECT
type, tipo_comprobante, metodo_pago,
COALESCE(cfdi_tipo_relacion, '') AS tipo_rel,
COUNT(*)::int AS n,
SUM(total_mxn) AS total_bruto,
SUM(COALESCE(total_mxn,0) - (${IMP_TRAS}) - (${EXCL_MONTO})) AS total_neto,
SUM(COALESCE(monto_pago_mxn,0) - (${IMP_TRAS_PAGO})) AS pago_neto
FROM cfdis
WHERE (
(type = 'RECIBIDO' AND tipo_comprobante = 'I' AND metodo_pago = 'PUE'
AND COALESCE(cfdi_tipo_relacion, '') <> '07')
OR (type = 'RECIBIDO' AND tipo_comprobante = 'P')
OR (type = 'RECIBIDO' AND tipo_comprobante = 'E' AND metodo_pago = 'PUE'
AND COALESCE(cfdi_tipo_relacion, '') <> '07')
)
AND regimen_fiscal_receptor IN ('605','606','612','621','625','626','601','603','607','608','610','611','614','615','620','622','623','624')
AND status NOT IN ('Cancelado','0')
AND ((tipo_comprobante='P' AND fecha_pago_p >= $1::date AND fecha_pago_p < ($2::date + interval '1 day'))
OR (tipo_comprobante!='P' AND fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')))
AND contribuyente_id = $3
GROUP BY type, tipo_comprobante, metodo_pago, tipo_rel
ORDER BY tipo_comprobante, metodo_pago`,
[fi, ff, contribuyenteId],
);
console.log(`\nDRILL-DOWN bucket=gastos (filas del drill por bucket):`);
let drillSumaFacturas = 0, drillSumaPagos = 0, drillSumaNC = 0;
for (const r of drillRows) {
const tc = r.tipo_comprobante;
const valor = tc === 'P' ? Number(r.pago_neto) : Number(r.total_neto);
console.log(` ${r.type} ${tc} ${r.metodo_pago || '-'} rel=${r.tipo_rel || '-'} n=${r.n} total_bruto=${Number(r.total_bruto).toFixed(2)} valor_neto=${valor.toFixed(2)}`);
if (tc === 'I') drillSumaFacturas += valor;
else if (tc === 'P') drillSumaPagos += valor;
else if (tc === 'E') drillSumaNC += valor;
}
const drillTotal = drillSumaFacturas + drillSumaPagos - drillSumaNC;
console.log(` → facturas=${drillSumaFacturas.toFixed(2)} pagos=${drillSumaPagos.toFixed(2)} NC=${drillSumaNC.toFixed(2)}`);
console.log(` → drill total = ${drillTotal.toFixed(2)}`);
// 3. Comparación
const delta = dashboard.total - drillTotal;
console.log(`\n=== COMPARATIVA ===`);
console.log(` Dashboard: ${dashboard.total.toFixed(2)}`);
console.log(` Drill-down: ${drillTotal.toFixed(2)}`);
console.log(` Delta: ${delta.toFixed(2)}`);
if (Math.abs(delta) < 0.01) {
console.log(` ✓ CUADRAN`);
} else {
console.log(` ✗ NO CUADRAN — investigar`);
}
// 4. Régimenes del receptor que aparecen vs los ignorados
const { rows: regsReceptor } = await pool.query(
`SELECT DISTINCT regimen_fiscal_receptor
FROM cfdis
WHERE contribuyente_id = $1
AND type = 'RECIBIDO'
AND fecha_emision >= $2::date AND fecha_emision < ($3::date + interval '1 day')
ORDER BY regimen_fiscal_receptor`,
[contribuyenteId, fi, ff],
);
console.log(`\nRegímenes en CFDIs RECIBIDOS del periodo:`, regsReceptor.map(r => r.regimen_fiscal_receptor).join(', '));
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,39 @@
/**
* Paridad dashboard vs drill para INGRESOS de un contribuyente en un año.
* Similar a validate-gastos pero para el lado emisor.
*/
import { prisma, tenantDb } from '../src/config/database.js';
import { calcularIngresosPorRegimen } from '../src/services/dashboard.service.js';
const tenantRfc = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG';
const contribuyenteId = process.argv[3] || '414b22a8-c6e2-4f39-be0f-7537a848107e';
const año = Number(process.argv[4] || '2025');
async function main() {
const tenant = await prisma.tenant.findFirst({ where: { rfc: tenantRfc }, select: { id: true, databaseName: true } });
if (!tenant) { console.error('Tenant not found'); process.exit(1); }
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
console.log(`\n=== Ingresos ${año} Contribuyente ${contribuyenteId} ===\n`);
console.log(`mes | total por régimen | total mes`);
let totalAño = 0;
for (let m = 1; m <= 12; m++) {
const lastDay = new Date(año, m, 0).getDate();
const mm = String(m).padStart(2, '0');
const fi = `${año}-${mm}-01`;
const ff = `${año}-${mm}-${String(lastDay).padStart(2, '0')}`;
const ingresos = await calcularIngresosPorRegimen(
pool, tenant.id, fi, ff, undefined, undefined, false, contribuyenteId,
);
const porReg = ingresos.porRegimen.map(r => `${r.regimenClave}:${r.monto.toFixed(2)}`).join(' / ');
console.log(`${mm} | ${porReg || '(sin datos)'} | ${ingresos.total.toFixed(2)}`);
totalAño += ingresos.total;
}
console.log(`\nTotal año: ${totalAño.toFixed(2)}`);
await prisma.$disconnect();
}
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });

View File

@@ -0,0 +1,160 @@
/**
* Validación Tanda A: para cada contribuyente con datos en metricas_mensuales,
* toma 5 filas al azar y compara contra el cálculo on-the-fly usando los
* servicios canónicos (dashboard, impuestos). Reporta PASS/FAIL por celda.
*
* Uso:
* pnpm --filter @horux/api exec tsx scripts/validate-metricas.ts
*/
import { prisma, tenantDb } from '../src/config/database.js';
import {
calcularIngresosPorRegimen,
calcularEgresosPorRegimen,
} from '../src/services/dashboard.service.js';
import { getResumenIva } from '../src/services/impuestos.service.js';
const TOL = 0.01; // tolerancia de $0.01 para redondeo decimal
interface StoredRow {
contribuyente_id: string;
anio: number;
mes: number;
regimen_fiscal: string | null;
ingresos_cobrados: string;
egresos_pagados: string;
iva_trasladado_total: string;
iva_acreditable: string;
iva_retenido_cobrado: string;
iva_resultado: string;
cfdis_emitidos_count: number;
cfdis_recibidos_count: number;
cfdis_cancelados_count: number;
}
function cmp(a: number, b: number): boolean {
return Math.abs(a - b) <= TOL;
}
function fmt(n: number): string {
return n.toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
async function validateRow(
tenantId: string,
row: StoredRow,
): Promise<{ pass: boolean; diffs: string[] }> {
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { databaseName: true },
});
if (!tenant) return { pass: false, diffs: ['tenant no encontrado'] };
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
const fi = `${row.anio}-${String(row.mes).padStart(2, '0')}-01`;
const lastDay = new Date(row.anio, row.mes, 0).getDate();
const ff = `${row.anio}-${String(row.mes).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
// Ejecutamos secuencial para evitar interferencia entre queries bajo el pool
// limit del tenant (max 3 conexiones). Con Promise.all concurrente, algunas
// queries compartidas de getResumenIva devolvían valores parciales.
const ingresos = await calcularIngresosPorRegimen(pool, tenantId, fi, ff, [], undefined, false, row.contribuyente_id);
const egresos = await calcularEgresosPorRegimen(pool, tenantId, fi, ff, [], undefined, false, row.contribuyente_id);
const resumenIva = await getResumenIva(pool, fi, ff, tenantId, false, row.contribuyente_id);
const reg = row.regimen_fiscal;
const ingOtf = ingresos.porRegimen.find(r => r.regimenClave === reg)?.monto || 0;
const egrOtf = egresos.porRegimen.find(r => r.regimenClave === reg)?.monto || 0;
const trasOtf = resumenIva.trasladadoPorRegimen.find(r => r.regimenClave === reg)?.monto || 0;
const acrOtf = resumenIva.acreditablePorRegimen.find(r => r.regimenClave === reg)?.monto || 0;
const retOtf = resumenIva.retenidoPorRegimen.find(r => r.regimenClave === reg)?.monto || 0;
const resOtf = trasOtf - acrOtf - retOtf;
const diffs: string[] = [];
const ingStored = Number(row.ingresos_cobrados);
const egrStored = Number(row.egresos_pagados);
const trasStored = Number(row.iva_trasladado_total);
const acrStored = Number(row.iva_acreditable);
const retStored = Number(row.iva_retenido_cobrado);
const resStored = Number(row.iva_resultado);
if (!cmp(ingStored, ingOtf)) diffs.push(`ingresos: tabla=${fmt(ingStored)} vs otf=${fmt(ingOtf)}`);
if (!cmp(egrStored, egrOtf)) diffs.push(`egresos: tabla=${fmt(egrStored)} vs otf=${fmt(egrOtf)}`);
if (!cmp(trasStored, trasOtf)) diffs.push(`ivaTras: tabla=${fmt(trasStored)} vs otf=${fmt(trasOtf)}`);
if (!cmp(acrStored, acrOtf)) diffs.push(`ivaAcr: tabla=${fmt(acrStored)} vs otf=${fmt(acrOtf)}`);
if (!cmp(retStored, retOtf)) diffs.push(`ivaRet: tabla=${fmt(retStored)} vs otf=${fmt(retOtf)}`);
if (!cmp(resStored, resOtf)) diffs.push(`ivaResultado: tabla=${fmt(resStored)} vs otf=${fmt(resOtf)}`);
return { pass: diffs.length === 0, diffs };
}
async function main() {
console.log('=== Validación metricas_mensuales (5 muestras aleatorias por contribuyente) ===\n');
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true, rfc: true, databaseName: true },
});
let totalMuestras = 0;
let totalPass = 0;
let totalFail = 0;
for (const t of tenants) {
const pool = await tenantDb.getPool(t.id, t.databaseName);
const { rows: contribs } = await pool.query<{ entidad_id: string; nombre: string }>(
`SELECT c.entidad_id, eg.nombre
FROM contribuyentes c
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
WHERE EXISTS (
SELECT 1 FROM metricas_mensuales m WHERE m.contribuyente_id = c.entidad_id
)`,
);
if (contribs.length === 0) continue;
console.log(`\n[${t.rfc}] ${contribs.length} contribuyentes con datos`);
for (const c of contribs) {
const { rows: samples } = await pool.query<StoredRow>(
`SELECT contribuyente_id::text, anio, mes, regimen_fiscal,
ingresos_cobrados, egresos_pagados,
iva_trasladado_total, iva_acreditable, iva_retenido_cobrado, iva_resultado,
cfdis_emitidos_count, cfdis_recibidos_count, cfdis_cancelados_count
FROM metricas_mensuales
WHERE contribuyente_id = $1
ORDER BY random()
LIMIT 5`,
[c.entidad_id],
);
console.log(` ${c.nombre} (${samples.length} muestras):`);
for (const s of samples) {
totalMuestras++;
const { pass, diffs } = await validateRow(t.id, s);
const mesLabel = `${s.anio}-${String(s.mes).padStart(2, '0')}`;
const reg = s.regimen_fiscal || 'null';
if (pass) {
totalPass++;
console.log(`${mesLabel} reg=${reg} ingresos=$${fmt(Number(s.ingresos_cobrados))}`);
} else {
totalFail++;
console.log(`${mesLabel} reg=${reg} DIFFS:`);
for (const d of diffs) console.log(` - ${d}`);
}
}
}
}
console.log(`\n=== Resumen ===`);
console.log(` Muestras totales: ${totalMuestras}`);
console.log(` PASS: ${totalPass}`);
console.log(` FAIL: ${totalFail}`);
await prisma.$disconnect();
process.exit(totalFail > 0 ? 1 : 0);
}
main().catch(async (err) => {
console.error('Fatal:', err);
await prisma.$disconnect().catch(() => {});
process.exit(1);
});

112
apps/api/src/app.ts Normal file
View File

@@ -0,0 +1,112 @@
import express, { type Express } from 'express';
import cors from 'cors';
import helmet from 'helmet';
import { env, getCorsOrigins } from './config/env.js';
import { errorMiddleware } from './middlewares/error.middleware.js';
import { authRoutes } from './routes/auth.routes.js';
import { dashboardRoutes } from './routes/dashboard.routes.js';
import { cfdiRoutes } from './routes/cfdi.routes.js';
import { impuestosRoutes } from './routes/impuestos.routes.js';
import { exportRoutes } from './routes/export.routes.js';
import { alertasRoutes } from './routes/alertas.routes.js';
import { notificationPreferencesRoutes } from './routes/notification-preferences.routes.js';
import { tareasRoutes } from './routes/tareas.routes.js';
import { papeleriaRoutes } from './routes/papeleria.routes.js';
import { despachoStatsRoutes } from './routes/despacho-stats.routes.js';
import { calendarioRoutes } from './routes/calendario.routes.js';
import { reportesRoutes } from './routes/reportes.routes.js';
import { usuariosRoutes } from './routes/usuarios.routes.js';
import { tenantsRoutes } from './routes/tenants.routes.js';
import fielRoutes from './routes/fiel.routes.js';
import satRoutes from './routes/sat.routes.js';
import { webhookRoutes } from './routes/webhook.routes.js';
import { subscriptionRoutes } from './routes/subscription.routes.js';
import { regimenRoutes } from './routes/regimen.routes.js';
import { bancosRoutes } from './routes/bancos.routes.js';
import { conciliacionRoutes } from './routes/conciliacion.routes.js';
import { facturacionRoutes } from './routes/facturacion.routes.js';
import { catalogosRoutes } from './routes/catalogos.routes.js';
import { documentosRoutes } from './routes/documentos.routes.js';
import { auditLogRoutes } from './routes/audit-log.routes.js';
import { platformStaffRoutes } from './routes/platform-staff.routes.js';
import despachoRoutes from './routes/despacho.routes.js';
import contribuyenteRoutes from './routes/contribuyente.routes.js';
import carteraRoutes from './routes/cartera.routes.js';
import planCatalogoRoutes from './routes/plan-catalogo.routes.js';
import connectorRoutes from './routes/connector.routes.js';
import adminDashboardRoutes from './routes/admin-dashboard.routes.js';
import adminImpersonateRoutes from './routes/admin-impersonate.routes.js';
import adminClientesRoutes from './routes/admin-clientes.routes.js';
import adminAddonsRoutes from './routes/admin-addons.routes.js';
import despachoAuditRoutes from './routes/despacho-audit.routes.js';
import metricasRoutes from './routes/metricas.routes.js';
const app: Express = express();
// Security. Helmet default incluye un CSP restrictivo que puede chocar con el
// frontend cuando éste embebe recursos propios (ej: /terminos embebe el PDF de
// /legal/). Dejamos CSP off en el API y centralizamos los headers de seguridad
// en next.config del web (X-Frame-Options, CSP frame-ancestors, HSTS, nosniff,
// Referrer-Policy) que es quien sirve la UI. El API solo responde JSON y
// archivos binarios (PDFs, XMLs) — no tiene contenido HTML que requiera CSP.
app.use(helmet({
contentSecurityPolicy: false,
crossOriginResourcePolicy: { policy: 'cross-origin' }, // permite /legal/*.pdf embebido
}));
app.use(cors({
origin: getCorsOrigins(),
credentials: true,
}));
// Body parsing - 10MB default, bulk CFDI route has its own higher limit
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// API Routes
app.use('/api/auth', authRoutes);
app.use('/api/dashboard', dashboardRoutes);
app.use('/api/cfdi', cfdiRoutes);
app.use('/api/impuestos', impuestosRoutes);
app.use('/api/export', exportRoutes);
app.use('/api/alertas', alertasRoutes);
app.use('/api/notificaciones', notificationPreferencesRoutes);
app.use('/api/tareas', tareasRoutes);
app.use('/api/papeleria', papeleriaRoutes);
app.use('/api/despachos', despachoStatsRoutes);
app.use('/api/calendario', calendarioRoutes);
app.use('/api/reportes', reportesRoutes);
app.use('/api/usuarios', usuariosRoutes);
app.use('/api/tenants', tenantsRoutes);
app.use('/api/fiel', fielRoutes);
app.use('/api/sat', satRoutes);
app.use('/api/webhooks', webhookRoutes);
app.use('/api/subscriptions', subscriptionRoutes);
app.use('/api/regimenes', regimenRoutes);
app.use('/api/bancos', bancosRoutes);
app.use('/api/conciliacion', conciliacionRoutes);
app.use('/api/facturacion', facturacionRoutes);
app.use('/api/catalogos', catalogosRoutes);
app.use('/api/documentos', documentosRoutes);
app.use('/api/audit-log', auditLogRoutes);
app.use('/api/platform-staff', platformStaffRoutes);
app.use('/api/despachos', despachoRoutes);
app.use('/api/contribuyentes', contribuyenteRoutes);
app.use('/api/carteras', carteraRoutes);
app.use('/api/planes', planCatalogoRoutes);
app.use('/api/connector', connectorRoutes);
app.use('/api/admin/dashboard', adminDashboardRoutes);
app.use('/api/admin/impersonate', adminImpersonateRoutes);
app.use('/api/admin/clientes', adminClientesRoutes);
app.use('/api/admin/addons', adminAddonsRoutes);
app.use('/api/despacho/audit-log', despachoAuditRoutes);
app.use('/api/metricas', metricasRoutes);
// Error handling
app.use(errorMiddleware);
export { app };

View File

@@ -0,0 +1 @@
export { hashPassword, verifyPassword } from '@horux/core';

View File

@@ -0,0 +1,30 @@
import {
generateAccessToken as coreGenerateAccessToken,
generateRefreshToken as coreGenerateRefreshToken,
verifyToken as coreVerifyToken,
decodeToken,
type TokenConfig,
} from '@horux/core';
import type { JWTPayload } from '@horux/shared';
import { env } from '../config/env.js';
const tokenConfig: TokenConfig = {
secret: env.JWT_SECRET,
accessExpiresIn: env.JWT_EXPIRES_IN,
refreshExpiresIn: env.JWT_REFRESH_EXPIRES_IN,
};
export function generateAccessToken(payload: Omit<JWTPayload, 'iat' | 'exp'>): string {
return coreGenerateAccessToken(payload, tokenConfig);
}
export function generateRefreshToken(payload: Omit<JWTPayload, 'iat' | 'exp'>): string {
return coreGenerateRefreshToken(payload, tokenConfig);
}
export function verifyToken(token: string): JWTPayload {
return coreVerifyToken(token, tokenConfig.secret);
}
export { decodeToken };
export type { JWTPayload };

View File

@@ -0,0 +1,234 @@
import { PrismaClient } from '@prisma/client';
import { Pool, type PoolConfig } from 'pg';
import { env } from './env.js';
import { migrate } from './tenant-migrations.js';
// ===========================================
// Prisma Client (central database: horux360)
// ===========================================
declare global {
var prisma: PrismaClient | undefined;
}
export const prisma = globalThis.prisma || new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});
if (process.env.NODE_ENV !== 'production') {
globalThis.prisma = prisma;
}
// ===========================================
// TenantConnectionManager (per-tenant DBs)
// ===========================================
interface PoolEntry {
pool: Pool;
lastAccess: Date;
}
function parseDatabaseUrl(url: string) {
const parsed = new URL(url);
return {
host: parsed.hostname,
port: parseInt(parsed.port || '5432'),
user: decodeURIComponent(parsed.username),
password: decodeURIComponent(parsed.password),
};
}
class TenantConnectionManager {
private pools: Map<string, PoolEntry> = new Map();
private cleanupInterval: NodeJS.Timeout | null = null;
private dbConfig: { host: string; port: number; user: string; password: string };
private migratedPools: Set<string> = new Set();
constructor() {
this.dbConfig = parseDatabaseUrl(env.DATABASE_URL);
this.cleanupInterval = setInterval(() => this.cleanupIdlePools(), 60_000);
}
/**
* Get or create a connection pool for a tenant's database.
* Runs lazy migrations on first access (or after pool invalidation).
*/
async getPool(
tenantId: string,
databaseName: string,
connectionOverride?: { host: string; port: number; user: string; password: string },
): Promise<Pool> {
let pool: Pool;
const entry = this.pools.get(tenantId);
if (entry) {
entry.lastAccess = new Date();
pool = entry.pool;
} else {
const poolConfig: PoolConfig = {
host: connectionOverride?.host ?? this.dbConfig.host,
port: connectionOverride?.port ?? this.dbConfig.port,
user: connectionOverride?.user ?? this.dbConfig.user,
password: connectionOverride?.password ?? this.dbConfig.password,
database: databaseName,
max: 3,
idleTimeoutMillis: 300_000,
connectionTimeoutMillis: 10_000,
};
pool = new Pool(poolConfig);
pool.on('error', (err) => {
console.error(`[TenantDB] Pool error for tenant ${tenantId} (${databaseName}):`, err.message);
});
this.pools.set(tenantId, { pool, lastAccess: new Date() });
}
if (!this.migratedPools.has(tenantId)) {
try {
await migrate(pool, databaseName);
} catch (err) {
console.error(`[TenantDB] Migration error for tenant ${tenantId} (${databaseName}):`, err);
}
this.migratedPools.add(tenantId);
}
return pool;
}
/**
* Create a new database for a tenant with all required tables and indexes.
*/
async provisionDatabase(rfc: string, overrideDatabaseName?: string): Promise<string> {
const databaseName = overrideDatabaseName || `horux_${rfc.toLowerCase().replace(/[^a-z0-9]/g, '')}`;
const adminPool = new Pool({
...this.dbConfig,
database: 'postgres',
max: 1,
});
try {
const exists = await adminPool.query(
`SELECT 1 FROM pg_database WHERE datname = $1`,
[databaseName]
);
if (exists.rows.length > 0) {
throw new Error(`Database ${databaseName} already exists`);
}
await adminPool.query(`CREATE DATABASE "${databaseName}"`);
const tenantPool = new Pool({
...this.dbConfig,
database: databaseName,
max: 1,
});
try {
await migrate(tenantPool, databaseName);
} finally {
await tenantPool.end();
}
return databaseName;
} finally {
await adminPool.end();
}
}
/**
* Soft-delete: rename database so it can be recovered.
*/
async deprovisionDatabase(databaseName: string): Promise<void> {
// Close any active pool for this tenant
for (const [tenantId, entry] of this.pools.entries()) {
// We check pool config to match the database
if ((entry.pool as any).options?.database === databaseName) {
await entry.pool.end().catch(() => {});
this.pools.delete(tenantId);
}
}
const timestamp = Date.now();
const adminPool = new Pool({
...this.dbConfig,
database: 'postgres',
max: 1,
});
try {
await adminPool.query(`
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = $1 AND pid <> pg_backend_pid()
`, [databaseName]);
await adminPool.query(
`ALTER DATABASE "${databaseName}" RENAME TO "${databaseName}_deleted_${timestamp}"`
);
} finally {
await adminPool.end();
}
}
/**
* Invalidate (close and remove) a specific tenant's pool.
*/
invalidatePool(tenantId: string): void {
const entry = this.pools.get(tenantId);
if (entry) {
entry.pool.end().catch(() => {});
this.pools.delete(tenantId);
}
this.migratedPools.delete(tenantId);
}
/**
* Remove idle pools (not accessed in last 5 minutes).
*/
private cleanupIdlePools(): void {
const now = Date.now();
const maxIdle = 5 * 60 * 1000;
for (const [tenantId, entry] of this.pools.entries()) {
if (now - entry.lastAccess.getTime() > maxIdle) {
entry.pool.end().catch((err) =>
console.error(`[TenantDB] Error closing idle pool for ${tenantId}:`, err.message)
);
this.pools.delete(tenantId);
}
}
}
/**
* Graceful shutdown: close all pools.
*/
async shutdown(): Promise<void> {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
}
const closePromises = Array.from(this.pools.values()).map((entry) =>
entry.pool.end()
);
await Promise.all(closePromises);
this.pools.clear();
}
/**
* Get stats about active pools.
*/
getStats(): { activePools: number; tenantIds: string[] } {
return {
activePools: this.pools.size,
tenantIds: Array.from(this.pools.keys()),
};
}
}
// Singleton instance
export const tenantDb = new TenantConnectionManager();

View File

@@ -0,0 +1,63 @@
import { z } from 'zod';
import { config } from 'dotenv';
import { resolve } from 'path';
// Load .env file from the api package root
config({ path: resolve(process.cwd(), '.env') });
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.string().default('4000'),
DATABASE_URL: z.string(),
JWT_SECRET: z.string().min(32),
JWT_EXPIRES_IN: z.string().default('15m'),
JWT_REFRESH_EXPIRES_IN: z.string().default('7d'),
CORS_ORIGIN: z.string().default('http://localhost:3000'),
// Frontend URL (for MercadoPago back_url, emails, etc.)
FRONTEND_URL: z.string().default('https://horuxfin.com'),
// FIEL encryption (separate from JWT to allow independent rotation)
FIEL_ENCRYPTION_KEY: z.string().min(32),
FIEL_STORAGE_PATH: z.string().default('/var/horux/fiel'),
// MercadoPago
MP_ACCESS_TOKEN: z.string().optional(),
MP_WEBHOOK_SECRET: z.string().optional(),
MP_NOTIFICATION_URL: z.string().optional(),
// SMTP (Gmail Workspace)
SMTP_HOST: z.string().default('smtp.gmail.com'),
SMTP_PORT: z.string().default('587'),
SMTP_USER: z.string().optional(),
SMTP_PASS: z.string().optional(),
SMTP_FROM: z.string().default('Horux360 <noreply@horuxfin.com>'),
// Admin notification email
ADMIN_EMAIL: z.string().default('carlos@horuxfin.com'),
// Facturapi
FACTURAPI_USER_KEY: z.string().optional(),
// Cloudflare Tunnel (connector BYO-DB)
CLOUDFLARE_API_TOKEN: z.string().optional(),
CLOUDFLARE_ACCOUNT_ID: z.string().optional(),
CLOUDFLARE_TUNNEL_DOMAIN: z.string().default('tunnel.horux.mx'),
// KMS for encrypting DB connection strings and connector tokens
CONNECTOR_ENCRYPTION_KEY: z.string().optional(),
});
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error('❌ Invalid environment variables:', parsed.error.flatten().fieldErrors);
process.exit(1);
}
export const env = parsed.data;
// Parse CORS origins (comma-separated) into array
export function getCorsOrigins(): string[] {
return env.CORS_ORIGIN.split(',').map(origin => origin.trim());
}

Some files were not shown because too many files have changed in this diff Show More