Initial commit - Horux Despachos NL

This commit is contained in:
2026-05-03 16:47:53 -06:00
commit b00b677c54
647 changed files with 133843 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

523
CLAUDE.md Normal file
View File

@@ -0,0 +1,523 @@
# 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, Metabase) |
| `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 vía Facturapi (org de Horux 360, RESICO PM, clave prod/serv `81112502`). **El primer pago aprobado de cada tenant se skip-ea** (lo factura manual el admin para capturar datos fiscales); los subsecuentes van auto. **Receptor según preferencia del tenant** (`Tenant.factPreferencia``mis_datos` por default vs `publico_general`): si tiene CSF cargada + `factPreferencia='mis_datos'` se factura con datos reales del cliente usando `factUsoCfdi` (default `G03`) y `factRegimenPreferido` (o primer régimen activo si no se especifica); si falta cualquier dato fiscal cae automáticamente a Público en General + `S01`. Configurable desde `/configuracion/facturacion` (UI limita uso CFDI a G03/S01). 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` y `docs/plans/2026-05-02-session.md` sección 22.
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`.
**Onboarding auto-dismiss:** `/onboarding` muestra los pasos de configuración inicial (cuenta → contribuyente → FIEL → CSD → equipo opc. → plan opc.) para owner/cfo/contador. Deja de mostrarse cuando se cumple cualquiera de las dos condiciones (lo que pase primero): (1) `User.loginCount > 4` — incrementado solo en `auth.service.login()`, NO en refresh, para que el threshold sea sesiones reales y no horas; (2) `User.onboardingDismissedAt != null` — seteado por `POST /auth/onboarding/dismiss` (idempotente, preserva timestamp original) que la página `/onboarding` invoca automáticamente cuando todos los pasos requeridos están completos. Helper compartido en `apps/web/lib/onboarding.ts:shouldShowOnboarding(user)` con `ONBOARDING_LOGIN_THRESHOLD = 4` exportado. El `LoginResponse.user` incluye ambos campos para que el frontend decida sin round-trip extra. Roles `cliente/auxiliar/supervisor` y admin global nunca ven onboarding (van directo a `/dashboard` o `/clientes`). Ver `docs/plans/2026-05-02-session.md` sección 23.
**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, RESICO PF cerca de límite anual |
| `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`
### Alerta RESICO PF — límite anual ($3.5M Art. 113-E LISR)
`alertaResicoPfLimiteIngresos` en `alertas-auto.service.ts` aplica solo cuando el contribuyente: (1) tiene RFC de 13 chars (PF), y (2) tiene `626` en su CSV `regimen_fiscal`. **El cálculo de ingresos NO filtra por régimen** — el SAT considera ingresos acumulados de TODOS los regímenes para el límite del Art. 113-E ($3.5M). Thresholds: $2.5M (media), $3M (alta), $3.5M (alta + "ya saliste del régimen"). Suma `I PUE + P E PUE` del año en curso usando `total_mxn` (con IVA — proxy conservador para alerta preventiva). RESICO PM (RFC 12) no aplica — ese régimen tiene reglas distintas y se filtra por longitud de RFC.
### 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 (con políticas por tipo):**
- Poll interval: 60 segundos, máximo 45 intentos (45 min) → `MAX_POLL_ATTEMPTS`
- Tiempos de retry **absolutos desde `startedAt`** (no desde el timeout): si la sync arrancó a las 3:00 y timeoutea a 3:45, el primer retry queda programado para 9:00 exacto, no 9:45.
- Política por tipo (`RETRY_POLICIES` en `sat.service.ts`):
- `daily` → 2 retries a T+6h, T+12h
- `initial` con `isCustomRange=true` (rango UI) → 2 retries a T+6h, T+12h
- `initial` bootstrap (sin fechas) → 3 retries a T+6h, T+12h, T+24h
- `incremental`**0 retries** (próximo cron cada 4h cubre el gap, dedup por UUID)
- Cron horario revisa `pending` con `nextRetryAt <= now` y reintenta. Tras agotar retries: `failed` con mensaje "Fallo conexión SAT, vuelve a intentar con un rango de fechas menor."
**Reuso de `satRequestId` en retries (`sat_request_ids` JSONB map):** cada vez que `requestAndDownload` necesita un request al SAT, primero busca en `job.satRequestIds[kindKey]` (kindKey = `${requestType}-${tipoCfdi}-${dateFrom}-${dateTo}`). Si encuentra uno, llama `verifySatRequest` directo: si está `ready` salta polling y descarga; si `processing` continúa polling con MISMO id; si `failed/rejected` o lanza excepción, fallback a crear nuevo. Esto evita agotar la cuota de solicitudes activas del SAT en reintentos. `satRequestId` (singular) sigue actualizándose al último creado para backward-compat de queries que ya lo leen. Persistencia atómica vía `jsonb || jsonb` SQL en `persistSatRequestId()`.
**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.
### Integración Metabase
**`apps/api/src/services/metabase.service.ts`** auto-registra cada BD postgres de tenant nueva en Metabase para BI (queries/dashboards). Auth con session token cacheado 13 días via POST `/api/session` → header `X-Metabase-Session`. Funciones expuestas: `registerDatabase({nombre, dbName})` y `deleteDatabase(databaseName)` (busca por `details.dbname` o `name` contains, DELETE).
**Integrado en `tenants.service.ts`** en 3 puntos: `createTenant`, `addTenantToOwner`, `deleteTenant`. Todas son llamadas **fire-and-forget con `.catch()`** — un fallo de Metabase NO bloquea la creación/borrado del tenant.
**7 variables `.env`** (todas opcionales): `METABASE_URL`, `METABASE_USERNAME`, `METABASE_PASSWORD`, `METABASE_PG_HOST`, `METABASE_PG_PORT`, `METABASE_PG_USER`, `METABASE_PG_PASSWORD`. Sin `METABASE_PASSWORD` o `METABASE_PG_PASSWORD`, el service skip-ea cada llamada con un log `[METABASE] Skipping...` y el sistema funciona sin Metabase. **No expone routes HTTP propias** — es solo integración interna; los usuarios consultan dashboards directo en la URL del Metabase.
### 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.

196
README.md Normal file
View File

@@ -0,0 +1,196 @@
# 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
cp apps/api/.env.example apps/api/.env # rellenar con secrets reales
echo "NEXT_PUBLIC_API_URL=http://localhost:4000/api" > apps/web/.env.local
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
```
## Deploy a producción
### Pre-requisitos del servidor
- Ubuntu 22.04+ (probado en 24.04)
- Node 20+, pnpm 9+
- PostgreSQL 16+ (con extensión `pg_trgm` para búsquedas full-text)
- Nginx (proxy + SSL Let's Encrypt)
- PM2 (process manager) — `npm i -g pm2`
- Playwright dependencies para SAT scrapers (`npx playwright install-deps chromium`)
### Procedimiento de deploy fresco
```bash
# 1. Clonar
git clone https://github.com/Torch2196/Horux_despachos_NL.git /root/Horux
cd /root/Horux
# 2. Configurar env vars
cp apps/api/.env.example apps/api/.env
nano apps/api/.env # rellenar con secrets
echo "NEXT_PUBLIC_API_URL=https://api.horuxfin.com/api" > apps/web/.env.local
# 3. Instalar deps con versiones exactas
pnpm install --frozen-lockfile
# 4. BD central — schema y catálogos
pnpm db:generate
cd apps/api && npx prisma migrate deploy && cd ../..
pnpm db:seed
# 5. BD tenant — migraciones de cada tenant existente (idempotente)
pnpm --filter @horux/api db:migrate-tenants
# 6. Tenant admin global (Horux 360 — RFC HTS240708LJA)
pnpm --filter @horux/api bootstrap:admin-global
# ↑ imprime password temporal del admin en consola — guardarla
# 7. Lista negra del SAT (opcional, ~1MB de RFCs)
pnpm --filter @horux/api import:lista-negra
# 8. Build de producción
pnpm build
# 9. Configurar Nginx + SSL
sudo cp deploy/nginx/horux360.conf /etc/nginx/sites-available/
sudo ln -s /etc/nginx/sites-available/horux360.conf /etc/nginx/sites-enabled/
sudo certbot --nginx -d horuxfin.com -d api.horuxfin.com
sudo systemctl reload nginx
# 10. Levantar con PM2
pm2 start ecosystem.config.js
pm2 save
pm2 startup # autostart al boot
```
### Updates posteriores
```bash
cd /root/Horux
git pull
pnpm install --frozen-lockfile
pnpm db:generate
cd apps/api && npx prisma migrate deploy && cd ../..
pnpm --filter @horux/api db:migrate-tenants # si hay nuevas migraciones tenant
pnpm build
pm2 restart all
```
### Crons en producción
Los crons internos arrancan automáticamente en `NODE_ENV=production`:
- **SAT sync diario** — 03:00 AM (todos los planes)
- **SAT incremental** — 11:00, 15:00, 19:00 (solo Enterprise)
- **Subscription lifecycle** — 02:30 AM (apply pending changes, expire trials, purge)
- **Reporte semanal email** — Lunes 08:00 AM
- **Opinión cumplimiento** — Domingos 04:00 AM
- **CSF mensual** — Día 1 cada mes 04:00 AM
- **SAT retry cron** — cada hora (reintentos jobs `pending`)
- **Watchdog jobs SAT** — cada 2h (mata jobs zombies)
Si necesitás simular crons en dev: `ENABLE_CRONS_IN_DEV=1` en `apps/api/.env`.
## 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)**
---

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

@@ -0,0 +1,82 @@
# =============================================================================
# Horux 360 — API .env template
# =============================================================================
# Copiá este archivo a `.env` en producción y rellená cada variable.
# Las marcadas REQUIRED son obligatorias — la app no arranca sin ellas (Zod).
# Las opcionales pueden quedar comentadas; sus features se desactivan en runtime.
#
# Validación: apps/api/src/config/env.ts
# =============================================================================
# ----- Runtime ---------------------------------------------------------------
NODE_ENV=production # development | production | test
PORT=4000 # default 4000
# ----- BD central (Prisma) — REQUIRED ----------------------------------------
DATABASE_URL=postgresql://user:password@localhost:5432/horux360
# ----- JWT — REQUIRED --------------------------------------------------------
# Generar con: `openssl rand -hex 64`
JWT_SECRET= # min 32 chars
JWT_EXPIRES_IN=15m # access token TTL
JWT_REFRESH_EXPIRES_IN=7d # refresh token TTL
# ----- CORS / URLs -----------------------------------------------------------
CORS_ORIGIN=https://horuxfin.com # comma-separated si son varios
FRONTEND_URL=https://horuxfin.com # usado por MP back_url, emails, etc.
# ----- FIEL (cifrado de credenciales SAT) — REQUIRED -------------------------
# Generar con: `openssl rand -hex 64` (DISTINTA al JWT_SECRET — rotación independiente)
FIEL_ENCRYPTION_KEY= # min 32 chars
FIEL_STORAGE_PATH=/var/horux/fiel # path donde se guardan archivos FIEL temporales
# ----- MercadoPago (suscripciones self-serve) --------------------------------
MP_ACCESS_TOKEN= # producción: APP_USR-...
MP_ACCESS_TOKEN_SANDBOX= # opcional: TEST-... para dev local sin cobro
MP_USE_SANDBOX=false # true → usa MP_ACCESS_TOKEN_SANDBOX
MP_WEBHOOK_SECRET= # firma HMAC del webhook MP (Settings → Notifs)
MP_NOTIFICATION_URL=https://api.horuxfin.com/api/webhooks/mercadopago
# Solo dev/staging — override del payer_email cuando el owner = collector. Vacío en prod.
MP_TEST_PAYER_EMAIL=
# ----- SMTP (Nodemailer) — opcional pero recomendado -------------------------
SMTP_HOST=smtp.gmail.com # default
SMTP_PORT=587 # default
SMTP_USER= # cuenta Gmail Workspace
SMTP_PASS= # app password (NO la password de la cuenta)
SMTP_FROM=Horux360 <noreply@horuxfin.com>
# ----- Notificaciones admin --------------------------------------------------
ADMIN_EMAIL=carlos@horuxfin.com # destino de "nuevo cliente" + alertas internas
# ----- Facturapi (emisión CFDI) — opcional -----------------------------------
# Sin esto, los tenants no pueden emitir facturas, pero la app arranca.
FACTURAPI_USER_KEY= # sk_user_... (cuenta maestra Horux 360)
# ----- Cloudflare Tunnel (BYO-DB connector) — opcional -----------------------
CLOUDFLARE_API_TOKEN=
CLOUDFLARE_ACCOUNT_ID=
CLOUDFLARE_TUNNEL_DOMAIN=tunnel.horux.mx
# ----- KMS para cifrar conexiones BYO-DB y tokens connector ------------------
CONNECTOR_ENCRYPTION_KEY= # generar con `openssl rand -hex 64`
# ----- Metabase (auto-registro BDs tenant para BI) — opcional ----------------
# Sin METABASE_PASSWORD/PG_PASSWORD el service skipea silenciosamente.
METABASE_URL=
METABASE_USERNAME=
METABASE_PASSWORD=
METABASE_PG_HOST=
METABASE_PG_PORT=
METABASE_PG_USER=
METABASE_PG_PASSWORD=
# ----- Cron control en dev (opcional) ----------------------------------------
# ENABLE_CRONS_IN_DEV=1 # activa SAT sync, weekly emails, etc. en NODE_ENV=development
# ----- Watchdog SAT thresholds (opcional, defaults razonables) ---------------
# STALE_PENDING_HOURS=12 # marca pending como failed si nextRetryAt > N h atrás
# STALE_RUNNING_HOURS=4 # marca running como failed si startedAt > N h atrás
# ----- SAT Playwright headless toggle (debug temporal) ----------------------
# SAT_HEADLESS=false # solo dev — muestra browser para debug de scrapers

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,19 @@
-- AlterEnum
BEGIN;
CREATE TYPE "Plan_new" AS ENUM ('trial', 'custom', 'business_control', 'business_cloud', 'mi_empresa', 'mi_empresa_plus');
ALTER TABLE "tenants" ALTER COLUMN "plan" DROP DEFAULT;
ALTER TABLE "tenants" ALTER COLUMN "plan" TYPE "Plan_new" USING ("plan"::text::"Plan_new");
ALTER TABLE "subscriptions" ALTER COLUMN "plan" TYPE "Plan_new" USING ("plan"::text::"Plan_new");
ALTER TABLE "subscriptions" ALTER COLUMN "pending_plan" TYPE "Plan_new" USING ("pending_plan"::text::"Plan_new");
ALTER TABLE "subscriptions" ALTER COLUMN "upgrade_target_plan" TYPE "Plan_new" USING ("upgrade_target_plan"::text::"Plan_new");
ALTER TABLE "plan_prices" ALTER COLUMN "plan" TYPE "Plan_new" USING ("plan"::text::"Plan_new");
ALTER TYPE "Plan" RENAME TO "Plan_old";
ALTER TYPE "Plan_new" RENAME TO "Plan";
DROP TYPE "Plan_old";
ALTER TABLE "tenants" ALTER COLUMN "plan" SET DEFAULT 'trial';
COMMIT;
-- AlterTable
ALTER TABLE "tenants" DROP COLUMN "cfdi_limit",
DROP COLUMN "users_limit",
ALTER COLUMN "plan" SET DEFAULT 'trial';

View File

@@ -0,0 +1,55 @@
-- Step 1: Add new columns as nullable (preserva las 4 filas existentes con sus precios)
ALTER TABLE "despacho_plan_prices"
ADD COLUMN "nombre" VARCHAR(50),
ADD COLUMN "max_rfcs" INTEGER,
ADD COLUMN "max_users" INTEGER,
ADD COLUMN "db_mode" "DbMode",
ADD COLUMN "timbres_incluidos_mes" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "permite_servidor_backup" BOOLEAN NOT NULL DEFAULT false;
-- Step 2: Backfill limits para las 4 filas existentes desde el catálogo TS
UPDATE "despacho_plan_prices" SET
"nombre" = 'Mi Empresa',
"max_rfcs" = 1,
"max_users" = 3,
"timbres_incluidos_mes" = 50,
"db_mode" = 'MANAGED'
WHERE "plan" = 'mi_empresa';
UPDATE "despacho_plan_prices" SET
"nombre" = 'Mi Empresa +',
"max_rfcs" = 1,
"max_users" = 3,
"timbres_incluidos_mes" = 50,
"db_mode" = 'MANAGED'
WHERE "plan" = 'mi_empresa_plus';
UPDATE "despacho_plan_prices" SET
"nombre" = 'Business Control',
"max_rfcs" = 100,
"max_users" = -1,
"timbres_incluidos_mes" = 0,
"db_mode" = 'BYO',
"permite_servidor_backup" = true
WHERE "plan" = 'business_control';
UPDATE "despacho_plan_prices" SET
"nombre" = 'Enterprise',
"max_rfcs" = 100,
"max_users" = -1,
"timbres_incluidos_mes" = 0,
"db_mode" = 'BYO',
"permite_servidor_backup" = true
WHERE "plan" = 'business_cloud';
-- Step 3: Set NOT NULL después del backfill (las 4 filas ya están completas)
ALTER TABLE "despacho_plan_prices"
ALTER COLUMN "nombre" SET NOT NULL,
ALTER COLUMN "max_rfcs" SET NOT NULL,
ALTER COLUMN "max_users" SET NOT NULL,
ALTER COLUMN "db_mode" SET NOT NULL;
-- Step 4: Hacer firstYear y renewal nullable para soportar trial y custom (sin precio fijo)
ALTER TABLE "despacho_plan_prices"
ALTER COLUMN "first_year" DROP NOT NULL,
ALTER COLUMN "renewal" DROP NOT NULL;

View File

@@ -0,0 +1,5 @@
-- Drop tabla plan_catalogo (modelo huérfano nunca usado por código activo).
-- Las 2 filas que tenía estaban desincronizadas con el catálogo TS y nunca
-- se referenciaron desde código real. El catálogo despacho vive ahora en
-- `despacho_plan_prices` (extendida con limits en migración 20260430195000).
DROP TABLE "plan_catalogo";

View File

@@ -0,0 +1,10 @@
-- Add column with default false (no-op para filas existentes)
ALTER TABLE "despacho_plan_prices"
ADD COLUMN "permite_sat_incremental" BOOLEAN NOT NULL DEFAULT false;
-- Backfill: planes que SÍ deben tener incremental (3 syncs/día adicionales).
-- Mi Empresa + tiene API + Lolita IA y precio premium ($9k anual);
-- Business Control y Enterprise son los planes despacho con escala alta.
UPDATE "despacho_plan_prices"
SET "permite_sat_incremental" = true
WHERE "plan" IN ('mi_empresa_plus', 'business_control', 'business_cloud');

View File

@@ -0,0 +1,7 @@
-- Cache cifrada de la Live Secret Key de la organización Facturapi del tenant
-- central (Horux 360 admin que emite facturas de subscripción a clientes).
-- AES-256-GCM con derivación FIEL_ENCRYPTION_KEY — mismo patrón que FIEL.
ALTER TABLE "tenants"
ADD COLUMN "facturapi_org_key_enc" BYTEA,
ADD COLUMN "facturapi_org_key_iv" BYTEA,
ADD COLUMN "facturapi_org_key_tag" BYTEA;

View File

@@ -0,0 +1,5 @@
-- Drop tabla plan_prices (modelo legacy Horux 360 sin filas activas).
-- Catálogo se reemplazó por DespachoPlanPrice (despacho_plan_prices) en
-- migración 20260430195000_extend_despacho_plan_prices_with_limits.
-- Sin callers activos en código (verificado vía typecheck post-cleanup).
DROP TABLE "plan_prices";

View File

@@ -0,0 +1,5 @@
-- Tracking de aviso pre-vencimiento por suscripción. Permite que el cron diario
-- evite enviar dos emails del mismo bucket de días al mismo owner.
ALTER TABLE "subscriptions"
ADD COLUMN "last_reminder_day" INTEGER,
ADD COLUMN "last_reminder_sent_at" TIMESTAMP(3);

View File

@@ -0,0 +1,8 @@
-- Preferencias de auto-facturación de pagos de suscripción.
-- factPreferencia: 'publico_general' o 'mis_datos' (default: mis_datos)
-- factUsoCfdi: clave SAT del uso CFDI default (G03 = Gastos en general)
-- factRegimenPreferido: clave del régimen fiscal a usar cuando hay multi-régimen
ALTER TABLE "tenants"
ADD COLUMN "fact_preferencia" VARCHAR(20) DEFAULT 'mis_datos' NOT NULL,
ADD COLUMN "fact_uso_cfdi" VARCHAR(5) DEFAULT 'G03' NOT NULL,
ADD COLUMN "fact_regimen_preferido" VARCHAR(3);

View File

@@ -0,0 +1,4 @@
-- Onboarding auto-dismiss: 4 logins ó pasos completados, lo que pase primero.
ALTER TABLE "users"
ADD COLUMN "login_count" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "onboarding_dismissed_at" TIMESTAMP(3);

View File

@@ -0,0 +1,6 @@
-- Mapa { kindKey: requestId } para reusar requests del SAT en reintentos.
-- Hasta antes de este cambio, cada retry creaba nuevas solicitudes — agotaba
-- la cuota del SAT y abandonaba requests anteriores. Ahora el retry consulta
-- los requestIds previos antes de crear nuevos.
ALTER TABLE "sat_sync_jobs"
ADD COLUMN "sat_request_ids" JSONB NOT NULL DEFAULT '{}'::jsonb;

View File

@@ -0,0 +1,6 @@
-- Distingue extracciones tipo `initial` con rango personalizado (UI custom)
-- de bootstrap inicial puro. Política de retry distinta:
-- initial bootstrap → 3 retries a 6h, 12h, 24h
-- initial custom → 2 retries a 6h, 12h
ALTER TABLE "sat_sync_jobs"
ADD COLUMN "is_custom_range" BOOLEAN NOT NULL DEFAULT false;

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,760 @@
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(trial)
databaseName String @unique @map("database_name")
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")
/// Live Secret Key cifrada (AES-256-GCM, misma derivación FIEL_ENCRYPTION_KEY).
/// Cacheada tras primer PUT idempotente a /v2/organizations/{id}/apikeys/live.
facturapiOrgKeyEnc Bytes? @map("facturapi_org_key_enc")
facturapiOrgKeyIv Bytes? @map("facturapi_org_key_iv")
facturapiOrgKeyTag Bytes? @map("facturapi_org_key_tag")
// 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)
// Preferencias de auto-facturación de pagos de suscripción.
// Default: facturar con datos del cliente cuando hay CSF disponible.
// Si `factPreferencia='publico_general'` siempre va a XAXX010101000.
factPreferencia String @default("mis_datos") @map("fact_preferencia") @db.VarChar(20)
// Uso CFDI default cuando se factura con datos del cliente.
// G03 = Gastos en general (más común para SaaS).
factUsoCfdi String @default("G03") @map("fact_uso_cfdi") @db.VarChar(5)
// Si el tenant tiene múltiples regímenes activos, cuál usar para factura.
// Null = usar el primero activo (heurística por createdAt).
factRegimenPreferido String? @map("fact_regimen_preferido") @db.VarChar(3)
// === 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")
// Cuenta sesiones (login exitoso, NO refresh). Usado para auto-dismiss del
// onboarding tras N logins. Default 0 → users pre-rollout siguen viendo el
// onboarding hasta acumular logins post-deploy.
loginCount Int @default(0) @map("login_count")
// Marca explícita de que el onboarding ya no debe mostrarse. Se setea cuando
// el user completa todos los pasos requeridos o desde el endpoint de dismiss.
onboardingDismissedAt DateTime? @map("onboarding_dismissed_at")
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 {
trial
custom
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")
// Idempotencia del cron de aviso pre-vencimiento. Guarda el bucket de días
// que ya se notificó (7, 3, 1 ó 0) para no spamear al owner si el cron corre
// dos veces el mismo día. Se resetea cuando se renueva la suscripción o
// arranca un período nuevo.
lastReminderDay Int? @map("last_reminder_day")
lastReminderSentAt DateTime? @map("last_reminder_sent_at")
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")
}
/// Catálogo despacho — precios + limits editables por admin global.
/// Las `features` siguen viviendo en TS (`DESPACHO_PLANS` en `@horux/shared`)
/// porque están acopladas a UI/middleware y son contrato de código.
/// Incluye filas para `trial` y `custom` (sin precios — null).
model DespachoPlanPrice {
plan String @id // trial | custom | mi_empresa | mi_empresa_plus | business_control | business_cloud
nombre String @db.VarChar(50)
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")
/// Limits del plan. -1 = ilimitado donde aplique (maxUsers).
maxRfcs Int @map("max_rfcs")
maxUsers Int @map("max_users")
timbresIncluidosMes Int @default(0) @map("timbres_incluidos_mes")
dbMode DbMode @map("db_mode")
permiteServidorBackup Boolean @default(false) @map("permite_servidor_backup")
/// Habilita SAT incremental (3 syncs/día adicionales al daily). Mi Empresa +,
/// Business Control y Enterprise lo tienen activo por default; planes
/// inferiores se quedan solo con el daily de las 03:00.
permiteSatIncremental Boolean @default(false) @map("permite_sat_incremental")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("despacho_plan_prices")
}
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)
// Mapa { kindKey: requestId } de TODOS los requests creados durante el job.
// Permite que retries reusen requestIds previos en lugar de quemar cuota
// del SAT creando nuevos. kindKey = `${requestType}-${tipoCfdi}-${from}-${to}`.
satRequestIds Json @default("{}") @map("sat_request_ids")
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")
// True cuando el job es `initial` con rango de fechas personalizado por el
// usuario (botón UI). Cambia la política de retry: 2 intentos vs 3 del
// bootstrap puro. Daily/incremental ignoran este campo.
isCustomRange Boolean @default(false) @map("is_custom_range")
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")
}

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

@@ -0,0 +1,528 @@
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`);
// Tabla `plan_prices` (modelo PlanPrice) era el catálogo Horux 360 legacy.
// Tras eliminar los planes legacy (2026-04-30), no se siembran filas. Los
// precios despacho viven en `despacho_plan_prices` (modelo DespachoPlanPrice).
// Catálogo despacho — precios + limits. UPSERT idempotente: precios y limits
// se actualizan al re-correr seed (decisión: el seed es source of truth para
// valores iniciales; el admin puede sobreescribir vía UI y NO debe re-correr
// seed si no quiere perder ajustes manuales). Si quieres preservar edits del
// admin, cambiar `update` a `{}` y aplicar manualmente.
const DESPACHO_PLAN_CATALOGO = [
{ plan: 'trial', nombre: 'Prueba', monthly: null, firstYear: null, renewal: null, permiteMonthly: false, maxRfcs: 3, maxUsers: 1, timbresIncluidosMes: 0, dbMode: 'MANAGED' as const, permiteServidorBackup: false, permiteSatIncremental: false },
{ plan: 'custom', nombre: 'Custom', monthly: null, firstYear: null, renewal: null, permiteMonthly: false, maxRfcs: 1, maxUsers: 3, timbresIncluidosMes: 50, dbMode: 'MANAGED' as const, permiteServidorBackup: false, permiteSatIncremental: false },
{ plan: 'mi_empresa', nombre: 'Mi Empresa', monthly: 580, firstYear: 5800, renewal: 5800, permiteMonthly: true, maxRfcs: 1, maxUsers: 3, timbresIncluidosMes: 50, dbMode: 'MANAGED' as const, permiteServidorBackup: false, permiteSatIncremental: false },
{ plan: 'mi_empresa_plus', nombre: 'Mi Empresa +', monthly: 900, firstYear: 9000, renewal: 9000, permiteMonthly: true, maxRfcs: 1, maxUsers: 3, timbresIncluidosMes: 50, dbMode: 'MANAGED' as const, permiteServidorBackup: false, permiteSatIncremental: true },
{ plan: 'business_control', nombre: 'Business Control', monthly: null, firstYear: 25850, renewal: 25850, permiteMonthly: false, maxRfcs: 100, maxUsers: -1, timbresIncluidosMes: 0, dbMode: 'BYO' as const, permiteServidorBackup: true, permiteSatIncremental: true },
{ plan: 'business_cloud', nombre: 'Enterprise', monthly: null, firstYear: 43000, renewal: 43000, permiteMonthly: false, maxRfcs: 100, maxUsers: -1, timbresIncluidosMes: 0, dbMode: 'BYO' as const, permiteServidorBackup: true, permiteSatIncremental: true },
];
for (const p of DESPACHO_PLAN_CATALOGO) {
await prisma.despachoPlanPrice.upsert({
where: { plan: p.plan },
update: { ...p },
create: { ...p },
});
}
console.log(`${DESPACHO_PLAN_CATALOGO.length} planes despacho cargados (precios + limits)`);
// 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: 'mi_empresa_plus',
databaseName,
},
});
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. Filtramos por
// longitud porque trial_usages.rfc es varchar(13) y los tenants despacho usan
// slugs largos (DESPACHO_xxx) que no encajan — el padrón anti-abuso de trial
// solo aplica a RFCs SAT reales de personas/empresas, no a slugs.
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 AND LENGTH(rfc) <= 13
ON CONFLICT (rfc) DO NOTHING
`);
// Backfill de user_platform_roles: los owners del tenant HTS240708LJA se
// convierten automáticamente en platform_admin. Migrado a tenant_memberships
// tras F6 (User.tenantId/rolId eliminados). Idempotente.
await prisma.$executeRawUnsafe(`
INSERT INTO user_platform_roles (user_id, role, created_at)
SELECT tm.user_id, 'platform_admin'::"PlatformRole", NOW()
FROM tenant_memberships tm
JOIN tenants t ON tm.tenant_id = t.id
WHERE t.rfc = 'HTS240708LJA' AND tm.is_owner = true AND tm.active = true
ON CONFLICT (user_id, role) DO NOTHING
`);
// (Backfill de tenant_memberships eliminado — F6 ya migró todos los users
// legacy y los campos `User.tenantId` y `User.rolId` ya no existen. Los
// users nuevos se crean directamente con su membership.)
// 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 rolId = rolMap.get(userData.rolNombre)!;
const user = await prisma.user.upsert({
where: { email: userData.email },
update: {},
create: {
email: userData.email,
passwordHash,
nombre: userData.nombre,
lastTenantId: tenant.id,
},
});
// Membership al tenant demo (idempotente — F6 multi-tenant: la autorización
// vive en tenant_memberships, no en User.tenantId/rolId).
await prisma.tenantMembership.upsert({
where: { userId_tenantId: { userId: user.id, tenantId: tenant.id } },
update: { rolId, isOwner: userData.rolNombre === 'owner' || userData.rolNombre === 'cfo', active: true },
create: {
userId: user.id,
tenantId: tenant.id,
rolId,
isOwner: userData.rolNombre === 'owner' || userData.rolNombre === 'cfo',
active: true,
},
});
console.log(`✅ User created: ${user.email} (${userData.rolNombre})`);
}
// 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');
// Sin ON CONFLICT: las tablas se dropean en línea 342-352 antes de seed
// y los UUIDs son crypto.randomUUID() (probabilidad de colisión ~0).
// El UNIQUE en cfdis es funcional (LOWER(uuid)), no acepta ON CONFLICT
// por columna plana — ver migración 027_cfdi_uuid_unique_case_insensitive.
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)
`, [
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();
}
// (PlanCatalogo seed eliminado — el modelo se dropeó en migración
// 20260430200000_drop_plan_catalogo_orphan; el catálogo despacho vive en
// `despacho_plan_prices` y se siembra arriba en DESPACHO_PLAN_CATALOGO.)
// 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,37 @@
/**
* Aplica la migración 042 (ncs_emitidas + ncs_recibidas) a todos los tenants.
* Idempotente — ADD COLUMN IF NOT EXISTS no falla si ya existe.
*/
import { prisma, tenantDb } from '../src/config/database.js';
import { migrate } from '../src/config/tenant-migrations.js';
async function main() {
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true, rfc: true, databaseName: true, nombre: true },
orderBy: { rfc: 'asc' },
});
console.log(`Aplicando migraciones a ${tenants.length} tenants...\n`);
let ok = 0;
let failed = 0;
for (const t of tenants) {
try {
const pool = await tenantDb.getPool(t.id, t.databaseName);
await migrate(pool);
console.log(`${t.rfc.padEnd(25)} ${t.nombre}`);
ok++;
} catch (err: any) {
console.error(`${t.rfc.padEnd(25)} ${t.nombre}${err.message || err}`);
failed++;
}
}
console.log(`\nCompletado: ${ok} OK, ${failed} fallidos`);
await prisma.$disconnect();
process.exit(failed > 0 ? 1 : 0);
}
main().catch(e => { console.error(e); process.exit(1); });

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,78 @@
/**
* Backfill: re-parsea CFDIs tipo P emitidos vía Facturapi (source='facturapi')
* que tienen `monto_pago_mxn` o `fecha_pago_p` NULL, y popula esos campos
* desde el XML original. Bug histórico — el INSERT de facturapi.controller.ts
* no incluía los campos del complemento Pagos hasta el fix de hoy.
*
* Idempotente — solo actualiza si el XML tiene datos y el row tiene NULL.
*/
import { prisma, tenantDb } from '../src/config/database.js';
import { parseXml } from '../src/services/sat/sat-parser.service.js';
async function main() {
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true, rfc: true, nombre: true, databaseName: true },
});
let totalUpdated = 0;
let totalChecked = 0;
for (const t of tenants) {
const pool = await tenantDb.getPool(t.id, t.databaseName);
const { rows } = await pool.query(`
SELECT uuid, xml_original
FROM cfdis
WHERE source = 'facturapi'
AND tipo_comprobante = 'P'
AND xml_original IS NOT NULL
AND (monto_pago_mxn IS NULL OR fecha_pago_p IS NULL)
`);
if (rows.length === 0) continue;
console.log(`\n>>> ${t.rfc} (${t.nombre}): ${rows.length} P por backfill`);
for (const r of rows) {
totalChecked++;
const parsed = parseXml(r.xml_original, 'emitidos');
if (!parsed || parsed.tipoComprobante !== 'P') continue;
const fechaPagoP = parsed.fechaPagoP
? new Date(String(parsed.fechaPagoP).split('|')[0])
: null;
if (!parsed.montoPago && !fechaPagoP) {
console.log(` ${r.uuid}: XML sin datos de Pago — skip`);
continue;
}
await pool.query(`
UPDATE cfdis SET
monto_pago = COALESCE(monto_pago, $1),
monto_pago_mxn = COALESCE(monto_pago_mxn, $1),
fecha_pago_p = COALESCE(fecha_pago_p, $2),
iva_traslado_pago = COALESCE(iva_traslado_pago, $3),
iva_traslado_pago_mxn = COALESCE(iva_traslado_pago_mxn, $3),
iva_retencion_pago = COALESCE(iva_retencion_pago, $4),
iva_retencion_pago_mxn = COALESCE(iva_retencion_pago_mxn, $4),
ieps_traslado_pago = COALESCE(ieps_traslado_pago, $5),
ieps_traslado_pago_mxn = COALESCE(ieps_traslado_pago_mxn, $5)
WHERE uuid = $6
`, [
parsed.montoPago || 0,
fechaPagoP,
parsed.ivaTrasladoPago || 0,
parsed.ivaRetencionPago || 0,
parsed.iepsTrasladoPago || 0,
r.uuid,
]);
totalUpdated++;
console.log(` ${r.uuid}: ✓ monto=$${parsed.montoPago} fecha_pago=${fechaPagoP?.toISOString().slice(0, 10)} iva=$${parsed.ivaTrasladoPago}`);
}
}
console.log(`\n[Backfill] Completado: ${totalUpdated}/${totalChecked} actualizadas`);
await prisma.$disconnect();
}
main().catch(e => { console.error(e); 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,131 @@
/**
* 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 = 'custom' as const;
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} (sin cobro — admin global)`);
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,
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,103 @@
/**
* Inspecciona un CFDI específico para entender por qué el filtro de
* "Considerar activos" no lo captura. Imprime los campos relevantes y
* cualquier CFDI relacionado.
*/
import { prisma, tenantDb } from '../src/config/database.js';
async function main() {
const uuid = process.argv[2] || '8ec2eaf3-7879-11f0-81a8-8daae9822b10';
// Buscar en TODOS los tenants
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true, rfc: true, nombre: true, databaseName: true },
});
for (const t of tenants) {
const pool = await tenantDb.getPool(t.id, t.databaseName);
const { rows } = await pool.query(`
SELECT
uuid, type, tipo_comprobante, metodo_pago, forma_pago,
uso_cfdi, cfdi_tipo_relacion,
rfc_emisor, nombre_emisor, regimen_fiscal_emisor,
rfc_receptor, nombre_receptor, regimen_fiscal_receptor,
total_mxn, monto_pago_mxn,
fecha_emision, fecha_pago_p, status,
uuid_relacionado, cfdis_relacionados
FROM cfdis
WHERE uuid = $1
`, [uuid]);
if (rows.length === 0) continue;
console.log(`\n═══ Tenant: ${t.rfc} (${t.nombre}) ═══`);
const r = rows[0];
for (const [k, v] of Object.entries(r)) {
console.log(` ${k.padEnd(28)} ${v}`);
}
// Si hay uuid_relacionado o cfdis_relacionados, traer esos también
if (r.uuid_relacionado) {
const { rows: rel } = await pool.query(
`SELECT uuid, tipo_comprobante, uso_cfdi, total_mxn FROM cfdis WHERE LOWER(uuid) = LOWER($1)`,
[r.uuid_relacionado],
);
console.log(`\n Relacionado vía uuid_relacionado (${r.uuid_relacionado}):`);
console.log(rel[0] || '(no encontrado)');
}
if (r.cfdis_relacionados) {
const uuids = String(r.cfdis_relacionados).split('|').map(s => s.trim()).filter(Boolean);
console.log(`\n Relacionados vía cfdis_relacionados (${uuids.length}):`);
for (const u of uuids) {
const { rows: rel } = await pool.query(
`SELECT uuid, tipo_comprobante, uso_cfdi, total_mxn FROM cfdis WHERE LOWER(uuid) = LOWER($1)`,
[u],
);
console.log(` ${u}`, rel[0] || '(no encontrado)');
}
}
// Test del filtro: aplica activosExclusionNoAlias y verifica
const ACTIVOS_USOS = "('I01','I02','I03','I04','I05','I06','I07','I08')";
const test = await pool.query(`
SELECT
(tipo_comprobante = 'I' AND uso_cfdi IN ${ACTIVOS_USOS}) AS regla1_directo,
(tipo_comprobante = 'P' AND EXISTS (
SELECT 1 FROM cfdis i_act
WHERE LOWER(i_act.uuid) = LOWER(cfdis.uuid_relacionado)
AND i_act.tipo_comprobante = 'I'
AND i_act.uso_cfdi IN ${ACTIVOS_USOS}
)) AS regla2_p_paga_activo,
(tipo_comprobante = 'E' AND cfdis.cfdis_relacionados IS NOT NULL AND EXISTS (
SELECT 1 FROM cfdis r_act
WHERE LOWER(r_act.uuid) = ANY(string_to_array(LOWER(cfdis.cfdis_relacionados), '|'))
AND (r_act.tipo_comprobante = 'I' AND r_act.uso_cfdi IN ${ACTIVOS_USOS})
)) AS regla3_e_referencia_activo,
(tipo_comprobante = 'I' AND EXISTS (
SELECT 1 FROM cfdis i07_act
WHERE i07_act.tipo_comprobante = 'I'
AND i07_act.metodo_pago = 'PPD'
AND COALESCE(i07_act.cfdi_tipo_relacion, '') = '07'
AND i07_act.uso_cfdi IN ${ACTIVOS_USOS}
AND i07_act.status NOT IN ('Cancelado', '0')
AND i07_act.cfdis_relacionados IS NOT NULL
AND LOWER(cfdis.uuid) = ANY(string_to_array(LOWER(i07_act.cfdis_relacionados), '|'))
)) AS regla4_anticipo_activo
FROM cfdis
WHERE uuid = $1
`, [uuid]);
console.log(`\n Filtro activos:`);
console.log(` regla1 (I directo activo): ${test.rows[0].regla1_directo}`);
console.log(` regla2 (P paga I activo): ${test.rows[0].regla2_p_paga_activo}`);
console.log(` regla3 (E ref. I/P activo): ${test.rows[0].regla3_e_referencia_activo}`);
console.log(` regla4 (anticipo de I/07 act): ${test.rows[0].regla4_anticipo_activo}`);
const filtrado = test.rows[0].regla1_directo || test.rows[0].regla2_p_paga_activo || test.rows[0].regla3_e_referencia_activo || test.rows[0].regla4_anticipo_activo;
console.log(`${filtrado ? '🔴 FILTRADO (excluido del cálculo)' : '🟢 PASA (incluido en cálculo)'}`);
}
await prisma.$disconnect();
}
main().catch(e => { console.error(e); process.exit(1); });

View File

@@ -0,0 +1,154 @@
/**
* Diseca cómo el CFDI 8ec2eaf3-7879-11f0-81a8-8daae9822b10 (P de pago $295,100,
* uuid_relacionado → I de activo I03) se compensa en el cálculo de deducciones
* de Husberto en agosto 2025, con considerarActivos=true vs false.
*
* Reproduce las queries reales de calcularEgresosPorRegimen para mostrar
* el aporte categoría por categoría.
*/
import { prisma, tenantDb } from '../src/config/database.js';
async function main() {
const t = await prisma.tenant.findFirst({ where: { rfc: 'DESPACHO_MO3NI6U8_B9VGG' } });
if (!t) { console.log('Patito tenant not found'); return; }
const pool = await tenantDb.getPool(t.id, t.databaseName);
const RFC = 'TOAH680201RA2';
const FI = '2025-08-01';
const FF = '2025-08-31';
const TARGET_UUID = '8ec2eaf3-7879-11f0-81a8-8daae9822b10';
// ───────────────────────────────────────────────────────────────────────────
// 0) Datos del CFDI target
// ───────────────────────────────────────────────────────────────────────────
const { rows: [cfdi] } = await pool.query(`
SELECT uuid, tipo_comprobante, metodo_pago, forma_pago, uso_cfdi,
total_mxn, monto_pago_mxn, iva_traslado_pago_mxn, ieps_traslado_pago_mxn,
uuid_relacionado, regimen_fiscal_receptor, fecha_pago_p
FROM cfdis WHERE uuid = $1
`, [TARGET_UUID]);
console.log('═══ CFDI target ═══');
console.log(` UUID: ${cfdi.uuid}`);
console.log(` Tipo: ${cfdi.tipo_comprobante} (${cfdi.uso_cfdi || '?'})`);
console.log(` monto_pago_mxn: $${cfdi.monto_pago_mxn}`);
console.log(` iva_traslado_pago: $${cfdi.iva_traslado_pago_mxn ?? 'null'}`);
console.log(` ieps_traslado_pago: $${cfdi.ieps_traslado_pago_mxn ?? 'null'}`);
console.log(` forma_pago: ${cfdi.forma_pago ?? 'NULL'}`);
console.log(` uuid_relacionado: ${cfdi.uuid_relacionado}`);
console.log(` fecha_pago_p: ${cfdi.fecha_pago_p}`);
// Net pagado según fórmula de deducciones (P)
const monto = Number(cfdi.monto_pago_mxn || 0);
const ivaPago = Number(cfdi.iva_traslado_pago_mxn || 0);
const iepsPago = Number(cfdi.ieps_traslado_pago_mxn || 0);
const ivaClamped = Math.min(ivaPago, monto * 0.16);
const netoP = monto - ivaClamped - iepsPago;
console.log(`\n → Aporte neto a deducciones (formula P): $${netoP.toFixed(2)}`);
console.log(` monto - LEAST(iva, monto*0.16) - ieps = ${monto} - ${ivaClamped.toFixed(2)} - ${iepsPago}`);
// CFDI relacionado
const { rows: [rel] } = await pool.query(`
SELECT uuid, tipo_comprobante, metodo_pago, uso_cfdi, total_mxn, cfdi_tipo_relacion
FROM cfdis WHERE LOWER(uuid) = LOWER($1)
`, [cfdi.uuid_relacionado]);
if (rel) {
console.log(`\n uuid_relacionado apunta a:`);
console.log(` ${rel.uuid} | ${rel.tipo_comprobante} ${rel.metodo_pago} | uso_cfdi=${rel.uso_cfdi} | total=$${rel.total_mxn}`);
console.log(` cfdi_tipo_relacion: ${rel.cfdi_tipo_relacion ?? 'null'}`);
console.log(` ¿es activo? uso_cfdi=${rel.uso_cfdi}${['I01','I02','I03','I04','I05','I06','I07','I08'].includes(rel.uso_cfdi) ? '🔴 SÍ' : '🟢 NO'}`);
}
// ───────────────────────────────────────────────────────────────────────────
// 1) Predicado de filtros
// ───────────────────────────────────────────────────────────────────────────
console.log('\n═══ Evaluación de predicados sobre el CFDI target ═══');
const ACTIVOS = "('I01','I02','I03','I04','I05','I06','I07','I08')";
const t1 = await pool.query(`
SELECT
(COALESCE(forma_pago, '') = '01' AND COALESCE(monto_pago_mxn, 0) > 2000) AS no_deducible_efectivo,
(tipo_comprobante = 'P' AND EXISTS (
SELECT 1 FROM cfdis i_act
WHERE LOWER(i_act.uuid) = LOWER(cfdis.uuid_relacionado)
AND i_act.tipo_comprobante = 'I'
AND i_act.uso_cfdi IN ${ACTIVOS}
)) AS p_paga_activo
FROM cfdis WHERE uuid = $1
`, [TARGET_UUID]);
console.log(` no_deducible_efectivo (forma_pago=01 AND >2k): ${t1.rows[0].no_deducible_efectivo ? '🔴 TRUE' : '🟢 FALSE'}`);
console.log(` p_paga_activo (regla activos): ${t1.rows[0].p_paga_activo ? '🔴 TRUE' : '🟢 FALSE'}`);
// ───────────────────────────────────────────────────────────────────────────
// 2) Total de deducciones de Husberto en agosto, con/sin filtro de activos
// ───────────────────────────────────────────────────────────────────────────
console.log('\n═══ Suma TOTAL de deducciones (régimen 612 — Husberto, agosto 2025) ═══');
const sumar = async (extraSQL: string) => {
// I PUE
const { rows: [iPUE] } = await pool.query(`
SELECT COALESCE(SUM(COALESCE(total_mxn,0) - COALESCE(iva_traslado_mxn,0) - COALESCE(ieps_traslado_mxn,0) - COALESCE(impuestos_locales_trasladado_mxn,0)),0)::numeric(14,2) as monto
FROM cfdis
WHERE UPPER(rfc_receptor) = $1 AND tipo_comprobante = 'I' AND metodo_pago = 'PUE'
AND status NOT IN ('Cancelado','0')
AND fecha_emision >= $2::date AND fecha_emision < ($3::date + interval '1 day')
AND NOT (COALESCE(forma_pago,'') = '01' AND COALESCE(total_mxn,0) > 2000)
${extraSQL}
`, [RFC, FI, FF]);
// P
const { rows: [pCfdis] } = await pool.query(`
SELECT COALESCE(SUM(COALESCE(monto_pago_mxn,0) - LEAST(COALESCE(iva_traslado_pago_mxn,0), COALESCE(monto_pago_mxn,0)*0.16) - COALESCE(ieps_traslado_pago_mxn,0)),0)::numeric(14,2) as monto
FROM cfdis
WHERE UPPER(rfc_receptor) = $1 AND tipo_comprobante = 'P'
AND status NOT IN ('Cancelado','0')
AND fecha_pago_p >= $2::date AND fecha_pago_p < ($3::date + interval '1 day')
AND NOT (COALESCE(forma_pago,'') = '01' AND COALESCE(monto_pago_mxn,0) > 2000)
${extraSQL}
`, [RFC, FI, FF]);
return { iPUE: Number(iPUE.monto), p: Number(pCfdis.monto) };
};
const ACTIVOS_FILTER = `
AND NOT (tipo_comprobante = 'I' AND uso_cfdi IN ${ACTIVOS})
AND NOT (tipo_comprobante = 'P' AND EXISTS (
SELECT 1 FROM cfdis i_act
WHERE LOWER(i_act.uuid) = LOWER(cfdis.uuid_relacionado)
AND i_act.tipo_comprobante = 'I'
AND i_act.uso_cfdi IN ${ACTIVOS}
))
AND NOT (tipo_comprobante = 'E' AND cfdis.cfdis_relacionados IS NOT NULL AND EXISTS (
SELECT 1 FROM cfdis r_act
WHERE LOWER(r_act.uuid) = ANY(string_to_array(LOWER(cfdis.cfdis_relacionados), '|'))
AND (
(r_act.tipo_comprobante = 'I' AND r_act.uso_cfdi IN ${ACTIVOS})
OR (r_act.tipo_comprobante = 'P' AND EXISTS (
SELECT 1 FROM cfdis pi_act
WHERE LOWER(pi_act.uuid) = LOWER(r_act.uuid_relacionado)
AND pi_act.tipo_comprobante = 'I'
AND pi_act.uso_cfdi IN ${ACTIVOS}
))
)
))
`;
const ON = await sumar('');
const OFF = await sumar(ACTIVOS_FILTER);
console.log(`\n┌──────────────────┬────────────────┬────────────────┬────────────────┐`);
console.log(`│ Categoría │ Activos ON │ Activos OFF │ Diferencia │`);
console.log(`├──────────────────┼────────────────┼────────────────┼────────────────┤`);
console.log(`│ I PUE recibidas │ $${String(ON.iPUE.toFixed(2)).padStart(13)}$${String(OFF.iPUE.toFixed(2)).padStart(13)}$${String((ON.iPUE-OFF.iPUE).toFixed(2)).padStart(13)}`);
console.log(`│ P recibidos │ $${String(ON.p.toFixed(2)).padStart(13)}$${String(OFF.p.toFixed(2)).padStart(13)}$${String((ON.p-OFF.p).toFixed(2)).padStart(13)}`);
console.log(`├──────────────────┼────────────────┼────────────────┼────────────────┤`);
const totON = ON.iPUE + ON.p;
const totOFF = OFF.iPUE + OFF.p;
console.log(`│ TOTAL deducción │ $${String(totON.toFixed(2)).padStart(13)}$${String(totOFF.toFixed(2)).padStart(13)}$${String((totON-totOFF).toFixed(2)).padStart(13)}`);
console.log(`└──────────────────┴────────────────┴────────────────┴────────────────┘`);
console.log(`\n→ El CFDI ${TARGET_UUID.slice(0,8)} aporta $${netoP.toFixed(2)} a "P recibidos" cuando ON, $0 cuando OFF`);
console.log(` (Su exclusión por activos representa el ${((netoP / (ON.p - OFF.p)) * 100).toFixed(0)}% de la diferencia en P recibidos)`);
await prisma.$disconnect();
}
main().catch(e => { console.error(e); process.exit(1); });

View File

@@ -0,0 +1,111 @@
/**
* Reproduce el cálculo de deducciones para Husberto en agosto 2025 con
* considerarActivos=true vs false, y muestra la diferencia esperada.
* Apunta directo al SQL para descartar bugs de wire/cache/UI.
*/
import { prisma, tenantDb } from '../src/config/database.js';
async function main() {
const t = await prisma.tenant.findFirst({ where: { rfc: 'DESPACHO_MO3NI6U8_B9VGG' } });
if (!t) { console.log('Patito tenant not found'); return; }
const pool = await tenantDb.getPool(t.id, t.databaseName);
const RFC = 'TOAH680201RA2';
const FI = '2025-08-01';
const FF = '2025-08-31';
console.log(`Husberto (${RFC}), agosto 2025\n`);
// 0) Lista TODOS los P recibidos en el período (sin filtros)
const all = await pool.query(`
SELECT uuid, monto_pago_mxn, forma_pago, fecha_pago_p, uuid_relacionado
FROM cfdis
WHERE UPPER(rfc_receptor) = $1
AND tipo_comprobante = 'P'
AND status NOT IN ('Cancelado','0')
AND fecha_pago_p >= $2::date AND fecha_pago_p < ($3::date + interval '1 day')
ORDER BY monto_pago_mxn DESC
`, [RFC, FI, FF]);
console.log(`Total P recibidos en agosto 2025 (sin filtros): ${all.rows.length}`);
for (const r of all.rows) {
console.log(` ${r.uuid} | $${r.monto_pago_mxn} | forma_pago=${r.forma_pago} | uuid_rel=${r.uuid_relacionado}`);
}
console.log();
// 1) Suma de P recibidos sin filtro extra
const sinFiltro = await pool.query(`
SELECT COUNT(*)::int as n,
COALESCE(SUM(COALESCE(monto_pago_mxn,0)),0)::numeric(14,2) as bruto,
COALESCE(SUM(COALESCE(monto_pago_mxn,0) - LEAST(COALESCE(iva_traslado_pago_mxn,0), COALESCE(monto_pago_mxn,0)*0.16) - COALESCE(ieps_traslado_pago_mxn,0)),0)::numeric(14,2) as neto
FROM cfdis
WHERE UPPER(rfc_receptor) = $1
AND tipo_comprobante = 'P'
AND status NOT IN ('Cancelado','0')
AND fecha_pago_p >= $2::date AND fecha_pago_p < ($3::date + interval '1 day')
AND NOT (COALESCE(forma_pago, '') = '01' AND COALESCE(monto_pago_mxn, 0) > 2000)
`, [RFC, FI, FF]);
console.log(`P recibidos SIN filtro activos (CON filtro no-deducible): n=${sinFiltro.rows[0].n}, bruto=$${sinFiltro.rows[0].bruto}, neto=$${sinFiltro.rows[0].neto}`);
// 2) Misma query CON el filtro de activos (regla 2: P paga I de activo)
const ACTIVOS = "('I01','I02','I03','I04','I05','I06','I07','I08')";
const conFiltro = await pool.query(`
SELECT COUNT(*)::int as n,
COALESCE(SUM(COALESCE(monto_pago_mxn,0)),0)::numeric(14,2) as bruto,
COALESCE(SUM(COALESCE(monto_pago_mxn,0) - LEAST(COALESCE(iva_traslado_pago_mxn,0), COALESCE(monto_pago_mxn,0)*0.16) - COALESCE(ieps_traslado_pago_mxn,0)),0)::numeric(14,2) as neto
FROM cfdis
WHERE UPPER(rfc_receptor) = $1
AND tipo_comprobante = 'P'
AND status NOT IN ('Cancelado','0')
AND fecha_pago_p >= $2::date AND fecha_pago_p < ($3::date + interval '1 day')
AND NOT (COALESCE(forma_pago, '') = '01' AND COALESCE(monto_pago_mxn, 0) > 2000)
AND NOT (tipo_comprobante = 'I' AND uso_cfdi IN ${ACTIVOS})
AND NOT (tipo_comprobante = 'P' AND EXISTS (
SELECT 1 FROM cfdis i_act
WHERE LOWER(i_act.uuid) = LOWER(cfdis.uuid_relacionado)
AND i_act.tipo_comprobante = 'I'
AND i_act.uso_cfdi IN ${ACTIVOS}
))
AND NOT (tipo_comprobante = 'E' AND cfdis.cfdis_relacionados IS NOT NULL AND EXISTS (
SELECT 1 FROM cfdis r_act
WHERE LOWER(r_act.uuid) = ANY(string_to_array(LOWER(cfdis.cfdis_relacionados), '|'))
AND (
(r_act.tipo_comprobante = 'I' AND r_act.uso_cfdi IN ${ACTIVOS})
OR (r_act.tipo_comprobante = 'P' AND EXISTS (
SELECT 1 FROM cfdis pi_act
WHERE LOWER(pi_act.uuid) = LOWER(r_act.uuid_relacionado)
AND pi_act.tipo_comprobante = 'I'
AND pi_act.uso_cfdi IN ${ACTIVOS}
))
)
))
`, [RFC, FI, FF]);
console.log(`P recibidos CON filtro activos: n=${conFiltro.rows[0].n}, bruto=$${conFiltro.rows[0].bruto}, neto=$${conFiltro.rows[0].neto}`);
console.log(`\n→ Diferencia esperada al desactivar Considerar Activos:`);
console.log(` Bruto: $${(Number(sinFiltro.rows[0].bruto) - Number(conFiltro.rows[0].bruto)).toLocaleString('es-MX')}`);
console.log(` Neto: $${(Number(sinFiltro.rows[0].neto) - Number(conFiltro.rows[0].neto)).toLocaleString('es-MX')}`);
// 3) Lista los P específicos que se filtran
console.log(`\nDetalle de P que SE FILTRAN al desactivar activos:`);
const filtrados = await pool.query(`
SELECT uuid, monto_pago_mxn, iva_traslado_pago_mxn, uuid_relacionado, fecha_pago_p
FROM cfdis
WHERE UPPER(rfc_receptor) = $1
AND tipo_comprobante = 'P'
AND status NOT IN ('Cancelado','0')
AND fecha_pago_p >= $2::date AND fecha_pago_p < ($3::date + interval '1 day')
AND EXISTS (
SELECT 1 FROM cfdis i_act
WHERE LOWER(i_act.uuid) = LOWER(cfdis.uuid_relacionado)
AND i_act.tipo_comprobante = 'I'
AND i_act.uso_cfdi IN ${ACTIVOS}
)
`, [RFC, FI, FF]);
for (const r of filtrados.rows) {
console.log(` ${r.uuid} | $${r.monto_pago_mxn} → uuid_rel: ${r.uuid_relacionado}`);
}
await prisma.$disconnect();
}
main().catch(e => { console.error(e); process.exit(1); });

View File

@@ -0,0 +1,71 @@
/**
* Ejecuta los 3 nuevos buckets de drill-down (ncs_emitidas, ncs_recibidas,
* no_deducibles_efectivo) directamente contra una BD tenant para verificar
* que cada uno produce resultados distintos. Sirve para descartar hipótesis
* de bug en frontend / cache / dev server stale.
*/
import { prisma, tenantDb } from '../src/config/database.js';
async function main() {
const rfc = process.argv[2] || 'DESPACHO_MO7JE8BZ_VDOPR';
const fi = process.argv[3] || '2025-08-01';
const ff = process.argv[4] || '2025-08-31';
const t = await prisma.tenant.findFirst({ where: { rfc } });
if (!t) { console.log('Tenant', rfc, 'no encontrado'); return; }
const pool = await tenantDb.getPool(t.id, t.databaseName);
console.log(`Tenant: ${rfc} — Período: ${fi}${ff}\n`);
const buckets = [
{
name: 'ncs_emitidas',
sql: `
SELECT COUNT(*)::int as n, COALESCE(SUM(total_mxn),0)::numeric(14,2) as total
FROM cfdis
WHERE type = 'EMITIDO'
AND tipo_comprobante = 'E' 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_emisor IS NOT NULL
`,
},
{
name: 'ncs_recibidas',
sql: `
SELECT COUNT(*)::int as n, COALESCE(SUM(total_mxn),0)::numeric(14,2) as total
FROM cfdis
WHERE type = 'RECIBIDO'
AND tipo_comprobante = 'E' 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 IS NOT NULL
`,
},
{
name: 'no_deducibles_efectivo',
sql: `
SELECT COUNT(*)::int as n, COALESCE(SUM(total_mxn),0)::numeric(14,2) as total
FROM cfdis
WHERE type = 'RECIBIDO'
AND forma_pago = '01'
AND (
(tipo_comprobante = 'I' AND metodo_pago = 'PUE' AND COALESCE(total_mxn, 0) > 2000)
OR (tipo_comprobante = 'P' AND COALESCE(monto_pago_mxn, 0) > 2000)
)
AND status NOT IN ('Cancelado','0')
AND fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')
AND regimen_fiscal_receptor IS NOT NULL
`,
},
];
for (const b of buckets) {
const { rows: [r] } = await pool.query(b.sql, [fi, ff]);
console.log(`${b.name.padEnd(28)}${r.n} fila(s), total = $${Number(r.total).toLocaleString('es-MX')}`);
}
await prisma.$disconnect();
}
main().catch(e => { console.error(e); process.exit(1); });

View File

@@ -0,0 +1,169 @@
/**
* Diseca cómo se compensa la I PPD con cfdi_tipo_relacion='07' (aplicación
* de anticipo) en el cálculo de deducciones, evaluando:
* - Si NO entra al sumatorio normal (I PUE / P) por ser PPD
* - Si entra a la compensación I/07 PPD ↔ E del mismo mes
* - Si tiene cfdis_relacionados (qué referencia hacia atrás)
* - Si es referenciada por algún CFDI hacia adelante (P, E, otra I)
* - Cómo afecta con considerarActivos ON vs OFF
*/
import { prisma, tenantDb } from '../src/config/database.js';
async function main() {
const t = await prisma.tenant.findFirst({ where: { rfc: 'DESPACHO_MO3NI6U8_B9VGG' } });
if (!t) { console.log('Patito tenant not found'); return; }
const pool = await tenantDb.getPool(t.id, t.databaseName);
const TARGET = '5c874749-748f-11f0-96b1-2b9310891836';
const RFC = 'TOAH680201RA2';
// ───────────────────────────────────────────────────────────────────────────
// 0) Datos del CFDI
// ───────────────────────────────────────────────────────────────────────────
const { rows: [c] } = await pool.query(`
SELECT uuid, type, tipo_comprobante, metodo_pago, forma_pago, uso_cfdi,
cfdi_tipo_relacion, cfdis_relacionados,
total_mxn, iva_traslado_mxn, ieps_traslado_mxn,
rfc_emisor, nombre_emisor, regimen_fiscal_emisor,
rfc_receptor, nombre_receptor, regimen_fiscal_receptor,
fecha_emision, fecha_pago_p, status,
saldo_pendiente_mxn
FROM cfdis WHERE LOWER(uuid) = LOWER($1)
`, [TARGET]);
console.log('═══ CFDI ═══');
for (const [k, v] of Object.entries(c)) {
console.log(` ${k.padEnd(28)} ${v}`);
}
// ───────────────────────────────────────────────────────────────────────────
// 1) ¿Hace referencia hacia atrás (vía cfdis_relacionados)?
// ───────────────────────────────────────────────────────────────────────────
console.log('\n═══ Referencias hacia atrás (cfdis_relacionados) ═══');
if (!c.cfdis_relacionados) {
console.log(' (ninguna — cfdis_relacionados es NULL)');
} else {
const uuids = String(c.cfdis_relacionados).split('|').map((s: string) => s.trim()).filter(Boolean);
for (const u of uuids) {
const { rows: [rel] } = await pool.query(`
SELECT uuid, tipo_comprobante, metodo_pago, total_mxn, fecha_emision, cfdi_tipo_relacion
FROM cfdis WHERE LOWER(uuid) = LOWER($1)
`, [u]);
console.log(` ${u}`, rel ?? '(no encontrado)');
}
}
// ───────────────────────────────────────────────────────────────────────────
// 2) ¿Es referenciada hacia adelante? (P que la pague, E que la cancele, otra I tipo_relacion=07 que sustituya)
// ───────────────────────────────────────────────────────────────────────────
console.log('\n═══ CFDIs que referencian a este (hacia adelante) ═══');
// P que la pagan vía uuid_relacionado
const { rows: pagos } = await pool.query(`
SELECT uuid, monto_pago_mxn, fecha_pago_p
FROM cfdis
WHERE tipo_comprobante = 'P'
AND LOWER(uuid_relacionado) = LOWER($1)
AND status NOT IN ('Cancelado','0')
ORDER BY fecha_pago_p
`, [TARGET]);
console.log(` P que la pagan (${pagos.length}):`);
let totalPagado = 0;
for (const p of pagos) {
totalPagado += Number(p.monto_pago_mxn || 0);
console.log(` ${p.uuid} | $${p.monto_pago_mxn} | ${p.fecha_pago_p}`);
}
console.log(` → Total pagado vía P: $${totalPagado.toLocaleString('es-MX')}`);
console.log(` Total CFDI original: $${c.total_mxn}`);
console.log(` Saldo pendiente: $${c.saldo_pendiente_mxn ?? '?'}`);
// E que la cancelan vía cfdis_relacionados
const { rows: ecanc } = await pool.query(`
SELECT uuid, tipo_comprobante, metodo_pago, total_mxn, cfdi_tipo_relacion, fecha_emision
FROM cfdis
WHERE tipo_comprobante = 'E'
AND cfdis_relacionados IS NOT NULL
AND LOWER($1) = ANY(string_to_array(LOWER(cfdis_relacionados), '|'))
AND status NOT IN ('Cancelado','0')
`, [TARGET]);
console.log(`\n E que la referencian (${ecanc.length}):`);
for (const e of ecanc) {
console.log(` ${e.uuid} | total=$${e.total_mxn} | tipo_rel=${e.cfdi_tipo_relacion} | ${e.fecha_emision}`);
}
// ───────────────────────────────────────────────────────────────────────────
// 3) Compensación I/07 PPD ↔ E lado RECEPTOR (mismo mes)
// ───────────────────────────────────────────────────────────────────────────
console.log('\n═══ ¿Entra en compensación I/07 PPD ↔ E (mes/año del CFDI)? ═══');
const fecha = new Date(c.fecha_emision);
const mesAnio = `${fecha.getFullYear()}-${String(fecha.getMonth() + 1).padStart(2, '0')}`;
console.log(` CFDI mes/año: ${mesAnio}`);
console.log(` cfdi_tipo_relacion='07': ${c.cfdi_tipo_relacion === '07' ? '✓ SÍ' : '✗ NO'}`);
console.log(` metodo_pago='PPD': ${c.metodo_pago === 'PPD' ? '✓ SÍ' : '✗ NO'}`);
if (c.cfdi_tipo_relacion === '07' && c.metodo_pago === 'PPD') {
// Calcular el aporte que tendría a la compensación (suma de E del mismo mes)
const { rows: comp } = await pool.query(`
SELECT
COALESCE(SUM(
COALESCE(e.total_mxn, 0)
- COALESCE(e.iva_traslado_mxn, 0)
- COALESCE(e.ieps_traslado_mxn, 0)
- COALESCE(e.impuestos_locales_trasladado_mxn, 0)
), 0)::numeric(14,2) AS aporte
FROM cfdis e
WHERE e.tipo_comprobante = 'E'
AND e.metodo_pago = 'PUE'
AND e.status NOT IN ('Cancelado','0')
AND UPPER(e.rfc_receptor) = $1
AND LOWER($2) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
AND date_trunc('month', e.fecha_emision) = date_trunc('month', $3::timestamp)
`, [RFC, TARGET, c.fecha_emision]);
console.log(`\n Aporte a la compensación (suma E mismo mes): $${comp[0].aporte}`);
if (Number(comp[0].aporte) > 0) {
console.log(` → SÍ entra en compensación`);
} else {
console.log(` → NO entra (no hay E en mismo mes que la referencien)`);
}
}
// ───────────────────────────────────────────────────────────────────────────
// 4) ¿Aparece en el cálculo "I PUE recibidas" o "P recibidos"?
// ───────────────────────────────────────────────────────────────────────────
console.log('\n═══ ¿Aparece en el cálculo directo? ═══');
console.log(` I PUE recibidas requiere: tipo_comprobante='I' AND metodo_pago='PUE'`);
console.log(` Este CFDI: tipo_comprobante='${c.tipo_comprobante}', metodo_pago='${c.metodo_pago}'`);
console.log(`${c.tipo_comprobante === 'I' && c.metodo_pago === 'PUE' ? '✓ SÍ entra' : '✗ NO entra'} (es ${c.tipo_comprobante} ${c.metodo_pago})`);
// ───────────────────────────────────────────────────────────────────────────
// 5) Predicado de filtro de activos
// ───────────────────────────────────────────────────────────────────────────
console.log('\n═══ Predicado de filtro de activos sobre este CFDI ═══');
const ACTIVOS = "('I01','I02','I03','I04','I05','I06','I07','I08')";
const t1 = await pool.query(`
SELECT
(tipo_comprobante = 'I' AND uso_cfdi IN ${ACTIVOS}) AS regla1_directo,
(tipo_comprobante = 'P' AND EXISTS (
SELECT 1 FROM cfdis i_act
WHERE LOWER(i_act.uuid) = LOWER(cfdis.uuid_relacionado)
AND i_act.tipo_comprobante = 'I'
AND i_act.uso_cfdi IN ${ACTIVOS}
)) AS regla2,
(tipo_comprobante = 'E' AND cfdis.cfdis_relacionados IS NOT NULL AND EXISTS (
SELECT 1 FROM cfdis r_act
WHERE LOWER(r_act.uuid) = ANY(string_to_array(LOWER(cfdis.cfdis_relacionados), '|'))
AND (r_act.tipo_comprobante = 'I' AND r_act.uso_cfdi IN ${ACTIVOS})
)) AS regla3
FROM cfdis WHERE LOWER(uuid) = LOWER($1)
`, [TARGET]);
console.log(` regla1 (I directo activo): ${t1.rows[0].regla1_directo ? '🔴 TRUE' : '🟢 FALSE'}`);
console.log(` regla2 (P paga I activo): ${t1.rows[0].regla2 ? '🔴 TRUE' : '🟢 FALSE'}`);
console.log(` regla3 (E ref. I/P activo): ${t1.rows[0].regla3 ? '🔴 TRUE' : '🟢 FALSE'}`);
const filtrado = t1.rows[0].regla1_directo || t1.rows[0].regla2 || t1.rows[0].regla3;
console.log(` → Si "Considerar activos" OFF → ${filtrado ? '🔴 EXCLUIDO' : '🟢 PASA'}`);
await prisma.$disconnect();
}
main().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,34 @@
import { prisma, tenantDb } from '../src/config/database.js';
async function main() {
const t = await prisma.tenant.findFirst({ where: { rfc: 'DESPACHO_MO7JE8BZ_VDOPR' } });
if (!t) { console.log('Zorro tenant no encontrado'); return; }
const pool = await tenantDb.getPool(t.id, t.databaseName);
console.log('--- E PUE EMITIDAS (cualquier fecha) ---');
const emit = await pool.query(`
SELECT EXTRACT(year FROM fecha_emision) as anio,
regimen_fiscal_emisor, count(*) as n,
SUM(total_mxn)::numeric(14,2) as total
FROM cfdis
WHERE tipo_comprobante = 'E' AND metodo_pago = 'PUE'
AND status NOT IN ('Cancelado','0')
GROUP BY 1, 2 ORDER BY 1 DESC, 2
`);
console.table(emit.rows);
console.log('\n--- E PUE RECIBIDAS (cualquier fecha) ---');
const rec = await pool.query(`
SELECT EXTRACT(year FROM fecha_emision) as anio,
regimen_fiscal_receptor, count(*) as n,
SUM(total_mxn)::numeric(14,2) as total
FROM cfdis
WHERE tipo_comprobante = 'E' AND metodo_pago = 'PUE'
AND status NOT IN ('Cancelado','0')
GROUP BY 1, 2 ORDER BY 1 DESC, 2
`);
console.table(rec.rows);
await prisma.$disconnect();
}
main().catch(e => { console.error(e); process.exit(1); });

View File

@@ -0,0 +1,67 @@
/**
* Diseca 2 complementos P de Horux 360 que el usuario espera ver en mayo
* pero no aparecen. Verifica fecha_emision vs fecha_pago_p para entender
* en qué mes los está sumando el cálculo.
*/
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, nombre: true, databaseName: true },
});
const UUIDS = [
'CFACB97E-5426-48D4-A3B9-06B5D160F307',
'384CF943-EFB0-475A-B6B6-240E96088B37',
];
// Loop por todos los tenants
for (const t of tenants) {
const pool = await tenantDb.getPool(t.id, t.databaseName);
console.log(`\n>>> Tenant: ${t.rfc} (${t.nombre}) <<<`);
for (const uuid of UUIDS) {
const { rows: [c] } = await pool.query(`
SELECT uuid, type, tipo_comprobante, metodo_pago, forma_pago, uso_cfdi,
cfdi_tipo_relacion,
total_mxn, monto_pago_mxn, iva_traslado_pago_mxn,
rfc_emisor, regimen_fiscal_emisor,
rfc_receptor, regimen_fiscal_receptor,
fecha_emision, fecha_pago_p, status
FROM cfdis WHERE LOWER(uuid) = LOWER($1)
`, [uuid]);
console.log(`\n═══ CFDI ${uuid} ═══`);
if (!c) { console.log(' (NO ENCONTRADO en BD de Horux 360)'); continue; }
console.log(` Tipo: ${c.tipo_comprobante} ${c.metodo_pago || ''}`);
console.log(` Status: ${c.status}`);
console.log(` type (lado): ${c.type}`);
console.log(` rfc_emisor: ${c.rfc_emisor} (régimen ${c.regimen_fiscal_emisor})`);
console.log(` rfc_receptor: ${c.rfc_receptor} (régimen ${c.regimen_fiscal_receptor})`);
console.log(` total_mxn: $${c.total_mxn}`);
console.log(` monto_pago_mxn: $${c.monto_pago_mxn}`);
console.log(` iva_traslado_pago: $${c.iva_traslado_pago_mxn}`);
console.log(` ──────────────────────────────────────`);
console.log(` fecha_emision: ${c.fecha_emision}`);
console.log(` fecha_pago_p: ${c.fecha_pago_p}`);
console.log(` ──────────────────────────────────────`);
// Análisis: en qué mes "cae" según el cálculo de ingresos (Grupo 1 — FR_PAGO usa fecha_pago_p)
const fecPago = c.fecha_pago_p ? new Date(c.fecha_pago_p) : null;
const fecEmi = c.fecha_emision ? new Date(c.fecha_emision) : null;
if (fecPago) {
console.log(` En cálculo Ingresos: APARECE EN ${fecPago.getFullYear()}-${String(fecPago.getMonth() + 1).padStart(2, '0')}`);
console.log(` (filtro: fecha_pago_p)`);
}
if (fecEmi) {
console.log(` En filtros UI fecha: se EMITIÓ en ${fecEmi.getFullYear()}-${String(fecEmi.getMonth() + 1).padStart(2, '0')}`);
}
}
} // close tenant loop
await prisma.$disconnect();
}
main().catch(e => { console.error(e); 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); });

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