Update: nueva version Horux Despachos
This commit is contained in:
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
53
.gitignore
vendored
Normal file
53
.gitignore
vendored
Normal 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/
|
||||
504
CLAUDE.md
Normal file
504
CLAUDE.md
Normal file
@@ -0,0 +1,504 @@
|
||||
# Horux360 - Contexto para Claude
|
||||
|
||||
## Qué es esto
|
||||
|
||||
Plataforma SaaS de análisis financiero y gestión fiscal para empresas mexicanas. Maneja CFDIs (facturas electrónicas del SAT), cálculos de IVA/ISR, reportes financieros, alertas de cumplimiento fiscal, conciliación bancaria, sincronización directa con el SAT y emisión de facturas vía Facturapi.
|
||||
|
||||
**Producción:** https://horuxfin.com
|
||||
**Autor:** Carlos e Ivan (Horux 360) (RFC: HTS240708LJA)
|
||||
|
||||
---
|
||||
|
||||
## Arquitectura en 30 segundos
|
||||
|
||||
```
|
||||
Monorepo (pnpm + Turborepo)
|
||||
├── apps/api → Express + TypeScript (tsx, puerto 4000)
|
||||
├── apps/web → Next.js 14 + App Router (puerto 3000)
|
||||
└── packages/shared → Tipos, constantes e interfaces compartidas
|
||||
```
|
||||
|
||||
**Multi-tenant database-per-tenant:**
|
||||
- `horux360` → BD central (Prisma): tenants, users, roles, subscriptions, catálogos fiscales, catálogos SAT, timbres Facturapi
|
||||
- `horux_<rfc>` → BD por tenant (pg Pool + raw SQL): cfdis, cfdi_conceptos, rfcs, bancos, conciliaciones, alertas, recordatorios, contribuyentes, carteras, obligaciones, tareas, papelería
|
||||
|
||||
**Dos capas de acceso a datos** (esto es intencional):
|
||||
- **Prisma** para la BD central (ORM, migraciones, tipos generados)
|
||||
- **pg Pool directo** para BDs de tenant (queries SQL complejos de cálculos fiscales)
|
||||
|
||||
---
|
||||
|
||||
## Archivos clave por área
|
||||
|
||||
### Configuración
|
||||
| Archivo | Qué hace |
|
||||
|---------|----------|
|
||||
| `apps/api/src/config/env.ts` | Variables de entorno validadas con Zod (incluye FACTURAPI_USER_KEY) |
|
||||
| `apps/api/src/config/database.ts` | Prisma client + `TenantConnectionManager` (pools, provisioning, lazy migration) |
|
||||
| `apps/api/src/config/tenant-migrations.ts` | `migrate()`, `migrateAll()`, `getMigrationFiles()` — sistema de migraciones SQL para BDs tenant |
|
||||
| `apps/api/src/migrations/tenant/*.sql` | Archivos SQL numerados (`001_initial_schema.sql`, etc.) |
|
||||
| `apps/api/.env` | Credenciales locales (DB, JWT, SMTP, MercadoPago, FIEL, Facturapi) |
|
||||
| `apps/api/prisma/schema.prisma` | Schema de la BD central |
|
||||
|
||||
### Autenticación y seguridad
|
||||
| Archivo | Qué hace |
|
||||
|---------|----------|
|
||||
| `src/middlewares/auth.middleware.ts` | JWT verify + `authorize(...roles)` |
|
||||
| `src/middlewares/tenant.middleware.ts` | Resuelve pool de BD del tenant, cache 5min, `X-View-Tenant` para admin global |
|
||||
| `src/middlewares/plan-limits.middleware.ts` | Verifica suscripción, limita CFDIs, read-only si inactiva |
|
||||
| `src/middlewares/feature-gate.middleware.ts` | `requireFeature('reportes')` por plan |
|
||||
| `src/utils/global-admin.ts` | Admin global = tenant con RFC `HTS240708LJA` |
|
||||
| `packages/shared/src/constants/roles.ts` | `GLOBAL_ADMIN_RFC` + `isGlobalAdminRfc()` compartido frontend/backend |
|
||||
|
||||
### Roles (tabla `roles` en BD central)
|
||||
| id | nombre | Label UI | Acceso |
|
||||
|----|--------|----------|--------|
|
||||
| 1 | `owner` | Dueño | Todo + gestión usuarios + configuración + reportes |
|
||||
| 7 | `cfo` | CFO | Todo (mismo nivel que owner) |
|
||||
| 2 | `contador` | Contador | Dashboard, CFDI, Impuestos, Calendario, Alertas, Conciliación, Facturación (puede completar alertas y crear recordatorios) |
|
||||
| 8 | `auxiliar` | Auxiliar | Mismos permisos que contador |
|
||||
| 3 | `visor` | Visor | CFDI, Impuestos, Calendario, Alertas, Conciliación (solo lectura) |
|
||||
|
||||
**"Admin global" (platform staff) — tabla `user_platform_roles`:** staff interno de Horux 360 con acceso transversal. 5 roles en enum `PlatformRole`:
|
||||
- `platform_admin` — Todo (gestión staff, precios, clientes, facturas)
|
||||
- `platform_ti` — Mismos permisos que admin (equipo TI, trazabilidad distinta en audit)
|
||||
- `platform_support` — Ver tenants, tickets; NO facturación/precios
|
||||
- `platform_sales` — Crear/editar clientes; NO precios
|
||||
- `platform_finance` — Pagos, facturas manuales, editar precios
|
||||
|
||||
`platform_admin` y `platform_ti` son **supersets** (implican todos los demás). Se checa vía helper: `hasPlatformRole(userId, role)`, `canManageTenants()`, `canEditPrices()`, `canEmitInvoicesManual()`, `isPlatformStaff()` en `apps/api/src/utils/platform-admin.ts`. JWT incluye `platformRoles[]` al login.
|
||||
|
||||
**`isGlobalAdmin()` compat:** `apps/api/src/utils/global-admin.ts` es shim que re-exporta de `platform-admin.ts` — resuelve "admin global" vía tabla (busca rol superset), fallback a RFC `HTS240708LJA` si tabla vacía. El concepto UX "admin global" se preserva; la implementación migró de hardcode a tabla.
|
||||
|
||||
**`isGlobalAdminRfc(rfc, role, platformRoles?)` en shared:** tercer parámetro opcional — prioriza `platformRoles` si existe, fallback al RFC check.
|
||||
|
||||
**UI `/admin/staff`:** gestión de roles (solo platform_admin/platform_ti). Protección "último superset": no te puedes quitar si serías el único con acceso transversal. Ver `docs/plans/2026-04-14-platform-admin-roles.md` — sección "Implementación ejecutada".
|
||||
|
||||
### Planes (5 planes)
|
||||
| Plan | CFDIs | Usuarios | Features |
|
||||
|------|-------|----------|----------|
|
||||
| starter | 0 | 1 | dashboard, cfdi_basic, iva_isr |
|
||||
| business | 50 | 3 | + reportes, alertas, calendario, conciliacion, forecasting, xml_sat, documentos |
|
||||
| business_ia | 50 | 3 | Mismas que business + Lolita (feature `ia_lolita`, agente IA fiscal) |
|
||||
| custom | 50 | 3 | Mismas que business_ia, precio variable por tenant |
|
||||
| enterprise | 100 | ilimitado | + api |
|
||||
|
||||
Cada empresa requiere su propia suscripción. `multi_empresa` no existe como feature.
|
||||
|
||||
### Suscripciones (self-serve con MercadoPago)
|
||||
|
||||
**Precios:** tabla `plan_prices` en BD central — 8 filas (4 planes × monthly/annual). Custom no se guarda aquí (el admin fija monto por tenant). Editables desde `/configuracion/suscripcion` (admin global) o SQL directo. Los cambios aplican solo a suscripciones nuevas; vigentes conservan su amount.
|
||||
|
||||
**Estados de `Subscription.status`:** `trial`, `trial_converted`, `trial_expired`, `pending`, `authorized`, `paused`, `cancelled`.
|
||||
|
||||
**Flujos self-serve (endpoints `/api/subscriptions/me/*`):**
|
||||
- `trial` — 30 días gratis sin tarjeta. Una sola vez **por RFC** (padrón persistente `trial_usages` — sobrevive borrado/recreación del tenant, bloquea abuso).
|
||||
- `subscribe` — crea preapproval MP con `auto_recurring` mensual (`months/1`) o anual (`months/12`).
|
||||
- `change` — downgrades y cambios de frecuencia van a `pendingPlan`/`pendingEffectiveAt = currentPeriodEnd`.
|
||||
- `upgrade` — plan más caro con misma frecuencia cobra prorateo inmediato vía MP Preference + `updatePreapprovalAmount` al recibir webhook. `external_reference = 'proration:${tenantId}:${subscriptionId}'` es el marcador que el webhook usa.
|
||||
- `cancel` — `status=cancelled` + `cancelPreapproval` en MP. Middleware respeta `currentPeriodEnd`.
|
||||
- `reactivate` — revive suscripción cancelada dentro del período pagado. Crea preapproval nuevo con `start_date=currentPeriodEnd` (sin doble cobro). Limpia pending/upgrade residuales.
|
||||
|
||||
**Cron `applyPendingChanges` + `expireTrials`:** 2:30 AM daily (`30 2 * * *`) — aplica cambios programados y transiciona trials vencidos.
|
||||
|
||||
**Auth pattern:** endpoints `/subscriptions/:tenantId`, `/:tenantId/payments`, `/:tenantId/generate-link` usan `requireOwnTenantOrGlobalAdmin` (el dueño puede consultar su propia sub, admin global puede ver cualquiera). `mark-paid`, `listar todas`, `editar precios` solo admin global.
|
||||
|
||||
**Webhook `external_reference` routing:**
|
||||
- `proration:*` → `applyApprovedUpgrade` (upgrade payment confirmado)
|
||||
- `${tenantId}` UUID → flujo recurrente
|
||||
|
||||
**Plan Custom:** sólo lo activa el admin global al provisionar tenant. El self-serve self-serve lo rechaza con error explícito.
|
||||
|
||||
**Auto-facturación de pagos (`invoicing.service.ts`):** cada webhook `payment.approved` dispara `emitInvoiceIfApplicable(payment.id)`. Emite CFDI al público en general vía Facturapi (org de Horux 360, RESICO PM, clave prod/serv `81112502`, `use: S01`). **El primer pago aprobado de cada tenant se skip-ea** (lo factura manual el admin para capturar datos fiscales); los subsecuentes van auto. Idempotente por `Payment.facturapiInvoiceId`. Fail-soft (error en Facturapi → log + invoice null + webhook sigue retornando 200). Ver `docs/plans/2026-04-13-auto-invoicing-mp-payments.md`.
|
||||
|
||||
Ver `docs/plans/2026-04-13-subscriptions-self-serve.md` para diseño completo y flujos.
|
||||
|
||||
**Schema tenant — single source of truth:** las migraciones SQL en `apps/api/src/migrations/tenant/*.sql` son el único lugar donde se declara el schema de las BDs tenant. El seed (`prisma/seed.ts`) ya NO hardcodea CREATE TABLE — llama a `migrate()` del runner. Añadir una migración = crear `NNN_descripcion.sql`. Se aplica eager via `pnpm db:migrate-tenants` y lazy en `getPool()`.
|
||||
|
||||
**Audit log (`utils/audit.ts`):** helper `auditLog()`/`auditFromReq()` fire-and-forget que escribe a tabla `audit_log` (BD central). Instrumentado en 12+ eventos críticos (login, subscribe, cancel, plan change, price edit, invoice auto, password reset, etc.). UI admin global en `/admin/audit-log` con filtros + expand JSON metadata. NUNCA debe romper la acción principal — cualquier fallo al escribir se logea en consola pero no re-lanza. Ver `docs/plans/2026-04-14-audit-log.md`.
|
||||
|
||||
**Recuperación de contraseña:** `/login` tiene link "¿Olvidaste tu contraseña?" → `/forgot-password` (solicita token por email) → `/reset-password?token=xxx` (nueva password). Backend: tabla `password_reset_tokens` (32 bytes hex, 1h expiry, single-use), endpoints `POST /auth/password-reset/{request,confirm}` con rate limits 3/h y 10/h. Anti-enumeration (respuesta genérica). Al confirmar reset se borran todos los refresh tokens del user. SMTP logea a consola en dev — copia el link del log del API para testing local. Ver `docs/plans/2026-04-14-password-reset.md`.
|
||||
|
||||
**Rate limiting por endpoint (`middlewares/rate-limit.middleware.ts`):** 4 tiers con key por `userId` (no IP, para no bloquear usuarios detrás de NAT compartido). Admin global (superset `platform_admin`/`platform_ti`) exento vía `skip`. Tiers: `veryStrictLimit` (2/día) en sync SAT manual y opinión cumplimiento; `strictLimit` (10/h) en emisión/cancelación factura, CFDI bulk, subs subscribe/change/upgrade, password-change; `normalLimit` (100/15min) a router level en dashboard/reportes/impuestos; `relaxedLimit` (500/15min) en catalogos/regimenes. `/auth/login` y `/auth/register` conservan sus propios limiters específicos. Headers `RateLimit-*` estándar. Key generator: usa `ipKeyGenerator(req.ip)` de `express-rate-limit` para el fallback anónimo — normaliza IPv6 correctamente (sin esto el lib emitía warning de potential bypass). Frontend (`lib/api/client.ts`) preserva el `message` del 429 para los try/catch existentes. Ver `docs/plans/2026-04-14-rate-limiting-expansion.md`.
|
||||
|
||||
**Input validation con Zod (defense-in-depth):** todos los controllers críticos que reciben `req.body` validan el shape con Zod antes de pasar al service. Prisma ya protege de SQL injection, pero Zod evita que un payload malformado llegue hasta la capa de persistencia y produzca 500s o comportamiento raro. Cobertura: `auth.controller` (register/login/refresh/reset/change/logout-all/switch-tenant), `bancos.controller` (create/update), `alertas.controller` (create/update), `calendario.controller` (create/update recordatorio), `usuarios.controller` (invite/update/updateGlobal), `tenants.controller` (addMyTenant), `subscription.controller` (varios), `platform-staff.controller`. Patrón uniforme: schema arriba del archivo, `.parse(req.body)` en el handler, catch `z.ZodError` → `next(new AppError(400, error.errors[0].message))`. Los schemas tipo `UserInvite`/`UserUpdate` en `@horux/shared` se mantienen alineados con los Zod enums.
|
||||
|
||||
**Helmet configurado explícitamente (`app.ts`):** `helmet({ contentSecurityPolicy: false, crossOriginResourcePolicy: 'cross-origin' })`. CSP se desactiva en el API porque (a) el API solo sirve JSON y binarios (PDF/XML) sin contenido HTML que requiera CSP, y (b) el default chocaba con el iframe del PDF que el frontend embebe en `/terminos`. Los security headers del frontend (Next) siguen siendo la capa de CSP activa: ver `apps/web/next.config.js` con `X-Frame-Options: SAMEORIGIN`, `Content-Security-Policy: frame-ancestors 'self'`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, HSTS.
|
||||
|
||||
**Multi-tenant memberships (owner-multi-rfc, en progreso):** Nueva tabla `tenant_memberships (user_id, tenant_id, rol_id, is_owner, active, joined_at)` con `@@unique([userId, tenantId])`. Permite que un mismo user pertenezca a múltiples tenants con rol distinto en cada uno. Backfilled idempotente desde `User.tenantId+rolId` (1 membership por user existente). `User.tenantId` y `User.rolId` se **mantienen** durante la transición como "default tenant" para UX al login. Login y refresh responses incluyen `user.tenants: [{id, nombre, rfc, plan, role, isOwner}]` derivado de memberships activas. Endpoint `POST /auth/switch-tenant { tenantId, refreshToken }` valida membership, revoca el refresh actual, emite nuevo par apuntando al target con el rol de ese tenant (audit event `user.tenant_switched`). Helpers en `apps/api/src/utils/memberships.ts`: `getUserTenants()`, `verifyMembership()`. Frontend: `MembershipSwitcher` en el header (`apps/web/components/membership-switcher.tsx`) visible cuando `tenants.length > 1` y NO es admin global; click llama `switchTenant` + `queryClient.clear()` + reload. Coexiste con `TenantSelector` (admin global usa impersonación X-View-Tenant, modelo distinto). Página `/mis-empresas` (owner-only) lista solo tenants donde `isOwner=true` con su subscripción joined; modal "Agregar empresa" llama `POST /api/tenants/mine` (gateado por `isOwnerSomewhere(userId)` en backend → 403 si no eres owner en ningún lado). Sidebar item "Mis empresas" usa flag `requireOwnerSomewhere` (no `roles[]`) para que un híbrido contador+owner siga viéndolo desde cualquier contexto activo. Helpers: `addTenantToOwner()`, `getMyTenantsDetailed()` en `tenants.service.ts`; `isOwnerSomewhere()`, `getTenantOwnerEmail()` en `utils/memberships.ts`. Trial gate por owner: `startTrial()` acepta `ownerUserId` opcional — si se pasa, busca otro tenant donde el user es owner activo Y tiene `trialEndsAt` set → si encuentra, rechaza con mensaje citando el RFC previo. Combinado con `trial_usages` (gate por RFC), cubre todos los vectores de abuso del trial. **F6 cleanup completo (2026-04-14):** `User.tenantId` y `User.rolId` **eliminados** del schema. `User.lastTenantId String?` agregado para "remember last tenant" UX. `auth.login` resuelve activeMembership desde `tenant_memberships` (prefiere lastTenantId, fallback al primer membership por joinedAt). 5 callsites de subscription.service migrados a `getTenantOwnerEmail()`. usuarios.service todo via memberships. Fases 1-6 ✅ (refactor completo). Ver `docs/plans/2026-04-14-owner-multi-rfc-subscriptions.md`.
|
||||
|
||||
**Sistema de emails (`apps/api/src/services/email/`):** transporte SMTP via Nodemailer (config en `.env`: `SMTP_HOST/PORT/USER/PASS`). Si SMTP no está configurado, los emails se logean a consola — útil en dev. 9 templates en `templates/`: `welcome`, `password-reset`, `payment-confirmed`, `payment-failed`, `subscription-cancelled`, `subscription-expiring`, `fiel-notification`, `new-client-admin`, `weekly-update`. Layout común en `base.ts` con identidad visual de horux360.com: header con gradiente azul→morado (`#2563EB → #7C3AED`), tipografía Inter (Google Fonts con fallback a system-ui), tokens de marca exportados como `BRAND_COLORS`. Helpers `primaryButton`, `infoBox`, `heading` para componentes consistentes. **Importante:** las familias de fuentes en `style="..."` deben usar comillas simples (`'Inter', ...`) — comillas dobles rompen el atributo HTML. Para previsualizar sin SMTP: `pnpm email:preview` genera HTMLs estáticos en `apps/api/email-previews/` (gitignored) con datos de ejemplo + index navegable. Ejecutar tras cualquier cambio de template para verificar visualmente.
|
||||
|
||||
**Reporte semanal automático (`weekly-update.job.ts`):** cron Lunes 8:00 AM (`0 8 * * 1` America/Mexico_City) que itera todos los tenants activos y envía a cada owner activo el correo "Actualización semanal — {empresa}". Solo arranca en `NODE_ENV=production`. Contenido: KPIs del mes en curso (ingresos/egresos/utilidad/IVA balance/CFDIs emitidos+recibidos vía `getKpis()`), alertas activas (rojo/ámbar/azul por prioridad vía `generarAlertasAutomaticas()`), y breakdown de discrepancias de régimen por mes en los últimos 6 meses (`getDiscrepanciasPorMes(pool, tenantId, 6)`). Recipientes resueltos vía `tenant_memberships.where(isOwner=true, active=true)`. Por-tenant + por-recipient try/catch — un fallo individual no bloquea al resto. `sendWeeklyUpdateForTenant(tenantId)` exportado para disparo manual desde un endpoint admin futuro.
|
||||
|
||||
**Revocación de JWT (tokenVersion):** `User.tokenVersion` (entero, default 0) se incluye en cada JWT. `authenticate` middleware compara `payload.tokenVersion` contra el actual en BD (cache 30s + broadcast PM2 via `process.send`). Incrementar el version invalida **todos** los access tokens del user en el siguiente request. Disparadores: (1) cambio de password autenticado (`POST /auth/password-change`), (2) "cerrar todas las sesiones" (`POST /auth/logout-all`), (3) confirmar password reset (`POST /auth/password-reset/confirm`). UI en `/configuracion/seguridad`. Backward compat: JWTs pre-deploy no incluyen el campo → se interpreta como 0 → matchea con default, no hay re-login forzado masivo. Ver `docs/plans/2026-04-14-jwt-revocation.md`.
|
||||
|
||||
### Lógica fiscal (lo más complejo)
|
||||
| Archivo | Qué hace |
|
||||
|---------|----------|
|
||||
| `src/services/dashboard.service.ts` | KPIs: ingresos/egresos/IVA/adquisición mercancías por régimen, IVA a favor acumulado. Filtro de conciliación. |
|
||||
| `src/services/impuestos.service.ts` | IVA mensual, resumen IVA/ISR, coeficiente de utilidad |
|
||||
| `src/services/cfdi.service.ts` | CRUD CFDIs, filtros, búsqueda, resúmenes, bulk insert |
|
||||
| `src/services/conciliacion.service.ts` | Conciliación bancaria: vincular CFDIs con pagos |
|
||||
| `src/services/bancos.service.ts` | CRUD bancos por tenant |
|
||||
| `src/services/reportes.service.ts` | Estado de resultados, flujo efectivo, comparativo, CxP, CxC |
|
||||
| `src/services/alertas-auto.service.ts` | Alertas automáticas: lista negra, concentración, discrepancias, cancelación retroactiva, TipoRelacion sospechoso, tareas próximas a vencer |
|
||||
| `src/services/calendario-fiscal.service.ts` | Genera calendario fiscal desde catálogo + días inhábiles |
|
||||
| `src/services/regimen.service.ts` | Catálogo de regímenes, configuración activos/ignorados por tenant |
|
||||
| `src/services/recordatorios.service.ts` | CRUD recordatorios custom por tenant (público/privado) |
|
||||
| `src/services/tareas.service.ts` | Tareas operativas recurrentes por contribuyente con materialización lazy de periodos |
|
||||
| `src/services/papeleria.service.ts` | Papelería de trabajo: archivos por contribuyente con flujo opcional de aprobación |
|
||||
| `src/services/despacho-stats.service.ts` | Métricas del módulo Despacho: contribuyentes, mis-asignados, equipo (jerárquico) |
|
||||
| `src/services/notification-preferences.service.ts` | Preferencias de email por contribuyente (JSONB en `contribuyentes`) |
|
||||
|
||||
### Claves de concepto excluidas de cálculos fiscales
|
||||
Los conceptos con las siguientes `clave_prod_serv` se excluyen de **todos** los cálculos de ingresos, egresos, IVA e ISR (dashboard + impuestos, base y conciliación):
|
||||
- `84121603` — Seguros
|
||||
- `93161608` — Servicios gubernamentales
|
||||
- `85101501` — Servicios de salud
|
||||
- `85121800` — Servicios médicos
|
||||
|
||||
Implementado con subqueries correlacionados a `cfdi_conceptos` que restan los montos de conceptos excluidos por CFDI. Si un CFDI tiene otros conceptos válidos, solo se excluyen los que coinciden con estas claves.
|
||||
|
||||
### Regla clave: régimen del tenant en queries fiscales
|
||||
- `type = 'EMITIDO'` → el tenant es el emisor → usar `regimen_fiscal_emisor`
|
||||
- `type = 'RECIBIDO'` → el tenant es el receptor → usar `regimen_fiscal_receptor`
|
||||
|
||||
### Filtro de Conciliación (Dashboard + Impuestos)
|
||||
Toggle global que cambia el comportamiento de todas las métricas:
|
||||
- **Desactivado (default):** Usa `fecha_emision` y todos los CFDIs vigentes
|
||||
- **Activado:** Solo CFDIs con `id_conciliacion IS NOT NULL`, usa `fecha_de_pago` de tabla `conciliaciones`
|
||||
- Implementado con `getFechaRango(conciliacion)` que retorna el SQL fragment correcto
|
||||
- Afecta: ingresos, egresos, adquisición mercancías, IVA balance, IVA mensual, resumen IVA, resumen ISR, IVA a favor, regímenes del periodo, chart ingresos/egresos
|
||||
|
||||
### Métricas del Dashboard
|
||||
- **Ingresos del Mes** — Por régimen del emisor, filtrable
|
||||
- **Gastos del Mes** — Por régimen del receptor, filtrable
|
||||
- **Adquisición de Mercancías** — Egresos con uso_cfdi G01, por régimen, filtrable
|
||||
- **Utilidad** — Ingresos - Egresos
|
||||
- **Balance IVA** — Causado - Acreditable, por régimen
|
||||
- **CFDIs Emitidos/Recibidos** — Contadores
|
||||
- **IVA a Favor** — Acumulado anual e histórico (5 años)
|
||||
|
||||
### Métricas de Impuestos
|
||||
**IVA:** Trasladado, Acreditable, Retenido, Resultado, Acumulado Anual — todos filtrables por régimen con `trasladadoPorRegimen`, `acreditablePorRegimen`, `retenidoPorRegimen`
|
||||
**ISR:** Ingresos acumulados, Deducciones, Base gravable, ISR causado, ISR retenido, ISR a pagar — todos filtrables por régimen con `ingresosPorRegimen`, `deduccionesPorRegimen`, `baseGravablePorRegimen`
|
||||
|
||||
### Conciliación — reglas importantes
|
||||
- **Recibidos:** excluye PPD (solo PUE y pagos)
|
||||
- **Emitidos:** excluye PPD para todos los regímenes excepto 605 y 616
|
||||
- **Facturas tipo P:** usan `monto_pago_mxn` en vez de `total_mxn`
|
||||
- **Auto-conciliación PPD:** cuando una factura P con `saldo_pendiente = 0` se concilia, la PPD relacionada (via `uuid_relacionado`) se auto-concilia con los mismos datos.
|
||||
- **Score cards:** PPD auto-conciliadas tienen `montoMxn = 0` para no duplicar montos
|
||||
|
||||
### SAT — Sincronización
|
||||
| Archivo | Qué hace |
|
||||
|---------|----------|
|
||||
| `src/services/sat/sat.service.ts` | Orquestación: sync jobs, chunking inteligente, saveCfdis, saveMetadata |
|
||||
| `src/services/sat/sat-client.service.ts` | HTTP client SAT: query, verify, download. DocumentStatus 'active' para XMLs |
|
||||
| `src/services/sat/sat-parser.service.ts` | Parseo de XML CFDI + metadata CSV del SAT |
|
||||
| `src/services/fiel.service.ts` | FIEL: upload, encriptación AES-256-GCM, validación |
|
||||
| `src/jobs/sat-sync.job.ts` | Cron 03:00 AM: sync automático para todos los tenants con FIEL |
|
||||
|
||||
**Cuatro modos de sincronización:**
|
||||
|
||||
| Modo | Cuándo | XMLs | Metadata |
|
||||
|------|--------|------|----------|
|
||||
| `initial` | Primera sync del tenant | Sondeo → bloques 3/6 meses | Bloques de 3 años |
|
||||
| `daily` | Cron 3:00 AM (todos los planes) | Últimos 7 días | Año fiscal completo (ene → hoy) |
|
||||
| `incremental` | Cron 11:00/15:00/19:00 (solo plan Enterprise) | Últimas 8 horas | Últimas 8 horas |
|
||||
| `custom` (rango personalizado) | Botón en UI | Directo si ≤6m, bloques de 6m si >6m | Rango completo |
|
||||
|
||||
**Incremental Enterprise:** Reduce latencia de CFDIs nuevos a ~4h en horario laboral. Ventana de 8h cubre el gap 03:00→11:00 y solape con disparos siguientes (dedup por UUID). Requiere `initial` completado previamente — el job se omite si el tenant no tiene backfill. Ver `docs/plans/2026-04-13-sat-incremental-enterprise.md`.
|
||||
|
||||
**Flujo de sincronización (initial):**
|
||||
1. Sondeo: metadata del rango completo (2 solicitudes) → determina volumen
|
||||
2. XMLs vigentes: bloques de 6 meses (≤15k CFDIs) o 3 meses (>15k)
|
||||
3. Metadata vigentes+cancelados: bloques de 3 años
|
||||
4. XMLs se guardan en disco (`data/xmls/<rfc>/<tipo>/<packageId>/`) antes de procesar
|
||||
5. RFCs se upsert en tabla `rfcs` con `rfc_emisor_id`/`rfc_receptor_id` FK
|
||||
6. Metadata: inserta CFDIs cancelados sin XML, actualiza status de existentes
|
||||
|
||||
**Retry automático en timeouts:**
|
||||
- Poll interval: 60 segundos, máximo 30 intentos (30 min)
|
||||
- Si timeout: job queda `pending` con `nextRetryAt` en +6 horas
|
||||
- Cron horario revisa y reintenta (máx 3 veces)
|
||||
- Tras 3 fallos: "Fallo conexión SAT, vuelve a intentar con un rango de fechas menor."
|
||||
|
||||
**Pool management:** `ctx.getPool()` (no estático) para mantener `lastAccess` fresco durante syncs largos.
|
||||
|
||||
### Facturapi — Emisión de facturas
|
||||
| Archivo | Qué hace |
|
||||
|---------|----------|
|
||||
| `src/services/facturapi.service.ts` | Facturapi: organizaciones, CSD, clientes, emisión, cancelación, descargas, logo, color, envío email |
|
||||
| `src/controllers/facturacion.controller.ts` | Endpoints: emitir, cancelar, PDF/XML, timbres, búsqueda RFCs, conceptos previos, datos fiscales, personalización |
|
||||
| `src/controllers/catalogos.controller.ts` | Catálogos SAT: forma pago, uso CFDI, clave prod/serv (con búsqueda sin acentos), unidades, etc. |
|
||||
| `src/routes/facturacion.routes.ts` | Rutas de facturación con tenantMiddleware |
|
||||
| `src/routes/catalogos.routes.ts` | Rutas de catálogos SAT |
|
||||
|
||||
**Modelo Facturapi:**
|
||||
- Una cuenta Horux360 (User Key `sk_user_...`) con organizaciones por tenant
|
||||
- `tenants.facturapi_org_id` vincula tenant ↔ organización Facturapi
|
||||
- API key de org se obtiene via HTTP directo (`/v2/organizations/{id}/apikeys/test`) — el SDK tiene bugs con este endpoint
|
||||
- CSD (Certificado de Sello Digital) se sube por organización (no requerido en modo test)
|
||||
- Timbres controlados por `timbre_suscripciones` (50/mes o 600/año, pool del plan) + `timbre_paquetes` (compras adicionales, vigencia 1 año). Catálogo de paquetes vendibles en `timbre_paquetes_catalogo` (100/$200, 1000/$1400, 10000/$8600 por default, editable por admin global en `/configuracion/precios-timbres`). Pool mensual se **resetea a 0 al final del periodo** (cron diario 2:30 AM via `resetExpiredMonthlyTimbres`, no acumulable). `consumeTimbre(tenantId)` en `$transaction`: primero pool mensual, luego `TimbrePaquete` con menor `expiraEn` (FIFO anti-desperdicio). Compra self-serve en `/facturacion/timbres` (owner/cfo): `POST /api/facturacion/timbres/paquetes/comprar` crea `Payment(kind=timbres_pack)` + MP Preference con `external_reference=timbres-pack:{paymentId}`. El webhook MP aprueba el Payment, llama `activarPaqueteTrasPago` (crea `TimbrePaquete` con `expiraEn = now + 1 año`), y dispara `invoicingService.emitInvoiceIfApplicable` que ramifica por `Payment.kind` (concepto "{cantidad} timbres adicionales" vs "Suscripción {plan}"). `Payment.kind` enum (`subscription | timbres_pack`) discrimina flujos en un solo modelo.
|
||||
- Al emitir: guarda en `cfdis` con `source: 'facturapi'` + `facturapi_id`, upsert RFCs con regimen/CP
|
||||
- Envío automático por email al receptor si tiene email configurado
|
||||
- **Cancelación:** botón en `/cfdi` para facturas con `source='facturapi'` y status vigente. Modal con los 4 motivos SAT (02 = errores sin relación, default; 01 = errores con relación, requiere UUID sustituto; 03 = no se llevó a cabo; 04 = incluida en factura global). `POST /facturacion/cancelar/:uuid` con `{ motive, substitution? }`. El estatus en BD local pasa a `Cancelado` al confirmar, pero el SAT puede dejarla en "pendiente" si requiere aceptación del receptor (copy en el modal lo advierte). Distinta a `DELETE /cfdi/:id` que solo borra el registro local.
|
||||
- Personalización por organización: logo (PNG/JPG ≤2MB) y color (HEX)
|
||||
|
||||
**Catálogos SAT en BD central (9 tablas):**
|
||||
- `cat_forma_pago` (22), `cat_metodo_pago` (2), `cat_uso_cfdi` (24), `cat_moneda` (7)
|
||||
- `cat_clave_unidad` (26), `cat_clave_prod_serv` (52,513 — importado de phpcfdi/resources-sat-catalogs), `cat_objeto_imp` (4)
|
||||
- `cat_tipo_relacion` (7), `cat_exportacion` (4)
|
||||
- Búsqueda de `cat_clave_prod_serv` soporta búsqueda sin acentos (regex PostgreSQL)
|
||||
|
||||
**Tipos de comprobante soportados:**
|
||||
| Tipo | Secciones dinámicas |
|
||||
|------|-------------------|
|
||||
| I - Ingreso | Conceptos con traslados/retenciones por concepto (IVA, ISR, IEPS, impuestos locales), exportación, serie/folio, condiciones, descuento, objeto de impuesto. Factura global (periodicidad, mes, año). Autocompletar receptor desde tabla RFCs. Búsqueda de conceptos previos en facturas. |
|
||||
| E - Egreso | Igual que Ingreso + documento relacionado (UUID + tipo relación 01-04) |
|
||||
| P - Pago | Solo comprobante (tipo+serie/folio) + receptor + complemento de pago. Autocompletar UUID desde CFDIs PPD pendientes del receptor con saldo. Desglose IVA del pago (base, tasa, monto). |
|
||||
| T - Traslado | Solo comprobante (tipo+serie/folio) + receptor + conceptos sin precio/impuestos. Unidades filtradas (sin servicio). |
|
||||
|
||||
**Recomendación automática de retenciones (tipo I):**
|
||||
- Emisor PF (13 chars RFC) con régimen 612, 626, o 606
|
||||
- Receptor PM (12 chars RFC) con régimen distinto a 612/626/606
|
||||
- Unidad de servicio (E48, ACT)
|
||||
- → Auto-agrega: IVA retenido 10.6667% (2/3) + ISR retenido (1.25% RESICO, 10% otros)
|
||||
|
||||
**Cliente extranjero:**
|
||||
- Detectado al escribir RFC `XEXX010101000`
|
||||
- Auto-llena: régimen 616, CP del tenant
|
||||
- Campos adicionales: Tax ID extranjero + País (selector con ~50 países)
|
||||
- Backend envía `country` en address, Facturapi agrega RFC genérico extranjero
|
||||
|
||||
**Factura global:**
|
||||
- Toggle en tipo I que auto-llena receptor con PUBLICO EN GENERAL (XAXX010101000, régimen 616)
|
||||
- Campos: periodicidad (day/week/fortnight/month/two_months), mes (01-18), año
|
||||
- CP se auto-llena desde datos fiscales del tenant
|
||||
|
||||
**Datos fiscales del tenant (tabla `tenants`):**
|
||||
- Columnas: codigo_postal, calle, num_exterior, num_interior, colonia, ciudad, municipio, estado, telefono
|
||||
- Editable desde Configuración > Domicilio Fiscal
|
||||
- Se usa para: CP en facturas globales, datos del emisor
|
||||
|
||||
### Calendario y Recordatorios
|
||||
| Archivo | Qué hace |
|
||||
|---------|----------|
|
||||
| `src/controllers/calendario.controller.ts` | GET eventos fiscales + recordatorios custom, CRUD recordatorios |
|
||||
| `src/services/recordatorios.service.ts` | CRUD recordatorios en tabla `recordatorios` del tenant |
|
||||
|
||||
**Recordatorios custom:**
|
||||
- Solo fechas únicas (sin recurrencia)
|
||||
- Público (visible para todo el equipo) o privado (solo el creador)
|
||||
- Admin y contador pueden crear/editar/eliminar
|
||||
- Estilo morado en el calendario
|
||||
- Se mezclan con eventos fiscales generados, ordenados por fecha
|
||||
|
||||
**Recordatorios automáticos de e.firma:**
|
||||
- Al subir la FIEL, se crean 3 recordatorios públicos: 60, 30 y 7 días antes del vencimiento
|
||||
- Prefijo `[e.firma]` en el título para identificarlos
|
||||
- Al re-subir la FIEL, se eliminan los anteriores y se crean nuevos
|
||||
- Solo se crean si la fecha no ha pasado
|
||||
|
||||
### Documentos — Opinión de Cumplimiento
|
||||
| Archivo | Qué hace |
|
||||
|---------|----------|
|
||||
| `src/services/opinion-cumplimiento.service.ts` | Orquestación: decrypt FIEL → Playwright headless → scrape PDF → parse → guardar en BD tenant |
|
||||
| `src/services/sat/sat-opinion-login.ts` | Playwright: navegación portal SAT → login FIEL → reporte opinión |
|
||||
| `src/services/sat/sat-opinion-scraper.ts` | 4 estrategias para extraer PDF base64 del DOM Angular del SAT |
|
||||
| `src/services/sat/sat-opinion-parser.ts` | Parseo de texto del PDF con regex (RFC, razón social, estatus, folio, cadena original) |
|
||||
| `src/controllers/documentos.controller.ts` | Endpoints: listar opiniones, descargar PDF, trigger manual |
|
||||
| `src/routes/documentos.routes.ts` | Rutas con tenantMiddleware + requireFeature('documentos') |
|
||||
|
||||
**Cron semanal:** Domingos 4:00 AM (`0 4 * * 0`). Descarga opinión para todos los tenants con FIEL. Limpia registros > 6 meses.
|
||||
|
||||
**Seguridad FIEL:** Archivos temporales con `0o600` en tmpdir con UUID. Cleanup garantizado en `finally`. Contraseña solo en memoria. Playwright headless. Timeout 3 min por tenant.
|
||||
|
||||
**Almacenamiento:** PDF completo en BYTEA en tabla `opiniones_cumplimiento` (BD tenant). 6 meses de retención. Frontend muestra últimas 5.
|
||||
|
||||
**Alerta automática:** Si última opinión no es Positiva → alerta alta en alertas automáticas.
|
||||
|
||||
**Feature gate:** `documentos` — disponible en planes Business y Enterprise.
|
||||
|
||||
### Documentos — Declaraciones Provisionales
|
||||
| Archivo | Qué hace |
|
||||
|---------|----------|
|
||||
| `src/migrations/tenant/003_create_declaraciones_provisionales.sql` | Tabla `declaraciones_provisionales` con 1 normal única por (año, mes) + N complementarias |
|
||||
| `src/migrations/tenant/004_declaraciones_liga_pago_pdf.sql` | Reemplaza `link_pago TEXT` por `pdf_liga_pago BYTEA` + filename (la liga de pago es PDF, no URL) |
|
||||
| `src/services/declaraciones.service.ts` | CRUD + auto-resolución de alertas por impuesto/mes + purge 5 años |
|
||||
|
||||
Flujo: el contador sube 3 PDFs por mes — **declaración** (obligatorio), **liga de pago** (opcional), **comprobante de pago** (opcional). Al subir la declaración se marcan como resueltos los recordatorios `decl-<impuesto>-<mes>` correspondientes a los impuestos seleccionados (IVA, ISR, IEPS, SUELDOS, DIOT, OTRO). Si es tipo `complementaria`, también se resuelven los recordatorios `pago-*` del mes (la complementaria sustituye a la normal en pago). Al subir comprobante de pago después, se resuelven los `pago-*` del mes. Retención 5 años (CFF Art. 30) purgada en cron lifecycle diario 2:30 AM. El flujo manual de "marcar como realizado" desde /alertas se mantiene para usuarios que no quieran subir documento.
|
||||
|
||||
UI: pestaña "Declaraciones Provisionales" en `/documentos` con selector de año, tabla mensual con badges tipo/impuestos y botones descarga/subir-pago/eliminar. Roles con upload: owner, cfo, contador, auxiliar.
|
||||
|
||||
### Documentos — Constancia de Situación Fiscal (CSF)
|
||||
| Archivo | Qué hace |
|
||||
|---------|----------|
|
||||
| `src/migrations/tenant/005_create_constancias_situacion_fiscal.sql` | Tabla con PDF BYTEA + datos JSONB (shape completo) + retención 5 años |
|
||||
| `src/services/sat/sat-csf-login.ts` | Playwright: página pública SAT → popup SERVICIO → login FIEL (con retry `dispatchEvent` si el click sintético se pierde) |
|
||||
| `src/services/sat/sat-csf-scraper.ts` | Busca "Generar Constancia" en cualquier `frame()` (vive en iframe JSF legacy `rfcampc.siat.sat.gob.mx`). 3 rutas: download event, popup viewer, response interception |
|
||||
| `src/services/sat/sat-csf-parser.ts` | Parser PF+PM: labels key:value + 3 tablas (actividades/regímenes/obligaciones, agrupadas por "chunk termina en dd/mm/yyyy") + sellos |
|
||||
| `src/services/constancia.service.ts` | Orquestación + `sincronizarDatosFiscales(tenantId, csf)` — auto-fill domicilio tenant (codigoPostal, calle, numExterior/Interior, colonia, ciudad, municipio, estado) y regímenes activos (matcheados contra catálogo `regimenes` por nombre normalizado) |
|
||||
|
||||
**Cron mensual:** Día 1 de cada mes 04:00 AM (`0 4 1 * *`). Descarga CSF para todos los tenants con FIEL. Por-tenant try/catch — un fallo no bloquea al resto.
|
||||
|
||||
**Retención:** 5 años purgados junto con declaraciones en cron lifecycle diario 2:30 AM.
|
||||
|
||||
**Trigger on first-upload FIEL:** En `fiel.service.ts`, al primer upload exitoso (`existingFiel` nulo o inactivo) se disparan en background Opinión de Cumplimiento + CSF con `import()` fire-and-forget. No bloquea la respuesta al usuario.
|
||||
|
||||
**Headless por default:** `chromium.launch({ headless: true })`. El fix clave es en `sat-csf-login.ts`: el click sintético a "e.firma" del portal SAT a veces no dispara el handler, por eso se espera a que aparezca `input[type=file]` (10s) y si no llega, reintenta con `dispatchEvent('click')`. Para debug visual temporal, setear `SAT_HEADLESS=false` en `.env`.
|
||||
|
||||
**UI:** pestaña "Constancia de Situación Fiscal" en `/documentos` con último CSF expandido (identificación, domicilio, regímenes activos, obligaciones), historial de 12 con detalle desplegable, descarga PDF, y botón "Consultar ahora" (owner/cfo). La UI refleja `datos: JSONB` de la BD sin re-parsear el PDF.
|
||||
|
||||
**Shape `ConstanciaSituacionFiscal`:** rfc, curp?, idCIF, nombre?/primerApellido?/segundoApellido?/razonSocial?, estatusPadron, fechaInicioOperaciones, lugarFechaEmision, domicilio (11 campos), actividadesEconomicas[], regimenes[], obligaciones[], cadenaOriginalSello, selloDigital.
|
||||
|
||||
### Frontend
|
||||
| Archivo | Qué hace |
|
||||
|---------|----------|
|
||||
| `apps/web/lib/api/client.ts` | Axios instance con auto-refresh JWT + `X-View-Tenant` |
|
||||
| `apps/web/stores/auth-store.ts` | Zustand: user, tokens, logout (persist localStorage) |
|
||||
| `apps/web/stores/tenant-view-store.ts` | Zustand: impersonación de tenant (admin global) |
|
||||
| `apps/web/stores/theme-store.ts` | Zustand: tema visual (light/dark) |
|
||||
| `apps/web/lib/export-excel.ts` | Utilidad client-side para export Excel |
|
||||
| `apps/web/lib/hooks/use-facturacion.ts` | Hooks para facturación + catálogos SAT |
|
||||
| `apps/web/lib/hooks/use-calendario.ts` | Hooks para calendario + recordatorios |
|
||||
|
||||
**Tenant-aware query keys:** Todos los hooks de datos (`use-dashboard`, `use-bancos`, `use-calendario`, `use-facturacion`) incluyen `viewingTenantId` en los query keys de React Query para refetchear al cambiar de empresa.
|
||||
|
||||
**Temas:** Solo Light y Dark habilitados. Fondo Light = lavanda sutil (270 50% 98%).
|
||||
|
||||
---
|
||||
|
||||
## Convenciones importantes
|
||||
|
||||
### Cálculos fiscales por régimen
|
||||
Los ingresos/egresos/IVA se calculan de forma diferente según el grupo de régimen:
|
||||
- **PF Empresarial (606, 612, 621, 625, 626):** Facturas PUE + Pagos - Notas de crédito PUE
|
||||
- **Sueldos (605):** Nóminas recibidas PUE
|
||||
- **PM y otros (601, 603, 607...):** Facturas PUE+PPD - Notas de crédito PUE
|
||||
|
||||
Los montos se calculan **sin impuestos** (total - IVA trasladado - IEPS - impuestos locales).
|
||||
Los montos en MXN se usan siempre para cálculos (campo `_mxn`).
|
||||
|
||||
### Convenciones de BD
|
||||
- BD central: nombres en `snake_case` mapeados por Prisma a `camelCase`
|
||||
- BD tenant: SQL directo, alias explícitos en queries (`rfc_emisor as "rfcEmisor"`)
|
||||
- `CFDI_SELECT` constant en `cfdi.service.ts` define todos los campos mapeados
|
||||
- Status vigente: `WHERE status NOT IN ('Cancelado', '0')`
|
||||
|
||||
### Tenant provisioning
|
||||
Al crear un tenant (`TenantConnectionManager.provisionDatabase(rfc)`):
|
||||
1. Crea BD `horux_<rfc_lowercase>`
|
||||
2. Ejecuta `createTables()`: crea `rfcs`, `bancos`, `cfdis`, `cfdi_conceptos`, `conciliaciones`, `alertas`, `recordatorios`
|
||||
3. Ejecuta `createIndexes()`: índices B-tree + trigram (pg_trgm) + FK diferida para `id_conciliacion`
|
||||
|
||||
### Tablas por tenant
|
||||
| Tabla | Propósito |
|
||||
|-------|-----------|
|
||||
| `rfcs` | Catálogo de RFCs (id, rfc, razon_social, regimen_fiscal, codigo_postal) |
|
||||
| `bancos` | Cuentas bancarias (id, banco, terminacion_cuenta) |
|
||||
| `cfdis` | Facturas electrónicas (100+ columnas, incluye conciliado, id_conciliacion, facturapi_id, source, cfdi_tipo_relacion, cfdis_relacionados, saldo_pendiente_mxn) |
|
||||
| `cfdi_conceptos` | Líneas de detalle por CFDI |
|
||||
| `cfdi_descartados` | CFDIs marcados como ignorados por tipo de alerta (whitelist por contador) |
|
||||
| `conciliaciones` | Registros de conciliación (id, anio, mes, id_cfdi, fecha_de_pago, id_banco) |
|
||||
| `alertas` | Alertas manuales persistidas |
|
||||
| `recordatorios` | Recordatorios custom del calendario (título, fecha, público/privado, creado_por) |
|
||||
| `entidades_gestionadas` | Entidades del despacho (clientes/contribuyentes) — tipo, nombre, supervisor_user_id |
|
||||
| `contribuyentes` | Contribuyentes con FK a entidades_gestionadas (rfc, regimen_fiscal CSV, domicilio, email_preferences jsonb) |
|
||||
| `carteras` | Carteras del despacho con supervisor_user_id, auxiliar_user_id, parent_id (subcarteras) |
|
||||
| `cartera_entidades` | M:N cartera ↔ contribuyente |
|
||||
| `cartera_auxiliares` | M:N cartera ↔ auxiliar (legacy, ahora en `auxiliar_user_id` directo) |
|
||||
| `auxiliar_supervisores` | Override 1:1 auxiliar → supervisor (editado desde `/usuarios`) |
|
||||
| `obligaciones_contribuyente` | Catálogo de obligaciones por contribuyente |
|
||||
| `obligacion_periodos` | Instancias mensuales de cada obligación; estado completada |
|
||||
| `tareas_catalogo` | Tareas operativas recurrentes por contribuyente (semanal a anual) |
|
||||
| `tarea_periodos` | Instancias materializadas con fecha_limite y estado |
|
||||
| `papeleria_trabajo` | Archivos del despacho (PDF/Word/Excel ≤5MB) por contribuyente con flujo opcional de aprobación |
|
||||
| `declaraciones_provisionales` | PDFs de declaraciones por (contribuyente, año, mes, tipo) con liga de pago + comprobante de pago |
|
||||
| `documentos_extras` | PDFs libres por contribuyente con categoría y descripción |
|
||||
| `opiniones_cumplimiento` | Cache 6 meses de Opinión SAT (PDF + datos parseados) |
|
||||
| `constancias_situacion_fiscal` | Cache 5 años de CSF (PDF + datos JSONB) |
|
||||
| `facturapi_orgs` | Org Facturapi por contribuyente (api_key cacheada, csd_uploaded, last_lco_rejection_at) |
|
||||
|
||||
### Campos source en cfdis
|
||||
- `'manual'` — Cargado por usuario via XML upload
|
||||
- `'sat'` — Descargado del SAT via sync (tiene xml_original)
|
||||
- `'sat-metadata'` — Solo metadata del SAT (sin XML, típicamente cancelados)
|
||||
- `'facturapi'` — Emitido desde Horux360 via Facturapi (tiene facturapi_id)
|
||||
|
||||
### Impersonación de tenant (X-View-Tenant)
|
||||
El admin global puede ver/gestionar datos de otros tenants. Los endpoints que lo soportan usan:
|
||||
- Backend: `tenantMiddleware` + `effectiveTenantId(req)` = `req.viewingTenantId || req.user!.tenantId`
|
||||
- Frontend: `useTenantViewStore()` + tenant key en query keys de React Query
|
||||
|
||||
Rutas con tenantMiddleware: dashboard, cfdi, impuestos, reportes, conciliación, bancos, calendario, regímenes, fiel, sat, facturación.
|
||||
|
||||
---
|
||||
|
||||
## Setup local
|
||||
|
||||
```bash
|
||||
# Requisitos: Node 20+, pnpm 9+, PostgreSQL 16+
|
||||
pnpm install
|
||||
pnpm db:generate # Genera Prisma client
|
||||
pnpm dev # Arranca API (4000) + Web (3000) via Turborepo
|
||||
```
|
||||
|
||||
### Bootstrap desde BD vacía (deploy fresco)
|
||||
|
||||
```bash
|
||||
cd apps/api
|
||||
pnpm prisma migrate deploy # schema central
|
||||
pnpm db:seed # catálogos + tenant demo + roles
|
||||
pnpm bootstrap:admin-global # tenant Horux 360 (HTS240708LJA)
|
||||
pnpm import:lista-negra # opcional
|
||||
```
|
||||
|
||||
`bootstrap:admin-global` usa `tenantsService.createTenant()` con overrides enterprise (`cfdiLimit: -1`, `usersLimit: 10`) + eleva la subscripción a `authorized` por 1 año. Imprime password temporal del admin en consola. Configurable vía `HORUX_ADMIN_EMAIL` / `HORUX_ADMIN_NOMBRE`.
|
||||
|
||||
**Variables de entorno:** `apps/api/.env` y `apps/web/.env.local`
|
||||
**BD de prueba:** tenant `EDE123456AB1` → BD `horux_ede123456ab1`
|
||||
|
||||
**Credenciales demo:**
|
||||
- admin@demo.com / demo123 (admin)
|
||||
- contador@demo.com / demo123 (contador)
|
||||
- visor@demo.com / demo123 (visor)
|
||||
|
||||
**Credenciales Horux 360:**
|
||||
- carlos@horuxfin.com / (password en consola del bootstrap) — owner + platform_admin
|
||||
- ivan@horuxfin.com / (password en consola del bootstrap) — contador + platform_ti (TI superset)
|
||||
|
||||
---
|
||||
|
||||
## Producción
|
||||
|
||||
- **Servidor:** Ubuntu 24.04, 22GB RAM, 8 cores
|
||||
- **Process manager:** PM2 (fork mode)
|
||||
- **Proxy:** Nginx con SSL (Let's Encrypt)
|
||||
- **Cron:** SAT sync 03:00 AM, Backups 01:00 AM
|
||||
- **Deploy:** `git pull → pnpm install → pnpm build → pnpm db:migrate-tenants → pm2 restart all`
|
||||
|
||||
---
|
||||
|
||||
## Problemas conocidos / pendientes
|
||||
|
||||
**Typecheck limpio.** `pnpm typecheck` pasa 0 errores en `@horux/api` y `@horux/shared` tras el cleanup del 2026-04-13. El proyecto corre con `tsx watch` que no valida tipos en runtime, por eso agregar `pnpm typecheck` a CI/pre-commit es recomendado para no re-acumular deuda. Ver `docs/plans/2026-04-13-typescript-debt-cleanup.md` para el inventario completo y decisiones descartadas (incluye por qué no se downgradeo `@types/express@5` ni se migró a `"type": "module"`).
|
||||
|
||||
**Express version mismatch:** `@types/express@^5.0.0` corre sobre `express@^4.21.0`. La diferencia se manifiesta en `req.params.id` tipado como `string | string[]`. Solución aplicada: cast `String(req.params.id)` en los 8 controllers afectados. Cuando migren a Express 5, los casts siguen siendo correctos.
|
||||
|
||||
**Facturapi SDK interop:** el paquete `facturapi@4.14.2` no declara `"exports"` en su package.json, solo `main`/`module`. Esto impide migrar el api a `"type": "module"` sin workarounds en cada `import Facturapi from 'facturapi'` (el default export se trata como namespace bajo ESM estricto). `tenant-migrations.ts` usa `__dirname` directo para evitar `import.meta` y mantenerse compatible con el modo CJS efectivo.
|
||||
|
||||
1. ~~**Schema drift multi-tenant:**~~ Resuelto. Migraciones SQL numeradas en `apps/api/src/migrations/tenant/`. Se aplican eager (`pnpm db:migrate-tenants`) en deploy y lazy (auto en `getPool()`) como safety net. Para agregar un cambio de schema tenant: crear `NNN_description.sql` en el directorio de migraciones.
|
||||
|
||||
**Términos y Condiciones:** fuente legal = `docs/legal/Terminos y condiciones.pdf`. Pipeline: `pnpm legal:sync` (corre `scripts/extract-terminos.mjs` usando `pdf-parse` v2 con `PDFParse.getText()`) copia el PDF a `apps/web/public/legal/terminos-y-condiciones.pdf` y genera `apps/web/content/terminos.ts` con el texto extraído (cheap insurance para indexado/búsqueda futura). Página `/terminos` (pública, fuera de `(auth)`/`(dashboard)`) **embebe el PDF con `<object type="application/pdf">`** — usa el viewer nativo del navegador (PDF.js/QuickLook) y preserva fidelidad total de formato (tipografía, tablas, listas). Header con "Descargar" (download attribute) y "Abrir" (nueva pestaña). Fallback dentro del object para móviles sin inline PDF: botones centrados. Register checkbox obligatorio con link a `/terminos` (target=\_blank). Workflow para actualizar: reemplazar el PDF en `docs/legal/` con el mismo nombre → `pnpm legal:sync` → commit los 3 artefactos (PDF fuente + PDF copia + `terminos.ts`).
|
||||
|
||||
**Security headers (`next.config.js`):** aplicados a todas las rutas para proteger contra clickjacking y otros vectores. `X-Frame-Options: SAMEORIGIN` + `Content-Security-Policy: frame-ancestors 'self'` impiden que sitios externos embeban Horux 360 en iframes propios (preservando el `/terminos` que embebe un PDF del mismo origen). `X-Content-Type-Options: nosniff` previene MIME sniffing. `Referrer-Policy: strict-origin-when-cross-origin` evita leak de URLs completas al navegar externo. `Strict-Transport-Security: max-age=31536000; includeSubDomains` fuerza HTTPS (ignorado en dev). Si en el futuro se necesita embeber Horux 360 en otro dominio propio (app móvil híbrida, portal partner), extender `frame-ancestors 'self' https://otro-dominio`.
|
||||
|
||||
**BD central**: usa Prisma migrations en `apps/api/prisma/migrations/`. Baseline `20260414152220_initial_schema_v0_9_2/migration.sql` consolida todo el schema acumulado hasta v0.9.2. Para futuros cambios: `pnpm prisma migrate dev --name <descripción>` genera SQL versionado. En prod: `pnpm prisma migrate deploy`. **NO** usar `prisma db push` en prod — se pierde el trail.
|
||||
2. **Nómina (pendiente):** Tipo de comprobante N no implementado en facturación. Requiere complemento de nómina con datos del empleado, percepciones, deducciones.
|
||||
3. **Carta Porte (pendiente):** Complemento para facturas tipo T. Facturapi lo soporta en Beta. No implementado.
|
||||
4. **Notificaciones por email de alertas/recordatorios:** El sistema de email existe (Nodemailer + Gmail) pero no envía alertas/recordatorios automáticos. Solo bienvenida, pagos, notificaciones admin y facturas (via Facturapi).
|
||||
5. **Catálogo c_ClaveProdServ:** 52,513 registros importados desde phpcfdi/resources-sat-catalogs. Si se necesita actualizar, re-importar desde la BD SQLite del release.
|
||||
6. **SMTP local:** No configurado en desarrollo. Emails se logean a consola. Configurar `SMTP_USER` y `SMTP_PASS` en `.env` para envío real.
|
||||
BIN
CSF_ejemplos/9f877571-c527-445d-979a-9ab99479d851.pdf
Normal file
BIN
CSF_ejemplos/9f877571-c527-445d-979a-9ab99479d851.pdf
Normal file
Binary file not shown.
BIN
CSF_ejemplos/AUZA640701TI9-tax-certificate-1767252856.pdf
Normal file
BIN
CSF_ejemplos/AUZA640701TI9-tax-certificate-1767252856.pdf
Normal file
Binary file not shown.
BIN
CSF_ejemplos/CAS2408138W2-tax-certificate-1767252858 (1).pdf
Normal file
BIN
CSF_ejemplos/CAS2408138W2-tax-certificate-1767252858 (1).pdf
Normal file
Binary file not shown.
BIN
CSF_ejemplos/CBO230302410-tax-certificate-1767252857.pdf
Normal file
BIN
CSF_ejemplos/CBO230302410-tax-certificate-1767252857.pdf
Normal file
Binary file not shown.
BIN
CSF_ejemplos/FAGC961208BXA-tax-certificate-1767252858.pdf
Normal file
BIN
CSF_ejemplos/FAGC961208BXA-tax-certificate-1767252858.pdf
Normal file
Binary file not shown.
BIN
CSF_ejemplos/GADM9107165I0-tax-certificate-1767252858.pdf
Normal file
BIN
CSF_ejemplos/GADM9107165I0-tax-certificate-1767252858.pdf
Normal file
Binary file not shown.
BIN
CSF_ejemplos/HTS240708LJA-tax-certificate-1767252856.pdf
Normal file
BIN
CSF_ejemplos/HTS240708LJA-tax-certificate-1767252856.pdf
Normal file
Binary file not shown.
BIN
CSF_ejemplos/ITP020524UW0-tax-certificate-1767252857.pdf
Normal file
BIN
CSF_ejemplos/ITP020524UW0-tax-certificate-1767252857.pdf
Normal file
Binary file not shown.
BIN
CSF_ejemplos/JISJ870518SD7-tax-certificate-1767252856.pdf
Normal file
BIN
CSF_ejemplos/JISJ870518SD7-tax-certificate-1767252856.pdf
Normal file
Binary file not shown.
BIN
CSF_ejemplos/MOMC8311199VA-tax-certificate-1767252858.pdf
Normal file
BIN
CSF_ejemplos/MOMC8311199VA-tax-certificate-1767252858.pdf
Normal file
Binary file not shown.
BIN
CSF_ejemplos/RORD791109L98-tax-certificate-1767252858.pdf
Normal file
BIN
CSF_ejemplos/RORD791109L98-tax-certificate-1767252858.pdf
Normal file
Binary file not shown.
BIN
CSF_ejemplos/RORE790609168-tax-certificate-1767252857.pdf
Normal file
BIN
CSF_ejemplos/RORE790609168-tax-certificate-1767252857.pdf
Normal file
Binary file not shown.
BIN
CSF_ejemplos/TOAH680201RA2-tax-certificate-1767252854 (1).pdf
Normal file
BIN
CSF_ejemplos/TOAH680201RA2-tax-certificate-1767252854 (1).pdf
Normal file
Binary file not shown.
BIN
CSF_ejemplos/TORA0007099R6-tax-certificate-1767252857.pdf
Normal file
BIN
CSF_ejemplos/TORA0007099R6-tax-certificate-1767252857.pdf
Normal file
Binary file not shown.
BIN
CSF_ejemplos/TORC9611214CA-tax-certificate-1767252856.pdf
Normal file
BIN
CSF_ejemplos/TORC9611214CA-tax-certificate-1767252856.pdf
Normal file
Binary file not shown.
BIN
CSF_ejemplos/TORS980325FH2-tax-certificate-1756664608.pdf
Normal file
BIN
CSF_ejemplos/TORS980325FH2-tax-certificate-1756664608.pdf
Normal file
Binary file not shown.
BIN
CSF_ejemplos/TPR840604D98-tax-certificate-1767252856.pdf
Normal file
BIN
CSF_ejemplos/TPR840604D98-tax-certificate-1767252856.pdf
Normal file
Binary file not shown.
112
README.md
Normal file
112
README.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# Horux Despachos
|
||||
|
||||
Plataforma SaaS para despachos profesionales mexicanos. Gestión fiscal multi-RFC con roles jerárquicos (Owner/Supervisor/Auxiliar/Cliente), carteras de contribuyentes, y arquitectura BYO-DB.
|
||||
|
||||
**Autor:** Carlos e Ivan (Horux 360)
|
||||
|
||||
---
|
||||
|
||||
## Qué es
|
||||
|
||||
Horux Despachos permite a despachos contables gestionar múltiples contribuyentes (RFCs) desde una sola cuenta. Cada contribuyente tiene su propia FIEL, CSD, y organización Facturapi. Los supervisores organizan contribuyentes en carteras y delegan trabajo a auxiliares.
|
||||
|
||||
## Arquitectura
|
||||
|
||||
```
|
||||
Monorepo (pnpm + Turborepo)
|
||||
├── apps/api → Express + TypeScript (puerto 4000)
|
||||
├── apps/web → Next.js 14 + App Router (puerto 3000)
|
||||
└── packages/
|
||||
├── core → Auth (JWT), email transport, crypto (AES-256-GCM)
|
||||
├── shared → Tipos, constantes, interfaces compartidas
|
||||
├── shared-ui → Componentes UI (Button, Card, Dialog, selectors, hooks)
|
||||
└── vertical-contable → (scaffold) Lógica fiscal compartida
|
||||
```
|
||||
|
||||
## Funcionalidades implementadas
|
||||
|
||||
### Gestión de despachos
|
||||
- Signup multi-paso (formulario → vertical → plan)
|
||||
- Onboarding wizard (6 pasos)
|
||||
- Planes: Trial (30 días), Business Control (BYO-DB), Business Cloud (Managed)
|
||||
|
||||
### Contribuyentes (RFCs)
|
||||
- CRUD de contribuyentes por despacho
|
||||
- FIEL per contribuyente (almacenada en BD tenant, cifrada AES-256-GCM)
|
||||
- Facturapi org per contribuyente (CSD independiente)
|
||||
- Emisión de CFDI con contribuyente_id
|
||||
|
||||
### Roles y autorización
|
||||
- **Owner**: acceso total, actúa como supervisor implícito
|
||||
- **Supervisor**: titular de RFCs, crea carteras, gestiona auxiliares
|
||||
- **Auxiliar**: accede solo a RFCs en carteras asignadas
|
||||
- **Cliente**: visor externo read-only de sus RFCs
|
||||
- `getEntidadesVisibles()`: cascada de permisos automática
|
||||
|
||||
### Carteras
|
||||
- CRUD completo (crear, editar, eliminar)
|
||||
- Asignar/remover contribuyentes
|
||||
- Asignar/remover auxiliares
|
||||
- Cascada: si supervisor pierde RFC → auxiliares pierden acceso
|
||||
|
||||
### Pricing
|
||||
- Catálogo de planes (Business Control $21,000/año, Business Cloud $15,000/año + $45/RFC/mes)
|
||||
- Add-ons recurrentes con multi-preapproval MercadoPago
|
||||
- Paquetes de timbres one-shot
|
||||
|
||||
### Connector BYO-DB
|
||||
- Provisioning de tunnel (Cloudflare Tunnel ready)
|
||||
- Heartbeat cada 30s con status en UI
|
||||
- getPool() refactorizado para decrypt de conexiones BYO
|
||||
|
||||
### Admin global
|
||||
- Dashboard cross-despacho (métricas, lista despachos, actividad)
|
||||
- Impersonación con motivo obligatorio + audit log
|
||||
- Audit log expuesto al owner del despacho
|
||||
|
||||
### Métricas pre-calculadas
|
||||
- Hot/cold: año actual on-the-fly, años pasados pre-calculados
|
||||
- Invalidación dirigida por cambios retroactivos en CFDIs
|
||||
- Tablas: metricas_mensuales, acumuladas_anuales, contraparte, invalidaciones
|
||||
|
||||
## Stack técnico
|
||||
|
||||
| Capa | Tecnología |
|
||||
|------|------------|
|
||||
| Frontend | Next.js 14, React 18, Tailwind, shadcn/ui, Zustand, React Query |
|
||||
| Backend | Node.js 20+, Express 4, TypeScript 5, Prisma 5.22 |
|
||||
| BD Central | PostgreSQL 16 (Prisma ORM) |
|
||||
| BD Tenant | PostgreSQL 16 (pg Pool + SQL raw + 17 migraciones numeradas) |
|
||||
| Auth | JWT (15min) + refresh (7d) + bcrypt + magic link ready |
|
||||
| Pagos | MercadoPago (preapproval + webhooks) |
|
||||
| Email | Nodemailer + SMTP |
|
||||
| Facturación | Facturapi (cuenta maestra broker) |
|
||||
|
||||
## Setup local
|
||||
|
||||
```bash
|
||||
# Requisitos: Node 20+, pnpm 9+, PostgreSQL 16+
|
||||
pnpm install
|
||||
cd apps/api && npx prisma generate
|
||||
cd apps/api && npx prisma migrate deploy
|
||||
cd apps/api && pnpm db:seed
|
||||
pnpm dev # API :4000 + Web :3000
|
||||
```
|
||||
|
||||
## Estructura de BD
|
||||
|
||||
### BD Central (Prisma)
|
||||
Tenant, User, TenantMembership, Rol, Subscription, SubscriptionAddon, Payment, PlanCatalogo, PlanAddonCatalogo, FielCredential, ConnectorHeartbeat, AuditLog, TimbreSuscripcion, TimbrePaquete, catálogos SAT.
|
||||
|
||||
### BD Tenant (SQL migrations 001-017)
|
||||
001-005: Schema base (rfcs, cfdis, conciliaciones, alertas, opiniones, declaraciones, constancias)
|
||||
006: tenant_migrations tracking
|
||||
007-009: Core (entidades_gestionadas, carteras, cliente_accesos)
|
||||
010-013: Vertical contable (contribuyentes, fiel_contribuyente, facturapi_orgs, cfdi contribuyente_id)
|
||||
014-017: Métricas (mensuales, acumuladas, contraparte, invalidaciones)
|
||||
|
||||
## Autor
|
||||
|
||||
**Carlos e Ivan (Horux 360)**
|
||||
|
||||
---
|
||||
7
apps/api/.env.example
Normal file
7
apps/api/.env.example
Normal file
@@ -0,0 +1,7 @@
|
||||
NODE_ENV=development
|
||||
PORT=4000
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/horux360?schema=public"
|
||||
JWT_SECRET=your-super-secret-jwt-key-min-32-chars-long-for-development
|
||||
JWT_EXPIRES_IN=15m
|
||||
JWT_REFRESH_EXPIRES_IN=7d
|
||||
CORS_ORIGIN=http://localhost:3000
|
||||
65
apps/api/package.json
Normal file
65
apps/api/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
121
apps/api/prisma/catalogos-sat-data.ts
Normal file
121
apps/api/prisma/catalogos-sat-data.ts
Normal 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' },
|
||||
];
|
||||
185
apps/api/prisma/eventos-fiscales-data.ts
Normal file
185
apps/api/prisma/eventos-fiscales-data.ts
Normal 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
103
apps/api/prisma/isr-data.ts
Normal 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 },
|
||||
],
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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");
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "sat_sync_jobs" ADD COLUMN "contribuyente_id" TEXT;
|
||||
@@ -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';
|
||||
@@ -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");
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "Plan" ADD VALUE 'mi_empresa';
|
||||
@@ -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());
|
||||
3
apps/api/prisma/migrations/migration_lock.toml
Normal file
3
apps/api/prisma/migrations/migration_lock.toml
Normal 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"
|
||||
744
apps/api/prisma/schema.prisma
Normal file
744
apps/api/prisma/schema.prisma
Normal file
@@ -0,0 +1,744 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Tenant {
|
||||
id String @id @default(uuid())
|
||||
nombre String
|
||||
rfc String @unique
|
||||
plan Plan @default(starter)
|
||||
databaseName String @unique @map("database_name")
|
||||
cfdiLimit Int @default(100) @map("cfdi_limit")
|
||||
usersLimit Int @default(1) @map("users_limit")
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
expiresAt DateTime? @map("expires_at")
|
||||
// Prueba gratuita: si está set y en el futuro, el tenant está en trial.
|
||||
// Se consume una sola vez por tenant (al activarla, nunca se regenera).
|
||||
trialEndsAt DateTime? @map("trial_ends_at")
|
||||
|
||||
facturapiOrgId String? @map("facturapi_org_id")
|
||||
|
||||
// Domicilio fiscal
|
||||
codigoPostal String? @map("codigo_postal") @db.VarChar(5)
|
||||
calle String? @db.VarChar(255)
|
||||
numExterior String? @map("num_exterior") @db.VarChar(20)
|
||||
numInterior String? @map("num_interior") @db.VarChar(20)
|
||||
colonia String? @db.VarChar(255)
|
||||
ciudad String? @db.VarChar(100)
|
||||
municipio String? @db.VarChar(100)
|
||||
estado String? @db.VarChar(100)
|
||||
telefono String? @db.VarChar(20)
|
||||
|
||||
// === Despacho fields ===
|
||||
verticalProfile VerticalProfile? @map("vertical_profile")
|
||||
dbMode DbMode? @map("db_mode")
|
||||
dbConnectionEnc String? @map("db_connection_enc")
|
||||
dbConnectionIv String? @map("db_connection_iv")
|
||||
dbSchemaVersion Int @default(0) @map("db_schema_version")
|
||||
connectorTokenEnc String? @map("connector_token_enc")
|
||||
connectorTunnelHostname String? @map("connector_tunnel_hostname")
|
||||
connectorLastSeen DateTime? @map("connector_last_seen")
|
||||
connectorVersion String? @map("connector_version") @db.VarChar(20)
|
||||
|
||||
memberships TenantMembership[]
|
||||
fielCredential FielCredential?
|
||||
satSyncJobs SatSyncJob[]
|
||||
subscriptions Subscription[]
|
||||
payments Payment[]
|
||||
regimenesIgnorados TenantRegimenIgnorado[]
|
||||
regimenesActivos TenantRegimenActivo[]
|
||||
coeficientes CoeficienteUtilidad[]
|
||||
timbreSuscripcion TimbreSuscripcion?
|
||||
timbrePaquetes TimbrePaquete[]
|
||||
connectorHeartbeats ConnectorHeartbeat[]
|
||||
|
||||
@@map("tenants")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
passwordHash String @map("password_hash")
|
||||
nombre String
|
||||
active Boolean @default(true)
|
||||
lastLogin DateTime? @map("last_login")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
// Contador para invalidar sesiones masivamente. Al incrementar, todos los
|
||||
// JWT emitidos antes (con tokenVersion menor) quedan rechazados en el
|
||||
// siguiente request. Se incrementa en: password change, password reset,
|
||||
// logout-all. Default 0 para compat con users pre-rollout.
|
||||
tokenVersion Int @default(0) @map("token_version")
|
||||
// Último tenant que el user activó (via switch-tenant). Se usa para resolver
|
||||
// el "tenant activo al login". Si es null, el login cae al primer membership
|
||||
// por joinedAt. Se actualiza en cada switch.
|
||||
lastTenantId String? @map("last_tenant_id")
|
||||
|
||||
memberships TenantMembership[]
|
||||
platformRoles UserPlatformRole[]
|
||||
passwordResetTokens PasswordResetToken[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
/// Relación many-to-many entre User y Tenant. Permite que un mismo user (p.ej.
|
||||
/// un dueño/contador) pertenezca a varios tenants con distintos roles. Esta
|
||||
/// tabla es la fuente de verdad del "¿a qué tenants tiene acceso este user?".
|
||||
///
|
||||
/// Durante la transición, `User.tenantId` y `User.rolId` se mantienen como
|
||||
/// "default tenant" para login UX. El backfill inicial crea 1 membership por
|
||||
/// user basado en esos campos. Cuando se agregue la UI de multi-tenant, los
|
||||
/// nuevos accesos solo tocarán esta tabla.
|
||||
model TenantMembership {
|
||||
id Int @id @default(autoincrement())
|
||||
userId String @map("user_id")
|
||||
tenantId String @map("tenant_id")
|
||||
rolId Int @map("rol_id")
|
||||
isOwner Boolean @default(false) @map("is_owner")
|
||||
active Boolean @default(true)
|
||||
joinedAt DateTime @default(now()) @map("joined_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
rol Rol @relation(fields: [rolId], references: [id])
|
||||
|
||||
@@unique([userId, tenantId])
|
||||
@@index([userId, active])
|
||||
@@index([tenantId, active])
|
||||
@@map("tenant_memberships")
|
||||
}
|
||||
|
||||
model Rol {
|
||||
id Int @id @default(autoincrement())
|
||||
nombre String @unique @db.VarChar(20)
|
||||
descripcion String?
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
memberships TenantMembership[]
|
||||
|
||||
@@map("roles")
|
||||
}
|
||||
|
||||
model RefreshToken {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
token String @unique
|
||||
expiresAt DateTime @map("expires_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@map("refresh_tokens")
|
||||
}
|
||||
|
||||
/// Tokens para recuperación de contraseña. Expiran en 1 hora, son single-use
|
||||
/// (se marca `usedAt` al consumir). Al completar reset se invalidan todos los
|
||||
/// refresh tokens del user — cierra todas sus sesiones forzando re-login.
|
||||
model PasswordResetToken {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
token String @unique
|
||||
expiresAt DateTime @map("expires_at")
|
||||
usedAt DateTime? @map("used_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@index([expiresAt])
|
||||
@@map("password_reset_tokens")
|
||||
}
|
||||
|
||||
enum Plan {
|
||||
starter
|
||||
business
|
||||
business_ia
|
||||
custom
|
||||
enterprise
|
||||
business_control
|
||||
business_cloud
|
||||
mi_empresa
|
||||
mi_empresa_plus
|
||||
}
|
||||
|
||||
enum VerticalProfile {
|
||||
CONTABLE
|
||||
JURIDICO
|
||||
ARQUITECTURA
|
||||
}
|
||||
|
||||
enum DbMode {
|
||||
BYO
|
||||
MANAGED
|
||||
}
|
||||
|
||||
|
||||
// ============================================
|
||||
// Catálogo de Regímenes Fiscales SAT
|
||||
// ============================================
|
||||
|
||||
model Regimen {
|
||||
id Int @id @default(autoincrement())
|
||||
clave String @unique @db.VarChar(3)
|
||||
descripcion String
|
||||
tipoPersona String @map("tipo_persona") @db.VarChar(20) // fisica, moral, ambos
|
||||
activo Boolean @default(true)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
tenantIgnorados TenantRegimenIgnorado[]
|
||||
tenantActivos TenantRegimenActivo[]
|
||||
|
||||
@@map("regimenes")
|
||||
}
|
||||
|
||||
model TenantRegimenIgnorado {
|
||||
id Int @id @default(autoincrement())
|
||||
tenantId String @map("tenant_id")
|
||||
regimenId Int @map("regimen_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
regimen Regimen @relation(fields: [regimenId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([tenantId, regimenId])
|
||||
@@map("tenant_regimenes_ignorados")
|
||||
}
|
||||
|
||||
model TenantRegimenActivo {
|
||||
id Int @id @default(autoincrement())
|
||||
tenantId String @map("tenant_id")
|
||||
regimenId Int @map("regimen_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
regimen Regimen @relation(fields: [regimenId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([tenantId, regimenId])
|
||||
@@map("tenant_regimenes_activos")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Catálogo de Eventos Fiscales
|
||||
// ============================================
|
||||
|
||||
model EventoFiscalCatalogo {
|
||||
id Int @id @default(autoincrement())
|
||||
titulo String
|
||||
descripcion String?
|
||||
tipo String @db.VarChar(20) // declaracion, pago, obligacion, informativa
|
||||
diaBase Int @map("dia_base") // día del mes (17, 3, 31, etc.)
|
||||
mesRelativo Int @default(1) @map("mes_relativo") // 1=mes posterior, 2=segundo mes posterior, 0=mes fijo
|
||||
mesFijo Int? @map("mes_fijo") // para anuales: 2=feb, 3=mar, 4=abr
|
||||
recurrencia String @default("mensual") @db.VarChar(20) // mensual, anual
|
||||
usaExtensionRfc Boolean @default(false) @map("usa_extension_rfc")
|
||||
regimenes String @default("todos") // 'todos' o CSV de claves: '601,603,612'
|
||||
condicion String? @db.VarChar(50) // null, 'tiene_nomina', 'ingresos_4m'
|
||||
activo Boolean @default(true)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@map("eventos_fiscales_catalogo")
|
||||
}
|
||||
|
||||
/// Lista negra SAT (Art. 69-B CFF)
|
||||
model ListaNegra {
|
||||
id Int @id @default(autoincrement())
|
||||
rfc String @unique @db.VarChar(13)
|
||||
nombre String
|
||||
situacion String @db.VarChar(30) // Definitivo, Presunto, Desvirtuado, Sentencia Favorable
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([rfc])
|
||||
@@map("lista_negra")
|
||||
}
|
||||
|
||||
/// Días inhábiles fiscales (festivos oficiales de México)
|
||||
model DiaInhabil {
|
||||
id Int @id @default(autoincrement())
|
||||
fecha DateTime @unique @db.Date
|
||||
nombre String
|
||||
|
||||
@@map("dias_inhabiles")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ISR Tables
|
||||
// ============================================
|
||||
|
||||
/// Tasas RESICO (Art. 113-E) - tasa plana por bracket mensual
|
||||
model IsrResicoTasa {
|
||||
id Int @id @default(autoincrement())
|
||||
anio Int @map("anio")
|
||||
montoMaximo Decimal @map("monto_maximo") @db.Decimal(18, 2)
|
||||
porcentaje Decimal @db.Decimal(5, 2)
|
||||
|
||||
@@unique([anio, montoMaximo])
|
||||
@@map("isr_resico_tasas")
|
||||
}
|
||||
|
||||
/// Tarifa ISR progresiva (Art. 96) - mensual
|
||||
model IsrTarifa {
|
||||
id Int @id @default(autoincrement())
|
||||
anio Int @map("anio")
|
||||
limiteInferior Decimal @map("limite_inferior") @db.Decimal(18, 2)
|
||||
limiteSuperior Decimal? @map("limite_superior") @db.Decimal(18, 2)
|
||||
cuotaFija Decimal @map("cuota_fija") @db.Decimal(18, 2)
|
||||
porcentajeExcedente Decimal @map("porcentaje_excedente") @db.Decimal(5, 2)
|
||||
|
||||
@@unique([anio, limiteInferior])
|
||||
@@map("isr_tarifas")
|
||||
}
|
||||
|
||||
/// Coeficiente de utilidad por tenant/año (no se sobrescribe)
|
||||
model CoeficienteUtilidad {
|
||||
id Int @id @default(autoincrement())
|
||||
tenantId String @map("tenant_id")
|
||||
anio Int @map("anio")
|
||||
coeficiente Decimal @db.Decimal(10, 4)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([tenantId, anio])
|
||||
@@map("coeficiente_utilidad")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// SAT Sync Models
|
||||
// ============================================
|
||||
|
||||
model FielCredential {
|
||||
id String @id @default(uuid())
|
||||
tenantId String @unique @map("tenant_id")
|
||||
rfc String @db.VarChar(13)
|
||||
cerData Bytes @map("cer_data")
|
||||
keyData Bytes @map("key_data")
|
||||
keyPasswordEncrypted Bytes @map("key_password_encrypted")
|
||||
cerIv Bytes @map("cer_iv")
|
||||
cerTag Bytes @map("cer_tag")
|
||||
keyIv Bytes @map("key_iv")
|
||||
keyTag Bytes @map("key_tag")
|
||||
passwordIv Bytes @map("password_iv")
|
||||
passwordTag Bytes @map("password_tag")
|
||||
serialNumber String? @map("serial_number") @db.VarChar(50)
|
||||
validFrom DateTime @map("valid_from")
|
||||
validUntil DateTime @map("valid_until")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("fiel_credentials")
|
||||
}
|
||||
|
||||
model Subscription {
|
||||
id String @id @default(uuid())
|
||||
tenantId String @map("tenant_id")
|
||||
plan Plan
|
||||
mpPreapprovalId String? @map("mp_preapproval_id")
|
||||
status String @default("pending")
|
||||
amount Decimal @db.Decimal(10, 2)
|
||||
frequency String @default("monthly")
|
||||
currentPeriodStart DateTime? @map("current_period_start")
|
||||
currentPeriodEnd DateTime? @map("current_period_end")
|
||||
// Cambio programado al próximo período (downgrades y cambios de frecuencia)
|
||||
pendingPlan Plan? @map("pending_plan")
|
||||
pendingFrequency String? @map("pending_frequency")
|
||||
pendingEffectiveAt DateTime? @map("pending_effective_at")
|
||||
// Upgrade inmediato en curso: preference MP esperando cobro prorateado.
|
||||
// Cuando el webhook confirma el pago, se aplica el plan nuevo y se limpian estos campos.
|
||||
upgradePreferenceId String? @map("upgrade_preference_id")
|
||||
upgradeTargetPlan Plan? @map("upgrade_target_plan")
|
||||
upgradeTargetAmount Decimal? @db.Decimal(10, 2) @map("upgrade_target_amount")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
payments Payment[]
|
||||
addons SubscriptionAddon[]
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([status])
|
||||
@@index([pendingEffectiveAt])
|
||||
@@map("subscriptions")
|
||||
}
|
||||
|
||||
model SubscriptionAddon {
|
||||
id String @id @default(uuid())
|
||||
subscriptionId String @map("subscription_id")
|
||||
planAddonCatalogoId String @map("plan_addon_catalogo_id")
|
||||
/// UUID del contribuyente (entidad_id en tenant BD) cuando el add-on
|
||||
/// aplica a un RFC específico. NULL para add-ons a nivel tenant (módulos
|
||||
/// globales, +RFCs, +timbres). Sin FK porque contribuyente vive en BD tenant.
|
||||
contribuyenteId String? @map("contribuyente_id")
|
||||
mpPreapprovalId String? @map("mp_preapproval_id")
|
||||
status String @default("pending")
|
||||
quantity Int @default(1)
|
||||
amount Decimal @db.Decimal(10, 2)
|
||||
currentPeriodStart DateTime? @map("current_period_start")
|
||||
currentPeriodEnd DateTime? @map("current_period_end")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
subscription Subscription @relation(fields: [subscriptionId], references: [id])
|
||||
planAddonCatalogo PlanAddonCatalogo @relation(fields: [planAddonCatalogoId], references: [id])
|
||||
|
||||
/// Sin UNIQUE compuesto: la validación de "un solo add-on activo por
|
||||
/// (subscription, addon, contribuyente?)" queda a nivel aplicación
|
||||
/// (findFirst en subscribeAddon), porque Postgres trata NULL!=NULL y no
|
||||
/// hay forma trivial de enforcar unicidad con contribuyenteId opcional.
|
||||
@@index([subscriptionId])
|
||||
@@index([subscriptionId, contribuyenteId])
|
||||
@@map("subscription_addons")
|
||||
}
|
||||
|
||||
/// Roles de plataforma (staff interno de Horux 360) — ortogonales al rol per-tenant.
|
||||
/// Un user puede tener 0, 1 o varios roles. `platform_admin` es el superrol.
|
||||
/// Ver `docs/plans/2026-04-14-platform-admin-roles.md`.
|
||||
enum PlatformRole {
|
||||
platform_admin // Todo: precios, clientes, facturas, suscripciones, gestión de staff
|
||||
platform_ti // Mismos permisos que admin (equipo de TI / tech ops). Diferencia solo en trazabilidad.
|
||||
platform_support // Ver todos los tenants, resolver tickets, NO facturación/precios
|
||||
platform_sales // Crear/editar tenants (onboarding), ver suscripciones, NO precios
|
||||
platform_finance // Ver payments, emitir facturas manuales, editar precios, reportes fiscales
|
||||
}
|
||||
|
||||
model UserPlatformRole {
|
||||
id Int @id @default(autoincrement())
|
||||
userId String @map("user_id")
|
||||
role PlatformRole
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
createdBy String? @map("created_by") // User.id de quien asignó (audit trail)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, role])
|
||||
@@index([role])
|
||||
@@map("user_platform_roles")
|
||||
}
|
||||
|
||||
/// Registro de acciones críticas para auditoría (SAT compliance, forense, disputas).
|
||||
/// Se instrumenta vía `utils/audit.ts` con helper fire-and-forget — un fallo al
|
||||
/// escribir aquí NUNCA debe romper la acción principal.
|
||||
model AuditLog {
|
||||
id String @id @default(uuid())
|
||||
userId String? @map("user_id")
|
||||
tenantId String? @map("tenant_id")
|
||||
action String @db.VarChar(64) // "price.updated", "subscription.cancelled", etc.
|
||||
entityType String? @map("entity_type") @db.VarChar(32)
|
||||
entityId String? @map("entity_id")
|
||||
metadata Json? // before/after, ip, userAgent, contexto
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([userId, createdAt])
|
||||
@@index([tenantId, createdAt])
|
||||
@@index([action, createdAt])
|
||||
@@index([entityType, entityId])
|
||||
@@map("audit_log")
|
||||
}
|
||||
|
||||
/// Padrón persistente de RFCs que ya consumieron su prueba gratuita de 30 días.
|
||||
/// Sobrevive al ciclo de vida del Tenant (si se borra/recrea, el RFC sigue aquí),
|
||||
/// bloqueando el abuso de "registro nuevo con el mismo RFC para otro trial".
|
||||
model TrialUsage {
|
||||
id Int @id @default(autoincrement())
|
||||
rfc String @unique @db.VarChar(13)
|
||||
tenantId String? @map("tenant_id") // Tenant que consumió (null si el tenant se borró después)
|
||||
startedAt DateTime @default(now()) @map("started_at")
|
||||
|
||||
@@map("trial_usages")
|
||||
}
|
||||
|
||||
/// Precios editables de los planes (self-serve). Custom no se guarda aquí
|
||||
/// porque cada cliente tiene su monto propio (lo fija el admin al crear tenant).
|
||||
model PlanPrice {
|
||||
id Int @id @default(autoincrement())
|
||||
plan Plan
|
||||
frequency String // "monthly" | "annual"
|
||||
amount Decimal @db.Decimal(10, 2)
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@unique([plan, frequency])
|
||||
@@map("plan_prices")
|
||||
}
|
||||
|
||||
/// Precios editables por admin global de los planes despacho.
|
||||
/// Antes vivía en `DESPACHO_PLAN_PRICES` (catálogo estático en `@horux/shared`);
|
||||
/// movido a BD para permitir actualización desde `/configuracion/precios-suscripcion`.
|
||||
/// Si una fila no existe, `getPlanPrice` cae al catálogo estático como fallback.
|
||||
model DespachoPlanPrice {
|
||||
plan String @id // mi_empresa | mi_empresa_plus | business_control | business_cloud
|
||||
monthly Decimal? @db.Decimal(10, 2)
|
||||
firstYear Decimal @db.Decimal(10, 2) @map("first_year")
|
||||
renewal Decimal @db.Decimal(10, 2)
|
||||
permiteMonthly Boolean @default(false) @map("permite_monthly")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("despacho_plan_prices")
|
||||
}
|
||||
|
||||
model PlanCatalogo {
|
||||
id String @id @default(uuid())
|
||||
codename String @unique @db.VarChar(50)
|
||||
nombre String
|
||||
verticalProfile VerticalProfile
|
||||
precioBase Decimal @db.Decimal(10, 2) @map("precio_base")
|
||||
frecuencia String @db.VarChar(10)
|
||||
limits Json
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@map("plan_catalogo")
|
||||
}
|
||||
|
||||
model PlanAddonCatalogo {
|
||||
id String @id @default(uuid())
|
||||
codename String @unique @db.VarChar(50)
|
||||
nombre String
|
||||
verticalProfile VerticalProfile?
|
||||
precio Decimal @db.Decimal(10, 2)
|
||||
frecuencia String @db.VarChar(10)
|
||||
delta Json
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
subscriptionAddons SubscriptionAddon[]
|
||||
|
||||
@@map("plan_addon_catalogo")
|
||||
}
|
||||
|
||||
model ConnectorHeartbeat {
|
||||
id String @id @default(uuid())
|
||||
tenantId String @map("tenant_id")
|
||||
timestamp DateTime @default(now())
|
||||
latencyMs Int @map("latency_ms")
|
||||
version String @db.VarChar(20)
|
||||
pgVersion String? @map("pg_version") @db.VarChar(50)
|
||||
status String @db.VarChar(20)
|
||||
errorMsg String? @map("error_msg")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([tenantId, timestamp])
|
||||
@@map("connector_heartbeats")
|
||||
}
|
||||
|
||||
enum PaymentKind {
|
||||
subscription
|
||||
timbres_pack
|
||||
}
|
||||
|
||||
model Payment {
|
||||
id String @id @default(uuid())
|
||||
tenantId String @map("tenant_id")
|
||||
subscriptionId String? @map("subscription_id")
|
||||
mpPaymentId String? @map("mp_payment_id")
|
||||
amount Decimal @db.Decimal(10, 2)
|
||||
status String @default("pending")
|
||||
paymentMethod String? @map("payment_method")
|
||||
paidAt DateTime? @map("paid_at")
|
||||
// Tipo de pago. subscription = cobro mensual/anual del plan.
|
||||
// timbres_pack = compra de paquete de timbres adicionales.
|
||||
kind PaymentKind @default(subscription)
|
||||
// ID de la factura emitida auto por Facturapi. Null si no se facturó:
|
||||
// primer pago (manual), trial sin monto, o fallo al emitir.
|
||||
facturapiInvoiceId String? @map("facturapi_invoice_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
subscription Subscription? @relation(fields: [subscriptionId], references: [id])
|
||||
timbrePaquete TimbrePaquete?
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([subscriptionId])
|
||||
@@map("payments")
|
||||
}
|
||||
|
||||
/// Catálogo de paquetes de timbres adicionales vendibles. Precios editables
|
||||
/// desde panel admin. Los 3 defaults (100/$200, 1000/$1400, 10000/$8600) se
|
||||
/// insertan en seed idempotente.
|
||||
model TimbrePaqueteCatalogo {
|
||||
id Int @id @default(autoincrement())
|
||||
cantidad Int @unique // 100, 1000, 10000
|
||||
precio Decimal @db.Decimal(10, 2)
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("timbre_paquetes_catalogo")
|
||||
}
|
||||
|
||||
/// Compra individual de timbres adicionales. Los timbres del plan (mensuales)
|
||||
/// se rastrean en TimbreSuscripcion — esto es SOLO para los extras pagados.
|
||||
/// Vigencia 1 año desde `adquiridoEn`. El orden de consumo es FIFO por
|
||||
/// `expiraEn` (menor primero) para no desperdiciar paquetes próximos a vencer.
|
||||
model TimbrePaquete {
|
||||
id Int @id @default(autoincrement())
|
||||
tenantId String @map("tenant_id")
|
||||
paymentId String? @unique @map("payment_id") // Payment que lo compró; null si admin grant manual
|
||||
cantidad Int // cuántos timbres tenía originalmente
|
||||
usados Int @default(0)
|
||||
precio Decimal @db.Decimal(10, 2) // precio pagado (historial, no cambia si el catálogo cambia)
|
||||
adquiridoEn DateTime @default(now()) @map("adquirido_en")
|
||||
expiraEn DateTime @map("expira_en") // adquiridoEn + 1 año
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
payment Payment? @relation(fields: [paymentId], references: [id])
|
||||
|
||||
@@index([tenantId, expiraEn])
|
||||
@@map("timbre_paquetes")
|
||||
}
|
||||
|
||||
model SatSyncJob {
|
||||
id String @id @default(uuid())
|
||||
tenantId String @map("tenant_id")
|
||||
contribuyenteId String? @map("contribuyente_id")
|
||||
type SatSyncType
|
||||
status SatSyncStatus @default(pending)
|
||||
dateFrom DateTime @map("date_from") @db.Date
|
||||
dateTo DateTime @map("date_to") @db.Date
|
||||
cfdiType CfdiSyncType? @map("cfdi_type")
|
||||
satRequestId String? @map("sat_request_id") @db.VarChar(50)
|
||||
satPackageIds String[] @map("sat_package_ids")
|
||||
cfdisFound Int @default(0) @map("cfdis_found")
|
||||
cfdisDownloaded Int @default(0) @map("cfdis_downloaded")
|
||||
cfdisInserted Int @default(0) @map("cfdis_inserted")
|
||||
cfdisUpdated Int @default(0) @map("cfdis_updated")
|
||||
progressPercent Int @default(0) @map("progress_percent")
|
||||
errorMessage String? @map("error_message")
|
||||
startedAt DateTime? @map("started_at")
|
||||
completedAt DateTime? @map("completed_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
retryCount Int @default(0) @map("retry_count")
|
||||
nextRetryAt DateTime? @map("next_retry_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([status])
|
||||
@@index([status, nextRetryAt])
|
||||
@@map("sat_sync_jobs")
|
||||
}
|
||||
|
||||
enum SatSyncType {
|
||||
initial
|
||||
daily
|
||||
incremental
|
||||
}
|
||||
|
||||
enum SatSyncStatus {
|
||||
pending
|
||||
running
|
||||
completed
|
||||
failed
|
||||
}
|
||||
|
||||
enum CfdiSyncType {
|
||||
emitidos
|
||||
recibidos
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Catálogos SAT para Facturación (CFDI 4.0)
|
||||
// ============================================
|
||||
|
||||
model CatFormaPago {
|
||||
id Int @id @default(autoincrement())
|
||||
clave String @unique @db.VarChar(2)
|
||||
descripcion String
|
||||
|
||||
@@map("cat_forma_pago")
|
||||
}
|
||||
|
||||
model CatMetodoPago {
|
||||
id Int @id @default(autoincrement())
|
||||
clave String @unique @db.VarChar(3)
|
||||
descripcion String
|
||||
|
||||
@@map("cat_metodo_pago")
|
||||
}
|
||||
|
||||
model CatUsoCfdi {
|
||||
id Int @id @default(autoincrement())
|
||||
clave String @unique @db.VarChar(4)
|
||||
descripcion String
|
||||
personaFisica Boolean @default(true) @map("persona_fisica")
|
||||
personaMoral Boolean @default(true) @map("persona_moral")
|
||||
|
||||
@@map("cat_uso_cfdi")
|
||||
}
|
||||
|
||||
model CatMoneda {
|
||||
id Int @id @default(autoincrement())
|
||||
clave String @unique @db.VarChar(3)
|
||||
descripcion String
|
||||
decimales Int @default(2)
|
||||
|
||||
@@map("cat_moneda")
|
||||
}
|
||||
|
||||
model CatClaveUnidad {
|
||||
id Int @id @default(autoincrement())
|
||||
clave String @unique @db.VarChar(10)
|
||||
descripcion String
|
||||
|
||||
@@map("cat_clave_unidad")
|
||||
}
|
||||
|
||||
model CatClaveProdServ {
|
||||
id Int @id @default(autoincrement())
|
||||
clave String @unique @db.VarChar(8)
|
||||
descripcion String
|
||||
|
||||
@@index([descripcion])
|
||||
@@map("cat_clave_prod_serv")
|
||||
}
|
||||
|
||||
model CatObjetoImp {
|
||||
id Int @id @default(autoincrement())
|
||||
clave String @unique @db.VarChar(2)
|
||||
descripcion String
|
||||
|
||||
@@map("cat_objeto_imp")
|
||||
}
|
||||
|
||||
model CatTipoRelacion {
|
||||
id Int @id @default(autoincrement())
|
||||
clave String @unique @db.VarChar(2)
|
||||
descripcion String
|
||||
|
||||
@@map("cat_tipo_relacion")
|
||||
}
|
||||
|
||||
model CatExportacion {
|
||||
id Int @id @default(autoincrement())
|
||||
clave String @unique @db.VarChar(2)
|
||||
descripcion String
|
||||
|
||||
@@map("cat_exportacion")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Gestión de Timbres Facturapi
|
||||
// ============================================
|
||||
|
||||
model TimbreSuscripcion {
|
||||
id Int @id @default(autoincrement())
|
||||
tenantId String @unique @map("tenant_id")
|
||||
tipo String @db.VarChar(10) // mensual, anual
|
||||
timbresLimite Int @map("timbres_limite") // 50 o 600
|
||||
timbresUsados Int @default(0) @map("timbres_usados")
|
||||
periodoInicio DateTime @map("periodo_inicio") @db.Date
|
||||
periodoFin DateTime @map("periodo_fin") @db.Date
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("timbre_suscripciones")
|
||||
}
|
||||
559
apps/api/prisma/seed.ts
Normal file
559
apps/api/prisma/seed.ts
Normal file
@@ -0,0 +1,559 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { Pool } from 'pg';
|
||||
import { migrate } from '../src/config/tenant-migrations.js';
|
||||
import { RESICO_TASAS, ISR_TARIFAS } from './isr-data.js';
|
||||
import { EVENTOS_FISCALES, DIAS_INHABILES } from './eventos-fiscales-data.js';
|
||||
import {
|
||||
FORMAS_PAGO, METODOS_PAGO, USOS_CFDI, MONEDAS, CLAVES_UNIDAD,
|
||||
OBJETOS_IMP, TIPOS_RELACION, EXPORTACIONES,
|
||||
} from './catalogos-sat-data.js';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
function parseDatabaseUrl(url: string) {
|
||||
const parsed = new URL(url);
|
||||
return {
|
||||
host: parsed.hostname,
|
||||
port: parseInt(parsed.port || '5432'),
|
||||
user: decodeURIComponent(parsed.username),
|
||||
password: decodeURIComponent(parsed.password),
|
||||
};
|
||||
}
|
||||
|
||||
const REGIMENES_SAT = [
|
||||
{ clave: '601', descripcion: 'General de Ley Personas Morales', tipoPersona: 'moral' },
|
||||
{ clave: '603', descripcion: 'Personas Morales con Fines no Lucrativos', tipoPersona: 'moral' },
|
||||
{ clave: '605', descripcion: 'Sueldos y Salarios e Ingresos Asimilados a Salarios', tipoPersona: 'fisica' },
|
||||
{ clave: '606', descripcion: 'Arrendamiento', tipoPersona: 'fisica' },
|
||||
{ clave: '607', descripcion: 'Régimen de Enajenación o Adquisición de Bienes', tipoPersona: 'fisica' },
|
||||
{ clave: '608', descripcion: 'Demás ingresos', tipoPersona: 'fisica' },
|
||||
{ clave: '610', descripcion: 'Residentes en el Extranjero sin Establecimiento Permanente en México', tipoPersona: 'ambos' },
|
||||
{ clave: '611', descripcion: 'Ingresos por Dividendos (socios y accionistas)', tipoPersona: 'fisica' },
|
||||
{ clave: '612', descripcion: 'Personas Físicas con Actividades Empresariales y Profesionales', tipoPersona: 'fisica' },
|
||||
{ clave: '614', descripcion: 'Ingresos por intereses', tipoPersona: 'fisica' },
|
||||
{ clave: '615', descripcion: 'Régimen de los ingresos por obtención de premios', tipoPersona: 'fisica' },
|
||||
{ clave: '616', descripcion: 'Sin obligaciones fiscales', tipoPersona: 'ambos' },
|
||||
{ clave: '620', descripcion: 'Sociedades Cooperativas de Producción que optan por diferir sus ingresos', tipoPersona: 'moral' },
|
||||
{ clave: '621', descripcion: 'Incorporación Fiscal', tipoPersona: 'fisica' },
|
||||
{ clave: '622', descripcion: 'Actividades Agrícolas, Ganaderas, Silvícolas y Pesqueras', tipoPersona: 'ambos' },
|
||||
{ clave: '623', descripcion: 'Opcional para Grupos de Sociedades', tipoPersona: 'moral' },
|
||||
{ clave: '624', descripcion: 'Coordinados', tipoPersona: 'moral' },
|
||||
{ clave: '625', descripcion: 'Régimen de las Actividades Empresariales con ingresos a través de Plataformas Tecnológicas', tipoPersona: 'fisica' },
|
||||
{ clave: '626', descripcion: 'Régimen Simplificado de Confianza', tipoPersona: 'ambos' },
|
||||
];
|
||||
|
||||
async function main() {
|
||||
console.log('🌱 Seeding database...');
|
||||
|
||||
// Seed regimenes catalog
|
||||
for (const r of REGIMENES_SAT) {
|
||||
await prisma.regimen.upsert({
|
||||
where: { clave: r.clave },
|
||||
update: { descripcion: r.descripcion, tipoPersona: r.tipoPersona },
|
||||
create: r,
|
||||
});
|
||||
}
|
||||
console.log(`✅ ${REGIMENES_SAT.length} regímenes fiscales SAT cargados`);
|
||||
|
||||
// Seed ISR tables — limpiar y recrear
|
||||
await prisma.isrResicoTasa.deleteMany();
|
||||
await prisma.isrTarifa.deleteMany();
|
||||
|
||||
for (const anio of [2020, 2021, 2022, 2023, 2024, 2025, 2026]) {
|
||||
if (anio >= 2022) {
|
||||
await prisma.isrResicoTasa.createMany({
|
||||
data: RESICO_TASAS.map(t => ({ anio, montoMaximo: t.montoMaximo, porcentaje: t.porcentaje })),
|
||||
});
|
||||
}
|
||||
|
||||
const tarifas = ISR_TARIFAS[anio];
|
||||
if (tarifas) {
|
||||
await prisma.isrTarifa.createMany({
|
||||
data: tarifas.map(t => ({
|
||||
anio,
|
||||
limiteInferior: t.li,
|
||||
limiteSuperior: t.ls,
|
||||
cuotaFija: t.cf,
|
||||
porcentajeExcedente: t.pe,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
console.log('✅ Tablas ISR 2020-2026 cargadas');
|
||||
|
||||
// Seed eventos fiscales catálogo
|
||||
await prisma.eventoFiscalCatalogo.deleteMany();
|
||||
await prisma.eventoFiscalCatalogo.createMany({
|
||||
data: EVENTOS_FISCALES.map(e => ({
|
||||
titulo: e.titulo,
|
||||
tipo: e.tipo,
|
||||
diaBase: e.diaBase,
|
||||
mesRelativo: e.mesRelativo,
|
||||
mesFijo: (e as any).mesFijo || null,
|
||||
recurrencia: e.recurrencia,
|
||||
usaExtensionRfc: e.usaExtensionRfc,
|
||||
regimenes: e.regimenes,
|
||||
condicion: e.condicion || null,
|
||||
})),
|
||||
});
|
||||
console.log(`✅ ${EVENTOS_FISCALES.length} eventos fiscales cargados`);
|
||||
|
||||
// Seed días inhábiles
|
||||
await prisma.diaInhabil.deleteMany();
|
||||
await prisma.diaInhabil.createMany({
|
||||
data: DIAS_INHABILES.map(d => ({ fecha: new Date(d.fecha), nombre: d.nombre })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
console.log(`✅ ${DIAS_INHABILES.length} días inhábiles cargados (2020-2027)`);
|
||||
|
||||
// Seed catálogos SAT para facturación
|
||||
for (const fp of FORMAS_PAGO) {
|
||||
await prisma.catFormaPago.upsert({ where: { clave: fp.clave }, update: { descripcion: fp.descripcion }, create: fp });
|
||||
}
|
||||
console.log(`✅ ${FORMAS_PAGO.length} formas de pago cargadas`);
|
||||
|
||||
for (const mp of METODOS_PAGO) {
|
||||
await prisma.catMetodoPago.upsert({ where: { clave: mp.clave }, update: { descripcion: mp.descripcion }, create: mp });
|
||||
}
|
||||
console.log(`✅ ${METODOS_PAGO.length} métodos de pago cargados`);
|
||||
|
||||
for (const u of USOS_CFDI) {
|
||||
await prisma.catUsoCfdi.upsert({ where: { clave: u.clave }, update: { descripcion: u.descripcion, personaFisica: u.personaFisica, personaMoral: u.personaMoral }, create: u });
|
||||
}
|
||||
console.log(`✅ ${USOS_CFDI.length} usos CFDI cargados`);
|
||||
|
||||
for (const m of MONEDAS) {
|
||||
await prisma.catMoneda.upsert({ where: { clave: m.clave }, update: { descripcion: m.descripcion, decimales: m.decimales }, create: m });
|
||||
}
|
||||
console.log(`✅ ${MONEDAS.length} monedas cargadas`);
|
||||
|
||||
for (const cu of CLAVES_UNIDAD) {
|
||||
await prisma.catClaveUnidad.upsert({ where: { clave: cu.clave }, update: { descripcion: cu.descripcion }, create: cu });
|
||||
}
|
||||
console.log(`✅ ${CLAVES_UNIDAD.length} claves de unidad cargadas`);
|
||||
|
||||
for (const oi of OBJETOS_IMP) {
|
||||
await prisma.catObjetoImp.upsert({ where: { clave: oi.clave }, update: { descripcion: oi.descripcion }, create: oi });
|
||||
}
|
||||
console.log(`✅ ${OBJETOS_IMP.length} objetos de impuesto cargados`);
|
||||
|
||||
for (const tr of TIPOS_RELACION) {
|
||||
await prisma.catTipoRelacion.upsert({ where: { clave: tr.clave }, update: { descripcion: tr.descripcion }, create: tr });
|
||||
}
|
||||
console.log(`✅ ${TIPOS_RELACION.length} tipos de relación cargados`);
|
||||
|
||||
for (const ex of EXPORTACIONES) {
|
||||
await prisma.catExportacion.upsert({ where: { clave: ex.clave }, update: { descripcion: ex.descripcion }, create: ex });
|
||||
}
|
||||
console.log(`✅ ${EXPORTACIONES.length} exportaciones cargadas`);
|
||||
|
||||
// Seed precios de planes (editables vía BD — custom no se incluye, se fija por tenant)
|
||||
const PLAN_PRICES = [
|
||||
{ plan: 'starter' as const, frequency: 'monthly', amount: 199 },
|
||||
{ plan: 'starter' as const, frequency: 'annual', amount: 1990 },
|
||||
{ plan: 'business' as const, frequency: 'monthly', amount: 480 },
|
||||
{ plan: 'business' as const, frequency: 'annual', amount: 4800 },
|
||||
{ plan: 'business_ia' as const, frequency: 'monthly', amount: 780 },
|
||||
{ plan: 'business_ia' as const, frequency: 'annual', amount: 7800 },
|
||||
{ plan: 'enterprise' as const, frequency: 'monthly', amount: 900 },
|
||||
{ plan: 'enterprise' as const, frequency: 'annual', amount: 9000 },
|
||||
];
|
||||
for (const p of PLAN_PRICES) {
|
||||
await prisma.planPrice.upsert({
|
||||
where: { plan_frequency: { plan: p.plan, frequency: p.frequency } },
|
||||
update: { amount: p.amount },
|
||||
create: p,
|
||||
});
|
||||
}
|
||||
console.log(`✅ ${PLAN_PRICES.length} precios de planes cargados`);
|
||||
|
||||
// Catálogo de paquetes de timbres adicionales. Editables desde panel admin.
|
||||
// Se crean con upsert por `cantidad` (unique) — permite reejecutar seed sin
|
||||
// sobrescribir precios ya ajustados manualmente: si el row existe, update
|
||||
// NO toca el precio (solo active + updatedAt si hace falta), sólo lo crea
|
||||
// si no existía. Si se quiere forzar reset de precios, borrar las filas.
|
||||
const TIMBRE_PAQUETES = [
|
||||
{ cantidad: 100, precio: 200 },
|
||||
{ cantidad: 1000, precio: 1400 },
|
||||
{ cantidad: 10000, precio: 8600 },
|
||||
];
|
||||
for (const p of TIMBRE_PAQUETES) {
|
||||
await prisma.timbrePaqueteCatalogo.upsert({
|
||||
where: { cantidad: p.cantidad },
|
||||
update: {}, // No tocamos `precio` si ya existe (admin pudo editarlo)
|
||||
create: { cantidad: p.cantidad, precio: p.precio, active: true },
|
||||
});
|
||||
}
|
||||
console.log(`✅ ${TIMBRE_PAQUETES.length} paquetes de timbres en catálogo`);
|
||||
|
||||
const databaseName = 'horux_ede123456ab1';
|
||||
|
||||
// Create demo tenant
|
||||
const tenant = await prisma.tenant.upsert({
|
||||
where: { rfc: 'EDE123456AB1' },
|
||||
update: {},
|
||||
create: {
|
||||
nombre: 'Empresa Demo SA de CV',
|
||||
rfc: 'EDE123456AB1',
|
||||
plan: 'business',
|
||||
databaseName,
|
||||
cfdiLimit: 500,
|
||||
usersLimit: 3,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('✅ Tenant created:', tenant.nombre);
|
||||
|
||||
// Migración: renombra el rol legacy 'admin' a 'owner' si sobrevive de un seed viejo.
|
||||
// Idempotente (no-op si ya se renombró o nunca existió).
|
||||
await prisma.$executeRawUnsafe(`UPDATE roles SET nombre = 'owner' WHERE nombre = 'admin'`);
|
||||
|
||||
// Backfill de trial_usages para tenants que ya consumieron su trial antes de que
|
||||
// existiera esta tabla. Idempotente: ON CONFLICT DO NOTHING.
|
||||
await prisma.$executeRawUnsafe(`
|
||||
INSERT INTO trial_usages (rfc, tenant_id, started_at)
|
||||
SELECT UPPER(rfc), id, COALESCE(created_at, NOW())
|
||||
FROM tenants
|
||||
WHERE trial_ends_at IS NOT NULL
|
||||
ON CONFLICT (rfc) DO NOTHING
|
||||
`);
|
||||
|
||||
// Backfill de user_platform_roles: los owners del tenant HTS240708LJA se
|
||||
// convierten automáticamente en platform_admin. Esto preserva el comportamiento
|
||||
// anterior (admin global por RFC) al mismo tiempo que abre la puerta al modelo
|
||||
// de roles granulares. Idempotente.
|
||||
await prisma.$executeRawUnsafe(`
|
||||
INSERT INTO user_platform_roles (user_id, role, created_at)
|
||||
SELECT u.id, 'platform_admin'::"PlatformRole", NOW()
|
||||
FROM users u
|
||||
JOIN tenants t ON u.tenant_id = t.id
|
||||
JOIN roles r ON u.rol_id = r.id
|
||||
WHERE t.rfc = 'HTS240708LJA' AND r.nombre = 'owner'
|
||||
ON CONFLICT (user_id, role) DO NOTHING
|
||||
`);
|
||||
|
||||
// Backfill de tenant_memberships: cada user existente genera una membership
|
||||
// con su tenant y rol actuales. isOwner = true si su rol es 'owner' (u 'cfo',
|
||||
// que es equivalente en permisos). Esto es el fundamento del modelo multi-tenant
|
||||
// — durante la transición, User.tenantId sigue siendo el "default tenant" para
|
||||
// login UX, pero las autorizaciones verdaderas vienen de esta tabla.
|
||||
// Idempotente: ON CONFLICT evita duplicados al re-correr seed.
|
||||
await prisma.$executeRawUnsafe(`
|
||||
INSERT INTO tenant_memberships (user_id, tenant_id, rol_id, is_owner, active, joined_at)
|
||||
SELECT u.id, u.tenant_id, u.rol_id, (r.nombre IN ('owner', 'cfo')), u.active, u.created_at
|
||||
FROM users u
|
||||
JOIN roles r ON u.rol_id = r.id
|
||||
ON CONFLICT (user_id, tenant_id) DO NOTHING
|
||||
`);
|
||||
|
||||
// Seed roles
|
||||
const rolesData = [
|
||||
{ nombre: 'owner', descripcion: 'Dueño - acceso completo' },
|
||||
{ nombre: 'cfo', descripcion: 'CFO - acceso completo (mismo nivel que el dueño)' },
|
||||
{ nombre: 'contador', descripcion: 'Contador - dashboard, CFDI, impuestos, calendario, alertas, facturación' },
|
||||
{ nombre: 'auxiliar', descripcion: 'Auxiliar - mismos permisos que contador' },
|
||||
{ nombre: 'visor', descripcion: 'Visor - solo lectura de CFDI, impuestos, calendario, alertas' },
|
||||
];
|
||||
for (const r of rolesData) {
|
||||
await prisma.rol.upsert({
|
||||
where: { nombre: r.nombre },
|
||||
update: { descripcion: r.descripcion },
|
||||
create: r,
|
||||
});
|
||||
}
|
||||
|
||||
// Seed despacho roles
|
||||
await prisma.rol.upsert({
|
||||
where: { nombre: 'supervisor' },
|
||||
update: {},
|
||||
create: { id: 9, nombre: 'supervisor', descripcion: 'Supervisor de despacho — titular de RFCs, crea carteras' },
|
||||
});
|
||||
|
||||
await prisma.rol.upsert({
|
||||
where: { nombre: 'cliente' },
|
||||
update: {},
|
||||
create: { id: 10, nombre: 'cliente', descripcion: 'Cliente visor externo — acceso read-only a sus RFCs' },
|
||||
});
|
||||
|
||||
const roles = await prisma.rol.findMany();
|
||||
const rolMap = new Map(roles.map(r => [r.nombre, r.id]));
|
||||
console.log(`✅ ${roles.length} roles cargados`);
|
||||
|
||||
// Create demo users
|
||||
const passwordHash = await bcrypt.hash('demo123', 12);
|
||||
|
||||
const users = [
|
||||
{ email: 'admin@demo.com', nombre: 'Dueño Demo', rolNombre: 'owner' },
|
||||
{ email: 'contador@demo.com', nombre: 'Contador Demo', rolNombre: 'contador' },
|
||||
{ email: 'visor@demo.com', nombre: 'Visor Demo', rolNombre: 'visor' },
|
||||
];
|
||||
|
||||
for (const userData of users) {
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email: userData.email },
|
||||
update: {},
|
||||
create: {
|
||||
tenantId: tenant.id,
|
||||
email: userData.email,
|
||||
passwordHash,
|
||||
nombre: userData.nombre,
|
||||
rolId: rolMap.get(userData.rolNombre)!,
|
||||
},
|
||||
include: { rol: true },
|
||||
});
|
||||
console.log(`✅ User created: ${user.email} (${user.rol.nombre})`);
|
||||
}
|
||||
|
||||
// Create tenant database
|
||||
const dbConfig = parseDatabaseUrl(process.env.DATABASE_URL!);
|
||||
const adminPool = new Pool({ ...dbConfig, database: 'postgres', max: 1 });
|
||||
|
||||
try {
|
||||
const exists = await adminPool.query(
|
||||
`SELECT 1 FROM pg_database WHERE datname = $1`,
|
||||
[databaseName]
|
||||
);
|
||||
|
||||
if (exists.rows.length === 0) {
|
||||
await adminPool.query(`CREATE DATABASE "${databaseName}"`);
|
||||
console.log(`✅ Tenant database created: ${databaseName}`);
|
||||
} else {
|
||||
console.log(`ℹ️ Tenant database already exists: ${databaseName}`);
|
||||
}
|
||||
} finally {
|
||||
await adminPool.end();
|
||||
}
|
||||
|
||||
// Create tables in tenant database
|
||||
const tenantPool = new Pool({ ...dbConfig, database: databaseName, max: 1 });
|
||||
|
||||
try {
|
||||
// Reset tenant tables so the re-seed parte de cero. Luego corremos las
|
||||
// migraciones (fuente única de verdad del schema tenant) para garantizar
|
||||
// que queden todas las tablas y columnas actuales.
|
||||
await tenantPool.query(`
|
||||
DROP TABLE IF EXISTS cfdi_conceptos CASCADE;
|
||||
DROP TABLE IF EXISTS cfdis CASCADE;
|
||||
DROP TABLE IF EXISTS conciliaciones CASCADE;
|
||||
DROP TABLE IF EXISTS bancos CASCADE;
|
||||
DROP TABLE IF EXISTS recordatorios CASCADE;
|
||||
DROP TABLE IF EXISTS alertas CASCADE;
|
||||
DROP TABLE IF EXISTS rfcs CASCADE;
|
||||
DROP TABLE IF EXISTS opiniones_cumplimiento CASCADE;
|
||||
DROP TABLE IF EXISTS schema_migrations;
|
||||
`);
|
||||
|
||||
await migrate(tenantPool, tenant.rfc);
|
||||
console.log('✅ Tenant schema aplicado vía migraciones');
|
||||
|
||||
// Bloque legacy de CREATE TABLE / CREATE INDEX retirado: vive ahora en
|
||||
// `apps/api/src/migrations/tenant/*.sql` (fuente única de verdad).
|
||||
|
||||
// Insert demo CFDIs with new structure
|
||||
const cfdiTypes = ['EMITIDO', 'RECIBIDO'];
|
||||
const tipoComprobantes: Record<string, string> = { EMITIDO: 'I', RECIBIDO: 'I' };
|
||||
const rfcs = ['XAXX010101000', 'MEXX020202000', 'AAXX030303000', 'BBXX040404000'];
|
||||
const nombres = ['Cliente Demo SA', 'Proveedor ABC', 'Servicios XYZ', 'Materiales 123'];
|
||||
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const tipo = cfdiTypes[i % 2];
|
||||
const rfcIndex = i % 4;
|
||||
const subtotal = Math.floor(Math.random() * 50000) + 1000;
|
||||
const iva = subtotal * 0.16;
|
||||
const total = subtotal + iva;
|
||||
const daysAgo = Math.floor(Math.random() * 180);
|
||||
const fecha = new Date();
|
||||
fecha.setDate(fecha.getDate() - daysAgo);
|
||||
const year = String(fecha.getFullYear());
|
||||
const month = String(fecha.getMonth() + 1).padStart(2, '0');
|
||||
|
||||
await tenantPool.query(`
|
||||
INSERT INTO cfdis (
|
||||
year, month, type, uuid, serie, folio, status, fecha_emision,
|
||||
rfc_emisor, nombre_emisor, rfc_receptor, nombre_receptor,
|
||||
subtotal, subtotal_mxn, descuento, descuento_mxn,
|
||||
total, total_mxn, moneda, tipo_cambio, tipo_comprobante,
|
||||
metodo_pago, iva_traslado, iva_traslado_mxn,
|
||||
regimen_fiscal_emisor, regimen_fiscal_receptor
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26)
|
||||
ON CONFLICT (uuid) DO NOTHING
|
||||
`, [
|
||||
year, month, tipo, crypto.randomUUID(), 'A', String(1000 + i),
|
||||
'Vigente', fecha,
|
||||
tipo === 'EMITIDO' ? 'EDE123456AB1' : rfcs[rfcIndex],
|
||||
tipo === 'EMITIDO' ? 'Empresa Demo SA de CV' : nombres[rfcIndex],
|
||||
tipo === 'RECIBIDO' ? 'EDE123456AB1' : rfcs[rfcIndex],
|
||||
tipo === 'RECIBIDO' ? 'Empresa Demo SA de CV' : nombres[rfcIndex],
|
||||
subtotal, subtotal, 0, 0,
|
||||
total, total, 'MXN', 1, tipoComprobantes[tipo],
|
||||
'PUE', iva, iva,
|
||||
'601', '601',
|
||||
]);
|
||||
}
|
||||
|
||||
console.log('✅ Demo CFDIs created (50)');
|
||||
|
||||
// Insert demo conceptos for each CFDI
|
||||
const { rows: allCfdis } = await tenantPool.query(`SELECT id FROM cfdis`);
|
||||
const productos = ['Servicio de consultoría', 'Licencia de software', 'Soporte técnico', 'Desarrollo web', 'Capacitación'];
|
||||
|
||||
for (const c of allCfdis) {
|
||||
const numConceptos = Math.floor(Math.random() * 3) + 1;
|
||||
for (let j = 0; j < numConceptos; j++) {
|
||||
const cantidad = Math.floor(Math.random() * 5) + 1;
|
||||
const valorUnitario = Math.floor(Math.random() * 5000) + 500;
|
||||
const importe = cantidad * valorUnitario;
|
||||
const iva = importe * 0.16;
|
||||
|
||||
await tenantPool.query(`
|
||||
INSERT INTO cfdi_conceptos (
|
||||
cfdi_id, clave_prod_serv, descripcion, cantidad, clave_unidad, unidad,
|
||||
valor_unitario, valor_unitario_mxn,
|
||||
importe, importe_mxn, descuento, descuento_mxn,
|
||||
iva_traslado, iva_traslado_mxn
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
|
||||
`, [
|
||||
c.id,
|
||||
'84111506', productos[j % productos.length], cantidad, 'E48', 'Servicio',
|
||||
valorUnitario, valorUnitario,
|
||||
importe, importe, 0, 0,
|
||||
iva, iva,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✅ Demo conceptos created');
|
||||
|
||||
} finally {
|
||||
await tenantPool.end();
|
||||
}
|
||||
|
||||
// Seed plan catalog for CONTABLE vertical
|
||||
const planCatalogoData = [
|
||||
{
|
||||
codename: 'trial_contable',
|
||||
nombre: 'Trial Contable',
|
||||
verticalProfile: 'CONTABLE' as const,
|
||||
precioBase: 0,
|
||||
frecuencia: 'mensual',
|
||||
limits: { maxRfcs: 3, maxUsers: 1, timbresIncluidosMes: 20, features: ['dashboard', 'cfdi_basic', 'iva_isr'] },
|
||||
},
|
||||
{
|
||||
codename: 'starter_contable',
|
||||
nombre: 'Starter Contable',
|
||||
verticalProfile: 'CONTABLE' as const,
|
||||
precioBase: 490,
|
||||
frecuencia: 'mensual',
|
||||
limits: { maxRfcs: 10, maxUsers: 3, timbresIncluidosMes: 50, features: ['dashboard', 'cfdi_basic', 'iva_isr', 'alertas', 'calendario'] },
|
||||
},
|
||||
{
|
||||
codename: 'business_contable',
|
||||
nombre: 'Business Contable',
|
||||
verticalProfile: 'CONTABLE' as const,
|
||||
precioBase: 1290,
|
||||
frecuencia: 'mensual',
|
||||
limits: { maxRfcs: 50, maxUsers: 10, timbresIncluidosMes: 200, features: ['dashboard', 'cfdi_basic', 'iva_isr', 'alertas', 'calendario', 'reportes', 'conciliacion', 'documentos', 'facturacion'] },
|
||||
},
|
||||
{
|
||||
codename: 'enterprise_contable',
|
||||
nombre: 'Enterprise Contable',
|
||||
verticalProfile: 'CONTABLE' as const,
|
||||
precioBase: 2990,
|
||||
frecuencia: 'mensual',
|
||||
limits: { maxRfcs: -1, maxUsers: -1, timbresIncluidosMes: 600, features: ['dashboard', 'cfdi_basic', 'iva_isr', 'alertas', 'calendario', 'reportes', 'conciliacion', 'documentos', 'facturacion', 'api'] },
|
||||
},
|
||||
];
|
||||
|
||||
for (const p of planCatalogoData) {
|
||||
await prisma.planCatalogo.upsert({
|
||||
where: { codename: p.codename },
|
||||
update: { nombre: p.nombre, precioBase: p.precioBase, limits: p.limits },
|
||||
create: p,
|
||||
});
|
||||
}
|
||||
console.log('✓ Plan catalog seeded (4 plans CONTABLE)');
|
||||
|
||||
// Seed addon catalog
|
||||
const addonCatalogoData = [
|
||||
{
|
||||
codename: 'rfcs_extra_10',
|
||||
nombre: '+10 RFCs adicionales',
|
||||
verticalProfile: 'CONTABLE' as const,
|
||||
precio: 190,
|
||||
frecuencia: 'mensual',
|
||||
delta: { maxRfcs: 10 },
|
||||
},
|
||||
{
|
||||
codename: 'rfcs_extra_50',
|
||||
nombre: '+50 RFCs adicionales',
|
||||
verticalProfile: 'CONTABLE' as const,
|
||||
precio: 690,
|
||||
frecuencia: 'mensual',
|
||||
delta: { maxRfcs: 50 },
|
||||
},
|
||||
{
|
||||
codename: 'timbres_extra_500',
|
||||
nombre: '+500 timbres mensuales',
|
||||
precio: 490,
|
||||
frecuencia: 'mensual',
|
||||
delta: { timbresIncluidosMes: 500 },
|
||||
},
|
||||
{
|
||||
codename: 'modulo_ia',
|
||||
nombre: 'Módulo IA Fiscal',
|
||||
precio: 390,
|
||||
frecuencia: 'mensual',
|
||||
delta: { features: ['ia_lolita'] },
|
||||
},
|
||||
{
|
||||
// Lolita IA activable por contribuyente específico del despacho.
|
||||
// SubscriptionAddon.contribuyenteId apunta al RFC que lo contrata.
|
||||
// Cobro mensual en preapproval propio (la licencia del despacho es anual;
|
||||
// el add-on va en ciclo independiente).
|
||||
codename: 'lolita_ia_contribuyente',
|
||||
nombre: 'Lolita IA (por contribuyente)',
|
||||
verticalProfile: 'CONTABLE' as const,
|
||||
precio: 250,
|
||||
frecuencia: 'mensual',
|
||||
delta: { features: ['ia_lolita'] },
|
||||
},
|
||||
{
|
||||
// Contribuyente adicional para planes Business Control y Enterprise
|
||||
// (ambos incluyen 100 base). Se cobra automáticamente según overage; no
|
||||
// requiere opt-in, pero se modela como add-on para que el preapproval MP
|
||||
// lo cubra. El codename mantiene el sufijo "business_cloud" por compat
|
||||
// con suscripciones existentes; el nombre display ya es genérico.
|
||||
codename: 'contribuyente_extra_business_cloud',
|
||||
nombre: 'Contribuyente adicional (RFC extra)',
|
||||
verticalProfile: 'CONTABLE' as const,
|
||||
precio: 45,
|
||||
frecuencia: 'mensual',
|
||||
delta: { maxRfcs: 1 },
|
||||
},
|
||||
];
|
||||
|
||||
for (const a of addonCatalogoData) {
|
||||
await prisma.planAddonCatalogo.upsert({
|
||||
where: { codename: a.codename },
|
||||
update: { nombre: a.nombre, precio: a.precio, delta: a.delta },
|
||||
create: { ...a, verticalProfile: a.verticalProfile ?? null },
|
||||
});
|
||||
}
|
||||
console.log('✓ Addon catalog seeded (6 addons)');
|
||||
|
||||
console.log('\n🎉 Seed completed successfully!');
|
||||
console.log('\n📝 Demo credentials:');
|
||||
console.log(' Admin: admin@demo.com / demo123');
|
||||
console.log(' Contador: contador@demo.com / demo123');
|
||||
console.log(' Visor: visor@demo.com / demo123');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('Error seeding database:', e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
158
apps/api/scripts/backfill-cfdi-contribuyente.ts
Normal file
158
apps/api/scripts/backfill-cfdi-contribuyente.ts
Normal 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);
|
||||
});
|
||||
209
apps/api/scripts/backfill-cfdis-relaciones.ts
Normal file
209
apps/api/scripts/backfill-cfdis-relaciones.ts
Normal 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);
|
||||
});
|
||||
126
apps/api/scripts/backfill-facturapi-cfdis.ts
Normal file
126
apps/api/scripts/backfill-facturapi-cfdis.ts
Normal 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); });
|
||||
174
apps/api/scripts/backfill-fechas-tz.ts
Normal file
174
apps/api/scripts/backfill-fechas-tz.ts
Normal 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); });
|
||||
101
apps/api/scripts/backfill-metricas.ts
Normal file
101
apps/api/scripts/backfill-metricas.ts
Normal 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);
|
||||
});
|
||||
163
apps/api/scripts/backfill-saldo-pendiente.ts
Normal file
163
apps/api/scripts/backfill-saldo-pendiente.ts
Normal 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);
|
||||
});
|
||||
135
apps/api/scripts/bootstrap-horux360-admin.ts
Normal file
135
apps/api/scripts/bootstrap-horux360-admin.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Bootstrap del tenant admin global (Horux 360 — HTS240708LJA) + usuarios staff.
|
||||
*
|
||||
* Crea:
|
||||
* 1. Tenant Horux 360 (RFC HTS240708LJA, plan enterprise)
|
||||
* 2. Carlos como owner del tenant + rol platform_admin
|
||||
* 3. Ivan como contador del tenant + rol platform_ti (TI superset)
|
||||
* 4. Suscripción authorized por 1 año
|
||||
*
|
||||
* Uso: `pnpm bootstrap:admin-global`
|
||||
*
|
||||
* Idempotente-ish: falla limpio si el tenant ya existe (RFC unique).
|
||||
* Para re-ejecutar, borra el tenant y su BD manualmente antes.
|
||||
*
|
||||
* Requisitos previos:
|
||||
* 1. `pnpm prisma migrate deploy` (schema central)
|
||||
* 2. `pnpm db:seed` (catálogos SAT, regímenes, ISR, eventos fiscales, roles)
|
||||
*
|
||||
* Env vars opcionales (con defaults):
|
||||
* HORUX_ADMIN_EMAIL (default: carlos@horuxfin.com)
|
||||
* HORUX_ADMIN_NOMBRE (default: Carlos)
|
||||
* HORUX_TI_EMAIL (default: ivan@horuxfin.com)
|
||||
* HORUX_TI_NOMBRE (default: Ivan)
|
||||
*/
|
||||
import { prisma } from '../src/config/database.js';
|
||||
import * as tenantsService from '../src/services/tenants.service.js';
|
||||
import * as usuariosService from '../src/services/usuarios.service.js';
|
||||
|
||||
const RFC = 'HTS240708LJA';
|
||||
const TENANT_NAME = 'Horux 360';
|
||||
const PLAN = 'enterprise' as const;
|
||||
const CFDI_LIMIT = -1; // ilimitado
|
||||
const USERS_LIMIT = 10;
|
||||
const SUBSCRIPTION_YEARS = 1;
|
||||
|
||||
async function main() {
|
||||
const adminEmail = process.env.HORUX_ADMIN_EMAIL || 'carlos@horuxfin.com';
|
||||
const adminNombre = process.env.HORUX_ADMIN_NOMBRE || 'Carlos';
|
||||
const tiEmail = process.env.HORUX_TI_EMAIL || 'ivan@horuxfin.com';
|
||||
const tiNombre = process.env.HORUX_TI_NOMBRE || 'Ivan';
|
||||
|
||||
console.log(`Bootstrap del tenant admin global`);
|
||||
console.log(` RFC: ${RFC}`);
|
||||
console.log(` Nombre: ${TENANT_NAME}`);
|
||||
console.log(` Admin: ${adminNombre} <${adminEmail}> (platform_admin)`);
|
||||
console.log(` TI: ${tiNombre} <${tiEmail}> (platform_ti)`);
|
||||
console.log(` Plan: ${PLAN} (cfdi: ${CFDI_LIMIT}, users: ${USERS_LIMIT})`);
|
||||
console.log('');
|
||||
|
||||
// 1. Crea tenant + BD provisionada + Carlos como owner + subscription pending
|
||||
const { tenant, user: carlosUser, tempPassword: carlosPassword } = await tenantsService.createTenant({
|
||||
nombre: TENANT_NAME,
|
||||
rfc: RFC,
|
||||
plan: PLAN,
|
||||
cfdiLimit: CFDI_LIMIT,
|
||||
usersLimit: USERS_LIMIT,
|
||||
adminEmail,
|
||||
adminNombre,
|
||||
amount: 0,
|
||||
});
|
||||
|
||||
console.log(`✓ Tenant creado: ${tenant.id}`);
|
||||
console.log(`✓ BD provisionada: ${tenant.databaseName}`);
|
||||
console.log(`✓ Carlos creado (owner): ${carlosUser.email}`);
|
||||
|
||||
// 2. Asigna platform_admin a Carlos (no se hace automáticamente desde tenants.service)
|
||||
const carlosFull = await prisma.user.findUnique({ where: { email: adminEmail } });
|
||||
if (carlosFull) {
|
||||
await prisma.userPlatformRole.upsert({
|
||||
where: { userId_role: { userId: carlosFull.id, role: 'platform_admin' } },
|
||||
update: {},
|
||||
create: { userId: carlosFull.id, role: 'platform_admin' },
|
||||
});
|
||||
console.log(`✓ Carlos: rol platform_admin asignado`);
|
||||
}
|
||||
|
||||
// 3. Crea Ivan como contador del tenant (membership) y le asigna platform_ti
|
||||
const ivan = await usuariosService.inviteUsuario(tenant.id, {
|
||||
email: tiEmail,
|
||||
nombre: tiNombre,
|
||||
role: 'contador',
|
||||
});
|
||||
console.log(`✓ Ivan creado: ${ivan.email} (membership contador)`);
|
||||
|
||||
await prisma.userPlatformRole.upsert({
|
||||
where: { userId_role: { userId: ivan.id, role: 'platform_ti' } },
|
||||
update: {},
|
||||
create: { userId: ivan.id, role: 'platform_ti' },
|
||||
});
|
||||
console.log(`✓ Ivan: rol platform_ti asignado (superset, mismos permisos que admin)`);
|
||||
|
||||
// 4. Sube la subscription a 'authorized' con vigencia de 1 año
|
||||
const existing = await prisma.subscription.findFirst({
|
||||
where: { tenantId: tenant.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
if (existing) {
|
||||
const now = new Date();
|
||||
const end = new Date(now);
|
||||
end.setFullYear(end.getFullYear() + SUBSCRIPTION_YEARS);
|
||||
|
||||
await prisma.subscription.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
status: 'authorized',
|
||||
currentPeriodStart: now,
|
||||
currentPeriodEnd: end,
|
||||
},
|
||||
});
|
||||
console.log(`✓ Suscripción marcada 'authorized' hasta ${end.toISOString().slice(0, 10)}`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log('=== DONE ===');
|
||||
console.log(`Credenciales temporales para primer login:`);
|
||||
console.log(` Carlos (admin): ${adminEmail}`);
|
||||
console.log(` Password: ${carlosPassword}`);
|
||||
console.log('');
|
||||
console.log(` Ivan (TI): ${tiEmail}`);
|
||||
console.log(` Password: revisa el correo de bienvenida (inviteUsuario lo envía por email)`);
|
||||
console.log('');
|
||||
console.log('Próximos pasos manuales:');
|
||||
console.log(` 1. Carlos login en /login con las credenciales de arriba`);
|
||||
console.log(` 2. Cambiar el password desde /configuracion/seguridad`);
|
||||
console.log(` 3. Verificar que Ivan recibió su correo de invitación`);
|
||||
console.log(` 4. Subir FIEL en /configuracion/sat para habilitar sincronización`);
|
||||
console.log(` 5. (Opcional) Configurar organización Facturapi en /configuracion`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error('✗ Bootstrap falló:', err.message || err);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
75
apps/api/scripts/breakdown-gastos.ts
Normal file
75
apps/api/scripts/breakdown-gastos.ts
Normal 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); });
|
||||
67
apps/api/scripts/breakdown-ingresos.ts
Normal file
67
apps/api/scripts/breakdown-ingresos.ts
Normal 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); });
|
||||
24
apps/api/scripts/check-cache-contrib.ts
Normal file
24
apps/api/scripts/check-cache-contrib.ts
Normal 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); });
|
||||
26
apps/api/scripts/check-cache.ts
Normal file
26
apps/api/scripts/check-cache.ts
Normal 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); });
|
||||
85
apps/api/scripts/check-carlos-emision.ts
Normal file
85
apps/api/scripts/check-carlos-emision.ts
Normal 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);
|
||||
});
|
||||
72
apps/api/scripts/check-carlos-lco.ts
Normal file
72
apps/api/scripts/check-carlos-lco.ts
Normal 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); });
|
||||
112
apps/api/scripts/check-ieps-inflation.ts
Normal file
112
apps/api/scripts/check-ieps-inflation.ts
Normal 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); });
|
||||
76
apps/api/scripts/check-recent-facturapi.ts
Normal file
76
apps/api/scripts/check-recent-facturapi.ts
Normal 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);
|
||||
});
|
||||
36
apps/api/scripts/check-rfc-emisor.ts
Normal file
36
apps/api/scripts/check-rfc-emisor.ts
Normal 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); });
|
||||
63
apps/api/scripts/check-saldo.ts
Normal file
63
apps/api/scripts/check-saldo.ts
Normal 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); });
|
||||
37
apps/api/scripts/compare-iva-full.ts
Normal file
37
apps/api/scripts/compare-iva-full.ts
Normal 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); });
|
||||
36
apps/api/scripts/compare-iva-gastos.ts
Normal file
36
apps/api/scripts/compare-iva-gastos.ts
Normal 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); });
|
||||
22
apps/api/scripts/count-07-types.ts
Normal file
22
apps/api/scripts/count-07-types.ts
Normal 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); });
|
||||
27
apps/api/scripts/count-husberto-07.ts
Normal file
27
apps/api/scripts/count-husberto-07.ts
Normal 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); });
|
||||
26
apps/api/scripts/create-carlos.ts
Normal file
26
apps/api/scripts/create-carlos.ts
Normal 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); });
|
||||
88
apps/api/scripts/debug-i07.ts
Normal file
88
apps/api/scripts/debug-i07.ts
Normal 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); });
|
||||
104
apps/api/scripts/debug-ingresos-horux-may-wider.ts
Normal file
104
apps/api/scripts/debug-ingresos-horux-may-wider.ts
Normal 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); });
|
||||
111
apps/api/scripts/debug-ingresos-horux-may.ts
Normal file
111
apps/api/scripts/debug-ingresos-horux-may.ts
Normal 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); });
|
||||
82
apps/api/scripts/decrypt-fiel.ts
Normal file
82
apps/api/scripts/decrypt-fiel.ts
Normal 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);
|
||||
});
|
||||
101
apps/api/scripts/deep-egresos.ts
Normal file
101
apps/api/scripts/deep-egresos.ts
Normal 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); });
|
||||
55
apps/api/scripts/detail-ingresos.ts
Normal file
55
apps/api/scripts/detail-ingresos.ts
Normal 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); });
|
||||
68
apps/api/scripts/detail-iva-mes.ts
Normal file
68
apps/api/scripts/detail-iva-mes.ts
Normal 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); });
|
||||
88
apps/api/scripts/drill-ingresos.ts
Normal file
88
apps/api/scripts/drill-ingresos.ts
Normal 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); });
|
||||
79
apps/api/scripts/extract-terminos.mjs
Normal file
79
apps/api/scripts/extract-terminos.mjs
Normal 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);
|
||||
});
|
||||
28
apps/api/scripts/find-contribuyente.ts
Normal file
28
apps/api/scripts/find-contribuyente.ts
Normal 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); });
|
||||
75
apps/api/scripts/find-i07-ppd-cases.ts
Normal file
75
apps/api/scripts/find-i07-ppd-cases.ts
Normal 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); });
|
||||
12
apps/api/scripts/find-uuid.ts
Normal file
12
apps/api/scripts/find-uuid.ts
Normal 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); });
|
||||
104
apps/api/scripts/import-lista-negra.ts
Normal file
104
apps/api/scripts/import-lista-negra.ts
Normal 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());
|
||||
26
apps/api/scripts/inspect-cfdi-full.ts
Normal file
26
apps/api/scripts/inspect-cfdi-full.ts
Normal 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); });
|
||||
90
apps/api/scripts/inspect-cfdi.ts
Normal file
90
apps/api/scripts/inspect-cfdi.ts
Normal 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);
|
||||
});
|
||||
66
apps/api/scripts/inspect-facturapi-invoice.ts
Normal file
66
apps/api/scripts/inspect-facturapi-invoice.ts
Normal 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); });
|
||||
41
apps/api/scripts/inspect-latest-facturapi.ts
Normal file
41
apps/api/scripts/inspect-latest-facturapi.ts
Normal 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); });
|
||||
52
apps/api/scripts/inspect-pair.ts
Normal file
52
apps/api/scripts/inspect-pair.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
const I_UUID = '5c874749-748f-11f0-96b1-2b9310891836';
|
||||
const E_UUID = '7163da3b-748f-11f0-9853-e97a8e1dedd9';
|
||||
|
||||
async function main() {
|
||||
const tenants = await prisma.tenant.findMany({ select: { id: true, rfc: true, databaseName: true } });
|
||||
|
||||
for (const t of tenants) {
|
||||
let pool;
|
||||
try { pool = await tenantDb.getPool(t.id, t.databaseName); } catch { continue; }
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT uuid, tipo_comprobante, metodo_pago, cfdi_tipo_relacion, cfdis_relacionados,
|
||||
status, fecha_emision, total_mxn, iva_traslado_mxn,
|
||||
rfc_emisor, rfc_receptor, contribuyente_id, type
|
||||
FROM cfdis WHERE LOWER(uuid) IN (LOWER($1), LOWER($2))`,
|
||||
[I_UUID, E_UUID],
|
||||
);
|
||||
|
||||
if (rows.length === 0) continue;
|
||||
console.log(`\n=== ${t.rfc} ===`);
|
||||
for (const r of rows) {
|
||||
const fe = new Date(r.fecha_emision).toISOString().slice(0, 10);
|
||||
console.log(`\n UUID: ${r.uuid}`);
|
||||
console.log(` tipo: ${r.tipo_comprobante}/${r.metodo_pago || '?'} rel=${r.cfdi_tipo_relacion ?? 'null'} status=${r.status} type=${r.type}`);
|
||||
console.log(` fecha: ${fe} total=${r.total_mxn} IVA=${r.iva_traslado_mxn}`);
|
||||
console.log(` ${r.rfc_emisor} → ${r.rfc_receptor} contrib_id=${r.contribuyente_id}`);
|
||||
console.log(` cfdis_relacionados: ${r.cfdis_relacionados ?? 'NULL'}`);
|
||||
}
|
||||
|
||||
// Si están ambos, verificar match de cfdis_relacionados
|
||||
if (rows.length === 2) {
|
||||
const i = rows.find((x: any) => x.uuid.toLowerCase() === I_UUID.toLowerCase());
|
||||
const e = rows.find((x: any) => x.uuid.toLowerCase() === E_UUID.toLowerCase());
|
||||
if (i && e) {
|
||||
const iRels = (i.cfdis_relacionados || '').split('|').map((u: string) => u.trim().toLowerCase()).filter(Boolean);
|
||||
const eRels = (e.cfdis_relacionados || '').split('|').map((u: string) => u.trim().toLowerCase()).filter(Boolean);
|
||||
const overlap = iRels.filter((u: string) => eRels.includes(u));
|
||||
console.log(`\n I refs (${iRels.length}): ${iRels.join(', ').substring(0, 200)}`);
|
||||
console.log(` E refs (${eRels.length}): ${eRels.join(', ').substring(0, 200)}`);
|
||||
console.log(` Overlap (${overlap.length}): ${overlap.join(', ')}`);
|
||||
|
||||
// Cruz: ¿la E referencia a la I directamente, o viceversa?
|
||||
if (eRels.includes(I_UUID.toLowerCase())) console.log(` → E.cfdis_relacionados INCLUYE el UUID de I/07 PPD`);
|
||||
if (iRels.includes(E_UUID.toLowerCase())) console.log(` → I.cfdis_relacionados INCLUYE el UUID de E`);
|
||||
}
|
||||
}
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
48
apps/api/scripts/inspect-rfc.ts
Normal file
48
apps/api/scripts/inspect-rfc.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
const rawRfc = process.argv[2];
|
||||
if (!rawRfc) {
|
||||
console.error('Usage: tsx scripts/inspect-rfc.ts <rfc>');
|
||||
process.exit(1);
|
||||
}
|
||||
const rfc = rawRfc.toUpperCase();
|
||||
|
||||
async function main() {
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
where: { active: true },
|
||||
select: { id: true, rfc: true, databaseName: true },
|
||||
});
|
||||
|
||||
for (const t of tenants) {
|
||||
const pool = await tenantDb.getPool(t.id, t.databaseName);
|
||||
|
||||
const { rows: contrib } = await pool.query(
|
||||
`SELECT * FROM contribuyentes WHERE UPPER(rfc) = $1`,
|
||||
[rfc],
|
||||
);
|
||||
if (contrib.length > 0) {
|
||||
console.log(`\n[${t.rfc}] Contribuyente ${rfc}:`);
|
||||
console.log(contrib[0]);
|
||||
}
|
||||
|
||||
const { rows: rfcEntry } = await pool.query(
|
||||
`SELECT id, rfc, razon_social, regimen_fiscal, codigo_postal FROM rfcs WHERE UPPER(rfc) = $1`,
|
||||
[rfc],
|
||||
);
|
||||
if (rfcEntry.length > 0) {
|
||||
console.log(`[${t.rfc}] rfcs table:`, rfcEntry[0]);
|
||||
}
|
||||
|
||||
if (contrib.length > 0) {
|
||||
const { rows: org } = await pool.query(
|
||||
`SELECT facturapi_org_id, csd_uploaded, active FROM facturapi_orgs WHERE contribuyente_id = $1`,
|
||||
[contrib[0].entidad_id],
|
||||
);
|
||||
if (org.length > 0) console.log(`[${t.rfc}] facturapi_orgs:`, org[0]);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
159
apps/api/scripts/invalidate-metricas-all.ts
Normal file
159
apps/api/scripts/invalidate-metricas-all.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Invalida TODAS las entradas en `metricas_mensuales` — marca para recompute
|
||||
* cada (contribuyente_id, anio, mes) que tenga datos cacheados. Diseñado para
|
||||
* usarse después de un cambio de fórmula que afecta resultados históricos
|
||||
* (ej. 2026-04-23: NC tipo E con TipoRelacion=07 dejan de restar en Grupo 1).
|
||||
*
|
||||
* El cron `metricas-invalidations.job` (cada 15min) procesa el backlog.
|
||||
* Para acelerar: `pnpm --filter @horux/api exec tsx -e "import { runProcessInvalidations } from './src/jobs/metricas-invalidations.job.js'; runProcessInvalidations().then(()=>process.exit(0))"`
|
||||
*
|
||||
* Uso:
|
||||
* pnpm --filter @horux/api exec tsx scripts/invalidate-metricas-all.ts # ejecuta
|
||||
* pnpm --filter @horux/api exec tsx scripts/invalidate-metricas-all.ts --dry # reporta sin escribir
|
||||
*/
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
const DRY_RUN = process.argv.includes('--dry') || process.argv.includes('--dry-run');
|
||||
const REASON = process.argv.find(a => a.startsWith('--reason='))?.slice(9) || 'FORMULA_CHANGE_E07_GRUPO1';
|
||||
|
||||
interface PerTenantResult {
|
||||
tenantId: string;
|
||||
rfc: string;
|
||||
databaseName: string;
|
||||
metricasRows: number;
|
||||
marcadasNuevas: number;
|
||||
marcadasUpdate: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function invalidateTenant(
|
||||
tenantId: string,
|
||||
rfc: string,
|
||||
databaseName: string,
|
||||
): Promise<PerTenantResult> {
|
||||
const result: PerTenantResult = {
|
||||
tenantId,
|
||||
rfc,
|
||||
databaseName,
|
||||
metricasRows: 0,
|
||||
marcadasNuevas: 0,
|
||||
marcadasUpdate: 0,
|
||||
};
|
||||
|
||||
const pool = await tenantDb.getPool(tenantId, databaseName);
|
||||
|
||||
// Cuenta filas existentes en metricas_mensuales para reportar
|
||||
const { rows: cnt } = await pool.query<{ n: number }>(
|
||||
`SELECT COUNT(DISTINCT (contribuyente_id, anio, mes))::int AS n FROM metricas_mensuales`,
|
||||
);
|
||||
result.metricasRows = cnt[0]?.n || 0;
|
||||
if (result.metricasRows === 0) return result;
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Insert-or-update: si ya estaba marcada, sobrescribe reason y marcado_at
|
||||
// para que el cron la re-procese con el motivo correcto.
|
||||
const { rows: inserted } = await client.query<{
|
||||
contribuyente_id: string;
|
||||
anio: number;
|
||||
mes: number;
|
||||
was_new: boolean;
|
||||
}>(
|
||||
`
|
||||
INSERT INTO metricas_invalidaciones (contribuyente_id, anio, mes, reason)
|
||||
SELECT DISTINCT contribuyente_id, anio, mes, $1 AS reason
|
||||
FROM metricas_mensuales
|
||||
ON CONFLICT (contribuyente_id, anio, mes) DO UPDATE
|
||||
SET reason = EXCLUDED.reason, marcado_at = now()
|
||||
RETURNING contribuyente_id, anio, mes, (xmax = 0) AS was_new
|
||||
`,
|
||||
[REASON],
|
||||
);
|
||||
|
||||
result.marcadasNuevas = inserted.filter(r => r.was_new).length;
|
||||
result.marcadasUpdate = inserted.length - result.marcadasNuevas;
|
||||
|
||||
if (DRY_RUN) {
|
||||
await client.query('ROLLBACK');
|
||||
} else {
|
||||
await client.query('COMMIT');
|
||||
}
|
||||
} catch (err: any) {
|
||||
await client.query('ROLLBACK').catch(() => {});
|
||||
result.error = err?.message || String(err);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`=== Invalidate metricas_mensuales ${DRY_RUN ? '(DRY RUN — no writes)' : ''} ===`);
|
||||
console.log(`Reason: ${REASON}\n`);
|
||||
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
where: { active: true },
|
||||
select: { id: true, rfc: true, databaseName: true },
|
||||
orderBy: { rfc: 'asc' },
|
||||
});
|
||||
|
||||
console.log(`Tenants activos: ${tenants.length}\n`);
|
||||
|
||||
const results: PerTenantResult[] = [];
|
||||
for (const t of tenants) {
|
||||
process.stdout.write(`[${t.rfc}] (${t.databaseName}) ... `);
|
||||
try {
|
||||
const r = await invalidateTenant(t.id, t.rfc, t.databaseName);
|
||||
results.push(r);
|
||||
if (r.error) {
|
||||
console.log(`ERROR: ${r.error}`);
|
||||
} else if (r.metricasRows === 0) {
|
||||
console.log(`sin cache (skip)`);
|
||||
} else {
|
||||
console.log(
|
||||
`cache=${r.metricasRows} (contrib,año,mes), marcadas=${r.marcadasNuevas + r.marcadasUpdate} (nuevas=${r.marcadasNuevas}, re-marcadas=${r.marcadasUpdate})`,
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.log(`FATAL: ${err?.message || err}`);
|
||||
results.push({
|
||||
tenantId: t.id,
|
||||
rfc: t.rfc,
|
||||
databaseName: t.databaseName,
|
||||
metricasRows: 0,
|
||||
marcadasNuevas: 0,
|
||||
marcadasUpdate: 0,
|
||||
error: err?.message || String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const totalMetricas = results.reduce((s, r) => s + r.metricasRows, 0);
|
||||
const totalMarcadas = results.reduce((s, r) => s + r.marcadasNuevas + r.marcadasUpdate, 0);
|
||||
const tenantsTouched = results.filter(r => r.marcadasNuevas + r.marcadasUpdate > 0).length;
|
||||
const tenantsFailed = results.filter(r => r.error).length;
|
||||
|
||||
console.log(`\n=== Resumen ===`);
|
||||
console.log(` Tenants procesados: ${results.length}`);
|
||||
console.log(` Tenants con cache: ${tenantsTouched}`);
|
||||
console.log(` Filas cache total: ${totalMetricas}`);
|
||||
console.log(` Invalidaciones: ${totalMarcadas}${DRY_RUN ? ' (rolled back)' : ''}`);
|
||||
if (tenantsFailed > 0) console.log(` Tenants con error: ${tenantsFailed}`);
|
||||
|
||||
if (!DRY_RUN && totalMarcadas > 0) {
|
||||
console.log(`\nCron metricas-invalidations procesará el backlog en <=15 min.`);
|
||||
console.log(`Para disparar manual: runProcessInvalidations() desde un tsx -e ad-hoc.`);
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
process.exit(tenantsFailed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
console.error('Fatal:', err);
|
||||
await prisma.$disconnect().catch(() => {});
|
||||
process.exit(1);
|
||||
});
|
||||
26
apps/api/scripts/list-contribuyentes.ts
Normal file
26
apps/api/scripts/list-contribuyentes.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
async function main() {
|
||||
const tenants = await prisma.tenant.findMany({ where: { active: true }, select: { id: true, rfc: true, databaseName: true } });
|
||||
for (const t of tenants) {
|
||||
const pool = await tenantDb.getPool(t.id, t.databaseName);
|
||||
// descubrir tablas con 'entidad' o 'contribuyente' en el nombre
|
||||
const { rows: tbls } = await pool.query(`SELECT table_name FROM information_schema.tables WHERE table_schema='public' AND (table_name LIKE '%entidad%' OR table_name LIKE '%contribuyente%') ORDER BY table_name`);
|
||||
console.log(`\n[${t.rfc}] tablas:`, tbls.map((r: any) => r.table_name).join(', '));
|
||||
|
||||
// Join con rfcs si existe
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT c.entidad_id, c.rfc, r.razon_social, c.regimen_fiscal, c.codigo_postal
|
||||
FROM contribuyentes c
|
||||
LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc)
|
||||
ORDER BY r.razon_social NULLS LAST, c.rfc`,
|
||||
);
|
||||
for (const r of rows) console.log(' ', r);
|
||||
} catch (e: any) {
|
||||
console.log(' ERR:', e.message);
|
||||
}
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
33
apps/api/scripts/migrate-tenants.ts
Normal file
33
apps/api/scripts/migrate-tenants.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Eager tenant migration script.
|
||||
* Run: pnpm --filter @horux/api db:migrate-tenants
|
||||
* Or: pnpm db:migrate-tenants (from monorepo root via Turborepo)
|
||||
*
|
||||
* Applies pending SQL migrations to all active tenant databases.
|
||||
*/
|
||||
import { migrateAll } from '../src/config/tenant-migrations.js';
|
||||
|
||||
async function main() {
|
||||
console.log('=== Tenant Schema Migration (Eager) ===\n');
|
||||
|
||||
const start = Date.now();
|
||||
const result = await migrateAll();
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
|
||||
console.log(`\n=== Done in ${elapsed}s ===`);
|
||||
console.log(` Migrated: ${result.success}`);
|
||||
console.log(` Up-to-date: ${result.skipped}`);
|
||||
console.log(` Failed: ${result.failed}`);
|
||||
|
||||
if (result.failed > 0) {
|
||||
console.error('\nSome tenants failed migration. Check logs above.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
27
apps/api/scripts/otf-ingresos.ts
Normal file
27
apps/api/scripts/otf-ingresos.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
process.env.METRICAS_BYPASS_CACHE = '1';
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
import { calcularIngresosPorRegimen } from '../src/services/dashboard.service.js';
|
||||
|
||||
const tenantRfc = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG';
|
||||
const contribuyenteId = process.argv[3] || 'b3761db6-0b8d-4251-8078-4ddc31e9c75b';
|
||||
const yearMonth = process.argv[4] || '2025-05';
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({ where: { rfc: tenantRfc }, select: { id: true, databaseName: true } });
|
||||
if (!tenant) return;
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
|
||||
const [anio, mes] = yearMonth.split('-').map(Number);
|
||||
const lastDay = new Date(anio, mes, 0).getDate();
|
||||
const fi = `${yearMonth}-01`;
|
||||
const ff = `${yearMonth}-${String(lastDay).padStart(2, '0')}`;
|
||||
|
||||
const r = await calcularIngresosPorRegimen(pool, tenant.id, fi, ff, undefined, undefined, false, contribuyenteId);
|
||||
console.log(`\n=== Ingresos ${yearMonth} contrib=${contribuyenteId} (BYPASS_CACHE=1) ===`);
|
||||
console.log(`Total: ${r.total.toFixed(2)}`);
|
||||
for (const p of r.porRegimen) {
|
||||
console.log(` ${p.regimenClave} (${p.regimenDescripcion}): ${p.monto.toFixed(2)}`);
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
164
apps/api/scripts/preview-emails.mjs
Normal file
164
apps/api/scripts/preview-emails.mjs
Normal file
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Genera los 8 templates de email como archivos HTML estáticos en
|
||||
* `apps/api/email-previews/` para revisar el diseño en el navegador
|
||||
* sin necesidad de SMTP configurado.
|
||||
*
|
||||
* Uso:
|
||||
* pnpm email:preview
|
||||
*
|
||||
* Tras correr, abre `apps/api/email-previews/index.html` para ver
|
||||
* el listado con links a cada template.
|
||||
*/
|
||||
import { writeFileSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dirname, '..');
|
||||
const OUT_DIR = resolve(ROOT, 'email-previews');
|
||||
|
||||
// Datos de ejemplo realistas para cada template
|
||||
const SAMPLES = {
|
||||
'welcome.html': {
|
||||
label: 'Bienvenida',
|
||||
fixture: { nombre: 'Carlos Hernández', email: 'carlos@empresa.com', tempPassword: 'a3f2c891' },
|
||||
importPath: '../src/services/email/templates/welcome.ts',
|
||||
fnName: 'welcomeEmail',
|
||||
},
|
||||
'password-reset.html': {
|
||||
label: 'Recuperación de contraseña',
|
||||
fixture: { nombre: 'Carlos Hernández', resetUrl: 'https://horuxfin.com/reset-password?token=a8e4f...' },
|
||||
importPath: '../src/services/email/templates/password-reset.ts',
|
||||
fnName: 'passwordResetEmail',
|
||||
},
|
||||
'payment-confirmed.html': {
|
||||
label: 'Pago confirmado',
|
||||
fixture: { nombre: 'Carlos Hernández', amount: 780, plan: 'Business + IA', date: new Date().toLocaleDateString('es-MX') },
|
||||
importPath: '../src/services/email/templates/payment-confirmed.ts',
|
||||
fnName: 'paymentConfirmedEmail',
|
||||
},
|
||||
'payment-failed.html': {
|
||||
label: 'Pago rechazado',
|
||||
fixture: { nombre: 'Carlos Hernández', amount: 780, plan: 'Business + IA' },
|
||||
importPath: '../src/services/email/templates/payment-failed.ts',
|
||||
fnName: 'paymentFailedEmail',
|
||||
},
|
||||
'subscription-cancelled.html': {
|
||||
label: 'Suscripción cancelada',
|
||||
fixture: { nombre: 'Carlos Hernández', plan: 'Business + IA' },
|
||||
importPath: '../src/services/email/templates/subscription-cancelled.ts',
|
||||
fnName: 'subscriptionCancelledEmail',
|
||||
},
|
||||
'subscription-expiring.html': {
|
||||
label: 'Suscripción por vencer',
|
||||
fixture: { nombre: 'Carlos Hernández', plan: 'Business + IA', expiresAt: '15 de mayo, 2026' },
|
||||
importPath: '../src/services/email/templates/subscription-expiring.ts',
|
||||
fnName: 'subscriptionExpiringEmail',
|
||||
},
|
||||
'fiel-notification.html': {
|
||||
label: 'e.firma cargada (admin)',
|
||||
fixture: { clienteNombre: 'Empresa Demo SA de CV', clienteRfc: 'EDE123456AB1' },
|
||||
importPath: '../src/services/email/templates/fiel-notification.ts',
|
||||
fnName: 'fielNotificationEmail',
|
||||
},
|
||||
'weekly-update.html': {
|
||||
label: 'Actualización semanal',
|
||||
fixture: {
|
||||
nombre: 'Carlos Hernández',
|
||||
empresa: 'Empresa Demo SA de CV',
|
||||
periodoLabel: 'Abril 2026',
|
||||
kpis: {
|
||||
ingresos: 285430.50,
|
||||
egresos: 142900.00,
|
||||
utilidad: 142530.50,
|
||||
margen: 49.9,
|
||||
ivaBalance: 18420.00,
|
||||
ivaAFavorAcumulado: 32100.00,
|
||||
cfdisEmitidos: 47,
|
||||
cfdisRecibidos: 23,
|
||||
},
|
||||
alertas: [
|
||||
{ titulo: 'Cliente en lista negra', mensaje: '1 cliente con situación SAT "Definitivo".', prioridad: 'alta' },
|
||||
{ titulo: 'Concentración alta de proveedores', mensaje: 'IHH = 6,840. Más del 50% del gasto en 1 proveedor.', prioridad: 'media' },
|
||||
{ titulo: 'Pago en efectivo', mensaje: '3 facturas recibidas con forma de pago "01-Efectivo" este mes.', prioridad: 'baja' },
|
||||
],
|
||||
discrepanciasPorMes: [
|
||||
{ label: 'Abril 2026', count: 2 },
|
||||
{ label: 'Marzo 2026', count: 5 },
|
||||
{ label: 'Febrero 2026', count: 0 },
|
||||
{ label: 'Enero 2026', count: 1 },
|
||||
],
|
||||
fechaGeneracion: new Date().toLocaleString('es-MX', { dateStyle: 'long', timeStyle: 'short' }),
|
||||
},
|
||||
importPath: '../src/services/email/templates/weekly-update.ts',
|
||||
fnName: 'weeklyUpdateEmail',
|
||||
},
|
||||
'new-client-admin.html': {
|
||||
label: 'Nuevo cliente registrado (admin)',
|
||||
fixture: {
|
||||
clienteNombre: 'Empresa Demo SA de CV',
|
||||
clienteRfc: 'EDE123456AB1',
|
||||
adminEmail: 'admin@empresademo.com',
|
||||
adminNombre: 'Carlos Hernández',
|
||||
tempPassword: 'a3f2c891',
|
||||
databaseName: 'horux_ede123456ab1',
|
||||
plan: 'business_ia',
|
||||
},
|
||||
importPath: '../src/services/email/templates/new-client-admin.ts',
|
||||
fnName: 'newClientAdminEmail',
|
||||
},
|
||||
};
|
||||
|
||||
async function main() {
|
||||
// Limpia output previo y recrea
|
||||
try { rmSync(OUT_DIR, { recursive: true, force: true }); } catch {}
|
||||
mkdirSync(OUT_DIR, { recursive: true });
|
||||
|
||||
const generated = [];
|
||||
for (const [filename, sample] of Object.entries(SAMPLES)) {
|
||||
const modPath = resolve(__dirname, sample.importPath);
|
||||
const mod = await import(pathToFileURL(modPath).href);
|
||||
const fn = mod[sample.fnName];
|
||||
if (typeof fn !== 'function') {
|
||||
console.error(`[email:preview] FAIL: ${sample.fnName} no exportada en ${modPath}`);
|
||||
continue;
|
||||
}
|
||||
const html = fn(sample.fixture);
|
||||
const outPath = resolve(OUT_DIR, filename);
|
||||
writeFileSync(outPath, html, 'utf8');
|
||||
generated.push({ filename, label: sample.label });
|
||||
console.log(`[email:preview] ✓ ${filename}`);
|
||||
}
|
||||
|
||||
// Index navegable
|
||||
const indexHtml = `<!DOCTYPE html>
|
||||
<html lang="es"><head><meta charset="utf-8"><title>Email previews — Horux 360</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 720px; margin: 40px auto; padding: 0 24px; color: #1E293B; }
|
||||
h1 { font-size: 24px; margin-bottom: 8px; }
|
||||
p.muted { color: #64748B; margin-top: 0; }
|
||||
ul { list-style: none; padding: 0; }
|
||||
li { margin: 8px 0; padding: 14px 18px; border: 1px solid #E2E8F0; border-radius: 8px; }
|
||||
li:hover { background: #F8FAFC; }
|
||||
a { color: #2563EB; text-decoration: none; font-weight: 500; }
|
||||
a:hover { text-decoration: underline; }
|
||||
small { color: #94A3B8; font-size: 12px; margin-left: 8px; }
|
||||
</style></head><body>
|
||||
<h1>Email previews — Horux 360</h1>
|
||||
<p class="muted">Generados desde los templates en <code>apps/api/src/services/email/templates/</code> con datos de ejemplo. Cada link abre el HTML renderizado tal como llegaría al inbox del cliente.</p>
|
||||
<ul>
|
||||
${generated.map(g => `<li><a href="${g.filename}">${g.label}</a> <small>(${g.filename})</small></li>`).join('\n ')}
|
||||
</ul>
|
||||
<p class="muted" style="margin-top:32px;font-size:13px;">Si modificas un template, vuelve a correr <code>pnpm email:preview</code> para regenerar.</p>
|
||||
</body></html>`;
|
||||
|
||||
writeFileSync(resolve(OUT_DIR, 'index.html'), indexHtml, 'utf8');
|
||||
console.log(`\n[email:preview] ${generated.length} templates generados.`);
|
||||
console.log(`[email:preview] Abre: ${resolve(OUT_DIR, 'index.html')}`);
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('[email:preview] FAIL:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
32
apps/api/scripts/process-metricas-now.ts
Normal file
32
apps/api/scripts/process-metricas-now.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Dispara manualmente el procesamiento de `metricas_invalidaciones` para todos
|
||||
* los tenants. Útil tras un `invalidate-metricas-all.ts` para no esperar al
|
||||
* cron (cada 15 min).
|
||||
*
|
||||
* Uso:
|
||||
* pnpm --filter @horux/api exec tsx scripts/process-metricas-now.ts
|
||||
*/
|
||||
import { prisma } from '../src/config/database.js';
|
||||
import { processAllTenantsInvalidations } from '../src/services/metricas-compute.service.js';
|
||||
|
||||
async function main() {
|
||||
console.log('=== Procesar metricas_invalidaciones (all tenants) ===\n');
|
||||
const start = Date.now();
|
||||
const r = await processAllTenantsInvalidations();
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
console.log(
|
||||
`\nTenants revisados: ${r.tenantsRevisados}\n` +
|
||||
`Invalidaciones procesadas: ${r.totalProcesadas}\n` +
|
||||
`Filas metricas_mensuales escritas: ${r.totalFilasEscritas}\n` +
|
||||
`Errores: ${r.totalErrores}\n` +
|
||||
`Tiempo: ${elapsed}s`,
|
||||
);
|
||||
await prisma.$disconnect();
|
||||
process.exit(r.totalErrores > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
console.error('Fatal:', err);
|
||||
await prisma.$disconnect().catch(() => {});
|
||||
process.exit(1);
|
||||
});
|
||||
71
apps/api/scripts/setup-despachos-db.ts
Normal file
71
apps/api/scripts/setup-despachos-db.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
console.log('Setting up horux_despachos database...');
|
||||
|
||||
// Create admin user
|
||||
const hash = await bcrypt.hash('Admin12345!', 12);
|
||||
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email: 'ivan@horuxfin.com' },
|
||||
update: {},
|
||||
create: {
|
||||
email: 'ivan@horuxfin.com',
|
||||
passwordHash: hash,
|
||||
nombre: 'Ivan Admin',
|
||||
},
|
||||
});
|
||||
console.log('✅ User created:', user.email);
|
||||
|
||||
// Find or create tenant
|
||||
let tenant = await prisma.tenant.findFirst();
|
||||
if (!tenant) {
|
||||
tenant = await prisma.tenant.create({
|
||||
data: {
|
||||
nombre: 'Despacho Demo',
|
||||
rfc: 'DDE250101AAA',
|
||||
plan: 'business',
|
||||
databaseName: 'horux_dde250101aaa',
|
||||
verticalProfile: 'CONTABLE',
|
||||
dbMode: 'MANAGED',
|
||||
trialEndsAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
console.log('✅ Tenant created:', tenant.nombre);
|
||||
} else {
|
||||
console.log('✅ Tenant exists:', tenant.nombre);
|
||||
}
|
||||
|
||||
// Create membership
|
||||
await prisma.tenantMembership.upsert({
|
||||
where: { userId_tenantId: { userId: user.id, tenantId: tenant.id } },
|
||||
update: {},
|
||||
create: {
|
||||
userId: user.id,
|
||||
tenantId: tenant.id,
|
||||
rolId: 1,
|
||||
isOwner: true,
|
||||
},
|
||||
});
|
||||
console.log('✅ Membership created (owner)');
|
||||
|
||||
// Set lastTenantId
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastTenantId: tenant.id },
|
||||
});
|
||||
|
||||
console.log('\n🎉 Setup complete!');
|
||||
console.log('Login: ivan@horuxfin.com / Admin12345!');
|
||||
console.log('Tenant:', tenant.nombre, `(${tenant.rfc})`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('Setup failed:', e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
47
apps/api/scripts/sweep-stale-sat-jobs.ts
Normal file
47
apps/api/scripts/sweep-stale-sat-jobs.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* CLI wrapper del watchdog. La lógica vive en
|
||||
* `src/services/sat/sweep-stale-jobs.service.ts` para que también se pueda
|
||||
* correr desde un cron (`sat-sync.job.ts`) sin duplicar código.
|
||||
*
|
||||
* Uso:
|
||||
* pnpm --filter @horux/api exec tsx scripts/sweep-stale-sat-jobs.ts # dry-run
|
||||
* pnpm --filter @horux/api exec tsx scripts/sweep-stale-sat-jobs.ts --apply # ejecuta
|
||||
* STALE_RUNNING_HOURS=2 pnpm --filter @horux/api exec tsx scripts/sweep-stale-sat-jobs.ts
|
||||
*/
|
||||
import { prisma } from '../src/config/database.js';
|
||||
import { sweepStaleSatJobs } from '../src/services/sat/sweep-stale-jobs.service.js';
|
||||
|
||||
async function main() {
|
||||
const apply = process.argv.includes('--apply');
|
||||
const pendingHours = Number(process.env.STALE_PENDING_HOURS || 12);
|
||||
const runningHours = Number(process.env.STALE_RUNNING_HOURS || 4);
|
||||
const mode = apply ? 'APPLY' : 'DRY-RUN';
|
||||
console.log(`=== SAT stale-jobs watchdog [${mode}] ===`);
|
||||
console.log(` pending: nextRetryAt < now − ${pendingHours}h`);
|
||||
console.log(` running: startedAt < now − ${runningHours}h`);
|
||||
console.log();
|
||||
|
||||
const result = await sweepStaleSatJobs({ apply, pendingHours, runningHours });
|
||||
|
||||
console.log(`Encontrados:`);
|
||||
console.log(` pending stale: ${result.pendingFound}`);
|
||||
console.log(` running stale: ${result.runningFound}`);
|
||||
|
||||
for (const e of result.entries) {
|
||||
console.log(` ─ ${e.id} tenant=${e.tenantId} kind=${e.kind} edad=${e.ageHours}h`);
|
||||
}
|
||||
|
||||
if (!apply) {
|
||||
console.log(`\n[DRY-RUN] No se aplicaron cambios. Pasa --apply para marcar como failed.`);
|
||||
} else {
|
||||
console.log(`\nMarcados como failed: pending=${result.pendingMarked} running=${result.runningMarked}`);
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
console.error('Fatal:', err);
|
||||
await prisma.$disconnect().catch(() => {});
|
||||
process.exit(1);
|
||||
});
|
||||
96
apps/api/scripts/test-emails.ts
Normal file
96
apps/api/scripts/test-emails.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { emailService } from '../src/services/email/email.service.js';
|
||||
|
||||
const recipients = ['ivan@horuxfin.com', 'carlos@horuxfin.com'];
|
||||
|
||||
async function sendAllSamples() {
|
||||
for (const to of recipients) {
|
||||
console.log(`\n=== Enviando a ${to} ===`);
|
||||
|
||||
// 1. Welcome
|
||||
console.log('1/6 Bienvenida...');
|
||||
await emailService.sendWelcome(to, {
|
||||
nombre: 'Ivan Alcaraz',
|
||||
email: 'ivan@horuxfin.com',
|
||||
tempPassword: 'TempPass123!',
|
||||
});
|
||||
|
||||
// 2. FIEL notification (goes to ADMIN_EMAIL, but we override for test)
|
||||
console.log('2/6 Notificación FIEL...');
|
||||
// Send directly since sendFielNotification goes to admin
|
||||
const { fielNotificationEmail } = await import('../src/services/email/templates/fiel-notification.js');
|
||||
const { createTransport } = await import('nodemailer');
|
||||
const { env } = await import('../src/config/env.js');
|
||||
const transport = createTransport({
|
||||
host: env.SMTP_HOST,
|
||||
port: parseInt(env.SMTP_PORT),
|
||||
secure: false,
|
||||
auth: { user: env.SMTP_USER, pass: env.SMTP_PASS },
|
||||
});
|
||||
const fielHtml = fielNotificationEmail({
|
||||
clienteNombre: 'Horux 360',
|
||||
clienteRfc: 'CAS200101XXX',
|
||||
});
|
||||
await transport.sendMail({
|
||||
from: env.SMTP_FROM,
|
||||
to,
|
||||
subject: '[Horux 360] subió su FIEL (MUESTRA)',
|
||||
html: fielHtml,
|
||||
});
|
||||
|
||||
// 3. Payment confirmed
|
||||
console.log('3/6 Pago confirmado...');
|
||||
await emailService.sendPaymentConfirmed(to, {
|
||||
nombre: 'Ivan Alcaraz',
|
||||
amount: 1499,
|
||||
plan: 'Enterprise',
|
||||
date: '16 de marzo de 2026',
|
||||
});
|
||||
|
||||
// 4. Payment failed
|
||||
console.log('4/6 Pago fallido...');
|
||||
const { paymentFailedEmail } = await import('../src/services/email/templates/payment-failed.js');
|
||||
const failedHtml = paymentFailedEmail({
|
||||
nombre: 'Ivan Alcaraz',
|
||||
amount: 1499,
|
||||
plan: 'Enterprise',
|
||||
});
|
||||
await transport.sendMail({
|
||||
from: env.SMTP_FROM,
|
||||
to,
|
||||
subject: 'Problema con tu pago - Horux360 (MUESTRA)',
|
||||
html: failedHtml,
|
||||
});
|
||||
|
||||
// 5. Subscription expiring
|
||||
console.log('5/6 Suscripción por vencer...');
|
||||
await emailService.sendSubscriptionExpiring(to, {
|
||||
nombre: 'Ivan Alcaraz',
|
||||
plan: 'Enterprise',
|
||||
expiresAt: '21 de marzo de 2026',
|
||||
});
|
||||
|
||||
// 6. Subscription cancelled
|
||||
console.log('6/6 Suscripción cancelada...');
|
||||
const { subscriptionCancelledEmail } = await import('../src/services/email/templates/subscription-cancelled.js');
|
||||
const cancelledHtml = subscriptionCancelledEmail({
|
||||
nombre: 'Ivan Alcaraz',
|
||||
plan: 'Enterprise',
|
||||
});
|
||||
await transport.sendMail({
|
||||
from: env.SMTP_FROM,
|
||||
to,
|
||||
subject: 'Suscripción cancelada - Horux360 (MUESTRA)',
|
||||
html: cancelledHtml,
|
||||
});
|
||||
|
||||
console.log(`Listo: 6 correos enviados a ${to}`);
|
||||
}
|
||||
|
||||
console.log('\n=== Todos los correos enviados ===');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
sendAllSamples().catch((err) => {
|
||||
console.error('Error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
97
apps/api/scripts/validate-dashboard-impuestos.ts
Normal file
97
apps/api/scripts/validate-dashboard-impuestos.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Valida la alineación dashboard ≡ impuestos tras refactor de getResumenIva.
|
||||
* Para 5 muestras aleatorias por contribuyente, compara:
|
||||
* dashboard.calcularIvaBalancePorRegimen().total vs
|
||||
* impuestos.getResumenIva().resultado
|
||||
*
|
||||
* Deben coincidir céntimo por céntimo (Resultado = Trasladado − Acreditable − Retenido,
|
||||
* usando los mismos 6 buckets del dashboard).
|
||||
*
|
||||
* Uso:
|
||||
* pnpm --filter @horux/api exec tsx scripts/validate-dashboard-impuestos.ts
|
||||
* METRICAS_BYPASS_CACHE=1 pnpm --filter @horux/api exec tsx scripts/validate-dashboard-impuestos.ts
|
||||
*/
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
import * as dashboard from '../src/services/dashboard.service.js';
|
||||
import { getResumenIva } from '../src/services/impuestos.service.js';
|
||||
|
||||
const TOL = 0.01;
|
||||
|
||||
function cmp(a: number, b: number): boolean { return Math.abs(a - b) <= TOL; }
|
||||
function fmt(n: number): string {
|
||||
return n.toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('=== Validación dashboard.balance ≡ impuestos.resultado ===');
|
||||
console.log(` BYPASS_CACHE=${process.env.METRICAS_BYPASS_CACHE === '1' ? 'YES' : 'no'}\n`);
|
||||
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
where: { active: true },
|
||||
select: { id: true, rfc: true, databaseName: true },
|
||||
});
|
||||
|
||||
let total = 0;
|
||||
let pass = 0;
|
||||
let fail = 0;
|
||||
|
||||
for (const t of tenants) {
|
||||
const pool = await tenantDb.getPool(t.id, t.databaseName);
|
||||
const { rows: contribs } = await pool.query<{ entidad_id: string; nombre: string }>(
|
||||
`SELECT c.entidad_id, eg.nombre
|
||||
FROM contribuyentes c
|
||||
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
|
||||
WHERE EXISTS (SELECT 1 FROM metricas_mensuales m WHERE m.contribuyente_id = c.entidad_id)`,
|
||||
);
|
||||
if (contribs.length === 0) continue;
|
||||
console.log(`[${t.rfc}] ${contribs.length} contribuyentes`);
|
||||
|
||||
for (const c of contribs) {
|
||||
const { rows: samples } = await pool.query<{ anio: number; mes: number }>(
|
||||
`SELECT anio, mes FROM (
|
||||
SELECT DISTINCT anio, mes FROM metricas_mensuales WHERE contribuyente_id = $1
|
||||
) t
|
||||
ORDER BY random() LIMIT 5`,
|
||||
[c.entidad_id],
|
||||
);
|
||||
console.log(` ${c.nombre}:`);
|
||||
|
||||
for (const s of samples) {
|
||||
total++;
|
||||
const fi = `${s.anio}-${String(s.mes).padStart(2, '0')}-01`;
|
||||
const lastDay = new Date(s.anio, s.mes, 0).getDate();
|
||||
const ff = `${s.anio}-${String(s.mes).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
|
||||
|
||||
const bal = await dashboard.calcularIvaBalancePorRegimen(
|
||||
pool, t.id, fi, ff, [], undefined, false, c.entidad_id,
|
||||
);
|
||||
const resumen = await getResumenIva(pool, fi, ff, t.id, false, c.entidad_id);
|
||||
|
||||
const mesLabel = `${s.anio}-${String(s.mes).padStart(2, '0')}`;
|
||||
if (cmp(bal.total, resumen.resultado)) {
|
||||
pass++;
|
||||
console.log(` ✓ ${mesLabel} balance=$${fmt(bal.total)} resultado=$${fmt(resumen.resultado)}`);
|
||||
} else {
|
||||
fail++;
|
||||
const delta = bal.total - resumen.resultado;
|
||||
console.log(` ✗ ${mesLabel} balance=$${fmt(bal.total)} resultado=$${fmt(resumen.resultado)} Δ=$${fmt(delta)}`);
|
||||
console.log(` T=$${fmt(resumen.trasladado)} A=$${fmt(resumen.acreditable)} R=$${fmt(resumen.retenido)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n=== Resumen ===`);
|
||||
console.log(` Muestras: ${total}`);
|
||||
console.log(` PASS: ${pass}`);
|
||||
console.log(` FAIL: ${fail}`);
|
||||
|
||||
await prisma.$disconnect();
|
||||
process.exit(fail > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
console.error('Fatal:', err);
|
||||
await prisma.$disconnect().catch(() => {});
|
||||
process.exit(1);
|
||||
});
|
||||
115
apps/api/scripts/validate-gastos.ts
Normal file
115
apps/api/scripts/validate-gastos.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Compara Gastos del Dashboard vs Drill-down para un mes/contribuyente.
|
||||
* Identifica discrepancias y rompe el detalle por lado (factura/pago/NC).
|
||||
*
|
||||
* Uso: tsx scripts/validate-gastos.ts <tenantRfc> <entidadId> <añoMes>
|
||||
*/
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
import { calcularEgresosPorRegimen } from '../src/services/dashboard.service.js';
|
||||
|
||||
const tenantRfcArg = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG';
|
||||
const contribuyenteId = process.argv[3] || 'd745a915-6a23-4818-944b-a7e1e18e536a';
|
||||
const yearMonth = process.argv[4] || '2025-02';
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({
|
||||
where: { rfc: tenantRfcArg, active: true },
|
||||
select: { id: true, rfc: true, databaseName: true },
|
||||
});
|
||||
if (!tenant) { console.error('Tenant not found'); process.exit(1); }
|
||||
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
|
||||
const [anio, mes] = yearMonth.split('-').map(Number);
|
||||
const lastDay = new Date(anio, mes, 0).getDate();
|
||||
const fi = `${yearMonth}-01`;
|
||||
const ff = `${yearMonth}-${String(lastDay).padStart(2, '0')}`;
|
||||
|
||||
console.log(`\n=== Contribuyente ${contribuyenteId} — ${fi} a ${ff} ===\n`);
|
||||
|
||||
// 1. Dashboard (calcularEgresosPorRegimen)
|
||||
const dashboard = await calcularEgresosPorRegimen(
|
||||
pool, tenant.id, fi, ff, undefined, undefined, false, contribuyenteId,
|
||||
);
|
||||
console.log('DASHBOARD calcularEgresosPorRegimen:');
|
||||
console.log(` total: ${dashboard.total.toFixed(2)}`);
|
||||
for (const r of dashboard.porRegimen) {
|
||||
console.log(` ${r.regimenClave} (${r.regimenDescripcion}): ${r.monto.toFixed(2)}`);
|
||||
}
|
||||
|
||||
// 2. Drill-down query (simulated — bucket=gastos uniforme)
|
||||
const IMP_TRAS = `COALESCE(iva_traslado_mxn,0) + COALESCE(ieps_traslado_mxn,0) + COALESCE(impuestos_locales_trasladado_mxn,0)`;
|
||||
const IMP_TRAS_PAGO = `COALESCE(iva_traslado_pago_mxn,0) + COALESCE(ieps_traslado_pago_mxn,0)`;
|
||||
const EXCL_MONTO = `COALESCE((SELECT SUM(COALESCE(cc.importe_mxn,0) - COALESCE(cc.descuento_mxn,0)) FROM cfdi_conceptos cc WHERE cc.cfdi_id = cfdis.id AND cc.clave_prod_serv IN ('84121603','93161608','85101501','85121800')), 0)`;
|
||||
|
||||
// bucket=gastos: RECIBIDO I PUE + RECIBIDO P + RECIBIDO E PUE (excl 07)
|
||||
// Sumamos tomando en cuenta el signo (E resta)
|
||||
const { rows: drillRows } = await pool.query(
|
||||
`SELECT
|
||||
type, tipo_comprobante, metodo_pago,
|
||||
COALESCE(cfdi_tipo_relacion, '') AS tipo_rel,
|
||||
COUNT(*)::int AS n,
|
||||
SUM(total_mxn) AS total_bruto,
|
||||
SUM(COALESCE(total_mxn,0) - (${IMP_TRAS}) - (${EXCL_MONTO})) AS total_neto,
|
||||
SUM(COALESCE(monto_pago_mxn,0) - (${IMP_TRAS_PAGO})) AS pago_neto
|
||||
FROM cfdis
|
||||
WHERE (
|
||||
(type = 'RECIBIDO' AND tipo_comprobante = 'I' AND metodo_pago = 'PUE'
|
||||
AND COALESCE(cfdi_tipo_relacion, '') <> '07')
|
||||
OR (type = 'RECIBIDO' AND tipo_comprobante = 'P')
|
||||
OR (type = 'RECIBIDO' AND tipo_comprobante = 'E' AND metodo_pago = 'PUE'
|
||||
AND COALESCE(cfdi_tipo_relacion, '') <> '07')
|
||||
)
|
||||
AND regimen_fiscal_receptor IN ('605','606','612','621','625','626','601','603','607','608','610','611','614','615','620','622','623','624')
|
||||
AND status NOT IN ('Cancelado','0')
|
||||
AND ((tipo_comprobante='P' AND fecha_pago_p >= $1::date AND fecha_pago_p < ($2::date + interval '1 day'))
|
||||
OR (tipo_comprobante!='P' AND fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')))
|
||||
AND contribuyente_id = $3
|
||||
GROUP BY type, tipo_comprobante, metodo_pago, tipo_rel
|
||||
ORDER BY tipo_comprobante, metodo_pago`,
|
||||
[fi, ff, contribuyenteId],
|
||||
);
|
||||
|
||||
console.log(`\nDRILL-DOWN bucket=gastos (filas del drill por bucket):`);
|
||||
let drillSumaFacturas = 0, drillSumaPagos = 0, drillSumaNC = 0;
|
||||
for (const r of drillRows) {
|
||||
const tc = r.tipo_comprobante;
|
||||
const valor = tc === 'P' ? Number(r.pago_neto) : Number(r.total_neto);
|
||||
console.log(` ${r.type} ${tc} ${r.metodo_pago || '-'} rel=${r.tipo_rel || '-'} n=${r.n} total_bruto=${Number(r.total_bruto).toFixed(2)} valor_neto=${valor.toFixed(2)}`);
|
||||
if (tc === 'I') drillSumaFacturas += valor;
|
||||
else if (tc === 'P') drillSumaPagos += valor;
|
||||
else if (tc === 'E') drillSumaNC += valor;
|
||||
}
|
||||
const drillTotal = drillSumaFacturas + drillSumaPagos - drillSumaNC;
|
||||
console.log(` → facturas=${drillSumaFacturas.toFixed(2)} pagos=${drillSumaPagos.toFixed(2)} NC=${drillSumaNC.toFixed(2)}`);
|
||||
console.log(` → drill total = ${drillTotal.toFixed(2)}`);
|
||||
|
||||
// 3. Comparación
|
||||
const delta = dashboard.total - drillTotal;
|
||||
console.log(`\n=== COMPARATIVA ===`);
|
||||
console.log(` Dashboard: ${dashboard.total.toFixed(2)}`);
|
||||
console.log(` Drill-down: ${drillTotal.toFixed(2)}`);
|
||||
console.log(` Delta: ${delta.toFixed(2)}`);
|
||||
|
||||
if (Math.abs(delta) < 0.01) {
|
||||
console.log(` ✓ CUADRAN`);
|
||||
} else {
|
||||
console.log(` ✗ NO CUADRAN — investigar`);
|
||||
}
|
||||
|
||||
// 4. Régimenes del receptor que aparecen vs los ignorados
|
||||
const { rows: regsReceptor } = await pool.query(
|
||||
`SELECT DISTINCT regimen_fiscal_receptor
|
||||
FROM cfdis
|
||||
WHERE contribuyente_id = $1
|
||||
AND type = 'RECIBIDO'
|
||||
AND fecha_emision >= $2::date AND fecha_emision < ($3::date + interval '1 day')
|
||||
ORDER BY regimen_fiscal_receptor`,
|
||||
[contribuyenteId, fi, ff],
|
||||
);
|
||||
console.log(`\nRegímenes en CFDIs RECIBIDOS del periodo:`, regsReceptor.map(r => r.regimen_fiscal_receptor).join(', '));
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
39
apps/api/scripts/validate-ingresos.ts
Normal file
39
apps/api/scripts/validate-ingresos.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Paridad dashboard vs drill para INGRESOS de un contribuyente en un año.
|
||||
* Similar a validate-gastos pero para el lado emisor.
|
||||
*/
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
import { calcularIngresosPorRegimen } from '../src/services/dashboard.service.js';
|
||||
|
||||
const tenantRfc = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG';
|
||||
const contribuyenteId = process.argv[3] || '414b22a8-c6e2-4f39-be0f-7537a848107e';
|
||||
const año = Number(process.argv[4] || '2025');
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({ where: { rfc: tenantRfc }, select: { id: true, databaseName: true } });
|
||||
if (!tenant) { console.error('Tenant not found'); process.exit(1); }
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
|
||||
console.log(`\n=== Ingresos ${año} Contribuyente ${contribuyenteId} ===\n`);
|
||||
console.log(`mes | total por régimen | total mes`);
|
||||
|
||||
let totalAño = 0;
|
||||
for (let m = 1; m <= 12; m++) {
|
||||
const lastDay = new Date(año, m, 0).getDate();
|
||||
const mm = String(m).padStart(2, '0');
|
||||
const fi = `${año}-${mm}-01`;
|
||||
const ff = `${año}-${mm}-${String(lastDay).padStart(2, '0')}`;
|
||||
|
||||
const ingresos = await calcularIngresosPorRegimen(
|
||||
pool, tenant.id, fi, ff, undefined, undefined, false, contribuyenteId,
|
||||
);
|
||||
|
||||
const porReg = ingresos.porRegimen.map(r => `${r.regimenClave}:${r.monto.toFixed(2)}`).join(' / ');
|
||||
console.log(`${mm} | ${porReg || '(sin datos)'} | ${ingresos.total.toFixed(2)}`);
|
||||
totalAño += ingresos.total;
|
||||
}
|
||||
console.log(`\nTotal año: ${totalAño.toFixed(2)}`);
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
160
apps/api/scripts/validate-metricas.ts
Normal file
160
apps/api/scripts/validate-metricas.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Validación Tanda A: para cada contribuyente con datos en metricas_mensuales,
|
||||
* toma 5 filas al azar y compara contra el cálculo on-the-fly usando los
|
||||
* servicios canónicos (dashboard, impuestos). Reporta PASS/FAIL por celda.
|
||||
*
|
||||
* Uso:
|
||||
* pnpm --filter @horux/api exec tsx scripts/validate-metricas.ts
|
||||
*/
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
import {
|
||||
calcularIngresosPorRegimen,
|
||||
calcularEgresosPorRegimen,
|
||||
} from '../src/services/dashboard.service.js';
|
||||
import { getResumenIva } from '../src/services/impuestos.service.js';
|
||||
|
||||
const TOL = 0.01; // tolerancia de $0.01 para redondeo decimal
|
||||
|
||||
interface StoredRow {
|
||||
contribuyente_id: string;
|
||||
anio: number;
|
||||
mes: number;
|
||||
regimen_fiscal: string | null;
|
||||
ingresos_cobrados: string;
|
||||
egresos_pagados: string;
|
||||
iva_trasladado_total: string;
|
||||
iva_acreditable: string;
|
||||
iva_retenido_cobrado: string;
|
||||
iva_resultado: string;
|
||||
cfdis_emitidos_count: number;
|
||||
cfdis_recibidos_count: number;
|
||||
cfdis_cancelados_count: number;
|
||||
}
|
||||
|
||||
function cmp(a: number, b: number): boolean {
|
||||
return Math.abs(a - b) <= TOL;
|
||||
}
|
||||
|
||||
function fmt(n: number): string {
|
||||
return n.toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
async function validateRow(
|
||||
tenantId: string,
|
||||
row: StoredRow,
|
||||
): Promise<{ pass: boolean; diffs: string[] }> {
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { databaseName: true },
|
||||
});
|
||||
if (!tenant) return { pass: false, diffs: ['tenant no encontrado'] };
|
||||
|
||||
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
|
||||
const fi = `${row.anio}-${String(row.mes).padStart(2, '0')}-01`;
|
||||
const lastDay = new Date(row.anio, row.mes, 0).getDate();
|
||||
const ff = `${row.anio}-${String(row.mes).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
|
||||
|
||||
// Ejecutamos secuencial para evitar interferencia entre queries bajo el pool
|
||||
// limit del tenant (max 3 conexiones). Con Promise.all concurrente, algunas
|
||||
// queries compartidas de getResumenIva devolvían valores parciales.
|
||||
const ingresos = await calcularIngresosPorRegimen(pool, tenantId, fi, ff, [], undefined, false, row.contribuyente_id);
|
||||
const egresos = await calcularEgresosPorRegimen(pool, tenantId, fi, ff, [], undefined, false, row.contribuyente_id);
|
||||
const resumenIva = await getResumenIva(pool, fi, ff, tenantId, false, row.contribuyente_id);
|
||||
|
||||
const reg = row.regimen_fiscal;
|
||||
const ingOtf = ingresos.porRegimen.find(r => r.regimenClave === reg)?.monto || 0;
|
||||
const egrOtf = egresos.porRegimen.find(r => r.regimenClave === reg)?.monto || 0;
|
||||
const trasOtf = resumenIva.trasladadoPorRegimen.find(r => r.regimenClave === reg)?.monto || 0;
|
||||
const acrOtf = resumenIva.acreditablePorRegimen.find(r => r.regimenClave === reg)?.monto || 0;
|
||||
const retOtf = resumenIva.retenidoPorRegimen.find(r => r.regimenClave === reg)?.monto || 0;
|
||||
const resOtf = trasOtf - acrOtf - retOtf;
|
||||
|
||||
const diffs: string[] = [];
|
||||
const ingStored = Number(row.ingresos_cobrados);
|
||||
const egrStored = Number(row.egresos_pagados);
|
||||
const trasStored = Number(row.iva_trasladado_total);
|
||||
const acrStored = Number(row.iva_acreditable);
|
||||
const retStored = Number(row.iva_retenido_cobrado);
|
||||
const resStored = Number(row.iva_resultado);
|
||||
|
||||
if (!cmp(ingStored, ingOtf)) diffs.push(`ingresos: tabla=${fmt(ingStored)} vs otf=${fmt(ingOtf)}`);
|
||||
if (!cmp(egrStored, egrOtf)) diffs.push(`egresos: tabla=${fmt(egrStored)} vs otf=${fmt(egrOtf)}`);
|
||||
if (!cmp(trasStored, trasOtf)) diffs.push(`ivaTras: tabla=${fmt(trasStored)} vs otf=${fmt(trasOtf)}`);
|
||||
if (!cmp(acrStored, acrOtf)) diffs.push(`ivaAcr: tabla=${fmt(acrStored)} vs otf=${fmt(acrOtf)}`);
|
||||
if (!cmp(retStored, retOtf)) diffs.push(`ivaRet: tabla=${fmt(retStored)} vs otf=${fmt(retOtf)}`);
|
||||
if (!cmp(resStored, resOtf)) diffs.push(`ivaResultado: tabla=${fmt(resStored)} vs otf=${fmt(resOtf)}`);
|
||||
|
||||
return { pass: diffs.length === 0, diffs };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('=== Validación metricas_mensuales (5 muestras aleatorias por contribuyente) ===\n');
|
||||
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
where: { active: true },
|
||||
select: { id: true, rfc: true, databaseName: true },
|
||||
});
|
||||
|
||||
let totalMuestras = 0;
|
||||
let totalPass = 0;
|
||||
let totalFail = 0;
|
||||
|
||||
for (const t of tenants) {
|
||||
const pool = await tenantDb.getPool(t.id, t.databaseName);
|
||||
const { rows: contribs } = await pool.query<{ entidad_id: string; nombre: string }>(
|
||||
`SELECT c.entidad_id, eg.nombre
|
||||
FROM contribuyentes c
|
||||
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM metricas_mensuales m WHERE m.contribuyente_id = c.entidad_id
|
||||
)`,
|
||||
);
|
||||
|
||||
if (contribs.length === 0) continue;
|
||||
console.log(`\n[${t.rfc}] ${contribs.length} contribuyentes con datos`);
|
||||
|
||||
for (const c of contribs) {
|
||||
const { rows: samples } = await pool.query<StoredRow>(
|
||||
`SELECT contribuyente_id::text, anio, mes, regimen_fiscal,
|
||||
ingresos_cobrados, egresos_pagados,
|
||||
iva_trasladado_total, iva_acreditable, iva_retenido_cobrado, iva_resultado,
|
||||
cfdis_emitidos_count, cfdis_recibidos_count, cfdis_cancelados_count
|
||||
FROM metricas_mensuales
|
||||
WHERE contribuyente_id = $1
|
||||
ORDER BY random()
|
||||
LIMIT 5`,
|
||||
[c.entidad_id],
|
||||
);
|
||||
|
||||
console.log(` ${c.nombre} (${samples.length} muestras):`);
|
||||
for (const s of samples) {
|
||||
totalMuestras++;
|
||||
const { pass, diffs } = await validateRow(t.id, s);
|
||||
const mesLabel = `${s.anio}-${String(s.mes).padStart(2, '0')}`;
|
||||
const reg = s.regimen_fiscal || 'null';
|
||||
if (pass) {
|
||||
totalPass++;
|
||||
console.log(` ✓ ${mesLabel} reg=${reg} ingresos=$${fmt(Number(s.ingresos_cobrados))}`);
|
||||
} else {
|
||||
totalFail++;
|
||||
console.log(` ✗ ${mesLabel} reg=${reg} DIFFS:`);
|
||||
for (const d of diffs) console.log(` - ${d}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n=== Resumen ===`);
|
||||
console.log(` Muestras totales: ${totalMuestras}`);
|
||||
console.log(` PASS: ${totalPass}`);
|
||||
console.log(` FAIL: ${totalFail}`);
|
||||
|
||||
await prisma.$disconnect();
|
||||
process.exit(totalFail > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
console.error('Fatal:', err);
|
||||
await prisma.$disconnect().catch(() => {});
|
||||
process.exit(1);
|
||||
});
|
||||
112
apps/api/src/app.ts
Normal file
112
apps/api/src/app.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import express, { type Express } from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import { env, getCorsOrigins } from './config/env.js';
|
||||
import { errorMiddleware } from './middlewares/error.middleware.js';
|
||||
import { authRoutes } from './routes/auth.routes.js';
|
||||
import { dashboardRoutes } from './routes/dashboard.routes.js';
|
||||
import { cfdiRoutes } from './routes/cfdi.routes.js';
|
||||
import { impuestosRoutes } from './routes/impuestos.routes.js';
|
||||
import { exportRoutes } from './routes/export.routes.js';
|
||||
import { alertasRoutes } from './routes/alertas.routes.js';
|
||||
import { notificationPreferencesRoutes } from './routes/notification-preferences.routes.js';
|
||||
import { tareasRoutes } from './routes/tareas.routes.js';
|
||||
import { papeleriaRoutes } from './routes/papeleria.routes.js';
|
||||
import { despachoStatsRoutes } from './routes/despacho-stats.routes.js';
|
||||
import { calendarioRoutes } from './routes/calendario.routes.js';
|
||||
import { reportesRoutes } from './routes/reportes.routes.js';
|
||||
import { usuariosRoutes } from './routes/usuarios.routes.js';
|
||||
import { tenantsRoutes } from './routes/tenants.routes.js';
|
||||
import fielRoutes from './routes/fiel.routes.js';
|
||||
import satRoutes from './routes/sat.routes.js';
|
||||
import { webhookRoutes } from './routes/webhook.routes.js';
|
||||
import { subscriptionRoutes } from './routes/subscription.routes.js';
|
||||
import { regimenRoutes } from './routes/regimen.routes.js';
|
||||
import { bancosRoutes } from './routes/bancos.routes.js';
|
||||
import { conciliacionRoutes } from './routes/conciliacion.routes.js';
|
||||
import { facturacionRoutes } from './routes/facturacion.routes.js';
|
||||
import { catalogosRoutes } from './routes/catalogos.routes.js';
|
||||
import { documentosRoutes } from './routes/documentos.routes.js';
|
||||
import { auditLogRoutes } from './routes/audit-log.routes.js';
|
||||
import { platformStaffRoutes } from './routes/platform-staff.routes.js';
|
||||
import despachoRoutes from './routes/despacho.routes.js';
|
||||
import contribuyenteRoutes from './routes/contribuyente.routes.js';
|
||||
import carteraRoutes from './routes/cartera.routes.js';
|
||||
import planCatalogoRoutes from './routes/plan-catalogo.routes.js';
|
||||
import connectorRoutes from './routes/connector.routes.js';
|
||||
import adminDashboardRoutes from './routes/admin-dashboard.routes.js';
|
||||
import adminImpersonateRoutes from './routes/admin-impersonate.routes.js';
|
||||
import adminClientesRoutes from './routes/admin-clientes.routes.js';
|
||||
import adminAddonsRoutes from './routes/admin-addons.routes.js';
|
||||
import despachoAuditRoutes from './routes/despacho-audit.routes.js';
|
||||
import metricasRoutes from './routes/metricas.routes.js';
|
||||
|
||||
const app: Express = express();
|
||||
|
||||
// Security. Helmet default incluye un CSP restrictivo que puede chocar con el
|
||||
// frontend cuando éste embebe recursos propios (ej: /terminos embebe el PDF de
|
||||
// /legal/). Dejamos CSP off en el API y centralizamos los headers de seguridad
|
||||
// en next.config del web (X-Frame-Options, CSP frame-ancestors, HSTS, nosniff,
|
||||
// Referrer-Policy) que es quien sirve la UI. El API solo responde JSON y
|
||||
// archivos binarios (PDFs, XMLs) — no tiene contenido HTML que requiera CSP.
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: false,
|
||||
crossOriginResourcePolicy: { policy: 'cross-origin' }, // permite /legal/*.pdf embebido
|
||||
}));
|
||||
app.use(cors({
|
||||
origin: getCorsOrigins(),
|
||||
credentials: true,
|
||||
}));
|
||||
|
||||
// Body parsing - 10MB default, bulk CFDI route has its own higher limit
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||
|
||||
// Health check
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// API Routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/dashboard', dashboardRoutes);
|
||||
app.use('/api/cfdi', cfdiRoutes);
|
||||
app.use('/api/impuestos', impuestosRoutes);
|
||||
app.use('/api/export', exportRoutes);
|
||||
app.use('/api/alertas', alertasRoutes);
|
||||
app.use('/api/notificaciones', notificationPreferencesRoutes);
|
||||
app.use('/api/tareas', tareasRoutes);
|
||||
app.use('/api/papeleria', papeleriaRoutes);
|
||||
app.use('/api/despachos', despachoStatsRoutes);
|
||||
app.use('/api/calendario', calendarioRoutes);
|
||||
app.use('/api/reportes', reportesRoutes);
|
||||
app.use('/api/usuarios', usuariosRoutes);
|
||||
app.use('/api/tenants', tenantsRoutes);
|
||||
app.use('/api/fiel', fielRoutes);
|
||||
app.use('/api/sat', satRoutes);
|
||||
app.use('/api/webhooks', webhookRoutes);
|
||||
app.use('/api/subscriptions', subscriptionRoutes);
|
||||
app.use('/api/regimenes', regimenRoutes);
|
||||
app.use('/api/bancos', bancosRoutes);
|
||||
app.use('/api/conciliacion', conciliacionRoutes);
|
||||
app.use('/api/facturacion', facturacionRoutes);
|
||||
app.use('/api/catalogos', catalogosRoutes);
|
||||
app.use('/api/documentos', documentosRoutes);
|
||||
app.use('/api/audit-log', auditLogRoutes);
|
||||
app.use('/api/platform-staff', platformStaffRoutes);
|
||||
app.use('/api/despachos', despachoRoutes);
|
||||
app.use('/api/contribuyentes', contribuyenteRoutes);
|
||||
app.use('/api/carteras', carteraRoutes);
|
||||
app.use('/api/planes', planCatalogoRoutes);
|
||||
app.use('/api/connector', connectorRoutes);
|
||||
app.use('/api/admin/dashboard', adminDashboardRoutes);
|
||||
app.use('/api/admin/impersonate', adminImpersonateRoutes);
|
||||
app.use('/api/admin/clientes', adminClientesRoutes);
|
||||
app.use('/api/admin/addons', adminAddonsRoutes);
|
||||
app.use('/api/despacho/audit-log', despachoAuditRoutes);
|
||||
app.use('/api/metricas', metricasRoutes);
|
||||
|
||||
// Error handling
|
||||
app.use(errorMiddleware);
|
||||
|
||||
export { app };
|
||||
1
apps/api/src/auth/passwords.ts
Normal file
1
apps/api/src/auth/passwords.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { hashPassword, verifyPassword } from '@horux/core';
|
||||
30
apps/api/src/auth/tokens.ts
Normal file
30
apps/api/src/auth/tokens.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
generateAccessToken as coreGenerateAccessToken,
|
||||
generateRefreshToken as coreGenerateRefreshToken,
|
||||
verifyToken as coreVerifyToken,
|
||||
decodeToken,
|
||||
type TokenConfig,
|
||||
} from '@horux/core';
|
||||
import type { JWTPayload } from '@horux/shared';
|
||||
import { env } from '../config/env.js';
|
||||
|
||||
const tokenConfig: TokenConfig = {
|
||||
secret: env.JWT_SECRET,
|
||||
accessExpiresIn: env.JWT_EXPIRES_IN,
|
||||
refreshExpiresIn: env.JWT_REFRESH_EXPIRES_IN,
|
||||
};
|
||||
|
||||
export function generateAccessToken(payload: Omit<JWTPayload, 'iat' | 'exp'>): string {
|
||||
return coreGenerateAccessToken(payload, tokenConfig);
|
||||
}
|
||||
|
||||
export function generateRefreshToken(payload: Omit<JWTPayload, 'iat' | 'exp'>): string {
|
||||
return coreGenerateRefreshToken(payload, tokenConfig);
|
||||
}
|
||||
|
||||
export function verifyToken(token: string): JWTPayload {
|
||||
return coreVerifyToken(token, tokenConfig.secret);
|
||||
}
|
||||
|
||||
export { decodeToken };
|
||||
export type { JWTPayload };
|
||||
234
apps/api/src/config/database.ts
Normal file
234
apps/api/src/config/database.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { Pool, type PoolConfig } from 'pg';
|
||||
import { env } from './env.js';
|
||||
import { migrate } from './tenant-migrations.js';
|
||||
|
||||
// ===========================================
|
||||
// Prisma Client (central database: horux360)
|
||||
// ===========================================
|
||||
|
||||
declare global {
|
||||
var prisma: PrismaClient | undefined;
|
||||
}
|
||||
|
||||
export const prisma = globalThis.prisma || new PrismaClient({
|
||||
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
globalThis.prisma = prisma;
|
||||
}
|
||||
|
||||
// ===========================================
|
||||
// TenantConnectionManager (per-tenant DBs)
|
||||
// ===========================================
|
||||
|
||||
interface PoolEntry {
|
||||
pool: Pool;
|
||||
lastAccess: Date;
|
||||
}
|
||||
|
||||
function parseDatabaseUrl(url: string) {
|
||||
const parsed = new URL(url);
|
||||
return {
|
||||
host: parsed.hostname,
|
||||
port: parseInt(parsed.port || '5432'),
|
||||
user: decodeURIComponent(parsed.username),
|
||||
password: decodeURIComponent(parsed.password),
|
||||
};
|
||||
}
|
||||
|
||||
class TenantConnectionManager {
|
||||
private pools: Map<string, PoolEntry> = new Map();
|
||||
private cleanupInterval: NodeJS.Timeout | null = null;
|
||||
private dbConfig: { host: string; port: number; user: string; password: string };
|
||||
private migratedPools: Set<string> = new Set();
|
||||
|
||||
constructor() {
|
||||
this.dbConfig = parseDatabaseUrl(env.DATABASE_URL);
|
||||
this.cleanupInterval = setInterval(() => this.cleanupIdlePools(), 60_000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a connection pool for a tenant's database.
|
||||
* Runs lazy migrations on first access (or after pool invalidation).
|
||||
*/
|
||||
async getPool(
|
||||
tenantId: string,
|
||||
databaseName: string,
|
||||
connectionOverride?: { host: string; port: number; user: string; password: string },
|
||||
): Promise<Pool> {
|
||||
let pool: Pool;
|
||||
|
||||
const entry = this.pools.get(tenantId);
|
||||
if (entry) {
|
||||
entry.lastAccess = new Date();
|
||||
pool = entry.pool;
|
||||
} else {
|
||||
const poolConfig: PoolConfig = {
|
||||
host: connectionOverride?.host ?? this.dbConfig.host,
|
||||
port: connectionOverride?.port ?? this.dbConfig.port,
|
||||
user: connectionOverride?.user ?? this.dbConfig.user,
|
||||
password: connectionOverride?.password ?? this.dbConfig.password,
|
||||
database: databaseName,
|
||||
max: 3,
|
||||
idleTimeoutMillis: 300_000,
|
||||
connectionTimeoutMillis: 10_000,
|
||||
};
|
||||
|
||||
pool = new Pool(poolConfig);
|
||||
|
||||
pool.on('error', (err) => {
|
||||
console.error(`[TenantDB] Pool error for tenant ${tenantId} (${databaseName}):`, err.message);
|
||||
});
|
||||
|
||||
this.pools.set(tenantId, { pool, lastAccess: new Date() });
|
||||
}
|
||||
|
||||
if (!this.migratedPools.has(tenantId)) {
|
||||
try {
|
||||
await migrate(pool, databaseName);
|
||||
} catch (err) {
|
||||
console.error(`[TenantDB] Migration error for tenant ${tenantId} (${databaseName}):`, err);
|
||||
}
|
||||
this.migratedPools.add(tenantId);
|
||||
}
|
||||
|
||||
return pool;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new database for a tenant with all required tables and indexes.
|
||||
*/
|
||||
async provisionDatabase(rfc: string, overrideDatabaseName?: string): Promise<string> {
|
||||
const databaseName = overrideDatabaseName || `horux_${rfc.toLowerCase().replace(/[^a-z0-9]/g, '')}`;
|
||||
|
||||
const adminPool = new Pool({
|
||||
...this.dbConfig,
|
||||
database: 'postgres',
|
||||
max: 1,
|
||||
});
|
||||
|
||||
try {
|
||||
const exists = await adminPool.query(
|
||||
`SELECT 1 FROM pg_database WHERE datname = $1`,
|
||||
[databaseName]
|
||||
);
|
||||
|
||||
if (exists.rows.length > 0) {
|
||||
throw new Error(`Database ${databaseName} already exists`);
|
||||
}
|
||||
|
||||
await adminPool.query(`CREATE DATABASE "${databaseName}"`);
|
||||
|
||||
const tenantPool = new Pool({
|
||||
...this.dbConfig,
|
||||
database: databaseName,
|
||||
max: 1,
|
||||
});
|
||||
|
||||
try {
|
||||
await migrate(tenantPool, databaseName);
|
||||
} finally {
|
||||
await tenantPool.end();
|
||||
}
|
||||
|
||||
return databaseName;
|
||||
} finally {
|
||||
await adminPool.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete: rename database so it can be recovered.
|
||||
*/
|
||||
async deprovisionDatabase(databaseName: string): Promise<void> {
|
||||
// Close any active pool for this tenant
|
||||
for (const [tenantId, entry] of this.pools.entries()) {
|
||||
// We check pool config to match the database
|
||||
if ((entry.pool as any).options?.database === databaseName) {
|
||||
await entry.pool.end().catch(() => {});
|
||||
this.pools.delete(tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
const timestamp = Date.now();
|
||||
const adminPool = new Pool({
|
||||
...this.dbConfig,
|
||||
database: 'postgres',
|
||||
max: 1,
|
||||
});
|
||||
|
||||
try {
|
||||
await adminPool.query(`
|
||||
SELECT pg_terminate_backend(pid)
|
||||
FROM pg_stat_activity
|
||||
WHERE datname = $1 AND pid <> pg_backend_pid()
|
||||
`, [databaseName]);
|
||||
|
||||
await adminPool.query(
|
||||
`ALTER DATABASE "${databaseName}" RENAME TO "${databaseName}_deleted_${timestamp}"`
|
||||
);
|
||||
} finally {
|
||||
await adminPool.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate (close and remove) a specific tenant's pool.
|
||||
*/
|
||||
invalidatePool(tenantId: string): void {
|
||||
const entry = this.pools.get(tenantId);
|
||||
if (entry) {
|
||||
entry.pool.end().catch(() => {});
|
||||
this.pools.delete(tenantId);
|
||||
}
|
||||
this.migratedPools.delete(tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove idle pools (not accessed in last 5 minutes).
|
||||
*/
|
||||
private cleanupIdlePools(): void {
|
||||
const now = Date.now();
|
||||
const maxIdle = 5 * 60 * 1000;
|
||||
|
||||
for (const [tenantId, entry] of this.pools.entries()) {
|
||||
if (now - entry.lastAccess.getTime() > maxIdle) {
|
||||
entry.pool.end().catch((err) =>
|
||||
console.error(`[TenantDB] Error closing idle pool for ${tenantId}:`, err.message)
|
||||
);
|
||||
this.pools.delete(tenantId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Graceful shutdown: close all pools.
|
||||
*/
|
||||
async shutdown(): Promise<void> {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
}
|
||||
|
||||
const closePromises = Array.from(this.pools.values()).map((entry) =>
|
||||
entry.pool.end()
|
||||
);
|
||||
await Promise.all(closePromises);
|
||||
this.pools.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stats about active pools.
|
||||
*/
|
||||
getStats(): { activePools: number; tenantIds: string[] } {
|
||||
return {
|
||||
activePools: this.pools.size,
|
||||
tenantIds: Array.from(this.pools.keys()),
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const tenantDb = new TenantConnectionManager();
|
||||
63
apps/api/src/config/env.ts
Normal file
63
apps/api/src/config/env.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { z } from 'zod';
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
|
||||
// Load .env file from the api package root
|
||||
config({ path: resolve(process.cwd(), '.env') });
|
||||
|
||||
const envSchema = z.object({
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||
PORT: z.string().default('4000'),
|
||||
DATABASE_URL: z.string(),
|
||||
JWT_SECRET: z.string().min(32),
|
||||
JWT_EXPIRES_IN: z.string().default('15m'),
|
||||
JWT_REFRESH_EXPIRES_IN: z.string().default('7d'),
|
||||
CORS_ORIGIN: z.string().default('http://localhost:3000'),
|
||||
|
||||
// Frontend URL (for MercadoPago back_url, emails, etc.)
|
||||
FRONTEND_URL: z.string().default('https://horuxfin.com'),
|
||||
|
||||
// FIEL encryption (separate from JWT to allow independent rotation)
|
||||
FIEL_ENCRYPTION_KEY: z.string().min(32),
|
||||
FIEL_STORAGE_PATH: z.string().default('/var/horux/fiel'),
|
||||
|
||||
// MercadoPago
|
||||
MP_ACCESS_TOKEN: z.string().optional(),
|
||||
MP_WEBHOOK_SECRET: z.string().optional(),
|
||||
MP_NOTIFICATION_URL: z.string().optional(),
|
||||
|
||||
// SMTP (Gmail Workspace)
|
||||
SMTP_HOST: z.string().default('smtp.gmail.com'),
|
||||
SMTP_PORT: z.string().default('587'),
|
||||
SMTP_USER: z.string().optional(),
|
||||
SMTP_PASS: z.string().optional(),
|
||||
SMTP_FROM: z.string().default('Horux360 <noreply@horuxfin.com>'),
|
||||
|
||||
// Admin notification email
|
||||
ADMIN_EMAIL: z.string().default('carlos@horuxfin.com'),
|
||||
|
||||
// Facturapi
|
||||
FACTURAPI_USER_KEY: z.string().optional(),
|
||||
|
||||
// Cloudflare Tunnel (connector BYO-DB)
|
||||
CLOUDFLARE_API_TOKEN: z.string().optional(),
|
||||
CLOUDFLARE_ACCOUNT_ID: z.string().optional(),
|
||||
CLOUDFLARE_TUNNEL_DOMAIN: z.string().default('tunnel.horux.mx'),
|
||||
|
||||
// KMS for encrypting DB connection strings and connector tokens
|
||||
CONNECTOR_ENCRYPTION_KEY: z.string().optional(),
|
||||
});
|
||||
|
||||
const parsed = envSchema.safeParse(process.env);
|
||||
|
||||
if (!parsed.success) {
|
||||
console.error('❌ Invalid environment variables:', parsed.error.flatten().fieldErrors);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
export const env = parsed.data;
|
||||
|
||||
// Parse CORS origins (comma-separated) into array
|
||||
export function getCorsOrigins(): string[] {
|
||||
return env.CORS_ORIGIN.split(',').map(origin => origin.trim());
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user