Initial commit - Horux Despachos NL
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/
|
||||
523
CLAUDE.md
Normal file
523
CLAUDE.md
Normal file
@@ -0,0 +1,523 @@
|
||||
# Horux360 - Contexto para Claude
|
||||
|
||||
## Qué es esto
|
||||
|
||||
Plataforma SaaS de análisis financiero y gestión fiscal para empresas mexicanas. Maneja CFDIs (facturas electrónicas del SAT), cálculos de IVA/ISR, reportes financieros, alertas de cumplimiento fiscal, conciliación bancaria, sincronización directa con el SAT y emisión de facturas vía Facturapi.
|
||||
|
||||
**Producción:** https://horuxfin.com
|
||||
**Autor:** Carlos e Ivan (Horux 360) (RFC: HTS240708LJA)
|
||||
|
||||
---
|
||||
|
||||
## Arquitectura en 30 segundos
|
||||
|
||||
```
|
||||
Monorepo (pnpm + Turborepo)
|
||||
├── apps/api → Express + TypeScript (tsx, puerto 4000)
|
||||
├── apps/web → Next.js 14 + App Router (puerto 3000)
|
||||
└── packages/shared → Tipos, constantes e interfaces compartidas
|
||||
```
|
||||
|
||||
**Multi-tenant database-per-tenant:**
|
||||
- `horux360` → BD central (Prisma): tenants, users, roles, subscriptions, catálogos fiscales, catálogos SAT, timbres Facturapi
|
||||
- `horux_<rfc>` → BD por tenant (pg Pool + raw SQL): cfdis, cfdi_conceptos, rfcs, bancos, conciliaciones, alertas, recordatorios, contribuyentes, carteras, obligaciones, tareas, papelería
|
||||
|
||||
**Dos capas de acceso a datos** (esto es intencional):
|
||||
- **Prisma** para la BD central (ORM, migraciones, tipos generados)
|
||||
- **pg Pool directo** para BDs de tenant (queries SQL complejos de cálculos fiscales)
|
||||
|
||||
---
|
||||
|
||||
## Archivos clave por área
|
||||
|
||||
### Configuración
|
||||
| Archivo | Qué hace |
|
||||
|---------|----------|
|
||||
| `apps/api/src/config/env.ts` | Variables de entorno validadas con Zod (incluye FACTURAPI_USER_KEY) |
|
||||
| `apps/api/src/config/database.ts` | Prisma client + `TenantConnectionManager` (pools, provisioning, lazy migration) |
|
||||
| `apps/api/src/config/tenant-migrations.ts` | `migrate()`, `migrateAll()`, `getMigrationFiles()` — sistema de migraciones SQL para BDs tenant |
|
||||
| `apps/api/src/migrations/tenant/*.sql` | Archivos SQL numerados (`001_initial_schema.sql`, etc.) |
|
||||
| `apps/api/.env` | Credenciales locales (DB, JWT, SMTP, MercadoPago, FIEL, Facturapi, Metabase) |
|
||||
| `apps/api/prisma/schema.prisma` | Schema de la BD central |
|
||||
|
||||
### Autenticación y seguridad
|
||||
| Archivo | Qué hace |
|
||||
|---------|----------|
|
||||
| `src/middlewares/auth.middleware.ts` | JWT verify + `authorize(...roles)` |
|
||||
| `src/middlewares/tenant.middleware.ts` | Resuelve pool de BD del tenant, cache 5min, `X-View-Tenant` para admin global |
|
||||
| `src/middlewares/plan-limits.middleware.ts` | Verifica suscripción, limita CFDIs, read-only si inactiva |
|
||||
| `src/middlewares/feature-gate.middleware.ts` | `requireFeature('reportes')` por plan |
|
||||
| `src/utils/global-admin.ts` | Admin global = tenant con RFC `HTS240708LJA` |
|
||||
| `packages/shared/src/constants/roles.ts` | `GLOBAL_ADMIN_RFC` + `isGlobalAdminRfc()` compartido frontend/backend |
|
||||
|
||||
### Roles (tabla `roles` en BD central)
|
||||
| id | nombre | Label UI | Acceso |
|
||||
|----|--------|----------|--------|
|
||||
| 1 | `owner` | Dueño | Todo + gestión usuarios + configuración + reportes |
|
||||
| 7 | `cfo` | CFO | Todo (mismo nivel que owner) |
|
||||
| 2 | `contador` | Contador | Dashboard, CFDI, Impuestos, Calendario, Alertas, Conciliación, Facturación (puede completar alertas y crear recordatorios) |
|
||||
| 8 | `auxiliar` | Auxiliar | Mismos permisos que contador |
|
||||
| 3 | `visor` | Visor | CFDI, Impuestos, Calendario, Alertas, Conciliación (solo lectura) |
|
||||
|
||||
**"Admin global" (platform staff) — tabla `user_platform_roles`:** staff interno de Horux 360 con acceso transversal. 5 roles en enum `PlatformRole`:
|
||||
- `platform_admin` — Todo (gestión staff, precios, clientes, facturas)
|
||||
- `platform_ti` — Mismos permisos que admin (equipo TI, trazabilidad distinta en audit)
|
||||
- `platform_support` — Ver tenants, tickets; NO facturación/precios
|
||||
- `platform_sales` — Crear/editar clientes; NO precios
|
||||
- `platform_finance` — Pagos, facturas manuales, editar precios
|
||||
|
||||
`platform_admin` y `platform_ti` son **supersets** (implican todos los demás). Se checa vía helper: `hasPlatformRole(userId, role)`, `canManageTenants()`, `canEditPrices()`, `canEmitInvoicesManual()`, `isPlatformStaff()` en `apps/api/src/utils/platform-admin.ts`. JWT incluye `platformRoles[]` al login.
|
||||
|
||||
**`isGlobalAdmin()` compat:** `apps/api/src/utils/global-admin.ts` es shim que re-exporta de `platform-admin.ts` — resuelve "admin global" vía tabla (busca rol superset), fallback a RFC `HTS240708LJA` si tabla vacía. El concepto UX "admin global" se preserva; la implementación migró de hardcode a tabla.
|
||||
|
||||
**`isGlobalAdminRfc(rfc, role, platformRoles?)` en shared:** tercer parámetro opcional — prioriza `platformRoles` si existe, fallback al RFC check.
|
||||
|
||||
**UI `/admin/staff`:** gestión de roles (solo platform_admin/platform_ti). Protección "último superset": no te puedes quitar si serías el único con acceso transversal. Ver `docs/plans/2026-04-14-platform-admin-roles.md` — sección "Implementación ejecutada".
|
||||
|
||||
### Planes (5 planes)
|
||||
| Plan | CFDIs | Usuarios | Features |
|
||||
|------|-------|----------|----------|
|
||||
| starter | 0 | 1 | dashboard, cfdi_basic, iva_isr |
|
||||
| business | 50 | 3 | + reportes, alertas, calendario, conciliacion, forecasting, xml_sat, documentos |
|
||||
| business_ia | 50 | 3 | Mismas que business + Lolita (feature `ia_lolita`, agente IA fiscal) |
|
||||
| custom | 50 | 3 | Mismas que business_ia, precio variable por tenant |
|
||||
| enterprise | 100 | ilimitado | + api |
|
||||
|
||||
Cada empresa requiere su propia suscripción. `multi_empresa` no existe como feature.
|
||||
|
||||
### Suscripciones (self-serve con MercadoPago)
|
||||
|
||||
**Precios:** tabla `plan_prices` en BD central — 8 filas (4 planes × monthly/annual). Custom no se guarda aquí (el admin fija monto por tenant). Editables desde `/configuracion/suscripcion` (admin global) o SQL directo. Los cambios aplican solo a suscripciones nuevas; vigentes conservan su amount.
|
||||
|
||||
**Estados de `Subscription.status`:** `trial`, `trial_converted`, `trial_expired`, `pending`, `authorized`, `paused`, `cancelled`.
|
||||
|
||||
**Flujos self-serve (endpoints `/api/subscriptions/me/*`):**
|
||||
- `trial` — 30 días gratis sin tarjeta. Una sola vez **por RFC** (padrón persistente `trial_usages` — sobrevive borrado/recreación del tenant, bloquea abuso).
|
||||
- `subscribe` — crea preapproval MP con `auto_recurring` mensual (`months/1`) o anual (`months/12`).
|
||||
- `change` — downgrades y cambios de frecuencia van a `pendingPlan`/`pendingEffectiveAt = currentPeriodEnd`.
|
||||
- `upgrade` — plan más caro con misma frecuencia cobra prorateo inmediato vía MP Preference + `updatePreapprovalAmount` al recibir webhook. `external_reference = 'proration:${tenantId}:${subscriptionId}'` es el marcador que el webhook usa.
|
||||
- `cancel` — `status=cancelled` + `cancelPreapproval` en MP. Middleware respeta `currentPeriodEnd`.
|
||||
- `reactivate` — revive suscripción cancelada dentro del período pagado. Crea preapproval nuevo con `start_date=currentPeriodEnd` (sin doble cobro). Limpia pending/upgrade residuales.
|
||||
|
||||
**Cron `applyPendingChanges` + `expireTrials`:** 2:30 AM daily (`30 2 * * *`) — aplica cambios programados y transiciona trials vencidos.
|
||||
|
||||
**Auth pattern:** endpoints `/subscriptions/:tenantId`, `/:tenantId/payments`, `/:tenantId/generate-link` usan `requireOwnTenantOrGlobalAdmin` (el dueño puede consultar su propia sub, admin global puede ver cualquiera). `mark-paid`, `listar todas`, `editar precios` solo admin global.
|
||||
|
||||
**Webhook `external_reference` routing:**
|
||||
- `proration:*` → `applyApprovedUpgrade` (upgrade payment confirmado)
|
||||
- `${tenantId}` UUID → flujo recurrente
|
||||
|
||||
**Plan Custom:** sólo lo activa el admin global al provisionar tenant. El self-serve self-serve lo rechaza con error explícito.
|
||||
|
||||
**Auto-facturación de pagos (`invoicing.service.ts`):** cada webhook `payment.approved` dispara `emitInvoiceIfApplicable(payment.id)`. Emite CFDI vía Facturapi (org de Horux 360, RESICO PM, clave prod/serv `81112502`). **El primer pago aprobado de cada tenant se skip-ea** (lo factura manual el admin para capturar datos fiscales); los subsecuentes van auto. **Receptor según preferencia del tenant** (`Tenant.factPreferencia` — `mis_datos` por default vs `publico_general`): si tiene CSF cargada + `factPreferencia='mis_datos'` se factura con datos reales del cliente usando `factUsoCfdi` (default `G03`) y `factRegimenPreferido` (o primer régimen activo si no se especifica); si falta cualquier dato fiscal cae automáticamente a Público en General + `S01`. Configurable desde `/configuracion/facturacion` (UI limita uso CFDI a G03/S01). Idempotente por `Payment.facturapiInvoiceId`. Fail-soft (error en Facturapi → log + invoice null + webhook sigue retornando 200). Ver `docs/plans/2026-04-13-auto-invoicing-mp-payments.md` y `docs/plans/2026-05-02-session.md` sección 22.
|
||||
|
||||
Ver `docs/plans/2026-04-13-subscriptions-self-serve.md` para diseño completo y flujos.
|
||||
|
||||
**Schema tenant — single source of truth:** las migraciones SQL en `apps/api/src/migrations/tenant/*.sql` son el único lugar donde se declara el schema de las BDs tenant. El seed (`prisma/seed.ts`) ya NO hardcodea CREATE TABLE — llama a `migrate()` del runner. Añadir una migración = crear `NNN_descripcion.sql`. Se aplica eager via `pnpm db:migrate-tenants` y lazy en `getPool()`.
|
||||
|
||||
**Audit log (`utils/audit.ts`):** helper `auditLog()`/`auditFromReq()` fire-and-forget que escribe a tabla `audit_log` (BD central). Instrumentado en 12+ eventos críticos (login, subscribe, cancel, plan change, price edit, invoice auto, password reset, etc.). UI admin global en `/admin/audit-log` con filtros + expand JSON metadata. NUNCA debe romper la acción principal — cualquier fallo al escribir se logea en consola pero no re-lanza. Ver `docs/plans/2026-04-14-audit-log.md`.
|
||||
|
||||
**Recuperación de contraseña:** `/login` tiene link "¿Olvidaste tu contraseña?" → `/forgot-password` (solicita token por email) → `/reset-password?token=xxx` (nueva password). Backend: tabla `password_reset_tokens` (32 bytes hex, 1h expiry, single-use), endpoints `POST /auth/password-reset/{request,confirm}` con rate limits 3/h y 10/h. Anti-enumeration (respuesta genérica). Al confirmar reset se borran todos los refresh tokens del user. SMTP logea a consola en dev — copia el link del log del API para testing local. Ver `docs/plans/2026-04-14-password-reset.md`.
|
||||
|
||||
**Onboarding auto-dismiss:** `/onboarding` muestra los pasos de configuración inicial (cuenta → contribuyente → FIEL → CSD → equipo opc. → plan opc.) para owner/cfo/contador. Deja de mostrarse cuando se cumple cualquiera de las dos condiciones (lo que pase primero): (1) `User.loginCount > 4` — incrementado solo en `auth.service.login()`, NO en refresh, para que el threshold sea sesiones reales y no horas; (2) `User.onboardingDismissedAt != null` — seteado por `POST /auth/onboarding/dismiss` (idempotente, preserva timestamp original) que la página `/onboarding` invoca automáticamente cuando todos los pasos requeridos están completos. Helper compartido en `apps/web/lib/onboarding.ts:shouldShowOnboarding(user)` con `ONBOARDING_LOGIN_THRESHOLD = 4` exportado. El `LoginResponse.user` incluye ambos campos para que el frontend decida sin round-trip extra. Roles `cliente/auxiliar/supervisor` y admin global nunca ven onboarding (van directo a `/dashboard` o `/clientes`). Ver `docs/plans/2026-05-02-session.md` sección 23.
|
||||
|
||||
**Rate limiting por endpoint (`middlewares/rate-limit.middleware.ts`):** 4 tiers con key por `userId` (no IP, para no bloquear usuarios detrás de NAT compartido). Admin global (superset `platform_admin`/`platform_ti`) exento vía `skip`. Tiers: `veryStrictLimit` (2/día) en sync SAT manual y opinión cumplimiento; `strictLimit` (10/h) en emisión/cancelación factura, CFDI bulk, subs subscribe/change/upgrade, password-change; `normalLimit` (100/15min) a router level en dashboard/reportes/impuestos; `relaxedLimit` (500/15min) en catalogos/regimenes. `/auth/login` y `/auth/register` conservan sus propios limiters específicos. Headers `RateLimit-*` estándar. Key generator: usa `ipKeyGenerator(req.ip)` de `express-rate-limit` para el fallback anónimo — normaliza IPv6 correctamente (sin esto el lib emitía warning de potential bypass). Frontend (`lib/api/client.ts`) preserva el `message` del 429 para los try/catch existentes. Ver `docs/plans/2026-04-14-rate-limiting-expansion.md`.
|
||||
|
||||
**Input validation con Zod (defense-in-depth):** todos los controllers críticos que reciben `req.body` validan el shape con Zod antes de pasar al service. Prisma ya protege de SQL injection, pero Zod evita que un payload malformado llegue hasta la capa de persistencia y produzca 500s o comportamiento raro. Cobertura: `auth.controller` (register/login/refresh/reset/change/logout-all/switch-tenant), `bancos.controller` (create/update), `alertas.controller` (create/update), `calendario.controller` (create/update recordatorio), `usuarios.controller` (invite/update/updateGlobal), `tenants.controller` (addMyTenant), `subscription.controller` (varios), `platform-staff.controller`. Patrón uniforme: schema arriba del archivo, `.parse(req.body)` en el handler, catch `z.ZodError` → `next(new AppError(400, error.errors[0].message))`. Los schemas tipo `UserInvite`/`UserUpdate` en `@horux/shared` se mantienen alineados con los Zod enums.
|
||||
|
||||
**Helmet configurado explícitamente (`app.ts`):** `helmet({ contentSecurityPolicy: false, crossOriginResourcePolicy: 'cross-origin' })`. CSP se desactiva en el API porque (a) el API solo sirve JSON y binarios (PDF/XML) sin contenido HTML que requiera CSP, y (b) el default chocaba con el iframe del PDF que el frontend embebe en `/terminos`. Los security headers del frontend (Next) siguen siendo la capa de CSP activa: ver `apps/web/next.config.js` con `X-Frame-Options: SAMEORIGIN`, `Content-Security-Policy: frame-ancestors 'self'`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, HSTS.
|
||||
|
||||
**Multi-tenant memberships (owner-multi-rfc, en progreso):** Nueva tabla `tenant_memberships (user_id, tenant_id, rol_id, is_owner, active, joined_at)` con `@@unique([userId, tenantId])`. Permite que un mismo user pertenezca a múltiples tenants con rol distinto en cada uno. Backfilled idempotente desde `User.tenantId+rolId` (1 membership por user existente). `User.tenantId` y `User.rolId` se **mantienen** durante la transición como "default tenant" para UX al login. Login y refresh responses incluyen `user.tenants: [{id, nombre, rfc, plan, role, isOwner}]` derivado de memberships activas. Endpoint `POST /auth/switch-tenant { tenantId, refreshToken }` valida membership, revoca el refresh actual, emite nuevo par apuntando al target con el rol de ese tenant (audit event `user.tenant_switched`). Helpers en `apps/api/src/utils/memberships.ts`: `getUserTenants()`, `verifyMembership()`. Frontend: `MembershipSwitcher` en el header (`apps/web/components/membership-switcher.tsx`) visible cuando `tenants.length > 1` y NO es admin global; click llama `switchTenant` + `queryClient.clear()` + reload. Coexiste con `TenantSelector` (admin global usa impersonación X-View-Tenant, modelo distinto). Página `/mis-empresas` (owner-only) lista solo tenants donde `isOwner=true` con su subscripción joined; modal "Agregar empresa" llama `POST /api/tenants/mine` (gateado por `isOwnerSomewhere(userId)` en backend → 403 si no eres owner en ningún lado). Sidebar item "Mis empresas" usa flag `requireOwnerSomewhere` (no `roles[]`) para que un híbrido contador+owner siga viéndolo desde cualquier contexto activo. Helpers: `addTenantToOwner()`, `getMyTenantsDetailed()` en `tenants.service.ts`; `isOwnerSomewhere()`, `getTenantOwnerEmail()` en `utils/memberships.ts`. Trial gate por owner: `startTrial()` acepta `ownerUserId` opcional — si se pasa, busca otro tenant donde el user es owner activo Y tiene `trialEndsAt` set → si encuentra, rechaza con mensaje citando el RFC previo. Combinado con `trial_usages` (gate por RFC), cubre todos los vectores de abuso del trial. **F6 cleanup completo (2026-04-14):** `User.tenantId` y `User.rolId` **eliminados** del schema. `User.lastTenantId String?` agregado para "remember last tenant" UX. `auth.login` resuelve activeMembership desde `tenant_memberships` (prefiere lastTenantId, fallback al primer membership por joinedAt). 5 callsites de subscription.service migrados a `getTenantOwnerEmail()`. usuarios.service todo via memberships. Fases 1-6 ✅ (refactor completo). Ver `docs/plans/2026-04-14-owner-multi-rfc-subscriptions.md`.
|
||||
|
||||
**Sistema de emails (`apps/api/src/services/email/`):** transporte SMTP via Nodemailer (config en `.env`: `SMTP_HOST/PORT/USER/PASS`). Si SMTP no está configurado, los emails se logean a consola — útil en dev. 9 templates en `templates/`: `welcome`, `password-reset`, `payment-confirmed`, `payment-failed`, `subscription-cancelled`, `subscription-expiring`, `fiel-notification`, `new-client-admin`, `weekly-update`. Layout común en `base.ts` con identidad visual de horux360.com: header con gradiente azul→morado (`#2563EB → #7C3AED`), tipografía Inter (Google Fonts con fallback a system-ui), tokens de marca exportados como `BRAND_COLORS`. Helpers `primaryButton`, `infoBox`, `heading` para componentes consistentes. **Importante:** las familias de fuentes en `style="..."` deben usar comillas simples (`'Inter', ...`) — comillas dobles rompen el atributo HTML. Para previsualizar sin SMTP: `pnpm email:preview` genera HTMLs estáticos en `apps/api/email-previews/` (gitignored) con datos de ejemplo + index navegable. Ejecutar tras cualquier cambio de template para verificar visualmente.
|
||||
|
||||
**Reporte semanal automático (`weekly-update.job.ts`):** cron Lunes 8:00 AM (`0 8 * * 1` America/Mexico_City) que itera todos los tenants activos y envía a cada owner activo el correo "Actualización semanal — {empresa}". Solo arranca en `NODE_ENV=production`. Contenido: KPIs del mes en curso (ingresos/egresos/utilidad/IVA balance/CFDIs emitidos+recibidos vía `getKpis()`), alertas activas (rojo/ámbar/azul por prioridad vía `generarAlertasAutomaticas()`), y breakdown de discrepancias de régimen por mes en los últimos 6 meses (`getDiscrepanciasPorMes(pool, tenantId, 6)`). Recipientes resueltos vía `tenant_memberships.where(isOwner=true, active=true)`. Por-tenant + por-recipient try/catch — un fallo individual no bloquea al resto. `sendWeeklyUpdateForTenant(tenantId)` exportado para disparo manual desde un endpoint admin futuro.
|
||||
|
||||
**Revocación de JWT (tokenVersion):** `User.tokenVersion` (entero, default 0) se incluye en cada JWT. `authenticate` middleware compara `payload.tokenVersion` contra el actual en BD (cache 30s + broadcast PM2 via `process.send`). Incrementar el version invalida **todos** los access tokens del user en el siguiente request. Disparadores: (1) cambio de password autenticado (`POST /auth/password-change`), (2) "cerrar todas las sesiones" (`POST /auth/logout-all`), (3) confirmar password reset (`POST /auth/password-reset/confirm`). UI en `/configuracion/seguridad`. Backward compat: JWTs pre-deploy no incluyen el campo → se interpreta como 0 → matchea con default, no hay re-login forzado masivo. Ver `docs/plans/2026-04-14-jwt-revocation.md`.
|
||||
|
||||
### Lógica fiscal (lo más complejo)
|
||||
| Archivo | Qué hace |
|
||||
|---------|----------|
|
||||
| `src/services/dashboard.service.ts` | KPIs: ingresos/egresos/IVA/adquisición mercancías por régimen, IVA a favor acumulado. Filtro de conciliación. |
|
||||
| `src/services/impuestos.service.ts` | IVA mensual, resumen IVA/ISR, coeficiente de utilidad |
|
||||
| `src/services/cfdi.service.ts` | CRUD CFDIs, filtros, búsqueda, resúmenes, bulk insert |
|
||||
| `src/services/conciliacion.service.ts` | Conciliación bancaria: vincular CFDIs con pagos |
|
||||
| `src/services/bancos.service.ts` | CRUD bancos por tenant |
|
||||
| `src/services/reportes.service.ts` | Estado de resultados, flujo efectivo, comparativo, CxP, CxC |
|
||||
| `src/services/alertas-auto.service.ts` | Alertas automáticas: lista negra, concentración, discrepancias, cancelación retroactiva, TipoRelacion sospechoso, tareas próximas a vencer, RESICO PF cerca de límite anual |
|
||||
| `src/services/calendario-fiscal.service.ts` | Genera calendario fiscal desde catálogo + días inhábiles |
|
||||
| `src/services/regimen.service.ts` | Catálogo de regímenes, configuración activos/ignorados por tenant |
|
||||
| `src/services/recordatorios.service.ts` | CRUD recordatorios custom por tenant (público/privado) |
|
||||
| `src/services/tareas.service.ts` | Tareas operativas recurrentes por contribuyente con materialización lazy de periodos |
|
||||
| `src/services/papeleria.service.ts` | Papelería de trabajo: archivos por contribuyente con flujo opcional de aprobación |
|
||||
| `src/services/despacho-stats.service.ts` | Métricas del módulo Despacho: contribuyentes, mis-asignados, equipo (jerárquico) |
|
||||
| `src/services/notification-preferences.service.ts` | Preferencias de email por contribuyente (JSONB en `contribuyentes`) |
|
||||
|
||||
### Claves de concepto excluidas de cálculos fiscales
|
||||
Los conceptos con las siguientes `clave_prod_serv` se excluyen de **todos** los cálculos de ingresos, egresos, IVA e ISR (dashboard + impuestos, base y conciliación):
|
||||
- `84121603` — Seguros
|
||||
- `93161608` — Servicios gubernamentales
|
||||
- `85101501` — Servicios de salud
|
||||
- `85121800` — Servicios médicos
|
||||
|
||||
Implementado con subqueries correlacionados a `cfdi_conceptos` que restan los montos de conceptos excluidos por CFDI. Si un CFDI tiene otros conceptos válidos, solo se excluyen los que coinciden con estas claves.
|
||||
|
||||
### Regla clave: régimen del tenant en queries fiscales
|
||||
- `type = 'EMITIDO'` → el tenant es el emisor → usar `regimen_fiscal_emisor`
|
||||
- `type = 'RECIBIDO'` → el tenant es el receptor → usar `regimen_fiscal_receptor`
|
||||
|
||||
### Alerta RESICO PF — límite anual ($3.5M Art. 113-E LISR)
|
||||
`alertaResicoPfLimiteIngresos` en `alertas-auto.service.ts` aplica solo cuando el contribuyente: (1) tiene RFC de 13 chars (PF), y (2) tiene `626` en su CSV `regimen_fiscal`. **El cálculo de ingresos NO filtra por régimen** — el SAT considera ingresos acumulados de TODOS los regímenes para el límite del Art. 113-E ($3.5M). Thresholds: $2.5M (media), $3M (alta), $3.5M (alta + "ya saliste del régimen"). Suma `I PUE + P − E PUE` del año en curso usando `total_mxn` (con IVA — proxy conservador para alerta preventiva). RESICO PM (RFC 12) no aplica — ese régimen tiene reglas distintas y se filtra por longitud de RFC.
|
||||
|
||||
### Filtro de Conciliación (Dashboard + Impuestos)
|
||||
Toggle global que cambia el comportamiento de todas las métricas:
|
||||
- **Desactivado (default):** Usa `fecha_emision` y todos los CFDIs vigentes
|
||||
- **Activado:** Solo CFDIs con `id_conciliacion IS NOT NULL`, usa `fecha_de_pago` de tabla `conciliaciones`
|
||||
- Implementado con `getFechaRango(conciliacion)` que retorna el SQL fragment correcto
|
||||
- Afecta: ingresos, egresos, adquisición mercancías, IVA balance, IVA mensual, resumen IVA, resumen ISR, IVA a favor, regímenes del periodo, chart ingresos/egresos
|
||||
|
||||
### Métricas del Dashboard
|
||||
- **Ingresos del Mes** — Por régimen del emisor, filtrable
|
||||
- **Gastos del Mes** — Por régimen del receptor, filtrable
|
||||
- **Adquisición de Mercancías** — Egresos con uso_cfdi G01, por régimen, filtrable
|
||||
- **Utilidad** — Ingresos - Egresos
|
||||
- **Balance IVA** — Causado - Acreditable, por régimen
|
||||
- **CFDIs Emitidos/Recibidos** — Contadores
|
||||
- **IVA a Favor** — Acumulado anual e histórico (5 años)
|
||||
|
||||
### Métricas de Impuestos
|
||||
**IVA:** Trasladado, Acreditable, Retenido, Resultado, Acumulado Anual — todos filtrables por régimen con `trasladadoPorRegimen`, `acreditablePorRegimen`, `retenidoPorRegimen`
|
||||
**ISR:** Ingresos acumulados, Deducciones, Base gravable, ISR causado, ISR retenido, ISR a pagar — todos filtrables por régimen con `ingresosPorRegimen`, `deduccionesPorRegimen`, `baseGravablePorRegimen`
|
||||
|
||||
### Conciliación — reglas importantes
|
||||
- **Recibidos:** excluye PPD (solo PUE y pagos)
|
||||
- **Emitidos:** excluye PPD para todos los regímenes excepto 605 y 616
|
||||
- **Facturas tipo P:** usan `monto_pago_mxn` en vez de `total_mxn`
|
||||
- **Auto-conciliación PPD:** cuando una factura P con `saldo_pendiente = 0` se concilia, la PPD relacionada (via `uuid_relacionado`) se auto-concilia con los mismos datos.
|
||||
- **Score cards:** PPD auto-conciliadas tienen `montoMxn = 0` para no duplicar montos
|
||||
|
||||
### SAT — Sincronización
|
||||
| Archivo | Qué hace |
|
||||
|---------|----------|
|
||||
| `src/services/sat/sat.service.ts` | Orquestación: sync jobs, chunking inteligente, saveCfdis, saveMetadata |
|
||||
| `src/services/sat/sat-client.service.ts` | HTTP client SAT: query, verify, download. DocumentStatus 'active' para XMLs |
|
||||
| `src/services/sat/sat-parser.service.ts` | Parseo de XML CFDI + metadata CSV del SAT |
|
||||
| `src/services/fiel.service.ts` | FIEL: upload, encriptación AES-256-GCM, validación |
|
||||
| `src/jobs/sat-sync.job.ts` | Cron 03:00 AM: sync automático para todos los tenants con FIEL |
|
||||
|
||||
**Cuatro modos de sincronización:**
|
||||
|
||||
| Modo | Cuándo | XMLs | Metadata |
|
||||
|------|--------|------|----------|
|
||||
| `initial` | Primera sync del tenant | Sondeo → bloques 3/6 meses | Bloques de 3 años |
|
||||
| `daily` | Cron 3:00 AM (todos los planes) | Últimos 7 días | Año fiscal completo (ene → hoy) |
|
||||
| `incremental` | Cron 11:00/15:00/19:00 (solo plan Enterprise) | Últimas 8 horas | Últimas 8 horas |
|
||||
| `custom` (rango personalizado) | Botón en UI | Directo si ≤6m, bloques de 6m si >6m | Rango completo |
|
||||
|
||||
**Incremental Enterprise:** Reduce latencia de CFDIs nuevos a ~4h en horario laboral. Ventana de 8h cubre el gap 03:00→11:00 y solape con disparos siguientes (dedup por UUID). Requiere `initial` completado previamente — el job se omite si el tenant no tiene backfill. Ver `docs/plans/2026-04-13-sat-incremental-enterprise.md`.
|
||||
|
||||
**Flujo de sincronización (initial):**
|
||||
1. Sondeo: metadata del rango completo (2 solicitudes) → determina volumen
|
||||
2. XMLs vigentes: bloques de 6 meses (≤15k CFDIs) o 3 meses (>15k)
|
||||
3. Metadata vigentes+cancelados: bloques de 3 años
|
||||
4. XMLs se guardan en disco (`data/xmls/<rfc>/<tipo>/<packageId>/`) antes de procesar
|
||||
5. RFCs se upsert en tabla `rfcs` con `rfc_emisor_id`/`rfc_receptor_id` FK
|
||||
6. Metadata: inserta CFDIs cancelados sin XML, actualiza status de existentes
|
||||
|
||||
**Retry automático en timeouts (con políticas por tipo):**
|
||||
- Poll interval: 60 segundos, máximo 45 intentos (45 min) → `MAX_POLL_ATTEMPTS`
|
||||
- Tiempos de retry **absolutos desde `startedAt`** (no desde el timeout): si la sync arrancó a las 3:00 y timeoutea a 3:45, el primer retry queda programado para 9:00 exacto, no 9:45.
|
||||
- Política por tipo (`RETRY_POLICIES` en `sat.service.ts`):
|
||||
- `daily` → 2 retries a T+6h, T+12h
|
||||
- `initial` con `isCustomRange=true` (rango UI) → 2 retries a T+6h, T+12h
|
||||
- `initial` bootstrap (sin fechas) → 3 retries a T+6h, T+12h, T+24h
|
||||
- `incremental` → **0 retries** (próximo cron cada 4h cubre el gap, dedup por UUID)
|
||||
- Cron horario revisa `pending` con `nextRetryAt <= now` y reintenta. Tras agotar retries: `failed` con mensaje "Fallo conexión SAT, vuelve a intentar con un rango de fechas menor."
|
||||
|
||||
**Reuso de `satRequestId` en retries (`sat_request_ids` JSONB map):** cada vez que `requestAndDownload` necesita un request al SAT, primero busca en `job.satRequestIds[kindKey]` (kindKey = `${requestType}-${tipoCfdi}-${dateFrom}-${dateTo}`). Si encuentra uno, llama `verifySatRequest` directo: si está `ready` salta polling y descarga; si `processing` continúa polling con MISMO id; si `failed/rejected` o lanza excepción, fallback a crear nuevo. Esto evita agotar la cuota de solicitudes activas del SAT en reintentos. `satRequestId` (singular) sigue actualizándose al último creado para backward-compat de queries que ya lo leen. Persistencia atómica vía `jsonb || jsonb` SQL en `persistSatRequestId()`.
|
||||
|
||||
**Pool management:** `ctx.getPool()` (no estático) para mantener `lastAccess` fresco durante syncs largos.
|
||||
|
||||
### Facturapi — Emisión de facturas
|
||||
| Archivo | Qué hace |
|
||||
|---------|----------|
|
||||
| `src/services/facturapi.service.ts` | Facturapi: organizaciones, CSD, clientes, emisión, cancelación, descargas, logo, color, envío email |
|
||||
| `src/controllers/facturacion.controller.ts` | Endpoints: emitir, cancelar, PDF/XML, timbres, búsqueda RFCs, conceptos previos, datos fiscales, personalización |
|
||||
| `src/controllers/catalogos.controller.ts` | Catálogos SAT: forma pago, uso CFDI, clave prod/serv (con búsqueda sin acentos), unidades, etc. |
|
||||
| `src/routes/facturacion.routes.ts` | Rutas de facturación con tenantMiddleware |
|
||||
| `src/routes/catalogos.routes.ts` | Rutas de catálogos SAT |
|
||||
|
||||
**Modelo Facturapi:**
|
||||
- Una cuenta Horux360 (User Key `sk_user_...`) con organizaciones por tenant
|
||||
- `tenants.facturapi_org_id` vincula tenant ↔ organización Facturapi
|
||||
- API key de org se obtiene via HTTP directo (`/v2/organizations/{id}/apikeys/test`) — el SDK tiene bugs con este endpoint
|
||||
- CSD (Certificado de Sello Digital) se sube por organización (no requerido en modo test)
|
||||
- Timbres controlados por `timbre_suscripciones` (50/mes o 600/año, pool del plan) + `timbre_paquetes` (compras adicionales, vigencia 1 año). Catálogo de paquetes vendibles en `timbre_paquetes_catalogo` (100/$200, 1000/$1400, 10000/$8600 por default, editable por admin global en `/configuracion/precios-timbres`). Pool mensual se **resetea a 0 al final del periodo** (cron diario 2:30 AM via `resetExpiredMonthlyTimbres`, no acumulable). `consumeTimbre(tenantId)` en `$transaction`: primero pool mensual, luego `TimbrePaquete` con menor `expiraEn` (FIFO anti-desperdicio). Compra self-serve en `/facturacion/timbres` (owner/cfo): `POST /api/facturacion/timbres/paquetes/comprar` crea `Payment(kind=timbres_pack)` + MP Preference con `external_reference=timbres-pack:{paymentId}`. El webhook MP aprueba el Payment, llama `activarPaqueteTrasPago` (crea `TimbrePaquete` con `expiraEn = now + 1 año`), y dispara `invoicingService.emitInvoiceIfApplicable` que ramifica por `Payment.kind` (concepto "{cantidad} timbres adicionales" vs "Suscripción {plan}"). `Payment.kind` enum (`subscription | timbres_pack`) discrimina flujos en un solo modelo.
|
||||
- Al emitir: guarda en `cfdis` con `source: 'facturapi'` + `facturapi_id`, upsert RFCs con regimen/CP
|
||||
- Envío automático por email al receptor si tiene email configurado
|
||||
- **Cancelación:** botón en `/cfdi` para facturas con `source='facturapi'` y status vigente. Modal con los 4 motivos SAT (02 = errores sin relación, default; 01 = errores con relación, requiere UUID sustituto; 03 = no se llevó a cabo; 04 = incluida en factura global). `POST /facturacion/cancelar/:uuid` con `{ motive, substitution? }`. El estatus en BD local pasa a `Cancelado` al confirmar, pero el SAT puede dejarla en "pendiente" si requiere aceptación del receptor (copy en el modal lo advierte). Distinta a `DELETE /cfdi/:id` que solo borra el registro local.
|
||||
- Personalización por organización: logo (PNG/JPG ≤2MB) y color (HEX)
|
||||
|
||||
**Catálogos SAT en BD central (9 tablas):**
|
||||
- `cat_forma_pago` (22), `cat_metodo_pago` (2), `cat_uso_cfdi` (24), `cat_moneda` (7)
|
||||
- `cat_clave_unidad` (26), `cat_clave_prod_serv` (52,513 — importado de phpcfdi/resources-sat-catalogs), `cat_objeto_imp` (4)
|
||||
- `cat_tipo_relacion` (7), `cat_exportacion` (4)
|
||||
- Búsqueda de `cat_clave_prod_serv` soporta búsqueda sin acentos (regex PostgreSQL)
|
||||
|
||||
**Tipos de comprobante soportados:**
|
||||
| Tipo | Secciones dinámicas |
|
||||
|------|-------------------|
|
||||
| I - Ingreso | Conceptos con traslados/retenciones por concepto (IVA, ISR, IEPS, impuestos locales), exportación, serie/folio, condiciones, descuento, objeto de impuesto. Factura global (periodicidad, mes, año). Autocompletar receptor desde tabla RFCs. Búsqueda de conceptos previos en facturas. |
|
||||
| E - Egreso | Igual que Ingreso + documento relacionado (UUID + tipo relación 01-04) |
|
||||
| P - Pago | Solo comprobante (tipo+serie/folio) + receptor + complemento de pago. Autocompletar UUID desde CFDIs PPD pendientes del receptor con saldo. Desglose IVA del pago (base, tasa, monto). |
|
||||
| T - Traslado | Solo comprobante (tipo+serie/folio) + receptor + conceptos sin precio/impuestos. Unidades filtradas (sin servicio). |
|
||||
|
||||
**Recomendación automática de retenciones (tipo I):**
|
||||
- Emisor PF (13 chars RFC) con régimen 612, 626, o 606
|
||||
- Receptor PM (12 chars RFC) con régimen distinto a 612/626/606
|
||||
- Unidad de servicio (E48, ACT)
|
||||
- → Auto-agrega: IVA retenido 10.6667% (2/3) + ISR retenido (1.25% RESICO, 10% otros)
|
||||
|
||||
**Cliente extranjero:**
|
||||
- Detectado al escribir RFC `XEXX010101000`
|
||||
- Auto-llena: régimen 616, CP del tenant
|
||||
- Campos adicionales: Tax ID extranjero + País (selector con ~50 países)
|
||||
- Backend envía `country` en address, Facturapi agrega RFC genérico extranjero
|
||||
|
||||
**Factura global:**
|
||||
- Toggle en tipo I que auto-llena receptor con PUBLICO EN GENERAL (XAXX010101000, régimen 616)
|
||||
- Campos: periodicidad (day/week/fortnight/month/two_months), mes (01-18), año
|
||||
- CP se auto-llena desde datos fiscales del tenant
|
||||
|
||||
**Datos fiscales del tenant (tabla `tenants`):**
|
||||
- Columnas: codigo_postal, calle, num_exterior, num_interior, colonia, ciudad, municipio, estado, telefono
|
||||
- Editable desde Configuración > Domicilio Fiscal
|
||||
- Se usa para: CP en facturas globales, datos del emisor
|
||||
|
||||
### Calendario y Recordatorios
|
||||
| Archivo | Qué hace |
|
||||
|---------|----------|
|
||||
| `src/controllers/calendario.controller.ts` | GET eventos fiscales + recordatorios custom, CRUD recordatorios |
|
||||
| `src/services/recordatorios.service.ts` | CRUD recordatorios en tabla `recordatorios` del tenant |
|
||||
|
||||
**Recordatorios custom:**
|
||||
- Solo fechas únicas (sin recurrencia)
|
||||
- Público (visible para todo el equipo) o privado (solo el creador)
|
||||
- Admin y contador pueden crear/editar/eliminar
|
||||
- Estilo morado en el calendario
|
||||
- Se mezclan con eventos fiscales generados, ordenados por fecha
|
||||
|
||||
**Recordatorios automáticos de e.firma:**
|
||||
- Al subir la FIEL, se crean 3 recordatorios públicos: 60, 30 y 7 días antes del vencimiento
|
||||
- Prefijo `[e.firma]` en el título para identificarlos
|
||||
- Al re-subir la FIEL, se eliminan los anteriores y se crean nuevos
|
||||
- Solo se crean si la fecha no ha pasado
|
||||
|
||||
### Documentos — Opinión de Cumplimiento
|
||||
| Archivo | Qué hace |
|
||||
|---------|----------|
|
||||
| `src/services/opinion-cumplimiento.service.ts` | Orquestación: decrypt FIEL → Playwright headless → scrape PDF → parse → guardar en BD tenant |
|
||||
| `src/services/sat/sat-opinion-login.ts` | Playwright: navegación portal SAT → login FIEL → reporte opinión |
|
||||
| `src/services/sat/sat-opinion-scraper.ts` | 4 estrategias para extraer PDF base64 del DOM Angular del SAT |
|
||||
| `src/services/sat/sat-opinion-parser.ts` | Parseo de texto del PDF con regex (RFC, razón social, estatus, folio, cadena original) |
|
||||
| `src/controllers/documentos.controller.ts` | Endpoints: listar opiniones, descargar PDF, trigger manual |
|
||||
| `src/routes/documentos.routes.ts` | Rutas con tenantMiddleware + requireFeature('documentos') |
|
||||
|
||||
**Cron semanal:** Domingos 4:00 AM (`0 4 * * 0`). Descarga opinión para todos los tenants con FIEL. Limpia registros > 6 meses.
|
||||
|
||||
**Seguridad FIEL:** Archivos temporales con `0o600` en tmpdir con UUID. Cleanup garantizado en `finally`. Contraseña solo en memoria. Playwright headless. Timeout 3 min por tenant.
|
||||
|
||||
**Almacenamiento:** PDF completo en BYTEA en tabla `opiniones_cumplimiento` (BD tenant). 6 meses de retención. Frontend muestra últimas 5.
|
||||
|
||||
**Alerta automática:** Si última opinión no es Positiva → alerta alta en alertas automáticas.
|
||||
|
||||
**Feature gate:** `documentos` — disponible en planes Business y Enterprise.
|
||||
|
||||
### Documentos — Declaraciones Provisionales
|
||||
| Archivo | Qué hace |
|
||||
|---------|----------|
|
||||
| `src/migrations/tenant/003_create_declaraciones_provisionales.sql` | Tabla `declaraciones_provisionales` con 1 normal única por (año, mes) + N complementarias |
|
||||
| `src/migrations/tenant/004_declaraciones_liga_pago_pdf.sql` | Reemplaza `link_pago TEXT` por `pdf_liga_pago BYTEA` + filename (la liga de pago es PDF, no URL) |
|
||||
| `src/services/declaraciones.service.ts` | CRUD + auto-resolución de alertas por impuesto/mes + purge 5 años |
|
||||
|
||||
Flujo: el contador sube 3 PDFs por mes — **declaración** (obligatorio), **liga de pago** (opcional), **comprobante de pago** (opcional). Al subir la declaración se marcan como resueltos los recordatorios `decl-<impuesto>-<mes>` correspondientes a los impuestos seleccionados (IVA, ISR, IEPS, SUELDOS, DIOT, OTRO). Si es tipo `complementaria`, también se resuelven los recordatorios `pago-*` del mes (la complementaria sustituye a la normal en pago). Al subir comprobante de pago después, se resuelven los `pago-*` del mes. Retención 5 años (CFF Art. 30) purgada en cron lifecycle diario 2:30 AM. El flujo manual de "marcar como realizado" desde /alertas se mantiene para usuarios que no quieran subir documento.
|
||||
|
||||
UI: pestaña "Declaraciones Provisionales" en `/documentos` con selector de año, tabla mensual con badges tipo/impuestos y botones descarga/subir-pago/eliminar. Roles con upload: owner, cfo, contador, auxiliar.
|
||||
|
||||
### Documentos — Constancia de Situación Fiscal (CSF)
|
||||
| Archivo | Qué hace |
|
||||
|---------|----------|
|
||||
| `src/migrations/tenant/005_create_constancias_situacion_fiscal.sql` | Tabla con PDF BYTEA + datos JSONB (shape completo) + retención 5 años |
|
||||
| `src/services/sat/sat-csf-login.ts` | Playwright: página pública SAT → popup SERVICIO → login FIEL (con retry `dispatchEvent` si el click sintético se pierde) |
|
||||
| `src/services/sat/sat-csf-scraper.ts` | Busca "Generar Constancia" en cualquier `frame()` (vive en iframe JSF legacy `rfcampc.siat.sat.gob.mx`). 3 rutas: download event, popup viewer, response interception |
|
||||
| `src/services/sat/sat-csf-parser.ts` | Parser PF+PM: labels key:value + 3 tablas (actividades/regímenes/obligaciones, agrupadas por "chunk termina en dd/mm/yyyy") + sellos |
|
||||
| `src/services/constancia.service.ts` | Orquestación + `sincronizarDatosFiscales(tenantId, csf)` — auto-fill domicilio tenant (codigoPostal, calle, numExterior/Interior, colonia, ciudad, municipio, estado) y regímenes activos (matcheados contra catálogo `regimenes` por nombre normalizado) |
|
||||
|
||||
**Cron mensual:** Día 1 de cada mes 04:00 AM (`0 4 1 * *`). Descarga CSF para todos los tenants con FIEL. Por-tenant try/catch — un fallo no bloquea al resto.
|
||||
|
||||
**Retención:** 5 años purgados junto con declaraciones en cron lifecycle diario 2:30 AM.
|
||||
|
||||
**Trigger on first-upload FIEL:** En `fiel.service.ts`, al primer upload exitoso (`existingFiel` nulo o inactivo) se disparan en background Opinión de Cumplimiento + CSF con `import()` fire-and-forget. No bloquea la respuesta al usuario.
|
||||
|
||||
**Headless por default:** `chromium.launch({ headless: true })`. El fix clave es en `sat-csf-login.ts`: el click sintético a "e.firma" del portal SAT a veces no dispara el handler, por eso se espera a que aparezca `input[type=file]` (10s) y si no llega, reintenta con `dispatchEvent('click')`. Para debug visual temporal, setear `SAT_HEADLESS=false` en `.env`.
|
||||
|
||||
**UI:** pestaña "Constancia de Situación Fiscal" en `/documentos` con último CSF expandido (identificación, domicilio, regímenes activos, obligaciones), historial de 12 con detalle desplegable, descarga PDF, y botón "Consultar ahora" (owner/cfo). La UI refleja `datos: JSONB` de la BD sin re-parsear el PDF.
|
||||
|
||||
**Shape `ConstanciaSituacionFiscal`:** rfc, curp?, idCIF, nombre?/primerApellido?/segundoApellido?/razonSocial?, estatusPadron, fechaInicioOperaciones, lugarFechaEmision, domicilio (11 campos), actividadesEconomicas[], regimenes[], obligaciones[], cadenaOriginalSello, selloDigital.
|
||||
|
||||
### Integración Metabase
|
||||
|
||||
**`apps/api/src/services/metabase.service.ts`** auto-registra cada BD postgres de tenant nueva en Metabase para BI (queries/dashboards). Auth con session token cacheado 13 días via POST `/api/session` → header `X-Metabase-Session`. Funciones expuestas: `registerDatabase({nombre, dbName})` y `deleteDatabase(databaseName)` (busca por `details.dbname` o `name` contains, DELETE).
|
||||
|
||||
**Integrado en `tenants.service.ts`** en 3 puntos: `createTenant`, `addTenantToOwner`, `deleteTenant`. Todas son llamadas **fire-and-forget con `.catch()`** — un fallo de Metabase NO bloquea la creación/borrado del tenant.
|
||||
|
||||
**7 variables `.env`** (todas opcionales): `METABASE_URL`, `METABASE_USERNAME`, `METABASE_PASSWORD`, `METABASE_PG_HOST`, `METABASE_PG_PORT`, `METABASE_PG_USER`, `METABASE_PG_PASSWORD`. Sin `METABASE_PASSWORD` o `METABASE_PG_PASSWORD`, el service skip-ea cada llamada con un log `[METABASE] Skipping...` y el sistema funciona sin Metabase. **No expone routes HTTP propias** — es solo integración interna; los usuarios consultan dashboards directo en la URL del Metabase.
|
||||
|
||||
### Frontend
|
||||
| Archivo | Qué hace |
|
||||
|---------|----------|
|
||||
| `apps/web/lib/api/client.ts` | Axios instance con auto-refresh JWT + `X-View-Tenant` |
|
||||
| `apps/web/stores/auth-store.ts` | Zustand: user, tokens, logout (persist localStorage) |
|
||||
| `apps/web/stores/tenant-view-store.ts` | Zustand: impersonación de tenant (admin global) |
|
||||
| `apps/web/stores/theme-store.ts` | Zustand: tema visual (light/dark) |
|
||||
| `apps/web/lib/export-excel.ts` | Utilidad client-side para export Excel |
|
||||
| `apps/web/lib/hooks/use-facturacion.ts` | Hooks para facturación + catálogos SAT |
|
||||
| `apps/web/lib/hooks/use-calendario.ts` | Hooks para calendario + recordatorios |
|
||||
|
||||
**Tenant-aware query keys:** Todos los hooks de datos (`use-dashboard`, `use-bancos`, `use-calendario`, `use-facturacion`) incluyen `viewingTenantId` en los query keys de React Query para refetchear al cambiar de empresa.
|
||||
|
||||
**Temas:** Solo Light y Dark habilitados. Fondo Light = lavanda sutil (270 50% 98%).
|
||||
|
||||
---
|
||||
|
||||
## Convenciones importantes
|
||||
|
||||
### Cálculos fiscales por régimen
|
||||
Los ingresos/egresos/IVA se calculan de forma diferente según el grupo de régimen:
|
||||
- **PF Empresarial (606, 612, 621, 625, 626):** Facturas PUE + Pagos - Notas de crédito PUE
|
||||
- **Sueldos (605):** Nóminas recibidas PUE
|
||||
- **PM y otros (601, 603, 607...):** Facturas PUE+PPD - Notas de crédito PUE
|
||||
|
||||
Los montos se calculan **sin impuestos** (total - IVA trasladado - IEPS - impuestos locales).
|
||||
Los montos en MXN se usan siempre para cálculos (campo `_mxn`).
|
||||
|
||||
### Convenciones de BD
|
||||
- BD central: nombres en `snake_case` mapeados por Prisma a `camelCase`
|
||||
- BD tenant: SQL directo, alias explícitos en queries (`rfc_emisor as "rfcEmisor"`)
|
||||
- `CFDI_SELECT` constant en `cfdi.service.ts` define todos los campos mapeados
|
||||
- Status vigente: `WHERE status NOT IN ('Cancelado', '0')`
|
||||
|
||||
### Tenant provisioning
|
||||
Al crear un tenant (`TenantConnectionManager.provisionDatabase(rfc)`):
|
||||
1. Crea BD `horux_<rfc_lowercase>`
|
||||
2. Ejecuta `createTables()`: crea `rfcs`, `bancos`, `cfdis`, `cfdi_conceptos`, `conciliaciones`, `alertas`, `recordatorios`
|
||||
3. Ejecuta `createIndexes()`: índices B-tree + trigram (pg_trgm) + FK diferida para `id_conciliacion`
|
||||
|
||||
### Tablas por tenant
|
||||
| Tabla | Propósito |
|
||||
|-------|-----------|
|
||||
| `rfcs` | Catálogo de RFCs (id, rfc, razon_social, regimen_fiscal, codigo_postal) |
|
||||
| `bancos` | Cuentas bancarias (id, banco, terminacion_cuenta) |
|
||||
| `cfdis` | Facturas electrónicas (100+ columnas, incluye conciliado, id_conciliacion, facturapi_id, source, cfdi_tipo_relacion, cfdis_relacionados, saldo_pendiente_mxn) |
|
||||
| `cfdi_conceptos` | Líneas de detalle por CFDI |
|
||||
| `cfdi_descartados` | CFDIs marcados como ignorados por tipo de alerta (whitelist por contador) |
|
||||
| `conciliaciones` | Registros de conciliación (id, anio, mes, id_cfdi, fecha_de_pago, id_banco) |
|
||||
| `alertas` | Alertas manuales persistidas |
|
||||
| `recordatorios` | Recordatorios custom del calendario (título, fecha, público/privado, creado_por) |
|
||||
| `entidades_gestionadas` | Entidades del despacho (clientes/contribuyentes) — tipo, nombre, supervisor_user_id |
|
||||
| `contribuyentes` | Contribuyentes con FK a entidades_gestionadas (rfc, regimen_fiscal CSV, domicilio, email_preferences jsonb) |
|
||||
| `carteras` | Carteras del despacho con supervisor_user_id, auxiliar_user_id, parent_id (subcarteras) |
|
||||
| `cartera_entidades` | M:N cartera ↔ contribuyente |
|
||||
| `cartera_auxiliares` | M:N cartera ↔ auxiliar (legacy, ahora en `auxiliar_user_id` directo) |
|
||||
| `auxiliar_supervisores` | Override 1:1 auxiliar → supervisor (editado desde `/usuarios`) |
|
||||
| `obligaciones_contribuyente` | Catálogo de obligaciones por contribuyente |
|
||||
| `obligacion_periodos` | Instancias mensuales de cada obligación; estado completada |
|
||||
| `tareas_catalogo` | Tareas operativas recurrentes por contribuyente (semanal a anual) |
|
||||
| `tarea_periodos` | Instancias materializadas con fecha_limite y estado |
|
||||
| `papeleria_trabajo` | Archivos del despacho (PDF/Word/Excel ≤5MB) por contribuyente con flujo opcional de aprobación |
|
||||
| `declaraciones_provisionales` | PDFs de declaraciones por (contribuyente, año, mes, tipo) con liga de pago + comprobante de pago |
|
||||
| `documentos_extras` | PDFs libres por contribuyente con categoría y descripción |
|
||||
| `opiniones_cumplimiento` | Cache 6 meses de Opinión SAT (PDF + datos parseados) |
|
||||
| `constancias_situacion_fiscal` | Cache 5 años de CSF (PDF + datos JSONB) |
|
||||
| `facturapi_orgs` | Org Facturapi por contribuyente (api_key cacheada, csd_uploaded, last_lco_rejection_at) |
|
||||
|
||||
### Campos source en cfdis
|
||||
- `'manual'` — Cargado por usuario via XML upload
|
||||
- `'sat'` — Descargado del SAT via sync (tiene xml_original)
|
||||
- `'sat-metadata'` — Solo metadata del SAT (sin XML, típicamente cancelados)
|
||||
- `'facturapi'` — Emitido desde Horux360 via Facturapi (tiene facturapi_id)
|
||||
|
||||
### Impersonación de tenant (X-View-Tenant)
|
||||
El admin global puede ver/gestionar datos de otros tenants. Los endpoints que lo soportan usan:
|
||||
- Backend: `tenantMiddleware` + `effectiveTenantId(req)` = `req.viewingTenantId || req.user!.tenantId`
|
||||
- Frontend: `useTenantViewStore()` + tenant key en query keys de React Query
|
||||
|
||||
Rutas con tenantMiddleware: dashboard, cfdi, impuestos, reportes, conciliación, bancos, calendario, regímenes, fiel, sat, facturación.
|
||||
|
||||
---
|
||||
|
||||
## Setup local
|
||||
|
||||
```bash
|
||||
# Requisitos: Node 20+, pnpm 9+, PostgreSQL 16+
|
||||
pnpm install
|
||||
pnpm db:generate # Genera Prisma client
|
||||
pnpm dev # Arranca API (4000) + Web (3000) via Turborepo
|
||||
```
|
||||
|
||||
### Bootstrap desde BD vacía (deploy fresco)
|
||||
|
||||
```bash
|
||||
cd apps/api
|
||||
pnpm prisma migrate deploy # schema central
|
||||
pnpm db:seed # catálogos + tenant demo + roles
|
||||
pnpm bootstrap:admin-global # tenant Horux 360 (HTS240708LJA)
|
||||
pnpm import:lista-negra # opcional
|
||||
```
|
||||
|
||||
`bootstrap:admin-global` usa `tenantsService.createTenant()` con overrides enterprise (`cfdiLimit: -1`, `usersLimit: 10`) + eleva la subscripción a `authorized` por 1 año. Imprime password temporal del admin en consola. Configurable vía `HORUX_ADMIN_EMAIL` / `HORUX_ADMIN_NOMBRE`.
|
||||
|
||||
**Variables de entorno:** `apps/api/.env` y `apps/web/.env.local`
|
||||
**BD de prueba:** tenant `EDE123456AB1` → BD `horux_ede123456ab1`
|
||||
|
||||
**Credenciales demo:**
|
||||
- admin@demo.com / demo123 (admin)
|
||||
- contador@demo.com / demo123 (contador)
|
||||
- visor@demo.com / demo123 (visor)
|
||||
|
||||
**Credenciales Horux 360:**
|
||||
- carlos@horuxfin.com / (password en consola del bootstrap) — owner + platform_admin
|
||||
- ivan@horuxfin.com / (password en consola del bootstrap) — contador + platform_ti (TI superset)
|
||||
|
||||
---
|
||||
|
||||
## Producción
|
||||
|
||||
- **Servidor:** Ubuntu 24.04, 22GB RAM, 8 cores
|
||||
- **Process manager:** PM2 (fork mode)
|
||||
- **Proxy:** Nginx con SSL (Let's Encrypt)
|
||||
- **Cron:** SAT sync 03:00 AM, Backups 01:00 AM
|
||||
- **Deploy:** `git pull → pnpm install → pnpm build → pnpm db:migrate-tenants → pm2 restart all`
|
||||
|
||||
---
|
||||
|
||||
## Problemas conocidos / pendientes
|
||||
|
||||
**Typecheck limpio.** `pnpm typecheck` pasa 0 errores en `@horux/api` y `@horux/shared` tras el cleanup del 2026-04-13. El proyecto corre con `tsx watch` que no valida tipos en runtime, por eso agregar `pnpm typecheck` a CI/pre-commit es recomendado para no re-acumular deuda. Ver `docs/plans/2026-04-13-typescript-debt-cleanup.md` para el inventario completo y decisiones descartadas (incluye por qué no se downgradeo `@types/express@5` ni se migró a `"type": "module"`).
|
||||
|
||||
**Express version mismatch:** `@types/express@^5.0.0` corre sobre `express@^4.21.0`. La diferencia se manifiesta en `req.params.id` tipado como `string | string[]`. Solución aplicada: cast `String(req.params.id)` en los 8 controllers afectados. Cuando migren a Express 5, los casts siguen siendo correctos.
|
||||
|
||||
**Facturapi SDK interop:** el paquete `facturapi@4.14.2` no declara `"exports"` en su package.json, solo `main`/`module`. Esto impide migrar el api a `"type": "module"` sin workarounds en cada `import Facturapi from 'facturapi'` (el default export se trata como namespace bajo ESM estricto). `tenant-migrations.ts` usa `__dirname` directo para evitar `import.meta` y mantenerse compatible con el modo CJS efectivo.
|
||||
|
||||
1. ~~**Schema drift multi-tenant:**~~ Resuelto. Migraciones SQL numeradas en `apps/api/src/migrations/tenant/`. Se aplican eager (`pnpm db:migrate-tenants`) en deploy y lazy (auto en `getPool()`) como safety net. Para agregar un cambio de schema tenant: crear `NNN_description.sql` en el directorio de migraciones.
|
||||
|
||||
**Términos y Condiciones:** fuente legal = `docs/legal/Terminos y condiciones.pdf`. Pipeline: `pnpm legal:sync` (corre `scripts/extract-terminos.mjs` usando `pdf-parse` v2 con `PDFParse.getText()`) copia el PDF a `apps/web/public/legal/terminos-y-condiciones.pdf` y genera `apps/web/content/terminos.ts` con el texto extraído (cheap insurance para indexado/búsqueda futura). Página `/terminos` (pública, fuera de `(auth)`/`(dashboard)`) **embebe el PDF con `<object type="application/pdf">`** — usa el viewer nativo del navegador (PDF.js/QuickLook) y preserva fidelidad total de formato (tipografía, tablas, listas). Header con "Descargar" (download attribute) y "Abrir" (nueva pestaña). Fallback dentro del object para móviles sin inline PDF: botones centrados. Register checkbox obligatorio con link a `/terminos` (target=\_blank). Workflow para actualizar: reemplazar el PDF en `docs/legal/` con el mismo nombre → `pnpm legal:sync` → commit los 3 artefactos (PDF fuente + PDF copia + `terminos.ts`).
|
||||
|
||||
**Security headers (`next.config.js`):** aplicados a todas las rutas para proteger contra clickjacking y otros vectores. `X-Frame-Options: SAMEORIGIN` + `Content-Security-Policy: frame-ancestors 'self'` impiden que sitios externos embeban Horux 360 en iframes propios (preservando el `/terminos` que embebe un PDF del mismo origen). `X-Content-Type-Options: nosniff` previene MIME sniffing. `Referrer-Policy: strict-origin-when-cross-origin` evita leak de URLs completas al navegar externo. `Strict-Transport-Security: max-age=31536000; includeSubDomains` fuerza HTTPS (ignorado en dev). Si en el futuro se necesita embeber Horux 360 en otro dominio propio (app móvil híbrida, portal partner), extender `frame-ancestors 'self' https://otro-dominio`.
|
||||
|
||||
**BD central**: usa Prisma migrations en `apps/api/prisma/migrations/`. Baseline `20260414152220_initial_schema_v0_9_2/migration.sql` consolida todo el schema acumulado hasta v0.9.2. Para futuros cambios: `pnpm prisma migrate dev --name <descripción>` genera SQL versionado. En prod: `pnpm prisma migrate deploy`. **NO** usar `prisma db push` en prod — se pierde el trail.
|
||||
2. **Nómina (pendiente):** Tipo de comprobante N no implementado en facturación. Requiere complemento de nómina con datos del empleado, percepciones, deducciones.
|
||||
3. **Carta Porte (pendiente):** Complemento para facturas tipo T. Facturapi lo soporta en Beta. No implementado.
|
||||
4. **Notificaciones por email de alertas/recordatorios:** El sistema de email existe (Nodemailer + Gmail) pero no envía alertas/recordatorios automáticos. Solo bienvenida, pagos, notificaciones admin y facturas (via Facturapi).
|
||||
5. **Catálogo c_ClaveProdServ:** 52,513 registros importados desde phpcfdi/resources-sat-catalogs. Si se necesita actualizar, re-importar desde la BD SQLite del release.
|
||||
6. **SMTP local:** No configurado en desarrollo. Emails se logean a consola. Configurar `SMTP_USER` y `SMTP_PASS` en `.env` para envío real.
|
||||
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.
196
README.md
Normal file
196
README.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# Horux Despachos
|
||||
|
||||
Plataforma SaaS para despachos profesionales mexicanos. Gestión fiscal multi-RFC con roles jerárquicos (Owner/Supervisor/Auxiliar/Cliente), carteras de contribuyentes, y arquitectura BYO-DB.
|
||||
|
||||
**Autor:** Carlos e Ivan (Horux 360)
|
||||
|
||||
---
|
||||
|
||||
## Qué es
|
||||
|
||||
Horux Despachos permite a despachos contables gestionar múltiples contribuyentes (RFCs) desde una sola cuenta. Cada contribuyente tiene su propia FIEL, CSD, y organización Facturapi. Los supervisores organizan contribuyentes en carteras y delegan trabajo a auxiliares.
|
||||
|
||||
## Arquitectura
|
||||
|
||||
```
|
||||
Monorepo (pnpm + Turborepo)
|
||||
├── apps/api → Express + TypeScript (puerto 4000)
|
||||
├── apps/web → Next.js 14 + App Router (puerto 3000)
|
||||
└── packages/
|
||||
├── core → Auth (JWT), email transport, crypto (AES-256-GCM)
|
||||
├── shared → Tipos, constantes, interfaces compartidas
|
||||
├── shared-ui → Componentes UI (Button, Card, Dialog, selectors, hooks)
|
||||
└── vertical-contable → (scaffold) Lógica fiscal compartida
|
||||
```
|
||||
|
||||
## Funcionalidades implementadas
|
||||
|
||||
### Gestión de despachos
|
||||
- Signup multi-paso (formulario → vertical → plan)
|
||||
- Onboarding wizard (6 pasos)
|
||||
- Planes: Trial (30 días), Business Control (BYO-DB), Business Cloud (Managed)
|
||||
|
||||
### Contribuyentes (RFCs)
|
||||
- CRUD de contribuyentes por despacho
|
||||
- FIEL per contribuyente (almacenada en BD tenant, cifrada AES-256-GCM)
|
||||
- Facturapi org per contribuyente (CSD independiente)
|
||||
- Emisión de CFDI con contribuyente_id
|
||||
|
||||
### Roles y autorización
|
||||
- **Owner**: acceso total, actúa como supervisor implícito
|
||||
- **Supervisor**: titular de RFCs, crea carteras, gestiona auxiliares
|
||||
- **Auxiliar**: accede solo a RFCs en carteras asignadas
|
||||
- **Cliente**: visor externo read-only de sus RFCs
|
||||
- `getEntidadesVisibles()`: cascada de permisos automática
|
||||
|
||||
### Carteras
|
||||
- CRUD completo (crear, editar, eliminar)
|
||||
- Asignar/remover contribuyentes
|
||||
- Asignar/remover auxiliares
|
||||
- Cascada: si supervisor pierde RFC → auxiliares pierden acceso
|
||||
|
||||
### Pricing
|
||||
- Catálogo de planes (Business Control $21,000/año, Business Cloud $15,000/año + $45/RFC/mes)
|
||||
- Add-ons recurrentes con multi-preapproval MercadoPago
|
||||
- Paquetes de timbres one-shot
|
||||
|
||||
### Connector BYO-DB
|
||||
- Provisioning de tunnel (Cloudflare Tunnel ready)
|
||||
- Heartbeat cada 30s con status en UI
|
||||
- getPool() refactorizado para decrypt de conexiones BYO
|
||||
|
||||
### Admin global
|
||||
- Dashboard cross-despacho (métricas, lista despachos, actividad)
|
||||
- Impersonación con motivo obligatorio + audit log
|
||||
- Audit log expuesto al owner del despacho
|
||||
|
||||
### Métricas pre-calculadas
|
||||
- Hot/cold: año actual on-the-fly, años pasados pre-calculados
|
||||
- Invalidación dirigida por cambios retroactivos en CFDIs
|
||||
- Tablas: metricas_mensuales, acumuladas_anuales, contraparte, invalidaciones
|
||||
|
||||
## Stack técnico
|
||||
|
||||
| Capa | Tecnología |
|
||||
|------|------------|
|
||||
| Frontend | Next.js 14, React 18, Tailwind, shadcn/ui, Zustand, React Query |
|
||||
| Backend | Node.js 20+, Express 4, TypeScript 5, Prisma 5.22 |
|
||||
| BD Central | PostgreSQL 16 (Prisma ORM) |
|
||||
| BD Tenant | PostgreSQL 16 (pg Pool + SQL raw + 17 migraciones numeradas) |
|
||||
| Auth | JWT (15min) + refresh (7d) + bcrypt + magic link ready |
|
||||
| Pagos | MercadoPago (preapproval + webhooks) |
|
||||
| Email | Nodemailer + SMTP |
|
||||
| Facturación | Facturapi (cuenta maestra broker) |
|
||||
|
||||
## Setup local
|
||||
|
||||
```bash
|
||||
# Requisitos: Node 20+, pnpm 9+, PostgreSQL 16+
|
||||
pnpm install
|
||||
cp apps/api/.env.example apps/api/.env # rellenar con secrets reales
|
||||
echo "NEXT_PUBLIC_API_URL=http://localhost:4000/api" > apps/web/.env.local
|
||||
cd apps/api && npx prisma generate
|
||||
cd apps/api && npx prisma migrate deploy
|
||||
cd apps/api && pnpm db:seed
|
||||
pnpm dev # API :4000 + Web :3000
|
||||
```
|
||||
|
||||
## Deploy a producción
|
||||
|
||||
### Pre-requisitos del servidor
|
||||
- Ubuntu 22.04+ (probado en 24.04)
|
||||
- Node 20+, pnpm 9+
|
||||
- PostgreSQL 16+ (con extensión `pg_trgm` para búsquedas full-text)
|
||||
- Nginx (proxy + SSL Let's Encrypt)
|
||||
- PM2 (process manager) — `npm i -g pm2`
|
||||
- Playwright dependencies para SAT scrapers (`npx playwright install-deps chromium`)
|
||||
|
||||
### Procedimiento de deploy fresco
|
||||
|
||||
```bash
|
||||
# 1. Clonar
|
||||
git clone https://github.com/Torch2196/Horux_despachos_NL.git /root/Horux
|
||||
cd /root/Horux
|
||||
|
||||
# 2. Configurar env vars
|
||||
cp apps/api/.env.example apps/api/.env
|
||||
nano apps/api/.env # rellenar con secrets
|
||||
echo "NEXT_PUBLIC_API_URL=https://api.horuxfin.com/api" > apps/web/.env.local
|
||||
|
||||
# 3. Instalar deps con versiones exactas
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
# 4. BD central — schema y catálogos
|
||||
pnpm db:generate
|
||||
cd apps/api && npx prisma migrate deploy && cd ../..
|
||||
pnpm db:seed
|
||||
|
||||
# 5. BD tenant — migraciones de cada tenant existente (idempotente)
|
||||
pnpm --filter @horux/api db:migrate-tenants
|
||||
|
||||
# 6. Tenant admin global (Horux 360 — RFC HTS240708LJA)
|
||||
pnpm --filter @horux/api bootstrap:admin-global
|
||||
# ↑ imprime password temporal del admin en consola — guardarla
|
||||
|
||||
# 7. Lista negra del SAT (opcional, ~1MB de RFCs)
|
||||
pnpm --filter @horux/api import:lista-negra
|
||||
|
||||
# 8. Build de producción
|
||||
pnpm build
|
||||
|
||||
# 9. Configurar Nginx + SSL
|
||||
sudo cp deploy/nginx/horux360.conf /etc/nginx/sites-available/
|
||||
sudo ln -s /etc/nginx/sites-available/horux360.conf /etc/nginx/sites-enabled/
|
||||
sudo certbot --nginx -d horuxfin.com -d api.horuxfin.com
|
||||
sudo systemctl reload nginx
|
||||
|
||||
# 10. Levantar con PM2
|
||||
pm2 start ecosystem.config.js
|
||||
pm2 save
|
||||
pm2 startup # autostart al boot
|
||||
```
|
||||
|
||||
### Updates posteriores
|
||||
|
||||
```bash
|
||||
cd /root/Horux
|
||||
git pull
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm db:generate
|
||||
cd apps/api && npx prisma migrate deploy && cd ../..
|
||||
pnpm --filter @horux/api db:migrate-tenants # si hay nuevas migraciones tenant
|
||||
pnpm build
|
||||
pm2 restart all
|
||||
```
|
||||
|
||||
### Crons en producción
|
||||
|
||||
Los crons internos arrancan automáticamente en `NODE_ENV=production`:
|
||||
- **SAT sync diario** — 03:00 AM (todos los planes)
|
||||
- **SAT incremental** — 11:00, 15:00, 19:00 (solo Enterprise)
|
||||
- **Subscription lifecycle** — 02:30 AM (apply pending changes, expire trials, purge)
|
||||
- **Reporte semanal email** — Lunes 08:00 AM
|
||||
- **Opinión cumplimiento** — Domingos 04:00 AM
|
||||
- **CSF mensual** — Día 1 cada mes 04:00 AM
|
||||
- **SAT retry cron** — cada hora (reintentos jobs `pending`)
|
||||
- **Watchdog jobs SAT** — cada 2h (mata jobs zombies)
|
||||
|
||||
Si necesitás simular crons en dev: `ENABLE_CRONS_IN_DEV=1` en `apps/api/.env`.
|
||||
|
||||
## Estructura de BD
|
||||
|
||||
### BD Central (Prisma)
|
||||
Tenant, User, TenantMembership, Rol, Subscription, SubscriptionAddon, Payment, PlanCatalogo, PlanAddonCatalogo, FielCredential, ConnectorHeartbeat, AuditLog, TimbreSuscripcion, TimbrePaquete, catálogos SAT.
|
||||
|
||||
### BD Tenant (SQL migrations 001-017)
|
||||
001-005: Schema base (rfcs, cfdis, conciliaciones, alertas, opiniones, declaraciones, constancias)
|
||||
006: tenant_migrations tracking
|
||||
007-009: Core (entidades_gestionadas, carteras, cliente_accesos)
|
||||
010-013: Vertical contable (contribuyentes, fiel_contribuyente, facturapi_orgs, cfdi contribuyente_id)
|
||||
014-017: Métricas (mensuales, acumuladas, contraparte, invalidaciones)
|
||||
|
||||
## Autor
|
||||
|
||||
**Carlos e Ivan (Horux 360)**
|
||||
|
||||
---
|
||||
82
apps/api/.env.example
Normal file
82
apps/api/.env.example
Normal file
@@ -0,0 +1,82 @@
|
||||
# =============================================================================
|
||||
# Horux 360 — API .env template
|
||||
# =============================================================================
|
||||
# Copiá este archivo a `.env` en producción y rellená cada variable.
|
||||
# Las marcadas REQUIRED son obligatorias — la app no arranca sin ellas (Zod).
|
||||
# Las opcionales pueden quedar comentadas; sus features se desactivan en runtime.
|
||||
#
|
||||
# Validación: apps/api/src/config/env.ts
|
||||
# =============================================================================
|
||||
|
||||
# ----- Runtime ---------------------------------------------------------------
|
||||
NODE_ENV=production # development | production | test
|
||||
PORT=4000 # default 4000
|
||||
|
||||
# ----- BD central (Prisma) — REQUIRED ----------------------------------------
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/horux360
|
||||
|
||||
# ----- JWT — REQUIRED --------------------------------------------------------
|
||||
# Generar con: `openssl rand -hex 64`
|
||||
JWT_SECRET= # min 32 chars
|
||||
JWT_EXPIRES_IN=15m # access token TTL
|
||||
JWT_REFRESH_EXPIRES_IN=7d # refresh token TTL
|
||||
|
||||
# ----- CORS / URLs -----------------------------------------------------------
|
||||
CORS_ORIGIN=https://horuxfin.com # comma-separated si son varios
|
||||
FRONTEND_URL=https://horuxfin.com # usado por MP back_url, emails, etc.
|
||||
|
||||
# ----- FIEL (cifrado de credenciales SAT) — REQUIRED -------------------------
|
||||
# Generar con: `openssl rand -hex 64` (DISTINTA al JWT_SECRET — rotación independiente)
|
||||
FIEL_ENCRYPTION_KEY= # min 32 chars
|
||||
FIEL_STORAGE_PATH=/var/horux/fiel # path donde se guardan archivos FIEL temporales
|
||||
|
||||
# ----- MercadoPago (suscripciones self-serve) --------------------------------
|
||||
MP_ACCESS_TOKEN= # producción: APP_USR-...
|
||||
MP_ACCESS_TOKEN_SANDBOX= # opcional: TEST-... para dev local sin cobro
|
||||
MP_USE_SANDBOX=false # true → usa MP_ACCESS_TOKEN_SANDBOX
|
||||
MP_WEBHOOK_SECRET= # firma HMAC del webhook MP (Settings → Notifs)
|
||||
MP_NOTIFICATION_URL=https://api.horuxfin.com/api/webhooks/mercadopago
|
||||
# Solo dev/staging — override del payer_email cuando el owner = collector. Vacío en prod.
|
||||
MP_TEST_PAYER_EMAIL=
|
||||
|
||||
# ----- SMTP (Nodemailer) — opcional pero recomendado -------------------------
|
||||
SMTP_HOST=smtp.gmail.com # default
|
||||
SMTP_PORT=587 # default
|
||||
SMTP_USER= # cuenta Gmail Workspace
|
||||
SMTP_PASS= # app password (NO la password de la cuenta)
|
||||
SMTP_FROM=Horux360 <noreply@horuxfin.com>
|
||||
|
||||
# ----- Notificaciones admin --------------------------------------------------
|
||||
ADMIN_EMAIL=carlos@horuxfin.com # destino de "nuevo cliente" + alertas internas
|
||||
|
||||
# ----- Facturapi (emisión CFDI) — opcional -----------------------------------
|
||||
# Sin esto, los tenants no pueden emitir facturas, pero la app arranca.
|
||||
FACTURAPI_USER_KEY= # sk_user_... (cuenta maestra Horux 360)
|
||||
|
||||
# ----- Cloudflare Tunnel (BYO-DB connector) — opcional -----------------------
|
||||
CLOUDFLARE_API_TOKEN=
|
||||
CLOUDFLARE_ACCOUNT_ID=
|
||||
CLOUDFLARE_TUNNEL_DOMAIN=tunnel.horux.mx
|
||||
|
||||
# ----- KMS para cifrar conexiones BYO-DB y tokens connector ------------------
|
||||
CONNECTOR_ENCRYPTION_KEY= # generar con `openssl rand -hex 64`
|
||||
|
||||
# ----- Metabase (auto-registro BDs tenant para BI) — opcional ----------------
|
||||
# Sin METABASE_PASSWORD/PG_PASSWORD el service skipea silenciosamente.
|
||||
METABASE_URL=
|
||||
METABASE_USERNAME=
|
||||
METABASE_PASSWORD=
|
||||
METABASE_PG_HOST=
|
||||
METABASE_PG_PORT=
|
||||
METABASE_PG_USER=
|
||||
METABASE_PG_PASSWORD=
|
||||
|
||||
# ----- Cron control en dev (opcional) ----------------------------------------
|
||||
# ENABLE_CRONS_IN_DEV=1 # activa SAT sync, weekly emails, etc. en NODE_ENV=development
|
||||
|
||||
# ----- Watchdog SAT thresholds (opcional, defaults razonables) ---------------
|
||||
# STALE_PENDING_HOURS=12 # marca pending como failed si nextRetryAt > N h atrás
|
||||
# STALE_RUNNING_HOURS=4 # marca running como failed si startedAt > N h atrás
|
||||
|
||||
# ----- SAT Playwright headless toggle (debug temporal) ----------------------
|
||||
# SAT_HEADLESS=false # solo dev — muestra browser para debug de scrapers
|
||||
65
apps/api/package.json
Normal file
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());
|
||||
@@ -0,0 +1,19 @@
|
||||
-- AlterEnum
|
||||
BEGIN;
|
||||
CREATE TYPE "Plan_new" AS ENUM ('trial', 'custom', 'business_control', 'business_cloud', 'mi_empresa', 'mi_empresa_plus');
|
||||
ALTER TABLE "tenants" ALTER COLUMN "plan" DROP DEFAULT;
|
||||
ALTER TABLE "tenants" ALTER COLUMN "plan" TYPE "Plan_new" USING ("plan"::text::"Plan_new");
|
||||
ALTER TABLE "subscriptions" ALTER COLUMN "plan" TYPE "Plan_new" USING ("plan"::text::"Plan_new");
|
||||
ALTER TABLE "subscriptions" ALTER COLUMN "pending_plan" TYPE "Plan_new" USING ("pending_plan"::text::"Plan_new");
|
||||
ALTER TABLE "subscriptions" ALTER COLUMN "upgrade_target_plan" TYPE "Plan_new" USING ("upgrade_target_plan"::text::"Plan_new");
|
||||
ALTER TABLE "plan_prices" ALTER COLUMN "plan" TYPE "Plan_new" USING ("plan"::text::"Plan_new");
|
||||
ALTER TYPE "Plan" RENAME TO "Plan_old";
|
||||
ALTER TYPE "Plan_new" RENAME TO "Plan";
|
||||
DROP TYPE "Plan_old";
|
||||
ALTER TABLE "tenants" ALTER COLUMN "plan" SET DEFAULT 'trial';
|
||||
COMMIT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "tenants" DROP COLUMN "cfdi_limit",
|
||||
DROP COLUMN "users_limit",
|
||||
ALTER COLUMN "plan" SET DEFAULT 'trial';
|
||||
@@ -0,0 +1,55 @@
|
||||
-- Step 1: Add new columns as nullable (preserva las 4 filas existentes con sus precios)
|
||||
ALTER TABLE "despacho_plan_prices"
|
||||
ADD COLUMN "nombre" VARCHAR(50),
|
||||
ADD COLUMN "max_rfcs" INTEGER,
|
||||
ADD COLUMN "max_users" INTEGER,
|
||||
ADD COLUMN "db_mode" "DbMode",
|
||||
ADD COLUMN "timbres_incluidos_mes" INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN "permite_servidor_backup" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- Step 2: Backfill limits para las 4 filas existentes desde el catálogo TS
|
||||
UPDATE "despacho_plan_prices" SET
|
||||
"nombre" = 'Mi Empresa',
|
||||
"max_rfcs" = 1,
|
||||
"max_users" = 3,
|
||||
"timbres_incluidos_mes" = 50,
|
||||
"db_mode" = 'MANAGED'
|
||||
WHERE "plan" = 'mi_empresa';
|
||||
|
||||
UPDATE "despacho_plan_prices" SET
|
||||
"nombre" = 'Mi Empresa +',
|
||||
"max_rfcs" = 1,
|
||||
"max_users" = 3,
|
||||
"timbres_incluidos_mes" = 50,
|
||||
"db_mode" = 'MANAGED'
|
||||
WHERE "plan" = 'mi_empresa_plus';
|
||||
|
||||
UPDATE "despacho_plan_prices" SET
|
||||
"nombre" = 'Business Control',
|
||||
"max_rfcs" = 100,
|
||||
"max_users" = -1,
|
||||
"timbres_incluidos_mes" = 0,
|
||||
"db_mode" = 'BYO',
|
||||
"permite_servidor_backup" = true
|
||||
WHERE "plan" = 'business_control';
|
||||
|
||||
UPDATE "despacho_plan_prices" SET
|
||||
"nombre" = 'Enterprise',
|
||||
"max_rfcs" = 100,
|
||||
"max_users" = -1,
|
||||
"timbres_incluidos_mes" = 0,
|
||||
"db_mode" = 'BYO',
|
||||
"permite_servidor_backup" = true
|
||||
WHERE "plan" = 'business_cloud';
|
||||
|
||||
-- Step 3: Set NOT NULL después del backfill (las 4 filas ya están completas)
|
||||
ALTER TABLE "despacho_plan_prices"
|
||||
ALTER COLUMN "nombre" SET NOT NULL,
|
||||
ALTER COLUMN "max_rfcs" SET NOT NULL,
|
||||
ALTER COLUMN "max_users" SET NOT NULL,
|
||||
ALTER COLUMN "db_mode" SET NOT NULL;
|
||||
|
||||
-- Step 4: Hacer firstYear y renewal nullable para soportar trial y custom (sin precio fijo)
|
||||
ALTER TABLE "despacho_plan_prices"
|
||||
ALTER COLUMN "first_year" DROP NOT NULL,
|
||||
ALTER COLUMN "renewal" DROP NOT NULL;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Drop tabla plan_catalogo (modelo huérfano nunca usado por código activo).
|
||||
-- Las 2 filas que tenía estaban desincronizadas con el catálogo TS y nunca
|
||||
-- se referenciaron desde código real. El catálogo despacho vive ahora en
|
||||
-- `despacho_plan_prices` (extendida con limits en migración 20260430195000).
|
||||
DROP TABLE "plan_catalogo";
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Add column with default false (no-op para filas existentes)
|
||||
ALTER TABLE "despacho_plan_prices"
|
||||
ADD COLUMN "permite_sat_incremental" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- Backfill: planes que SÍ deben tener incremental (3 syncs/día adicionales).
|
||||
-- Mi Empresa + tiene API + Lolita IA y precio premium ($9k anual);
|
||||
-- Business Control y Enterprise son los planes despacho con escala alta.
|
||||
UPDATE "despacho_plan_prices"
|
||||
SET "permite_sat_incremental" = true
|
||||
WHERE "plan" IN ('mi_empresa_plus', 'business_control', 'business_cloud');
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Cache cifrada de la Live Secret Key de la organización Facturapi del tenant
|
||||
-- central (Horux 360 admin que emite facturas de subscripción a clientes).
|
||||
-- AES-256-GCM con derivación FIEL_ENCRYPTION_KEY — mismo patrón que FIEL.
|
||||
ALTER TABLE "tenants"
|
||||
ADD COLUMN "facturapi_org_key_enc" BYTEA,
|
||||
ADD COLUMN "facturapi_org_key_iv" BYTEA,
|
||||
ADD COLUMN "facturapi_org_key_tag" BYTEA;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Drop tabla plan_prices (modelo legacy Horux 360 sin filas activas).
|
||||
-- Catálogo se reemplazó por DespachoPlanPrice (despacho_plan_prices) en
|
||||
-- migración 20260430195000_extend_despacho_plan_prices_with_limits.
|
||||
-- Sin callers activos en código (verificado vía typecheck post-cleanup).
|
||||
DROP TABLE "plan_prices";
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Tracking de aviso pre-vencimiento por suscripción. Permite que el cron diario
|
||||
-- evite enviar dos emails del mismo bucket de días al mismo owner.
|
||||
ALTER TABLE "subscriptions"
|
||||
ADD COLUMN "last_reminder_day" INTEGER,
|
||||
ADD COLUMN "last_reminder_sent_at" TIMESTAMP(3);
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Preferencias de auto-facturación de pagos de suscripción.
|
||||
-- factPreferencia: 'publico_general' o 'mis_datos' (default: mis_datos)
|
||||
-- factUsoCfdi: clave SAT del uso CFDI default (G03 = Gastos en general)
|
||||
-- factRegimenPreferido: clave del régimen fiscal a usar cuando hay multi-régimen
|
||||
ALTER TABLE "tenants"
|
||||
ADD COLUMN "fact_preferencia" VARCHAR(20) DEFAULT 'mis_datos' NOT NULL,
|
||||
ADD COLUMN "fact_uso_cfdi" VARCHAR(5) DEFAULT 'G03' NOT NULL,
|
||||
ADD COLUMN "fact_regimen_preferido" VARCHAR(3);
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Onboarding auto-dismiss: 4 logins ó pasos completados, lo que pase primero.
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN "login_count" INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN "onboarding_dismissed_at" TIMESTAMP(3);
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Mapa { kindKey: requestId } para reusar requests del SAT en reintentos.
|
||||
-- Hasta antes de este cambio, cada retry creaba nuevas solicitudes — agotaba
|
||||
-- la cuota del SAT y abandonaba requests anteriores. Ahora el retry consulta
|
||||
-- los requestIds previos antes de crear nuevos.
|
||||
ALTER TABLE "sat_sync_jobs"
|
||||
ADD COLUMN "sat_request_ids" JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Distingue extracciones tipo `initial` con rango personalizado (UI custom)
|
||||
-- de bootstrap inicial puro. Política de retry distinta:
|
||||
-- initial bootstrap → 3 retries a 6h, 12h, 24h
|
||||
-- initial custom → 2 retries a 6h, 12h
|
||||
ALTER TABLE "sat_sync_jobs"
|
||||
ADD COLUMN "is_custom_range" BOOLEAN NOT NULL DEFAULT false;
|
||||
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"
|
||||
760
apps/api/prisma/schema.prisma
Normal file
760
apps/api/prisma/schema.prisma
Normal file
@@ -0,0 +1,760 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Tenant {
|
||||
id String @id @default(uuid())
|
||||
nombre String
|
||||
rfc String @unique
|
||||
plan Plan @default(trial)
|
||||
databaseName String @unique @map("database_name")
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
expiresAt DateTime? @map("expires_at")
|
||||
// Prueba gratuita: si está set y en el futuro, el tenant está en trial.
|
||||
// Se consume una sola vez por tenant (al activarla, nunca se regenera).
|
||||
trialEndsAt DateTime? @map("trial_ends_at")
|
||||
|
||||
facturapiOrgId String? @map("facturapi_org_id")
|
||||
/// Live Secret Key cifrada (AES-256-GCM, misma derivación FIEL_ENCRYPTION_KEY).
|
||||
/// Cacheada tras primer PUT idempotente a /v2/organizations/{id}/apikeys/live.
|
||||
facturapiOrgKeyEnc Bytes? @map("facturapi_org_key_enc")
|
||||
facturapiOrgKeyIv Bytes? @map("facturapi_org_key_iv")
|
||||
facturapiOrgKeyTag Bytes? @map("facturapi_org_key_tag")
|
||||
|
||||
// Domicilio fiscal
|
||||
codigoPostal String? @map("codigo_postal") @db.VarChar(5)
|
||||
calle String? @db.VarChar(255)
|
||||
numExterior String? @map("num_exterior") @db.VarChar(20)
|
||||
numInterior String? @map("num_interior") @db.VarChar(20)
|
||||
colonia String? @db.VarChar(255)
|
||||
ciudad String? @db.VarChar(100)
|
||||
municipio String? @db.VarChar(100)
|
||||
estado String? @db.VarChar(100)
|
||||
telefono String? @db.VarChar(20)
|
||||
|
||||
// Preferencias de auto-facturación de pagos de suscripción.
|
||||
// Default: facturar con datos del cliente cuando hay CSF disponible.
|
||||
// Si `factPreferencia='publico_general'` siempre va a XAXX010101000.
|
||||
factPreferencia String @default("mis_datos") @map("fact_preferencia") @db.VarChar(20)
|
||||
// Uso CFDI default cuando se factura con datos del cliente.
|
||||
// G03 = Gastos en general (más común para SaaS).
|
||||
factUsoCfdi String @default("G03") @map("fact_uso_cfdi") @db.VarChar(5)
|
||||
// Si el tenant tiene múltiples regímenes activos, cuál usar para factura.
|
||||
// Null = usar el primero activo (heurística por createdAt).
|
||||
factRegimenPreferido String? @map("fact_regimen_preferido") @db.VarChar(3)
|
||||
|
||||
// === Despacho fields ===
|
||||
verticalProfile VerticalProfile? @map("vertical_profile")
|
||||
dbMode DbMode? @map("db_mode")
|
||||
dbConnectionEnc String? @map("db_connection_enc")
|
||||
dbConnectionIv String? @map("db_connection_iv")
|
||||
dbSchemaVersion Int @default(0) @map("db_schema_version")
|
||||
connectorTokenEnc String? @map("connector_token_enc")
|
||||
connectorTunnelHostname String? @map("connector_tunnel_hostname")
|
||||
connectorLastSeen DateTime? @map("connector_last_seen")
|
||||
connectorVersion String? @map("connector_version") @db.VarChar(20)
|
||||
|
||||
memberships TenantMembership[]
|
||||
fielCredential FielCredential?
|
||||
satSyncJobs SatSyncJob[]
|
||||
subscriptions Subscription[]
|
||||
payments Payment[]
|
||||
regimenesIgnorados TenantRegimenIgnorado[]
|
||||
regimenesActivos TenantRegimenActivo[]
|
||||
coeficientes CoeficienteUtilidad[]
|
||||
timbreSuscripcion TimbreSuscripcion?
|
||||
timbrePaquetes TimbrePaquete[]
|
||||
connectorHeartbeats ConnectorHeartbeat[]
|
||||
|
||||
@@map("tenants")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
passwordHash String @map("password_hash")
|
||||
nombre String
|
||||
active Boolean @default(true)
|
||||
lastLogin DateTime? @map("last_login")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
// Contador para invalidar sesiones masivamente. Al incrementar, todos los
|
||||
// JWT emitidos antes (con tokenVersion menor) quedan rechazados en el
|
||||
// siguiente request. Se incrementa en: password change, password reset,
|
||||
// logout-all. Default 0 para compat con users pre-rollout.
|
||||
tokenVersion Int @default(0) @map("token_version")
|
||||
// Último tenant que el user activó (via switch-tenant). Se usa para resolver
|
||||
// el "tenant activo al login". Si es null, el login cae al primer membership
|
||||
// por joinedAt. Se actualiza en cada switch.
|
||||
lastTenantId String? @map("last_tenant_id")
|
||||
// Cuenta sesiones (login exitoso, NO refresh). Usado para auto-dismiss del
|
||||
// onboarding tras N logins. Default 0 → users pre-rollout siguen viendo el
|
||||
// onboarding hasta acumular logins post-deploy.
|
||||
loginCount Int @default(0) @map("login_count")
|
||||
// Marca explícita de que el onboarding ya no debe mostrarse. Se setea cuando
|
||||
// el user completa todos los pasos requeridos o desde el endpoint de dismiss.
|
||||
onboardingDismissedAt DateTime? @map("onboarding_dismissed_at")
|
||||
|
||||
memberships TenantMembership[]
|
||||
platformRoles UserPlatformRole[]
|
||||
passwordResetTokens PasswordResetToken[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
/// Relación many-to-many entre User y Tenant. Permite que un mismo user (p.ej.
|
||||
/// un dueño/contador) pertenezca a varios tenants con distintos roles. Esta
|
||||
/// tabla es la fuente de verdad del "¿a qué tenants tiene acceso este user?".
|
||||
///
|
||||
/// Durante la transición, `User.tenantId` y `User.rolId` se mantienen como
|
||||
/// "default tenant" para login UX. El backfill inicial crea 1 membership por
|
||||
/// user basado en esos campos. Cuando se agregue la UI de multi-tenant, los
|
||||
/// nuevos accesos solo tocarán esta tabla.
|
||||
model TenantMembership {
|
||||
id Int @id @default(autoincrement())
|
||||
userId String @map("user_id")
|
||||
tenantId String @map("tenant_id")
|
||||
rolId Int @map("rol_id")
|
||||
isOwner Boolean @default(false) @map("is_owner")
|
||||
active Boolean @default(true)
|
||||
joinedAt DateTime @default(now()) @map("joined_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
rol Rol @relation(fields: [rolId], references: [id])
|
||||
|
||||
@@unique([userId, tenantId])
|
||||
@@index([userId, active])
|
||||
@@index([tenantId, active])
|
||||
@@map("tenant_memberships")
|
||||
}
|
||||
|
||||
model Rol {
|
||||
id Int @id @default(autoincrement())
|
||||
nombre String @unique @db.VarChar(20)
|
||||
descripcion String?
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
memberships TenantMembership[]
|
||||
|
||||
@@map("roles")
|
||||
}
|
||||
|
||||
model RefreshToken {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
token String @unique
|
||||
expiresAt DateTime @map("expires_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@map("refresh_tokens")
|
||||
}
|
||||
|
||||
/// Tokens para recuperación de contraseña. Expiran en 1 hora, son single-use
|
||||
/// (se marca `usedAt` al consumir). Al completar reset se invalidan todos los
|
||||
/// refresh tokens del user — cierra todas sus sesiones forzando re-login.
|
||||
model PasswordResetToken {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
token String @unique
|
||||
expiresAt DateTime @map("expires_at")
|
||||
usedAt DateTime? @map("used_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@index([expiresAt])
|
||||
@@map("password_reset_tokens")
|
||||
}
|
||||
|
||||
enum Plan {
|
||||
trial
|
||||
custom
|
||||
business_control
|
||||
business_cloud
|
||||
mi_empresa
|
||||
mi_empresa_plus
|
||||
}
|
||||
|
||||
enum VerticalProfile {
|
||||
CONTABLE
|
||||
JURIDICO
|
||||
ARQUITECTURA
|
||||
}
|
||||
|
||||
enum DbMode {
|
||||
BYO
|
||||
MANAGED
|
||||
}
|
||||
|
||||
|
||||
// ============================================
|
||||
// Catálogo de Regímenes Fiscales SAT
|
||||
// ============================================
|
||||
|
||||
model Regimen {
|
||||
id Int @id @default(autoincrement())
|
||||
clave String @unique @db.VarChar(3)
|
||||
descripcion String
|
||||
tipoPersona String @map("tipo_persona") @db.VarChar(20) // fisica, moral, ambos
|
||||
activo Boolean @default(true)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
tenantIgnorados TenantRegimenIgnorado[]
|
||||
tenantActivos TenantRegimenActivo[]
|
||||
|
||||
@@map("regimenes")
|
||||
}
|
||||
|
||||
model TenantRegimenIgnorado {
|
||||
id Int @id @default(autoincrement())
|
||||
tenantId String @map("tenant_id")
|
||||
regimenId Int @map("regimen_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
regimen Regimen @relation(fields: [regimenId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([tenantId, regimenId])
|
||||
@@map("tenant_regimenes_ignorados")
|
||||
}
|
||||
|
||||
model TenantRegimenActivo {
|
||||
id Int @id @default(autoincrement())
|
||||
tenantId String @map("tenant_id")
|
||||
regimenId Int @map("regimen_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
regimen Regimen @relation(fields: [regimenId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([tenantId, regimenId])
|
||||
@@map("tenant_regimenes_activos")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Catálogo de Eventos Fiscales
|
||||
// ============================================
|
||||
|
||||
model EventoFiscalCatalogo {
|
||||
id Int @id @default(autoincrement())
|
||||
titulo String
|
||||
descripcion String?
|
||||
tipo String @db.VarChar(20) // declaracion, pago, obligacion, informativa
|
||||
diaBase Int @map("dia_base") // día del mes (17, 3, 31, etc.)
|
||||
mesRelativo Int @default(1) @map("mes_relativo") // 1=mes posterior, 2=segundo mes posterior, 0=mes fijo
|
||||
mesFijo Int? @map("mes_fijo") // para anuales: 2=feb, 3=mar, 4=abr
|
||||
recurrencia String @default("mensual") @db.VarChar(20) // mensual, anual
|
||||
usaExtensionRfc Boolean @default(false) @map("usa_extension_rfc")
|
||||
regimenes String @default("todos") // 'todos' o CSV de claves: '601,603,612'
|
||||
condicion String? @db.VarChar(50) // null, 'tiene_nomina', 'ingresos_4m'
|
||||
activo Boolean @default(true)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@map("eventos_fiscales_catalogo")
|
||||
}
|
||||
|
||||
/// Lista negra SAT (Art. 69-B CFF)
|
||||
model ListaNegra {
|
||||
id Int @id @default(autoincrement())
|
||||
rfc String @unique @db.VarChar(13)
|
||||
nombre String
|
||||
situacion String @db.VarChar(30) // Definitivo, Presunto, Desvirtuado, Sentencia Favorable
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([rfc])
|
||||
@@map("lista_negra")
|
||||
}
|
||||
|
||||
/// Días inhábiles fiscales (festivos oficiales de México)
|
||||
model DiaInhabil {
|
||||
id Int @id @default(autoincrement())
|
||||
fecha DateTime @unique @db.Date
|
||||
nombre String
|
||||
|
||||
@@map("dias_inhabiles")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ISR Tables
|
||||
// ============================================
|
||||
|
||||
/// Tasas RESICO (Art. 113-E) - tasa plana por bracket mensual
|
||||
model IsrResicoTasa {
|
||||
id Int @id @default(autoincrement())
|
||||
anio Int @map("anio")
|
||||
montoMaximo Decimal @map("monto_maximo") @db.Decimal(18, 2)
|
||||
porcentaje Decimal @db.Decimal(5, 2)
|
||||
|
||||
@@unique([anio, montoMaximo])
|
||||
@@map("isr_resico_tasas")
|
||||
}
|
||||
|
||||
/// Tarifa ISR progresiva (Art. 96) - mensual
|
||||
model IsrTarifa {
|
||||
id Int @id @default(autoincrement())
|
||||
anio Int @map("anio")
|
||||
limiteInferior Decimal @map("limite_inferior") @db.Decimal(18, 2)
|
||||
limiteSuperior Decimal? @map("limite_superior") @db.Decimal(18, 2)
|
||||
cuotaFija Decimal @map("cuota_fija") @db.Decimal(18, 2)
|
||||
porcentajeExcedente Decimal @map("porcentaje_excedente") @db.Decimal(5, 2)
|
||||
|
||||
@@unique([anio, limiteInferior])
|
||||
@@map("isr_tarifas")
|
||||
}
|
||||
|
||||
/// Coeficiente de utilidad por tenant/año (no se sobrescribe)
|
||||
model CoeficienteUtilidad {
|
||||
id Int @id @default(autoincrement())
|
||||
tenantId String @map("tenant_id")
|
||||
anio Int @map("anio")
|
||||
coeficiente Decimal @db.Decimal(10, 4)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([tenantId, anio])
|
||||
@@map("coeficiente_utilidad")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// SAT Sync Models
|
||||
// ============================================
|
||||
|
||||
model FielCredential {
|
||||
id String @id @default(uuid())
|
||||
tenantId String @unique @map("tenant_id")
|
||||
rfc String @db.VarChar(13)
|
||||
cerData Bytes @map("cer_data")
|
||||
keyData Bytes @map("key_data")
|
||||
keyPasswordEncrypted Bytes @map("key_password_encrypted")
|
||||
cerIv Bytes @map("cer_iv")
|
||||
cerTag Bytes @map("cer_tag")
|
||||
keyIv Bytes @map("key_iv")
|
||||
keyTag Bytes @map("key_tag")
|
||||
passwordIv Bytes @map("password_iv")
|
||||
passwordTag Bytes @map("password_tag")
|
||||
serialNumber String? @map("serial_number") @db.VarChar(50)
|
||||
validFrom DateTime @map("valid_from")
|
||||
validUntil DateTime @map("valid_until")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("fiel_credentials")
|
||||
}
|
||||
|
||||
model Subscription {
|
||||
id String @id @default(uuid())
|
||||
tenantId String @map("tenant_id")
|
||||
plan Plan
|
||||
mpPreapprovalId String? @map("mp_preapproval_id")
|
||||
status String @default("pending")
|
||||
amount Decimal @db.Decimal(10, 2)
|
||||
frequency String @default("monthly")
|
||||
currentPeriodStart DateTime? @map("current_period_start")
|
||||
currentPeriodEnd DateTime? @map("current_period_end")
|
||||
// Cambio programado al próximo período (downgrades y cambios de frecuencia)
|
||||
pendingPlan Plan? @map("pending_plan")
|
||||
pendingFrequency String? @map("pending_frequency")
|
||||
pendingEffectiveAt DateTime? @map("pending_effective_at")
|
||||
// Upgrade inmediato en curso: preference MP esperando cobro prorateado.
|
||||
// Cuando el webhook confirma el pago, se aplica el plan nuevo y se limpian estos campos.
|
||||
upgradePreferenceId String? @map("upgrade_preference_id")
|
||||
upgradeTargetPlan Plan? @map("upgrade_target_plan")
|
||||
upgradeTargetAmount Decimal? @db.Decimal(10, 2) @map("upgrade_target_amount")
|
||||
// Idempotencia del cron de aviso pre-vencimiento. Guarda el bucket de días
|
||||
// que ya se notificó (7, 3, 1 ó 0) para no spamear al owner si el cron corre
|
||||
// dos veces el mismo día. Se resetea cuando se renueva la suscripción o
|
||||
// arranca un período nuevo.
|
||||
lastReminderDay Int? @map("last_reminder_day")
|
||||
lastReminderSentAt DateTime? @map("last_reminder_sent_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
payments Payment[]
|
||||
addons SubscriptionAddon[]
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([status])
|
||||
@@index([pendingEffectiveAt])
|
||||
@@map("subscriptions")
|
||||
}
|
||||
|
||||
model SubscriptionAddon {
|
||||
id String @id @default(uuid())
|
||||
subscriptionId String @map("subscription_id")
|
||||
planAddonCatalogoId String @map("plan_addon_catalogo_id")
|
||||
/// UUID del contribuyente (entidad_id en tenant BD) cuando el add-on
|
||||
/// aplica a un RFC específico. NULL para add-ons a nivel tenant (módulos
|
||||
/// globales, +RFCs, +timbres). Sin FK porque contribuyente vive en BD tenant.
|
||||
contribuyenteId String? @map("contribuyente_id")
|
||||
mpPreapprovalId String? @map("mp_preapproval_id")
|
||||
status String @default("pending")
|
||||
quantity Int @default(1)
|
||||
amount Decimal @db.Decimal(10, 2)
|
||||
currentPeriodStart DateTime? @map("current_period_start")
|
||||
currentPeriodEnd DateTime? @map("current_period_end")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
subscription Subscription @relation(fields: [subscriptionId], references: [id])
|
||||
planAddonCatalogo PlanAddonCatalogo @relation(fields: [planAddonCatalogoId], references: [id])
|
||||
|
||||
/// Sin UNIQUE compuesto: la validación de "un solo add-on activo por
|
||||
/// (subscription, addon, contribuyente?)" queda a nivel aplicación
|
||||
/// (findFirst en subscribeAddon), porque Postgres trata NULL!=NULL y no
|
||||
/// hay forma trivial de enforcar unicidad con contribuyenteId opcional.
|
||||
@@index([subscriptionId])
|
||||
@@index([subscriptionId, contribuyenteId])
|
||||
@@map("subscription_addons")
|
||||
}
|
||||
|
||||
/// Roles de plataforma (staff interno de Horux 360) — ortogonales al rol per-tenant.
|
||||
/// Un user puede tener 0, 1 o varios roles. `platform_admin` es el superrol.
|
||||
/// Ver `docs/plans/2026-04-14-platform-admin-roles.md`.
|
||||
enum PlatformRole {
|
||||
platform_admin // Todo: precios, clientes, facturas, suscripciones, gestión de staff
|
||||
platform_ti // Mismos permisos que admin (equipo de TI / tech ops). Diferencia solo en trazabilidad.
|
||||
platform_support // Ver todos los tenants, resolver tickets, NO facturación/precios
|
||||
platform_sales // Crear/editar tenants (onboarding), ver suscripciones, NO precios
|
||||
platform_finance // Ver payments, emitir facturas manuales, editar precios, reportes fiscales
|
||||
}
|
||||
|
||||
model UserPlatformRole {
|
||||
id Int @id @default(autoincrement())
|
||||
userId String @map("user_id")
|
||||
role PlatformRole
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
createdBy String? @map("created_by") // User.id de quien asignó (audit trail)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, role])
|
||||
@@index([role])
|
||||
@@map("user_platform_roles")
|
||||
}
|
||||
|
||||
/// Registro de acciones críticas para auditoría (SAT compliance, forense, disputas).
|
||||
/// Se instrumenta vía `utils/audit.ts` con helper fire-and-forget — un fallo al
|
||||
/// escribir aquí NUNCA debe romper la acción principal.
|
||||
model AuditLog {
|
||||
id String @id @default(uuid())
|
||||
userId String? @map("user_id")
|
||||
tenantId String? @map("tenant_id")
|
||||
action String @db.VarChar(64) // "price.updated", "subscription.cancelled", etc.
|
||||
entityType String? @map("entity_type") @db.VarChar(32)
|
||||
entityId String? @map("entity_id")
|
||||
metadata Json? // before/after, ip, userAgent, contexto
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([userId, createdAt])
|
||||
@@index([tenantId, createdAt])
|
||||
@@index([action, createdAt])
|
||||
@@index([entityType, entityId])
|
||||
@@map("audit_log")
|
||||
}
|
||||
|
||||
/// Padrón persistente de RFCs que ya consumieron su prueba gratuita de 30 días.
|
||||
/// Sobrevive al ciclo de vida del Tenant (si se borra/recrea, el RFC sigue aquí),
|
||||
/// bloqueando el abuso de "registro nuevo con el mismo RFC para otro trial".
|
||||
model TrialUsage {
|
||||
id Int @id @default(autoincrement())
|
||||
rfc String @unique @db.VarChar(13)
|
||||
tenantId String? @map("tenant_id") // Tenant que consumió (null si el tenant se borró después)
|
||||
startedAt DateTime @default(now()) @map("started_at")
|
||||
|
||||
@@map("trial_usages")
|
||||
}
|
||||
|
||||
/// Catálogo despacho — precios + limits editables por admin global.
|
||||
/// Las `features` siguen viviendo en TS (`DESPACHO_PLANS` en `@horux/shared`)
|
||||
/// porque están acopladas a UI/middleware y son contrato de código.
|
||||
/// Incluye filas para `trial` y `custom` (sin precios — null).
|
||||
model DespachoPlanPrice {
|
||||
plan String @id // trial | custom | mi_empresa | mi_empresa_plus | business_control | business_cloud
|
||||
nombre String @db.VarChar(50)
|
||||
monthly Decimal? @db.Decimal(10, 2)
|
||||
firstYear Decimal? @db.Decimal(10, 2) @map("first_year")
|
||||
renewal Decimal? @db.Decimal(10, 2)
|
||||
permiteMonthly Boolean @default(false) @map("permite_monthly")
|
||||
/// Limits del plan. -1 = ilimitado donde aplique (maxUsers).
|
||||
maxRfcs Int @map("max_rfcs")
|
||||
maxUsers Int @map("max_users")
|
||||
timbresIncluidosMes Int @default(0) @map("timbres_incluidos_mes")
|
||||
dbMode DbMode @map("db_mode")
|
||||
permiteServidorBackup Boolean @default(false) @map("permite_servidor_backup")
|
||||
/// Habilita SAT incremental (3 syncs/día adicionales al daily). Mi Empresa +,
|
||||
/// Business Control y Enterprise lo tienen activo por default; planes
|
||||
/// inferiores se quedan solo con el daily de las 03:00.
|
||||
permiteSatIncremental Boolean @default(false) @map("permite_sat_incremental")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("despacho_plan_prices")
|
||||
}
|
||||
|
||||
model PlanAddonCatalogo {
|
||||
id String @id @default(uuid())
|
||||
codename String @unique @db.VarChar(50)
|
||||
nombre String
|
||||
verticalProfile VerticalProfile?
|
||||
precio Decimal @db.Decimal(10, 2)
|
||||
frecuencia String @db.VarChar(10)
|
||||
delta Json
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
subscriptionAddons SubscriptionAddon[]
|
||||
|
||||
@@map("plan_addon_catalogo")
|
||||
}
|
||||
|
||||
model ConnectorHeartbeat {
|
||||
id String @id @default(uuid())
|
||||
tenantId String @map("tenant_id")
|
||||
timestamp DateTime @default(now())
|
||||
latencyMs Int @map("latency_ms")
|
||||
version String @db.VarChar(20)
|
||||
pgVersion String? @map("pg_version") @db.VarChar(50)
|
||||
status String @db.VarChar(20)
|
||||
errorMsg String? @map("error_msg")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([tenantId, timestamp])
|
||||
@@map("connector_heartbeats")
|
||||
}
|
||||
|
||||
enum PaymentKind {
|
||||
subscription
|
||||
timbres_pack
|
||||
}
|
||||
|
||||
model Payment {
|
||||
id String @id @default(uuid())
|
||||
tenantId String @map("tenant_id")
|
||||
subscriptionId String? @map("subscription_id")
|
||||
mpPaymentId String? @map("mp_payment_id")
|
||||
amount Decimal @db.Decimal(10, 2)
|
||||
status String @default("pending")
|
||||
paymentMethod String? @map("payment_method")
|
||||
paidAt DateTime? @map("paid_at")
|
||||
// Tipo de pago. subscription = cobro mensual/anual del plan.
|
||||
// timbres_pack = compra de paquete de timbres adicionales.
|
||||
kind PaymentKind @default(subscription)
|
||||
// ID de la factura emitida auto por Facturapi. Null si no se facturó:
|
||||
// primer pago (manual), trial sin monto, o fallo al emitir.
|
||||
facturapiInvoiceId String? @map("facturapi_invoice_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
subscription Subscription? @relation(fields: [subscriptionId], references: [id])
|
||||
timbrePaquete TimbrePaquete?
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([subscriptionId])
|
||||
@@map("payments")
|
||||
}
|
||||
|
||||
/// Catálogo de paquetes de timbres adicionales vendibles. Precios editables
|
||||
/// desde panel admin. Los 3 defaults (100/$200, 1000/$1400, 10000/$8600) se
|
||||
/// insertan en seed idempotente.
|
||||
model TimbrePaqueteCatalogo {
|
||||
id Int @id @default(autoincrement())
|
||||
cantidad Int @unique // 100, 1000, 10000
|
||||
precio Decimal @db.Decimal(10, 2)
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("timbre_paquetes_catalogo")
|
||||
}
|
||||
|
||||
/// Compra individual de timbres adicionales. Los timbres del plan (mensuales)
|
||||
/// se rastrean en TimbreSuscripcion — esto es SOLO para los extras pagados.
|
||||
/// Vigencia 1 año desde `adquiridoEn`. El orden de consumo es FIFO por
|
||||
/// `expiraEn` (menor primero) para no desperdiciar paquetes próximos a vencer.
|
||||
model TimbrePaquete {
|
||||
id Int @id @default(autoincrement())
|
||||
tenantId String @map("tenant_id")
|
||||
paymentId String? @unique @map("payment_id") // Payment que lo compró; null si admin grant manual
|
||||
cantidad Int // cuántos timbres tenía originalmente
|
||||
usados Int @default(0)
|
||||
precio Decimal @db.Decimal(10, 2) // precio pagado (historial, no cambia si el catálogo cambia)
|
||||
adquiridoEn DateTime @default(now()) @map("adquirido_en")
|
||||
expiraEn DateTime @map("expira_en") // adquiridoEn + 1 año
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
payment Payment? @relation(fields: [paymentId], references: [id])
|
||||
|
||||
@@index([tenantId, expiraEn])
|
||||
@@map("timbre_paquetes")
|
||||
}
|
||||
|
||||
model SatSyncJob {
|
||||
id String @id @default(uuid())
|
||||
tenantId String @map("tenant_id")
|
||||
contribuyenteId String? @map("contribuyente_id")
|
||||
type SatSyncType
|
||||
status SatSyncStatus @default(pending)
|
||||
dateFrom DateTime @map("date_from") @db.Date
|
||||
dateTo DateTime @map("date_to") @db.Date
|
||||
cfdiType CfdiSyncType? @map("cfdi_type")
|
||||
satRequestId String? @map("sat_request_id") @db.VarChar(50)
|
||||
// Mapa { kindKey: requestId } de TODOS los requests creados durante el job.
|
||||
// Permite que retries reusen requestIds previos en lugar de quemar cuota
|
||||
// del SAT creando nuevos. kindKey = `${requestType}-${tipoCfdi}-${from}-${to}`.
|
||||
satRequestIds Json @default("{}") @map("sat_request_ids")
|
||||
satPackageIds String[] @map("sat_package_ids")
|
||||
cfdisFound Int @default(0) @map("cfdis_found")
|
||||
cfdisDownloaded Int @default(0) @map("cfdis_downloaded")
|
||||
cfdisInserted Int @default(0) @map("cfdis_inserted")
|
||||
cfdisUpdated Int @default(0) @map("cfdis_updated")
|
||||
progressPercent Int @default(0) @map("progress_percent")
|
||||
errorMessage String? @map("error_message")
|
||||
startedAt DateTime? @map("started_at")
|
||||
completedAt DateTime? @map("completed_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
retryCount Int @default(0) @map("retry_count")
|
||||
nextRetryAt DateTime? @map("next_retry_at")
|
||||
// True cuando el job es `initial` con rango de fechas personalizado por el
|
||||
// usuario (botón UI). Cambia la política de retry: 2 intentos vs 3 del
|
||||
// bootstrap puro. Daily/incremental ignoran este campo.
|
||||
isCustomRange Boolean @default(false) @map("is_custom_range")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([status])
|
||||
@@index([status, nextRetryAt])
|
||||
@@map("sat_sync_jobs")
|
||||
}
|
||||
|
||||
enum SatSyncType {
|
||||
initial
|
||||
daily
|
||||
incremental
|
||||
}
|
||||
|
||||
enum SatSyncStatus {
|
||||
pending
|
||||
running
|
||||
completed
|
||||
failed
|
||||
}
|
||||
|
||||
enum CfdiSyncType {
|
||||
emitidos
|
||||
recibidos
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Catálogos SAT para Facturación (CFDI 4.0)
|
||||
// ============================================
|
||||
|
||||
model CatFormaPago {
|
||||
id Int @id @default(autoincrement())
|
||||
clave String @unique @db.VarChar(2)
|
||||
descripcion String
|
||||
|
||||
@@map("cat_forma_pago")
|
||||
}
|
||||
|
||||
model CatMetodoPago {
|
||||
id Int @id @default(autoincrement())
|
||||
clave String @unique @db.VarChar(3)
|
||||
descripcion String
|
||||
|
||||
@@map("cat_metodo_pago")
|
||||
}
|
||||
|
||||
model CatUsoCfdi {
|
||||
id Int @id @default(autoincrement())
|
||||
clave String @unique @db.VarChar(4)
|
||||
descripcion String
|
||||
personaFisica Boolean @default(true) @map("persona_fisica")
|
||||
personaMoral Boolean @default(true) @map("persona_moral")
|
||||
|
||||
@@map("cat_uso_cfdi")
|
||||
}
|
||||
|
||||
model CatMoneda {
|
||||
id Int @id @default(autoincrement())
|
||||
clave String @unique @db.VarChar(3)
|
||||
descripcion String
|
||||
decimales Int @default(2)
|
||||
|
||||
@@map("cat_moneda")
|
||||
}
|
||||
|
||||
model CatClaveUnidad {
|
||||
id Int @id @default(autoincrement())
|
||||
clave String @unique @db.VarChar(10)
|
||||
descripcion String
|
||||
|
||||
@@map("cat_clave_unidad")
|
||||
}
|
||||
|
||||
model CatClaveProdServ {
|
||||
id Int @id @default(autoincrement())
|
||||
clave String @unique @db.VarChar(8)
|
||||
descripcion String
|
||||
|
||||
@@index([descripcion])
|
||||
@@map("cat_clave_prod_serv")
|
||||
}
|
||||
|
||||
model CatObjetoImp {
|
||||
id Int @id @default(autoincrement())
|
||||
clave String @unique @db.VarChar(2)
|
||||
descripcion String
|
||||
|
||||
@@map("cat_objeto_imp")
|
||||
}
|
||||
|
||||
model CatTipoRelacion {
|
||||
id Int @id @default(autoincrement())
|
||||
clave String @unique @db.VarChar(2)
|
||||
descripcion String
|
||||
|
||||
@@map("cat_tipo_relacion")
|
||||
}
|
||||
|
||||
model CatExportacion {
|
||||
id Int @id @default(autoincrement())
|
||||
clave String @unique @db.VarChar(2)
|
||||
descripcion String
|
||||
|
||||
@@map("cat_exportacion")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Gestión de Timbres Facturapi
|
||||
// ============================================
|
||||
|
||||
model TimbreSuscripcion {
|
||||
id Int @id @default(autoincrement())
|
||||
tenantId String @unique @map("tenant_id")
|
||||
tipo String @db.VarChar(10) // mensual, anual
|
||||
timbresLimite Int @map("timbres_limite") // 50 o 600
|
||||
timbresUsados Int @default(0) @map("timbres_usados")
|
||||
periodoInicio DateTime @map("periodo_inicio") @db.Date
|
||||
periodoFin DateTime @map("periodo_fin") @db.Date
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("timbre_suscripciones")
|
||||
}
|
||||
528
apps/api/prisma/seed.ts
Normal file
528
apps/api/prisma/seed.ts
Normal file
@@ -0,0 +1,528 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { Pool } from 'pg';
|
||||
import { migrate } from '../src/config/tenant-migrations.js';
|
||||
import { RESICO_TASAS, ISR_TARIFAS } from './isr-data.js';
|
||||
import { EVENTOS_FISCALES, DIAS_INHABILES } from './eventos-fiscales-data.js';
|
||||
import {
|
||||
FORMAS_PAGO, METODOS_PAGO, USOS_CFDI, MONEDAS, CLAVES_UNIDAD,
|
||||
OBJETOS_IMP, TIPOS_RELACION, EXPORTACIONES,
|
||||
} from './catalogos-sat-data.js';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
function parseDatabaseUrl(url: string) {
|
||||
const parsed = new URL(url);
|
||||
return {
|
||||
host: parsed.hostname,
|
||||
port: parseInt(parsed.port || '5432'),
|
||||
user: decodeURIComponent(parsed.username),
|
||||
password: decodeURIComponent(parsed.password),
|
||||
};
|
||||
}
|
||||
|
||||
const REGIMENES_SAT = [
|
||||
{ clave: '601', descripcion: 'General de Ley Personas Morales', tipoPersona: 'moral' },
|
||||
{ clave: '603', descripcion: 'Personas Morales con Fines no Lucrativos', tipoPersona: 'moral' },
|
||||
{ clave: '605', descripcion: 'Sueldos y Salarios e Ingresos Asimilados a Salarios', tipoPersona: 'fisica' },
|
||||
{ clave: '606', descripcion: 'Arrendamiento', tipoPersona: 'fisica' },
|
||||
{ clave: '607', descripcion: 'Régimen de Enajenación o Adquisición de Bienes', tipoPersona: 'fisica' },
|
||||
{ clave: '608', descripcion: 'Demás ingresos', tipoPersona: 'fisica' },
|
||||
{ clave: '610', descripcion: 'Residentes en el Extranjero sin Establecimiento Permanente en México', tipoPersona: 'ambos' },
|
||||
{ clave: '611', descripcion: 'Ingresos por Dividendos (socios y accionistas)', tipoPersona: 'fisica' },
|
||||
{ clave: '612', descripcion: 'Personas Físicas con Actividades Empresariales y Profesionales', tipoPersona: 'fisica' },
|
||||
{ clave: '614', descripcion: 'Ingresos por intereses', tipoPersona: 'fisica' },
|
||||
{ clave: '615', descripcion: 'Régimen de los ingresos por obtención de premios', tipoPersona: 'fisica' },
|
||||
{ clave: '616', descripcion: 'Sin obligaciones fiscales', tipoPersona: 'ambos' },
|
||||
{ clave: '620', descripcion: 'Sociedades Cooperativas de Producción que optan por diferir sus ingresos', tipoPersona: 'moral' },
|
||||
{ clave: '621', descripcion: 'Incorporación Fiscal', tipoPersona: 'fisica' },
|
||||
{ clave: '622', descripcion: 'Actividades Agrícolas, Ganaderas, Silvícolas y Pesqueras', tipoPersona: 'ambos' },
|
||||
{ clave: '623', descripcion: 'Opcional para Grupos de Sociedades', tipoPersona: 'moral' },
|
||||
{ clave: '624', descripcion: 'Coordinados', tipoPersona: 'moral' },
|
||||
{ clave: '625', descripcion: 'Régimen de las Actividades Empresariales con ingresos a través de Plataformas Tecnológicas', tipoPersona: 'fisica' },
|
||||
{ clave: '626', descripcion: 'Régimen Simplificado de Confianza', tipoPersona: 'ambos' },
|
||||
];
|
||||
|
||||
async function main() {
|
||||
console.log('🌱 Seeding database...');
|
||||
|
||||
// Seed regimenes catalog
|
||||
for (const r of REGIMENES_SAT) {
|
||||
await prisma.regimen.upsert({
|
||||
where: { clave: r.clave },
|
||||
update: { descripcion: r.descripcion, tipoPersona: r.tipoPersona },
|
||||
create: r,
|
||||
});
|
||||
}
|
||||
console.log(`✅ ${REGIMENES_SAT.length} regímenes fiscales SAT cargados`);
|
||||
|
||||
// Seed ISR tables — limpiar y recrear
|
||||
await prisma.isrResicoTasa.deleteMany();
|
||||
await prisma.isrTarifa.deleteMany();
|
||||
|
||||
for (const anio of [2020, 2021, 2022, 2023, 2024, 2025, 2026]) {
|
||||
if (anio >= 2022) {
|
||||
await prisma.isrResicoTasa.createMany({
|
||||
data: RESICO_TASAS.map(t => ({ anio, montoMaximo: t.montoMaximo, porcentaje: t.porcentaje })),
|
||||
});
|
||||
}
|
||||
|
||||
const tarifas = ISR_TARIFAS[anio];
|
||||
if (tarifas) {
|
||||
await prisma.isrTarifa.createMany({
|
||||
data: tarifas.map(t => ({
|
||||
anio,
|
||||
limiteInferior: t.li,
|
||||
limiteSuperior: t.ls,
|
||||
cuotaFija: t.cf,
|
||||
porcentajeExcedente: t.pe,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
console.log('✅ Tablas ISR 2020-2026 cargadas');
|
||||
|
||||
// Seed eventos fiscales catálogo
|
||||
await prisma.eventoFiscalCatalogo.deleteMany();
|
||||
await prisma.eventoFiscalCatalogo.createMany({
|
||||
data: EVENTOS_FISCALES.map(e => ({
|
||||
titulo: e.titulo,
|
||||
tipo: e.tipo,
|
||||
diaBase: e.diaBase,
|
||||
mesRelativo: e.mesRelativo,
|
||||
mesFijo: (e as any).mesFijo || null,
|
||||
recurrencia: e.recurrencia,
|
||||
usaExtensionRfc: e.usaExtensionRfc,
|
||||
regimenes: e.regimenes,
|
||||
condicion: e.condicion || null,
|
||||
})),
|
||||
});
|
||||
console.log(`✅ ${EVENTOS_FISCALES.length} eventos fiscales cargados`);
|
||||
|
||||
// Seed días inhábiles
|
||||
await prisma.diaInhabil.deleteMany();
|
||||
await prisma.diaInhabil.createMany({
|
||||
data: DIAS_INHABILES.map(d => ({ fecha: new Date(d.fecha), nombre: d.nombre })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
console.log(`✅ ${DIAS_INHABILES.length} días inhábiles cargados (2020-2027)`);
|
||||
|
||||
// Seed catálogos SAT para facturación
|
||||
for (const fp of FORMAS_PAGO) {
|
||||
await prisma.catFormaPago.upsert({ where: { clave: fp.clave }, update: { descripcion: fp.descripcion }, create: fp });
|
||||
}
|
||||
console.log(`✅ ${FORMAS_PAGO.length} formas de pago cargadas`);
|
||||
|
||||
for (const mp of METODOS_PAGO) {
|
||||
await prisma.catMetodoPago.upsert({ where: { clave: mp.clave }, update: { descripcion: mp.descripcion }, create: mp });
|
||||
}
|
||||
console.log(`✅ ${METODOS_PAGO.length} métodos de pago cargados`);
|
||||
|
||||
for (const u of USOS_CFDI) {
|
||||
await prisma.catUsoCfdi.upsert({ where: { clave: u.clave }, update: { descripcion: u.descripcion, personaFisica: u.personaFisica, personaMoral: u.personaMoral }, create: u });
|
||||
}
|
||||
console.log(`✅ ${USOS_CFDI.length} usos CFDI cargados`);
|
||||
|
||||
for (const m of MONEDAS) {
|
||||
await prisma.catMoneda.upsert({ where: { clave: m.clave }, update: { descripcion: m.descripcion, decimales: m.decimales }, create: m });
|
||||
}
|
||||
console.log(`✅ ${MONEDAS.length} monedas cargadas`);
|
||||
|
||||
for (const cu of CLAVES_UNIDAD) {
|
||||
await prisma.catClaveUnidad.upsert({ where: { clave: cu.clave }, update: { descripcion: cu.descripcion }, create: cu });
|
||||
}
|
||||
console.log(`✅ ${CLAVES_UNIDAD.length} claves de unidad cargadas`);
|
||||
|
||||
for (const oi of OBJETOS_IMP) {
|
||||
await prisma.catObjetoImp.upsert({ where: { clave: oi.clave }, update: { descripcion: oi.descripcion }, create: oi });
|
||||
}
|
||||
console.log(`✅ ${OBJETOS_IMP.length} objetos de impuesto cargados`);
|
||||
|
||||
for (const tr of TIPOS_RELACION) {
|
||||
await prisma.catTipoRelacion.upsert({ where: { clave: tr.clave }, update: { descripcion: tr.descripcion }, create: tr });
|
||||
}
|
||||
console.log(`✅ ${TIPOS_RELACION.length} tipos de relación cargados`);
|
||||
|
||||
for (const ex of EXPORTACIONES) {
|
||||
await prisma.catExportacion.upsert({ where: { clave: ex.clave }, update: { descripcion: ex.descripcion }, create: ex });
|
||||
}
|
||||
console.log(`✅ ${EXPORTACIONES.length} exportaciones cargadas`);
|
||||
|
||||
// Tabla `plan_prices` (modelo PlanPrice) era el catálogo Horux 360 legacy.
|
||||
// Tras eliminar los planes legacy (2026-04-30), no se siembran filas. Los
|
||||
// precios despacho viven en `despacho_plan_prices` (modelo DespachoPlanPrice).
|
||||
|
||||
// Catálogo despacho — precios + limits. UPSERT idempotente: precios y limits
|
||||
// se actualizan al re-correr seed (decisión: el seed es source of truth para
|
||||
// valores iniciales; el admin puede sobreescribir vía UI y NO debe re-correr
|
||||
// seed si no quiere perder ajustes manuales). Si quieres preservar edits del
|
||||
// admin, cambiar `update` a `{}` y aplicar manualmente.
|
||||
const DESPACHO_PLAN_CATALOGO = [
|
||||
{ plan: 'trial', nombre: 'Prueba', monthly: null, firstYear: null, renewal: null, permiteMonthly: false, maxRfcs: 3, maxUsers: 1, timbresIncluidosMes: 0, dbMode: 'MANAGED' as const, permiteServidorBackup: false, permiteSatIncremental: false },
|
||||
{ plan: 'custom', nombre: 'Custom', monthly: null, firstYear: null, renewal: null, permiteMonthly: false, maxRfcs: 1, maxUsers: 3, timbresIncluidosMes: 50, dbMode: 'MANAGED' as const, permiteServidorBackup: false, permiteSatIncremental: false },
|
||||
{ plan: 'mi_empresa', nombre: 'Mi Empresa', monthly: 580, firstYear: 5800, renewal: 5800, permiteMonthly: true, maxRfcs: 1, maxUsers: 3, timbresIncluidosMes: 50, dbMode: 'MANAGED' as const, permiteServidorBackup: false, permiteSatIncremental: false },
|
||||
{ plan: 'mi_empresa_plus', nombre: 'Mi Empresa +', monthly: 900, firstYear: 9000, renewal: 9000, permiteMonthly: true, maxRfcs: 1, maxUsers: 3, timbresIncluidosMes: 50, dbMode: 'MANAGED' as const, permiteServidorBackup: false, permiteSatIncremental: true },
|
||||
{ plan: 'business_control', nombre: 'Business Control', monthly: null, firstYear: 25850, renewal: 25850, permiteMonthly: false, maxRfcs: 100, maxUsers: -1, timbresIncluidosMes: 0, dbMode: 'BYO' as const, permiteServidorBackup: true, permiteSatIncremental: true },
|
||||
{ plan: 'business_cloud', nombre: 'Enterprise', monthly: null, firstYear: 43000, renewal: 43000, permiteMonthly: false, maxRfcs: 100, maxUsers: -1, timbresIncluidosMes: 0, dbMode: 'BYO' as const, permiteServidorBackup: true, permiteSatIncremental: true },
|
||||
];
|
||||
for (const p of DESPACHO_PLAN_CATALOGO) {
|
||||
await prisma.despachoPlanPrice.upsert({
|
||||
where: { plan: p.plan },
|
||||
update: { ...p },
|
||||
create: { ...p },
|
||||
});
|
||||
}
|
||||
console.log(`✅ ${DESPACHO_PLAN_CATALOGO.length} planes despacho cargados (precios + limits)`);
|
||||
|
||||
// Catálogo de paquetes de timbres adicionales. Editables desde panel admin.
|
||||
// Se crean con upsert por `cantidad` (unique) — permite reejecutar seed sin
|
||||
// sobrescribir precios ya ajustados manualmente: si el row existe, update
|
||||
// NO toca el precio (solo active + updatedAt si hace falta), sólo lo crea
|
||||
// si no existía. Si se quiere forzar reset de precios, borrar las filas.
|
||||
const TIMBRE_PAQUETES = [
|
||||
{ cantidad: 100, precio: 200 },
|
||||
{ cantidad: 1000, precio: 1400 },
|
||||
{ cantidad: 10000, precio: 8600 },
|
||||
];
|
||||
for (const p of TIMBRE_PAQUETES) {
|
||||
await prisma.timbrePaqueteCatalogo.upsert({
|
||||
where: { cantidad: p.cantidad },
|
||||
update: {}, // No tocamos `precio` si ya existe (admin pudo editarlo)
|
||||
create: { cantidad: p.cantidad, precio: p.precio, active: true },
|
||||
});
|
||||
}
|
||||
console.log(`✅ ${TIMBRE_PAQUETES.length} paquetes de timbres en catálogo`);
|
||||
|
||||
const databaseName = 'horux_ede123456ab1';
|
||||
|
||||
// Create demo tenant
|
||||
const tenant = await prisma.tenant.upsert({
|
||||
where: { rfc: 'EDE123456AB1' },
|
||||
update: {},
|
||||
create: {
|
||||
nombre: 'Empresa Demo SA de CV',
|
||||
rfc: 'EDE123456AB1',
|
||||
plan: 'mi_empresa_plus',
|
||||
databaseName,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('✅ Tenant created:', tenant.nombre);
|
||||
|
||||
// Migración: renombra el rol legacy 'admin' a 'owner' si sobrevive de un seed viejo.
|
||||
// Idempotente (no-op si ya se renombró o nunca existió).
|
||||
await prisma.$executeRawUnsafe(`UPDATE roles SET nombre = 'owner' WHERE nombre = 'admin'`);
|
||||
|
||||
// Backfill de trial_usages para tenants que ya consumieron su trial antes de que
|
||||
// existiera esta tabla. Idempotente: ON CONFLICT DO NOTHING. Filtramos por
|
||||
// longitud porque trial_usages.rfc es varchar(13) y los tenants despacho usan
|
||||
// slugs largos (DESPACHO_xxx) que no encajan — el padrón anti-abuso de trial
|
||||
// solo aplica a RFCs SAT reales de personas/empresas, no a slugs.
|
||||
await prisma.$executeRawUnsafe(`
|
||||
INSERT INTO trial_usages (rfc, tenant_id, started_at)
|
||||
SELECT UPPER(rfc), id, COALESCE(created_at, NOW())
|
||||
FROM tenants
|
||||
WHERE trial_ends_at IS NOT NULL AND LENGTH(rfc) <= 13
|
||||
ON CONFLICT (rfc) DO NOTHING
|
||||
`);
|
||||
|
||||
// Backfill de user_platform_roles: los owners del tenant HTS240708LJA se
|
||||
// convierten automáticamente en platform_admin. Migrado a tenant_memberships
|
||||
// tras F6 (User.tenantId/rolId eliminados). Idempotente.
|
||||
await prisma.$executeRawUnsafe(`
|
||||
INSERT INTO user_platform_roles (user_id, role, created_at)
|
||||
SELECT tm.user_id, 'platform_admin'::"PlatformRole", NOW()
|
||||
FROM tenant_memberships tm
|
||||
JOIN tenants t ON tm.tenant_id = t.id
|
||||
WHERE t.rfc = 'HTS240708LJA' AND tm.is_owner = true AND tm.active = true
|
||||
ON CONFLICT (user_id, role) DO NOTHING
|
||||
`);
|
||||
|
||||
// (Backfill de tenant_memberships eliminado — F6 ya migró todos los users
|
||||
// legacy y los campos `User.tenantId` y `User.rolId` ya no existen. Los
|
||||
// users nuevos se crean directamente con su membership.)
|
||||
|
||||
// Seed roles
|
||||
const rolesData = [
|
||||
{ nombre: 'owner', descripcion: 'Dueño - acceso completo' },
|
||||
{ nombre: 'cfo', descripcion: 'CFO - acceso completo (mismo nivel que el dueño)' },
|
||||
{ nombre: 'contador', descripcion: 'Contador - dashboard, CFDI, impuestos, calendario, alertas, facturación' },
|
||||
{ nombre: 'auxiliar', descripcion: 'Auxiliar - mismos permisos que contador' },
|
||||
{ nombre: 'visor', descripcion: 'Visor - solo lectura de CFDI, impuestos, calendario, alertas' },
|
||||
];
|
||||
for (const r of rolesData) {
|
||||
await prisma.rol.upsert({
|
||||
where: { nombre: r.nombre },
|
||||
update: { descripcion: r.descripcion },
|
||||
create: r,
|
||||
});
|
||||
}
|
||||
|
||||
// Seed despacho roles
|
||||
await prisma.rol.upsert({
|
||||
where: { nombre: 'supervisor' },
|
||||
update: {},
|
||||
create: { id: 9, nombre: 'supervisor', descripcion: 'Supervisor de despacho — titular de RFCs, crea carteras' },
|
||||
});
|
||||
|
||||
await prisma.rol.upsert({
|
||||
where: { nombre: 'cliente' },
|
||||
update: {},
|
||||
create: { id: 10, nombre: 'cliente', descripcion: 'Cliente visor externo — acceso read-only a sus RFCs' },
|
||||
});
|
||||
|
||||
const roles = await prisma.rol.findMany();
|
||||
const rolMap = new Map(roles.map(r => [r.nombre, r.id]));
|
||||
console.log(`✅ ${roles.length} roles cargados`);
|
||||
|
||||
// Create demo users
|
||||
const passwordHash = await bcrypt.hash('demo123', 12);
|
||||
|
||||
const users = [
|
||||
{ email: 'admin@demo.com', nombre: 'Dueño Demo', rolNombre: 'owner' },
|
||||
{ email: 'contador@demo.com', nombre: 'Contador Demo', rolNombre: 'contador' },
|
||||
{ email: 'visor@demo.com', nombre: 'Visor Demo', rolNombre: 'visor' },
|
||||
];
|
||||
|
||||
for (const userData of users) {
|
||||
const rolId = rolMap.get(userData.rolNombre)!;
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email: userData.email },
|
||||
update: {},
|
||||
create: {
|
||||
email: userData.email,
|
||||
passwordHash,
|
||||
nombre: userData.nombre,
|
||||
lastTenantId: tenant.id,
|
||||
},
|
||||
});
|
||||
// Membership al tenant demo (idempotente — F6 multi-tenant: la autorización
|
||||
// vive en tenant_memberships, no en User.tenantId/rolId).
|
||||
await prisma.tenantMembership.upsert({
|
||||
where: { userId_tenantId: { userId: user.id, tenantId: tenant.id } },
|
||||
update: { rolId, isOwner: userData.rolNombre === 'owner' || userData.rolNombre === 'cfo', active: true },
|
||||
create: {
|
||||
userId: user.id,
|
||||
tenantId: tenant.id,
|
||||
rolId,
|
||||
isOwner: userData.rolNombre === 'owner' || userData.rolNombre === 'cfo',
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
console.log(`✅ User created: ${user.email} (${userData.rolNombre})`);
|
||||
}
|
||||
|
||||
// Create tenant database
|
||||
const dbConfig = parseDatabaseUrl(process.env.DATABASE_URL!);
|
||||
const adminPool = new Pool({ ...dbConfig, database: 'postgres', max: 1 });
|
||||
|
||||
try {
|
||||
const exists = await adminPool.query(
|
||||
`SELECT 1 FROM pg_database WHERE datname = $1`,
|
||||
[databaseName]
|
||||
);
|
||||
|
||||
if (exists.rows.length === 0) {
|
||||
await adminPool.query(`CREATE DATABASE "${databaseName}"`);
|
||||
console.log(`✅ Tenant database created: ${databaseName}`);
|
||||
} else {
|
||||
console.log(`ℹ️ Tenant database already exists: ${databaseName}`);
|
||||
}
|
||||
} finally {
|
||||
await adminPool.end();
|
||||
}
|
||||
|
||||
// Create tables in tenant database
|
||||
const tenantPool = new Pool({ ...dbConfig, database: databaseName, max: 1 });
|
||||
|
||||
try {
|
||||
// Reset tenant tables so the re-seed parte de cero. Luego corremos las
|
||||
// migraciones (fuente única de verdad del schema tenant) para garantizar
|
||||
// que queden todas las tablas y columnas actuales.
|
||||
await tenantPool.query(`
|
||||
DROP TABLE IF EXISTS cfdi_conceptos CASCADE;
|
||||
DROP TABLE IF EXISTS cfdis CASCADE;
|
||||
DROP TABLE IF EXISTS conciliaciones CASCADE;
|
||||
DROP TABLE IF EXISTS bancos CASCADE;
|
||||
DROP TABLE IF EXISTS recordatorios CASCADE;
|
||||
DROP TABLE IF EXISTS alertas CASCADE;
|
||||
DROP TABLE IF EXISTS rfcs CASCADE;
|
||||
DROP TABLE IF EXISTS opiniones_cumplimiento CASCADE;
|
||||
DROP TABLE IF EXISTS schema_migrations;
|
||||
`);
|
||||
|
||||
await migrate(tenantPool, tenant.rfc);
|
||||
console.log('✅ Tenant schema aplicado vía migraciones');
|
||||
|
||||
// Bloque legacy de CREATE TABLE / CREATE INDEX retirado: vive ahora en
|
||||
// `apps/api/src/migrations/tenant/*.sql` (fuente única de verdad).
|
||||
|
||||
// Insert demo CFDIs with new structure
|
||||
const cfdiTypes = ['EMITIDO', 'RECIBIDO'];
|
||||
const tipoComprobantes: Record<string, string> = { EMITIDO: 'I', RECIBIDO: 'I' };
|
||||
const rfcs = ['XAXX010101000', 'MEXX020202000', 'AAXX030303000', 'BBXX040404000'];
|
||||
const nombres = ['Cliente Demo SA', 'Proveedor ABC', 'Servicios XYZ', 'Materiales 123'];
|
||||
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const tipo = cfdiTypes[i % 2];
|
||||
const rfcIndex = i % 4;
|
||||
const subtotal = Math.floor(Math.random() * 50000) + 1000;
|
||||
const iva = subtotal * 0.16;
|
||||
const total = subtotal + iva;
|
||||
const daysAgo = Math.floor(Math.random() * 180);
|
||||
const fecha = new Date();
|
||||
fecha.setDate(fecha.getDate() - daysAgo);
|
||||
const year = String(fecha.getFullYear());
|
||||
const month = String(fecha.getMonth() + 1).padStart(2, '0');
|
||||
|
||||
// Sin ON CONFLICT: las tablas se dropean en línea 342-352 antes de seed
|
||||
// y los UUIDs son crypto.randomUUID() (probabilidad de colisión ~0).
|
||||
// El UNIQUE en cfdis es funcional (LOWER(uuid)), no acepta ON CONFLICT
|
||||
// por columna plana — ver migración 027_cfdi_uuid_unique_case_insensitive.
|
||||
await tenantPool.query(`
|
||||
INSERT INTO cfdis (
|
||||
year, month, type, uuid, serie, folio, status, fecha_emision,
|
||||
rfc_emisor, nombre_emisor, rfc_receptor, nombre_receptor,
|
||||
subtotal, subtotal_mxn, descuento, descuento_mxn,
|
||||
total, total_mxn, moneda, tipo_cambio, tipo_comprobante,
|
||||
metodo_pago, iva_traslado, iva_traslado_mxn,
|
||||
regimen_fiscal_emisor, regimen_fiscal_receptor
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26)
|
||||
`, [
|
||||
year, month, tipo, crypto.randomUUID(), 'A', String(1000 + i),
|
||||
'Vigente', fecha,
|
||||
tipo === 'EMITIDO' ? 'EDE123456AB1' : rfcs[rfcIndex],
|
||||
tipo === 'EMITIDO' ? 'Empresa Demo SA de CV' : nombres[rfcIndex],
|
||||
tipo === 'RECIBIDO' ? 'EDE123456AB1' : rfcs[rfcIndex],
|
||||
tipo === 'RECIBIDO' ? 'Empresa Demo SA de CV' : nombres[rfcIndex],
|
||||
subtotal, subtotal, 0, 0,
|
||||
total, total, 'MXN', 1, tipoComprobantes[tipo],
|
||||
'PUE', iva, iva,
|
||||
'601', '601',
|
||||
]);
|
||||
}
|
||||
|
||||
console.log('✅ Demo CFDIs created (50)');
|
||||
|
||||
// Insert demo conceptos for each CFDI
|
||||
const { rows: allCfdis } = await tenantPool.query(`SELECT id FROM cfdis`);
|
||||
const productos = ['Servicio de consultoría', 'Licencia de software', 'Soporte técnico', 'Desarrollo web', 'Capacitación'];
|
||||
|
||||
for (const c of allCfdis) {
|
||||
const numConceptos = Math.floor(Math.random() * 3) + 1;
|
||||
for (let j = 0; j < numConceptos; j++) {
|
||||
const cantidad = Math.floor(Math.random() * 5) + 1;
|
||||
const valorUnitario = Math.floor(Math.random() * 5000) + 500;
|
||||
const importe = cantidad * valorUnitario;
|
||||
const iva = importe * 0.16;
|
||||
|
||||
await tenantPool.query(`
|
||||
INSERT INTO cfdi_conceptos (
|
||||
cfdi_id, clave_prod_serv, descripcion, cantidad, clave_unidad, unidad,
|
||||
valor_unitario, valor_unitario_mxn,
|
||||
importe, importe_mxn, descuento, descuento_mxn,
|
||||
iva_traslado, iva_traslado_mxn
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
|
||||
`, [
|
||||
c.id,
|
||||
'84111506', productos[j % productos.length], cantidad, 'E48', 'Servicio',
|
||||
valorUnitario, valorUnitario,
|
||||
importe, importe, 0, 0,
|
||||
iva, iva,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✅ Demo conceptos created');
|
||||
|
||||
} finally {
|
||||
await tenantPool.end();
|
||||
}
|
||||
|
||||
// (PlanCatalogo seed eliminado — el modelo se dropeó en migración
|
||||
// 20260430200000_drop_plan_catalogo_orphan; el catálogo despacho vive en
|
||||
// `despacho_plan_prices` y se siembra arriba en DESPACHO_PLAN_CATALOGO.)
|
||||
|
||||
// Seed addon catalog
|
||||
const addonCatalogoData = [
|
||||
{
|
||||
codename: 'rfcs_extra_10',
|
||||
nombre: '+10 RFCs adicionales',
|
||||
verticalProfile: 'CONTABLE' as const,
|
||||
precio: 190,
|
||||
frecuencia: 'mensual',
|
||||
delta: { maxRfcs: 10 },
|
||||
},
|
||||
{
|
||||
codename: 'rfcs_extra_50',
|
||||
nombre: '+50 RFCs adicionales',
|
||||
verticalProfile: 'CONTABLE' as const,
|
||||
precio: 690,
|
||||
frecuencia: 'mensual',
|
||||
delta: { maxRfcs: 50 },
|
||||
},
|
||||
{
|
||||
codename: 'timbres_extra_500',
|
||||
nombre: '+500 timbres mensuales',
|
||||
precio: 490,
|
||||
frecuencia: 'mensual',
|
||||
delta: { timbresIncluidosMes: 500 },
|
||||
},
|
||||
{
|
||||
codename: 'modulo_ia',
|
||||
nombre: 'Módulo IA Fiscal',
|
||||
precio: 390,
|
||||
frecuencia: 'mensual',
|
||||
delta: { features: ['ia_lolita'] },
|
||||
},
|
||||
{
|
||||
// Lolita IA activable por contribuyente específico del despacho.
|
||||
// SubscriptionAddon.contribuyenteId apunta al RFC que lo contrata.
|
||||
// Cobro mensual en preapproval propio (la licencia del despacho es anual;
|
||||
// el add-on va en ciclo independiente).
|
||||
codename: 'lolita_ia_contribuyente',
|
||||
nombre: 'Lolita IA (por contribuyente)',
|
||||
verticalProfile: 'CONTABLE' as const,
|
||||
precio: 250,
|
||||
frecuencia: 'mensual',
|
||||
delta: { features: ['ia_lolita'] },
|
||||
},
|
||||
{
|
||||
// Contribuyente adicional para planes Business Control y Enterprise
|
||||
// (ambos incluyen 100 base). Se cobra automáticamente según overage; no
|
||||
// requiere opt-in, pero se modela como add-on para que el preapproval MP
|
||||
// lo cubra. El codename mantiene el sufijo "business_cloud" por compat
|
||||
// con suscripciones existentes; el nombre display ya es genérico.
|
||||
codename: 'contribuyente_extra_business_cloud',
|
||||
nombre: 'Contribuyente adicional (RFC extra)',
|
||||
verticalProfile: 'CONTABLE' as const,
|
||||
precio: 45,
|
||||
frecuencia: 'mensual',
|
||||
delta: { maxRfcs: 1 },
|
||||
},
|
||||
];
|
||||
|
||||
for (const a of addonCatalogoData) {
|
||||
await prisma.planAddonCatalogo.upsert({
|
||||
where: { codename: a.codename },
|
||||
update: { nombre: a.nombre, precio: a.precio, delta: a.delta },
|
||||
create: { ...a, verticalProfile: a.verticalProfile ?? null },
|
||||
});
|
||||
}
|
||||
console.log('✓ Addon catalog seeded (6 addons)');
|
||||
|
||||
console.log('\n🎉 Seed completed successfully!');
|
||||
console.log('\n📝 Demo credentials:');
|
||||
console.log(' Admin: admin@demo.com / demo123');
|
||||
console.log(' Contador: contador@demo.com / demo123');
|
||||
console.log(' Visor: visor@demo.com / demo123');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('Error seeding database:', e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
37
apps/api/scripts/apply-migration-042.ts
Normal file
37
apps/api/scripts/apply-migration-042.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Aplica la migración 042 (ncs_emitidas + ncs_recibidas) a todos los tenants.
|
||||
* Idempotente — ADD COLUMN IF NOT EXISTS no falla si ya existe.
|
||||
*/
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
import { migrate } from '../src/config/tenant-migrations.js';
|
||||
|
||||
async function main() {
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
where: { active: true },
|
||||
select: { id: true, rfc: true, databaseName: true, nombre: true },
|
||||
orderBy: { rfc: 'asc' },
|
||||
});
|
||||
|
||||
console.log(`Aplicando migraciones a ${tenants.length} tenants...\n`);
|
||||
|
||||
let ok = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const t of tenants) {
|
||||
try {
|
||||
const pool = await tenantDb.getPool(t.id, t.databaseName);
|
||||
await migrate(pool);
|
||||
console.log(`✓ ${t.rfc.padEnd(25)} ${t.nombre}`);
|
||||
ok++;
|
||||
} catch (err: any) {
|
||||
console.error(`✗ ${t.rfc.padEnd(25)} ${t.nombre} → ${err.message || err}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nCompletado: ${ok} OK, ${failed} fallidos`);
|
||||
await prisma.$disconnect();
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch(e => { console.error(e); process.exit(1); });
|
||||
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);
|
||||
});
|
||||
78
apps/api/scripts/backfill-pago-fields.ts
Normal file
78
apps/api/scripts/backfill-pago-fields.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Backfill: re-parsea CFDIs tipo P emitidos vía Facturapi (source='facturapi')
|
||||
* que tienen `monto_pago_mxn` o `fecha_pago_p` NULL, y popula esos campos
|
||||
* desde el XML original. Bug histórico — el INSERT de facturapi.controller.ts
|
||||
* no incluía los campos del complemento Pagos hasta el fix de hoy.
|
||||
*
|
||||
* Idempotente — solo actualiza si el XML tiene datos y el row tiene NULL.
|
||||
*/
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
import { parseXml } from '../src/services/sat/sat-parser.service.js';
|
||||
|
||||
async function main() {
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
where: { active: true },
|
||||
select: { id: true, rfc: true, nombre: true, databaseName: true },
|
||||
});
|
||||
|
||||
let totalUpdated = 0;
|
||||
let totalChecked = 0;
|
||||
|
||||
for (const t of tenants) {
|
||||
const pool = await tenantDb.getPool(t.id, t.databaseName);
|
||||
const { rows } = await pool.query(`
|
||||
SELECT uuid, xml_original
|
||||
FROM cfdis
|
||||
WHERE source = 'facturapi'
|
||||
AND tipo_comprobante = 'P'
|
||||
AND xml_original IS NOT NULL
|
||||
AND (monto_pago_mxn IS NULL OR fecha_pago_p IS NULL)
|
||||
`);
|
||||
|
||||
if (rows.length === 0) continue;
|
||||
console.log(`\n>>> ${t.rfc} (${t.nombre}): ${rows.length} P por backfill`);
|
||||
|
||||
for (const r of rows) {
|
||||
totalChecked++;
|
||||
const parsed = parseXml(r.xml_original, 'emitidos');
|
||||
if (!parsed || parsed.tipoComprobante !== 'P') continue;
|
||||
|
||||
const fechaPagoP = parsed.fechaPagoP
|
||||
? new Date(String(parsed.fechaPagoP).split('|')[0])
|
||||
: null;
|
||||
|
||||
if (!parsed.montoPago && !fechaPagoP) {
|
||||
console.log(` ${r.uuid}: XML sin datos de Pago — skip`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await pool.query(`
|
||||
UPDATE cfdis SET
|
||||
monto_pago = COALESCE(monto_pago, $1),
|
||||
monto_pago_mxn = COALESCE(monto_pago_mxn, $1),
|
||||
fecha_pago_p = COALESCE(fecha_pago_p, $2),
|
||||
iva_traslado_pago = COALESCE(iva_traslado_pago, $3),
|
||||
iva_traslado_pago_mxn = COALESCE(iva_traslado_pago_mxn, $3),
|
||||
iva_retencion_pago = COALESCE(iva_retencion_pago, $4),
|
||||
iva_retencion_pago_mxn = COALESCE(iva_retencion_pago_mxn, $4),
|
||||
ieps_traslado_pago = COALESCE(ieps_traslado_pago, $5),
|
||||
ieps_traslado_pago_mxn = COALESCE(ieps_traslado_pago_mxn, $5)
|
||||
WHERE uuid = $6
|
||||
`, [
|
||||
parsed.montoPago || 0,
|
||||
fechaPagoP,
|
||||
parsed.ivaTrasladoPago || 0,
|
||||
parsed.ivaRetencionPago || 0,
|
||||
parsed.iepsTrasladoPago || 0,
|
||||
r.uuid,
|
||||
]);
|
||||
totalUpdated++;
|
||||
console.log(` ${r.uuid}: ✓ monto=$${parsed.montoPago} fecha_pago=${fechaPagoP?.toISOString().slice(0, 10)} iva=$${parsed.ivaTrasladoPago}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n[Backfill] Completado: ${totalUpdated}/${totalChecked} actualizadas`);
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(e => { console.error(e); process.exit(1); });
|
||||
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);
|
||||
});
|
||||
131
apps/api/scripts/bootstrap-horux360-admin.ts
Normal file
131
apps/api/scripts/bootstrap-horux360-admin.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Bootstrap del tenant admin global (Horux 360 — HTS240708LJA) + usuarios staff.
|
||||
*
|
||||
* Crea:
|
||||
* 1. Tenant Horux 360 (RFC HTS240708LJA, plan enterprise)
|
||||
* 2. Carlos como owner del tenant + rol platform_admin
|
||||
* 3. Ivan como contador del tenant + rol platform_ti (TI superset)
|
||||
* 4. Suscripción authorized por 1 año
|
||||
*
|
||||
* Uso: `pnpm bootstrap:admin-global`
|
||||
*
|
||||
* Idempotente-ish: falla limpio si el tenant ya existe (RFC unique).
|
||||
* Para re-ejecutar, borra el tenant y su BD manualmente antes.
|
||||
*
|
||||
* Requisitos previos:
|
||||
* 1. `pnpm prisma migrate deploy` (schema central)
|
||||
* 2. `pnpm db:seed` (catálogos SAT, regímenes, ISR, eventos fiscales, roles)
|
||||
*
|
||||
* Env vars opcionales (con defaults):
|
||||
* HORUX_ADMIN_EMAIL (default: carlos@horuxfin.com)
|
||||
* HORUX_ADMIN_NOMBRE (default: Carlos)
|
||||
* HORUX_TI_EMAIL (default: ivan@horuxfin.com)
|
||||
* HORUX_TI_NOMBRE (default: Ivan)
|
||||
*/
|
||||
import { prisma } from '../src/config/database.js';
|
||||
import * as tenantsService from '../src/services/tenants.service.js';
|
||||
import * as usuariosService from '../src/services/usuarios.service.js';
|
||||
|
||||
const RFC = 'HTS240708LJA';
|
||||
const TENANT_NAME = 'Horux 360';
|
||||
const PLAN = 'custom' as const;
|
||||
const SUBSCRIPTION_YEARS = 1;
|
||||
|
||||
async function main() {
|
||||
const adminEmail = process.env.HORUX_ADMIN_EMAIL || 'carlos@horuxfin.com';
|
||||
const adminNombre = process.env.HORUX_ADMIN_NOMBRE || 'Carlos';
|
||||
const tiEmail = process.env.HORUX_TI_EMAIL || 'ivan@horuxfin.com';
|
||||
const tiNombre = process.env.HORUX_TI_NOMBRE || 'Ivan';
|
||||
|
||||
console.log(`Bootstrap del tenant admin global`);
|
||||
console.log(` RFC: ${RFC}`);
|
||||
console.log(` Nombre: ${TENANT_NAME}`);
|
||||
console.log(` Admin: ${adminNombre} <${adminEmail}> (platform_admin)`);
|
||||
console.log(` TI: ${tiNombre} <${tiEmail}> (platform_ti)`);
|
||||
console.log(` Plan: ${PLAN} (sin cobro — admin global)`);
|
||||
console.log('');
|
||||
|
||||
// 1. Crea tenant + BD provisionada + Carlos como owner + subscription pending
|
||||
const { tenant, user: carlosUser, tempPassword: carlosPassword } = await tenantsService.createTenant({
|
||||
nombre: TENANT_NAME,
|
||||
rfc: RFC,
|
||||
plan: PLAN,
|
||||
adminEmail,
|
||||
adminNombre,
|
||||
amount: 0,
|
||||
});
|
||||
|
||||
console.log(`✓ Tenant creado: ${tenant.id}`);
|
||||
console.log(`✓ BD provisionada: ${tenant.databaseName}`);
|
||||
console.log(`✓ Carlos creado (owner): ${carlosUser.email}`);
|
||||
|
||||
// 2. Asigna platform_admin a Carlos (no se hace automáticamente desde tenants.service)
|
||||
const carlosFull = await prisma.user.findUnique({ where: { email: adminEmail } });
|
||||
if (carlosFull) {
|
||||
await prisma.userPlatformRole.upsert({
|
||||
where: { userId_role: { userId: carlosFull.id, role: 'platform_admin' } },
|
||||
update: {},
|
||||
create: { userId: carlosFull.id, role: 'platform_admin' },
|
||||
});
|
||||
console.log(`✓ Carlos: rol platform_admin asignado`);
|
||||
}
|
||||
|
||||
// 3. Crea Ivan como contador del tenant (membership) y le asigna platform_ti
|
||||
const ivan = await usuariosService.inviteUsuario(tenant.id, {
|
||||
email: tiEmail,
|
||||
nombre: tiNombre,
|
||||
role: 'contador',
|
||||
});
|
||||
console.log(`✓ Ivan creado: ${ivan.email} (membership contador)`);
|
||||
|
||||
await prisma.userPlatformRole.upsert({
|
||||
where: { userId_role: { userId: ivan.id, role: 'platform_ti' } },
|
||||
update: {},
|
||||
create: { userId: ivan.id, role: 'platform_ti' },
|
||||
});
|
||||
console.log(`✓ Ivan: rol platform_ti asignado (superset, mismos permisos que admin)`);
|
||||
|
||||
// 4. Sube la subscription a 'authorized' con vigencia de 1 año
|
||||
const existing = await prisma.subscription.findFirst({
|
||||
where: { tenantId: tenant.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
if (existing) {
|
||||
const now = new Date();
|
||||
const end = new Date(now);
|
||||
end.setFullYear(end.getFullYear() + SUBSCRIPTION_YEARS);
|
||||
|
||||
await prisma.subscription.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
status: 'authorized',
|
||||
currentPeriodStart: now,
|
||||
currentPeriodEnd: end,
|
||||
},
|
||||
});
|
||||
console.log(`✓ Suscripción marcada 'authorized' hasta ${end.toISOString().slice(0, 10)}`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log('=== DONE ===');
|
||||
console.log(`Credenciales temporales para primer login:`);
|
||||
console.log(` Carlos (admin): ${adminEmail}`);
|
||||
console.log(` Password: ${carlosPassword}`);
|
||||
console.log('');
|
||||
console.log(` Ivan (TI): ${tiEmail}`);
|
||||
console.log(` Password: revisa el correo de bienvenida (inviteUsuario lo envía por email)`);
|
||||
console.log('');
|
||||
console.log('Próximos pasos manuales:');
|
||||
console.log(` 1. Carlos login en /login con las credenciales de arriba`);
|
||||
console.log(` 2. Cambiar el password desde /configuracion/seguridad`);
|
||||
console.log(` 3. Verificar que Ivan recibió su correo de invitación`);
|
||||
console.log(` 4. Subir FIEL en /configuracion/sat para habilitar sincronización`);
|
||||
console.log(` 5. (Opcional) Configurar organización Facturapi en /configuracion`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error('✗ Bootstrap falló:', err.message || err);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
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); });
|
||||
103
apps/api/scripts/debug-cfdi-activos.ts
Normal file
103
apps/api/scripts/debug-cfdi-activos.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Inspecciona un CFDI específico para entender por qué el filtro de
|
||||
* "Considerar activos" no lo captura. Imprime los campos relevantes y
|
||||
* cualquier CFDI relacionado.
|
||||
*/
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
async function main() {
|
||||
const uuid = process.argv[2] || '8ec2eaf3-7879-11f0-81a8-8daae9822b10';
|
||||
|
||||
// Buscar en TODOS los tenants
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
where: { active: true },
|
||||
select: { id: true, rfc: true, nombre: true, databaseName: true },
|
||||
});
|
||||
|
||||
for (const t of tenants) {
|
||||
const pool = await tenantDb.getPool(t.id, t.databaseName);
|
||||
const { rows } = await pool.query(`
|
||||
SELECT
|
||||
uuid, type, tipo_comprobante, metodo_pago, forma_pago,
|
||||
uso_cfdi, cfdi_tipo_relacion,
|
||||
rfc_emisor, nombre_emisor, regimen_fiscal_emisor,
|
||||
rfc_receptor, nombre_receptor, regimen_fiscal_receptor,
|
||||
total_mxn, monto_pago_mxn,
|
||||
fecha_emision, fecha_pago_p, status,
|
||||
uuid_relacionado, cfdis_relacionados
|
||||
FROM cfdis
|
||||
WHERE uuid = $1
|
||||
`, [uuid]);
|
||||
|
||||
if (rows.length === 0) continue;
|
||||
|
||||
console.log(`\n═══ Tenant: ${t.rfc} (${t.nombre}) ═══`);
|
||||
const r = rows[0];
|
||||
for (const [k, v] of Object.entries(r)) {
|
||||
console.log(` ${k.padEnd(28)} ${v}`);
|
||||
}
|
||||
|
||||
// Si hay uuid_relacionado o cfdis_relacionados, traer esos también
|
||||
if (r.uuid_relacionado) {
|
||||
const { rows: rel } = await pool.query(
|
||||
`SELECT uuid, tipo_comprobante, uso_cfdi, total_mxn FROM cfdis WHERE LOWER(uuid) = LOWER($1)`,
|
||||
[r.uuid_relacionado],
|
||||
);
|
||||
console.log(`\n Relacionado vía uuid_relacionado (${r.uuid_relacionado}):`);
|
||||
console.log(rel[0] || '(no encontrado)');
|
||||
}
|
||||
|
||||
if (r.cfdis_relacionados) {
|
||||
const uuids = String(r.cfdis_relacionados).split('|').map(s => s.trim()).filter(Boolean);
|
||||
console.log(`\n Relacionados vía cfdis_relacionados (${uuids.length}):`);
|
||||
for (const u of uuids) {
|
||||
const { rows: rel } = await pool.query(
|
||||
`SELECT uuid, tipo_comprobante, uso_cfdi, total_mxn FROM cfdis WHERE LOWER(uuid) = LOWER($1)`,
|
||||
[u],
|
||||
);
|
||||
console.log(` ${u} →`, rel[0] || '(no encontrado)');
|
||||
}
|
||||
}
|
||||
|
||||
// Test del filtro: aplica activosExclusionNoAlias y verifica
|
||||
const ACTIVOS_USOS = "('I01','I02','I03','I04','I05','I06','I07','I08')";
|
||||
const test = await pool.query(`
|
||||
SELECT
|
||||
(tipo_comprobante = 'I' AND uso_cfdi IN ${ACTIVOS_USOS}) AS regla1_directo,
|
||||
(tipo_comprobante = 'P' AND EXISTS (
|
||||
SELECT 1 FROM cfdis i_act
|
||||
WHERE LOWER(i_act.uuid) = LOWER(cfdis.uuid_relacionado)
|
||||
AND i_act.tipo_comprobante = 'I'
|
||||
AND i_act.uso_cfdi IN ${ACTIVOS_USOS}
|
||||
)) AS regla2_p_paga_activo,
|
||||
(tipo_comprobante = 'E' AND cfdis.cfdis_relacionados IS NOT NULL AND EXISTS (
|
||||
SELECT 1 FROM cfdis r_act
|
||||
WHERE LOWER(r_act.uuid) = ANY(string_to_array(LOWER(cfdis.cfdis_relacionados), '|'))
|
||||
AND (r_act.tipo_comprobante = 'I' AND r_act.uso_cfdi IN ${ACTIVOS_USOS})
|
||||
)) AS regla3_e_referencia_activo,
|
||||
(tipo_comprobante = 'I' AND EXISTS (
|
||||
SELECT 1 FROM cfdis i07_act
|
||||
WHERE i07_act.tipo_comprobante = 'I'
|
||||
AND i07_act.metodo_pago = 'PPD'
|
||||
AND COALESCE(i07_act.cfdi_tipo_relacion, '') = '07'
|
||||
AND i07_act.uso_cfdi IN ${ACTIVOS_USOS}
|
||||
AND i07_act.status NOT IN ('Cancelado', '0')
|
||||
AND i07_act.cfdis_relacionados IS NOT NULL
|
||||
AND LOWER(cfdis.uuid) = ANY(string_to_array(LOWER(i07_act.cfdis_relacionados), '|'))
|
||||
)) AS regla4_anticipo_activo
|
||||
FROM cfdis
|
||||
WHERE uuid = $1
|
||||
`, [uuid]);
|
||||
console.log(`\n Filtro activos:`);
|
||||
console.log(` regla1 (I directo activo): ${test.rows[0].regla1_directo}`);
|
||||
console.log(` regla2 (P paga I activo): ${test.rows[0].regla2_p_paga_activo}`);
|
||||
console.log(` regla3 (E ref. I/P activo): ${test.rows[0].regla3_e_referencia_activo}`);
|
||||
console.log(` regla4 (anticipo de I/07 act): ${test.rows[0].regla4_anticipo_activo}`);
|
||||
const filtrado = test.rows[0].regla1_directo || test.rows[0].regla2_p_paga_activo || test.rows[0].regla3_e_referencia_activo || test.rows[0].regla4_anticipo_activo;
|
||||
console.log(` → ${filtrado ? '🔴 FILTRADO (excluido del cálculo)' : '🟢 PASA (incluido en cálculo)'}`);
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(e => { console.error(e); process.exit(1); });
|
||||
154
apps/api/scripts/debug-compensacion-cfdi.ts
Normal file
154
apps/api/scripts/debug-compensacion-cfdi.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Diseca cómo el CFDI 8ec2eaf3-7879-11f0-81a8-8daae9822b10 (P de pago $295,100,
|
||||
* uuid_relacionado → I de activo I03) se compensa en el cálculo de deducciones
|
||||
* de Husberto en agosto 2025, con considerarActivos=true vs false.
|
||||
*
|
||||
* Reproduce las queries reales de calcularEgresosPorRegimen para mostrar
|
||||
* el aporte categoría por categoría.
|
||||
*/
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
async function main() {
|
||||
const t = await prisma.tenant.findFirst({ where: { rfc: 'DESPACHO_MO3NI6U8_B9VGG' } });
|
||||
if (!t) { console.log('Patito tenant not found'); return; }
|
||||
const pool = await tenantDb.getPool(t.id, t.databaseName);
|
||||
|
||||
const RFC = 'TOAH680201RA2';
|
||||
const FI = '2025-08-01';
|
||||
const FF = '2025-08-31';
|
||||
const TARGET_UUID = '8ec2eaf3-7879-11f0-81a8-8daae9822b10';
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 0) Datos del CFDI target
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
const { rows: [cfdi] } = await pool.query(`
|
||||
SELECT uuid, tipo_comprobante, metodo_pago, forma_pago, uso_cfdi,
|
||||
total_mxn, monto_pago_mxn, iva_traslado_pago_mxn, ieps_traslado_pago_mxn,
|
||||
uuid_relacionado, regimen_fiscal_receptor, fecha_pago_p
|
||||
FROM cfdis WHERE uuid = $1
|
||||
`, [TARGET_UUID]);
|
||||
|
||||
console.log('═══ CFDI target ═══');
|
||||
console.log(` UUID: ${cfdi.uuid}`);
|
||||
console.log(` Tipo: ${cfdi.tipo_comprobante} (${cfdi.uso_cfdi || '?'})`);
|
||||
console.log(` monto_pago_mxn: $${cfdi.monto_pago_mxn}`);
|
||||
console.log(` iva_traslado_pago: $${cfdi.iva_traslado_pago_mxn ?? 'null'}`);
|
||||
console.log(` ieps_traslado_pago: $${cfdi.ieps_traslado_pago_mxn ?? 'null'}`);
|
||||
console.log(` forma_pago: ${cfdi.forma_pago ?? 'NULL'}`);
|
||||
console.log(` uuid_relacionado: ${cfdi.uuid_relacionado}`);
|
||||
console.log(` fecha_pago_p: ${cfdi.fecha_pago_p}`);
|
||||
|
||||
// Net pagado según fórmula de deducciones (P)
|
||||
const monto = Number(cfdi.monto_pago_mxn || 0);
|
||||
const ivaPago = Number(cfdi.iva_traslado_pago_mxn || 0);
|
||||
const iepsPago = Number(cfdi.ieps_traslado_pago_mxn || 0);
|
||||
const ivaClamped = Math.min(ivaPago, monto * 0.16);
|
||||
const netoP = monto - ivaClamped - iepsPago;
|
||||
console.log(`\n → Aporte neto a deducciones (formula P): $${netoP.toFixed(2)}`);
|
||||
console.log(` monto - LEAST(iva, monto*0.16) - ieps = ${monto} - ${ivaClamped.toFixed(2)} - ${iepsPago}`);
|
||||
|
||||
// CFDI relacionado
|
||||
const { rows: [rel] } = await pool.query(`
|
||||
SELECT uuid, tipo_comprobante, metodo_pago, uso_cfdi, total_mxn, cfdi_tipo_relacion
|
||||
FROM cfdis WHERE LOWER(uuid) = LOWER($1)
|
||||
`, [cfdi.uuid_relacionado]);
|
||||
if (rel) {
|
||||
console.log(`\n uuid_relacionado apunta a:`);
|
||||
console.log(` ${rel.uuid} | ${rel.tipo_comprobante} ${rel.metodo_pago} | uso_cfdi=${rel.uso_cfdi} | total=$${rel.total_mxn}`);
|
||||
console.log(` cfdi_tipo_relacion: ${rel.cfdi_tipo_relacion ?? 'null'}`);
|
||||
console.log(` ¿es activo? uso_cfdi=${rel.uso_cfdi} → ${['I01','I02','I03','I04','I05','I06','I07','I08'].includes(rel.uso_cfdi) ? '🔴 SÍ' : '🟢 NO'}`);
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 1) Predicado de filtros
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
console.log('\n═══ Evaluación de predicados sobre el CFDI target ═══');
|
||||
|
||||
const ACTIVOS = "('I01','I02','I03','I04','I05','I06','I07','I08')";
|
||||
const t1 = await pool.query(`
|
||||
SELECT
|
||||
(COALESCE(forma_pago, '') = '01' AND COALESCE(monto_pago_mxn, 0) > 2000) AS no_deducible_efectivo,
|
||||
(tipo_comprobante = 'P' AND EXISTS (
|
||||
SELECT 1 FROM cfdis i_act
|
||||
WHERE LOWER(i_act.uuid) = LOWER(cfdis.uuid_relacionado)
|
||||
AND i_act.tipo_comprobante = 'I'
|
||||
AND i_act.uso_cfdi IN ${ACTIVOS}
|
||||
)) AS p_paga_activo
|
||||
FROM cfdis WHERE uuid = $1
|
||||
`, [TARGET_UUID]);
|
||||
console.log(` no_deducible_efectivo (forma_pago=01 AND >2k): ${t1.rows[0].no_deducible_efectivo ? '🔴 TRUE' : '🟢 FALSE'}`);
|
||||
console.log(` p_paga_activo (regla activos): ${t1.rows[0].p_paga_activo ? '🔴 TRUE' : '🟢 FALSE'}`);
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 2) Total de deducciones de Husberto en agosto, con/sin filtro de activos
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
console.log('\n═══ Suma TOTAL de deducciones (régimen 612 — Husberto, agosto 2025) ═══');
|
||||
|
||||
const sumar = async (extraSQL: string) => {
|
||||
// I PUE
|
||||
const { rows: [iPUE] } = await pool.query(`
|
||||
SELECT COALESCE(SUM(COALESCE(total_mxn,0) - COALESCE(iva_traslado_mxn,0) - COALESCE(ieps_traslado_mxn,0) - COALESCE(impuestos_locales_trasladado_mxn,0)),0)::numeric(14,2) as monto
|
||||
FROM cfdis
|
||||
WHERE UPPER(rfc_receptor) = $1 AND tipo_comprobante = 'I' AND metodo_pago = 'PUE'
|
||||
AND status NOT IN ('Cancelado','0')
|
||||
AND fecha_emision >= $2::date AND fecha_emision < ($3::date + interval '1 day')
|
||||
AND NOT (COALESCE(forma_pago,'') = '01' AND COALESCE(total_mxn,0) > 2000)
|
||||
${extraSQL}
|
||||
`, [RFC, FI, FF]);
|
||||
// P
|
||||
const { rows: [pCfdis] } = await pool.query(`
|
||||
SELECT COALESCE(SUM(COALESCE(monto_pago_mxn,0) - LEAST(COALESCE(iva_traslado_pago_mxn,0), COALESCE(monto_pago_mxn,0)*0.16) - COALESCE(ieps_traslado_pago_mxn,0)),0)::numeric(14,2) as monto
|
||||
FROM cfdis
|
||||
WHERE UPPER(rfc_receptor) = $1 AND tipo_comprobante = 'P'
|
||||
AND status NOT IN ('Cancelado','0')
|
||||
AND fecha_pago_p >= $2::date AND fecha_pago_p < ($3::date + interval '1 day')
|
||||
AND NOT (COALESCE(forma_pago,'') = '01' AND COALESCE(monto_pago_mxn,0) > 2000)
|
||||
${extraSQL}
|
||||
`, [RFC, FI, FF]);
|
||||
return { iPUE: Number(iPUE.monto), p: Number(pCfdis.monto) };
|
||||
};
|
||||
|
||||
const ACTIVOS_FILTER = `
|
||||
AND NOT (tipo_comprobante = 'I' AND uso_cfdi IN ${ACTIVOS})
|
||||
AND NOT (tipo_comprobante = 'P' AND EXISTS (
|
||||
SELECT 1 FROM cfdis i_act
|
||||
WHERE LOWER(i_act.uuid) = LOWER(cfdis.uuid_relacionado)
|
||||
AND i_act.tipo_comprobante = 'I'
|
||||
AND i_act.uso_cfdi IN ${ACTIVOS}
|
||||
))
|
||||
AND NOT (tipo_comprobante = 'E' AND cfdis.cfdis_relacionados IS NOT NULL AND EXISTS (
|
||||
SELECT 1 FROM cfdis r_act
|
||||
WHERE LOWER(r_act.uuid) = ANY(string_to_array(LOWER(cfdis.cfdis_relacionados), '|'))
|
||||
AND (
|
||||
(r_act.tipo_comprobante = 'I' AND r_act.uso_cfdi IN ${ACTIVOS})
|
||||
OR (r_act.tipo_comprobante = 'P' AND EXISTS (
|
||||
SELECT 1 FROM cfdis pi_act
|
||||
WHERE LOWER(pi_act.uuid) = LOWER(r_act.uuid_relacionado)
|
||||
AND pi_act.tipo_comprobante = 'I'
|
||||
AND pi_act.uso_cfdi IN ${ACTIVOS}
|
||||
))
|
||||
)
|
||||
))
|
||||
`;
|
||||
|
||||
const ON = await sumar('');
|
||||
const OFF = await sumar(ACTIVOS_FILTER);
|
||||
|
||||
console.log(`\n┌──────────────────┬────────────────┬────────────────┬────────────────┐`);
|
||||
console.log(`│ Categoría │ Activos ON │ Activos OFF │ Diferencia │`);
|
||||
console.log(`├──────────────────┼────────────────┼────────────────┼────────────────┤`);
|
||||
console.log(`│ I PUE recibidas │ $${String(ON.iPUE.toFixed(2)).padStart(13)} │ $${String(OFF.iPUE.toFixed(2)).padStart(13)} │ $${String((ON.iPUE-OFF.iPUE).toFixed(2)).padStart(13)} │`);
|
||||
console.log(`│ P recibidos │ $${String(ON.p.toFixed(2)).padStart(13)} │ $${String(OFF.p.toFixed(2)).padStart(13)} │ $${String((ON.p-OFF.p).toFixed(2)).padStart(13)} │`);
|
||||
console.log(`├──────────────────┼────────────────┼────────────────┼────────────────┤`);
|
||||
const totON = ON.iPUE + ON.p;
|
||||
const totOFF = OFF.iPUE + OFF.p;
|
||||
console.log(`│ TOTAL deducción │ $${String(totON.toFixed(2)).padStart(13)} │ $${String(totOFF.toFixed(2)).padStart(13)} │ $${String((totON-totOFF).toFixed(2)).padStart(13)} │`);
|
||||
console.log(`└──────────────────┴────────────────┴────────────────┴────────────────┘`);
|
||||
|
||||
console.log(`\n→ El CFDI ${TARGET_UUID.slice(0,8)} aporta $${netoP.toFixed(2)} a "P recibidos" cuando ON, $0 cuando OFF`);
|
||||
console.log(` (Su exclusión por activos representa el ${((netoP / (ON.p - OFF.p)) * 100).toFixed(0)}% de la diferencia en P recibidos)`);
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(e => { console.error(e); process.exit(1); });
|
||||
111
apps/api/scripts/debug-deducciones-husberto.ts
Normal file
111
apps/api/scripts/debug-deducciones-husberto.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Reproduce el cálculo de deducciones para Husberto en agosto 2025 con
|
||||
* considerarActivos=true vs false, y muestra la diferencia esperada.
|
||||
* Apunta directo al SQL para descartar bugs de wire/cache/UI.
|
||||
*/
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
async function main() {
|
||||
const t = await prisma.tenant.findFirst({ where: { rfc: 'DESPACHO_MO3NI6U8_B9VGG' } });
|
||||
if (!t) { console.log('Patito tenant not found'); return; }
|
||||
const pool = await tenantDb.getPool(t.id, t.databaseName);
|
||||
|
||||
const RFC = 'TOAH680201RA2';
|
||||
const FI = '2025-08-01';
|
||||
const FF = '2025-08-31';
|
||||
|
||||
console.log(`Husberto (${RFC}), agosto 2025\n`);
|
||||
|
||||
// 0) Lista TODOS los P recibidos en el período (sin filtros)
|
||||
const all = await pool.query(`
|
||||
SELECT uuid, monto_pago_mxn, forma_pago, fecha_pago_p, uuid_relacionado
|
||||
FROM cfdis
|
||||
WHERE UPPER(rfc_receptor) = $1
|
||||
AND tipo_comprobante = 'P'
|
||||
AND status NOT IN ('Cancelado','0')
|
||||
AND fecha_pago_p >= $2::date AND fecha_pago_p < ($3::date + interval '1 day')
|
||||
ORDER BY monto_pago_mxn DESC
|
||||
`, [RFC, FI, FF]);
|
||||
console.log(`Total P recibidos en agosto 2025 (sin filtros): ${all.rows.length}`);
|
||||
for (const r of all.rows) {
|
||||
console.log(` ${r.uuid} | $${r.monto_pago_mxn} | forma_pago=${r.forma_pago} | uuid_rel=${r.uuid_relacionado}`);
|
||||
}
|
||||
console.log();
|
||||
|
||||
// 1) Suma de P recibidos sin filtro extra
|
||||
const sinFiltro = await pool.query(`
|
||||
SELECT COUNT(*)::int as n,
|
||||
COALESCE(SUM(COALESCE(monto_pago_mxn,0)),0)::numeric(14,2) as bruto,
|
||||
COALESCE(SUM(COALESCE(monto_pago_mxn,0) - LEAST(COALESCE(iva_traslado_pago_mxn,0), COALESCE(monto_pago_mxn,0)*0.16) - COALESCE(ieps_traslado_pago_mxn,0)),0)::numeric(14,2) as neto
|
||||
FROM cfdis
|
||||
WHERE UPPER(rfc_receptor) = $1
|
||||
AND tipo_comprobante = 'P'
|
||||
AND status NOT IN ('Cancelado','0')
|
||||
AND fecha_pago_p >= $2::date AND fecha_pago_p < ($3::date + interval '1 day')
|
||||
AND NOT (COALESCE(forma_pago, '') = '01' AND COALESCE(monto_pago_mxn, 0) > 2000)
|
||||
`, [RFC, FI, FF]);
|
||||
console.log(`P recibidos SIN filtro activos (CON filtro no-deducible): n=${sinFiltro.rows[0].n}, bruto=$${sinFiltro.rows[0].bruto}, neto=$${sinFiltro.rows[0].neto}`);
|
||||
|
||||
// 2) Misma query CON el filtro de activos (regla 2: P paga I de activo)
|
||||
const ACTIVOS = "('I01','I02','I03','I04','I05','I06','I07','I08')";
|
||||
const conFiltro = await pool.query(`
|
||||
SELECT COUNT(*)::int as n,
|
||||
COALESCE(SUM(COALESCE(monto_pago_mxn,0)),0)::numeric(14,2) as bruto,
|
||||
COALESCE(SUM(COALESCE(monto_pago_mxn,0) - LEAST(COALESCE(iva_traslado_pago_mxn,0), COALESCE(monto_pago_mxn,0)*0.16) - COALESCE(ieps_traslado_pago_mxn,0)),0)::numeric(14,2) as neto
|
||||
FROM cfdis
|
||||
WHERE UPPER(rfc_receptor) = $1
|
||||
AND tipo_comprobante = 'P'
|
||||
AND status NOT IN ('Cancelado','0')
|
||||
AND fecha_pago_p >= $2::date AND fecha_pago_p < ($3::date + interval '1 day')
|
||||
AND NOT (COALESCE(forma_pago, '') = '01' AND COALESCE(monto_pago_mxn, 0) > 2000)
|
||||
AND NOT (tipo_comprobante = 'I' AND uso_cfdi IN ${ACTIVOS})
|
||||
AND NOT (tipo_comprobante = 'P' AND EXISTS (
|
||||
SELECT 1 FROM cfdis i_act
|
||||
WHERE LOWER(i_act.uuid) = LOWER(cfdis.uuid_relacionado)
|
||||
AND i_act.tipo_comprobante = 'I'
|
||||
AND i_act.uso_cfdi IN ${ACTIVOS}
|
||||
))
|
||||
AND NOT (tipo_comprobante = 'E' AND cfdis.cfdis_relacionados IS NOT NULL AND EXISTS (
|
||||
SELECT 1 FROM cfdis r_act
|
||||
WHERE LOWER(r_act.uuid) = ANY(string_to_array(LOWER(cfdis.cfdis_relacionados), '|'))
|
||||
AND (
|
||||
(r_act.tipo_comprobante = 'I' AND r_act.uso_cfdi IN ${ACTIVOS})
|
||||
OR (r_act.tipo_comprobante = 'P' AND EXISTS (
|
||||
SELECT 1 FROM cfdis pi_act
|
||||
WHERE LOWER(pi_act.uuid) = LOWER(r_act.uuid_relacionado)
|
||||
AND pi_act.tipo_comprobante = 'I'
|
||||
AND pi_act.uso_cfdi IN ${ACTIVOS}
|
||||
))
|
||||
)
|
||||
))
|
||||
`, [RFC, FI, FF]);
|
||||
console.log(`P recibidos CON filtro activos: n=${conFiltro.rows[0].n}, bruto=$${conFiltro.rows[0].bruto}, neto=$${conFiltro.rows[0].neto}`);
|
||||
|
||||
console.log(`\n→ Diferencia esperada al desactivar Considerar Activos:`);
|
||||
console.log(` Bruto: $${(Number(sinFiltro.rows[0].bruto) - Number(conFiltro.rows[0].bruto)).toLocaleString('es-MX')}`);
|
||||
console.log(` Neto: $${(Number(sinFiltro.rows[0].neto) - Number(conFiltro.rows[0].neto)).toLocaleString('es-MX')}`);
|
||||
|
||||
// 3) Lista los P específicos que se filtran
|
||||
console.log(`\nDetalle de P que SE FILTRAN al desactivar activos:`);
|
||||
const filtrados = await pool.query(`
|
||||
SELECT uuid, monto_pago_mxn, iva_traslado_pago_mxn, uuid_relacionado, fecha_pago_p
|
||||
FROM cfdis
|
||||
WHERE UPPER(rfc_receptor) = $1
|
||||
AND tipo_comprobante = 'P'
|
||||
AND status NOT IN ('Cancelado','0')
|
||||
AND fecha_pago_p >= $2::date AND fecha_pago_p < ($3::date + interval '1 day')
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM cfdis i_act
|
||||
WHERE LOWER(i_act.uuid) = LOWER(cfdis.uuid_relacionado)
|
||||
AND i_act.tipo_comprobante = 'I'
|
||||
AND i_act.uso_cfdi IN ${ACTIVOS}
|
||||
)
|
||||
`, [RFC, FI, FF]);
|
||||
for (const r of filtrados.rows) {
|
||||
console.log(` ${r.uuid} | $${r.monto_pago_mxn} → uuid_rel: ${r.uuid_relacionado}`);
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(e => { console.error(e); process.exit(1); });
|
||||
71
apps/api/scripts/debug-drill-buckets.ts
Normal file
71
apps/api/scripts/debug-drill-buckets.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Ejecuta los 3 nuevos buckets de drill-down (ncs_emitidas, ncs_recibidas,
|
||||
* no_deducibles_efectivo) directamente contra una BD tenant para verificar
|
||||
* que cada uno produce resultados distintos. Sirve para descartar hipótesis
|
||||
* de bug en frontend / cache / dev server stale.
|
||||
*/
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
async function main() {
|
||||
const rfc = process.argv[2] || 'DESPACHO_MO7JE8BZ_VDOPR';
|
||||
const fi = process.argv[3] || '2025-08-01';
|
||||
const ff = process.argv[4] || '2025-08-31';
|
||||
|
||||
const t = await prisma.tenant.findFirst({ where: { rfc } });
|
||||
if (!t) { console.log('Tenant', rfc, 'no encontrado'); return; }
|
||||
const pool = await tenantDb.getPool(t.id, t.databaseName);
|
||||
|
||||
console.log(`Tenant: ${rfc} — Período: ${fi} → ${ff}\n`);
|
||||
|
||||
const buckets = [
|
||||
{
|
||||
name: 'ncs_emitidas',
|
||||
sql: `
|
||||
SELECT COUNT(*)::int as n, COALESCE(SUM(total_mxn),0)::numeric(14,2) as total
|
||||
FROM cfdis
|
||||
WHERE type = 'EMITIDO'
|
||||
AND tipo_comprobante = 'E' AND metodo_pago = 'PUE'
|
||||
AND status NOT IN ('Cancelado','0')
|
||||
AND fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')
|
||||
AND regimen_fiscal_emisor IS NOT NULL
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'ncs_recibidas',
|
||||
sql: `
|
||||
SELECT COUNT(*)::int as n, COALESCE(SUM(total_mxn),0)::numeric(14,2) as total
|
||||
FROM cfdis
|
||||
WHERE type = 'RECIBIDO'
|
||||
AND tipo_comprobante = 'E' AND metodo_pago = 'PUE'
|
||||
AND status NOT IN ('Cancelado','0')
|
||||
AND fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')
|
||||
AND regimen_fiscal_receptor IS NOT NULL
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'no_deducibles_efectivo',
|
||||
sql: `
|
||||
SELECT COUNT(*)::int as n, COALESCE(SUM(total_mxn),0)::numeric(14,2) as total
|
||||
FROM cfdis
|
||||
WHERE type = 'RECIBIDO'
|
||||
AND forma_pago = '01'
|
||||
AND (
|
||||
(tipo_comprobante = 'I' AND metodo_pago = 'PUE' AND COALESCE(total_mxn, 0) > 2000)
|
||||
OR (tipo_comprobante = 'P' AND COALESCE(monto_pago_mxn, 0) > 2000)
|
||||
)
|
||||
AND status NOT IN ('Cancelado','0')
|
||||
AND fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')
|
||||
AND regimen_fiscal_receptor IS NOT NULL
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
for (const b of buckets) {
|
||||
const { rows: [r] } = await pool.query(b.sql, [fi, ff]);
|
||||
console.log(`${b.name.padEnd(28)} → ${r.n} fila(s), total = $${Number(r.total).toLocaleString('es-MX')}`);
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(e => { console.error(e); process.exit(1); });
|
||||
169
apps/api/scripts/debug-i07-ppd.ts
Normal file
169
apps/api/scripts/debug-i07-ppd.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Diseca cómo se compensa la I PPD con cfdi_tipo_relacion='07' (aplicación
|
||||
* de anticipo) en el cálculo de deducciones, evaluando:
|
||||
* - Si NO entra al sumatorio normal (I PUE / P) por ser PPD
|
||||
* - Si entra a la compensación I/07 PPD ↔ E del mismo mes
|
||||
* - Si tiene cfdis_relacionados (qué referencia hacia atrás)
|
||||
* - Si es referenciada por algún CFDI hacia adelante (P, E, otra I)
|
||||
* - Cómo afecta con considerarActivos ON vs OFF
|
||||
*/
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
async function main() {
|
||||
const t = await prisma.tenant.findFirst({ where: { rfc: 'DESPACHO_MO3NI6U8_B9VGG' } });
|
||||
if (!t) { console.log('Patito tenant not found'); return; }
|
||||
const pool = await tenantDb.getPool(t.id, t.databaseName);
|
||||
|
||||
const TARGET = '5c874749-748f-11f0-96b1-2b9310891836';
|
||||
const RFC = 'TOAH680201RA2';
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 0) Datos del CFDI
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
const { rows: [c] } = await pool.query(`
|
||||
SELECT uuid, type, tipo_comprobante, metodo_pago, forma_pago, uso_cfdi,
|
||||
cfdi_tipo_relacion, cfdis_relacionados,
|
||||
total_mxn, iva_traslado_mxn, ieps_traslado_mxn,
|
||||
rfc_emisor, nombre_emisor, regimen_fiscal_emisor,
|
||||
rfc_receptor, nombre_receptor, regimen_fiscal_receptor,
|
||||
fecha_emision, fecha_pago_p, status,
|
||||
saldo_pendiente_mxn
|
||||
FROM cfdis WHERE LOWER(uuid) = LOWER($1)
|
||||
`, [TARGET]);
|
||||
|
||||
console.log('═══ CFDI ═══');
|
||||
for (const [k, v] of Object.entries(c)) {
|
||||
console.log(` ${k.padEnd(28)} ${v}`);
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 1) ¿Hace referencia hacia atrás (vía cfdis_relacionados)?
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
console.log('\n═══ Referencias hacia atrás (cfdis_relacionados) ═══');
|
||||
if (!c.cfdis_relacionados) {
|
||||
console.log(' (ninguna — cfdis_relacionados es NULL)');
|
||||
} else {
|
||||
const uuids = String(c.cfdis_relacionados).split('|').map((s: string) => s.trim()).filter(Boolean);
|
||||
for (const u of uuids) {
|
||||
const { rows: [rel] } = await pool.query(`
|
||||
SELECT uuid, tipo_comprobante, metodo_pago, total_mxn, fecha_emision, cfdi_tipo_relacion
|
||||
FROM cfdis WHERE LOWER(uuid) = LOWER($1)
|
||||
`, [u]);
|
||||
console.log(` ${u} →`, rel ?? '(no encontrado)');
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 2) ¿Es referenciada hacia adelante? (P que la pague, E que la cancele, otra I tipo_relacion=07 que sustituya)
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
console.log('\n═══ CFDIs que referencian a este (hacia adelante) ═══');
|
||||
|
||||
// P que la pagan vía uuid_relacionado
|
||||
const { rows: pagos } = await pool.query(`
|
||||
SELECT uuid, monto_pago_mxn, fecha_pago_p
|
||||
FROM cfdis
|
||||
WHERE tipo_comprobante = 'P'
|
||||
AND LOWER(uuid_relacionado) = LOWER($1)
|
||||
AND status NOT IN ('Cancelado','0')
|
||||
ORDER BY fecha_pago_p
|
||||
`, [TARGET]);
|
||||
console.log(` P que la pagan (${pagos.length}):`);
|
||||
let totalPagado = 0;
|
||||
for (const p of pagos) {
|
||||
totalPagado += Number(p.monto_pago_mxn || 0);
|
||||
console.log(` ${p.uuid} | $${p.monto_pago_mxn} | ${p.fecha_pago_p}`);
|
||||
}
|
||||
console.log(` → Total pagado vía P: $${totalPagado.toLocaleString('es-MX')}`);
|
||||
console.log(` Total CFDI original: $${c.total_mxn}`);
|
||||
console.log(` Saldo pendiente: $${c.saldo_pendiente_mxn ?? '?'}`);
|
||||
|
||||
// E que la cancelan vía cfdis_relacionados
|
||||
const { rows: ecanc } = await pool.query(`
|
||||
SELECT uuid, tipo_comprobante, metodo_pago, total_mxn, cfdi_tipo_relacion, fecha_emision
|
||||
FROM cfdis
|
||||
WHERE tipo_comprobante = 'E'
|
||||
AND cfdis_relacionados IS NOT NULL
|
||||
AND LOWER($1) = ANY(string_to_array(LOWER(cfdis_relacionados), '|'))
|
||||
AND status NOT IN ('Cancelado','0')
|
||||
`, [TARGET]);
|
||||
console.log(`\n E que la referencian (${ecanc.length}):`);
|
||||
for (const e of ecanc) {
|
||||
console.log(` ${e.uuid} | total=$${e.total_mxn} | tipo_rel=${e.cfdi_tipo_relacion} | ${e.fecha_emision}`);
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 3) Compensación I/07 PPD ↔ E lado RECEPTOR (mismo mes)
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
console.log('\n═══ ¿Entra en compensación I/07 PPD ↔ E (mes/año del CFDI)? ═══');
|
||||
|
||||
const fecha = new Date(c.fecha_emision);
|
||||
const mesAnio = `${fecha.getFullYear()}-${String(fecha.getMonth() + 1).padStart(2, '0')}`;
|
||||
console.log(` CFDI mes/año: ${mesAnio}`);
|
||||
console.log(` cfdi_tipo_relacion='07': ${c.cfdi_tipo_relacion === '07' ? '✓ SÍ' : '✗ NO'}`);
|
||||
console.log(` metodo_pago='PPD': ${c.metodo_pago === 'PPD' ? '✓ SÍ' : '✗ NO'}`);
|
||||
|
||||
if (c.cfdi_tipo_relacion === '07' && c.metodo_pago === 'PPD') {
|
||||
// Calcular el aporte que tendría a la compensación (suma de E del mismo mes)
|
||||
const { rows: comp } = await pool.query(`
|
||||
SELECT
|
||||
COALESCE(SUM(
|
||||
COALESCE(e.total_mxn, 0)
|
||||
- COALESCE(e.iva_traslado_mxn, 0)
|
||||
- COALESCE(e.ieps_traslado_mxn, 0)
|
||||
- COALESCE(e.impuestos_locales_trasladado_mxn, 0)
|
||||
), 0)::numeric(14,2) AS aporte
|
||||
FROM cfdis e
|
||||
WHERE e.tipo_comprobante = 'E'
|
||||
AND e.metodo_pago = 'PUE'
|
||||
AND e.status NOT IN ('Cancelado','0')
|
||||
AND UPPER(e.rfc_receptor) = $1
|
||||
AND LOWER($2) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
|
||||
AND date_trunc('month', e.fecha_emision) = date_trunc('month', $3::timestamp)
|
||||
`, [RFC, TARGET, c.fecha_emision]);
|
||||
console.log(`\n Aporte a la compensación (suma E mismo mes): $${comp[0].aporte}`);
|
||||
if (Number(comp[0].aporte) > 0) {
|
||||
console.log(` → SÍ entra en compensación`);
|
||||
} else {
|
||||
console.log(` → NO entra (no hay E en mismo mes que la referencien)`);
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 4) ¿Aparece en el cálculo "I PUE recibidas" o "P recibidos"?
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
console.log('\n═══ ¿Aparece en el cálculo directo? ═══');
|
||||
console.log(` I PUE recibidas requiere: tipo_comprobante='I' AND metodo_pago='PUE'`);
|
||||
console.log(` Este CFDI: tipo_comprobante='${c.tipo_comprobante}', metodo_pago='${c.metodo_pago}'`);
|
||||
console.log(` → ${c.tipo_comprobante === 'I' && c.metodo_pago === 'PUE' ? '✓ SÍ entra' : '✗ NO entra'} (es ${c.tipo_comprobante} ${c.metodo_pago})`);
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 5) Predicado de filtro de activos
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
console.log('\n═══ Predicado de filtro de activos sobre este CFDI ═══');
|
||||
const ACTIVOS = "('I01','I02','I03','I04','I05','I06','I07','I08')";
|
||||
const t1 = await pool.query(`
|
||||
SELECT
|
||||
(tipo_comprobante = 'I' AND uso_cfdi IN ${ACTIVOS}) AS regla1_directo,
|
||||
(tipo_comprobante = 'P' AND EXISTS (
|
||||
SELECT 1 FROM cfdis i_act
|
||||
WHERE LOWER(i_act.uuid) = LOWER(cfdis.uuid_relacionado)
|
||||
AND i_act.tipo_comprobante = 'I'
|
||||
AND i_act.uso_cfdi IN ${ACTIVOS}
|
||||
)) AS regla2,
|
||||
(tipo_comprobante = 'E' AND cfdis.cfdis_relacionados IS NOT NULL AND EXISTS (
|
||||
SELECT 1 FROM cfdis r_act
|
||||
WHERE LOWER(r_act.uuid) = ANY(string_to_array(LOWER(cfdis.cfdis_relacionados), '|'))
|
||||
AND (r_act.tipo_comprobante = 'I' AND r_act.uso_cfdi IN ${ACTIVOS})
|
||||
)) AS regla3
|
||||
FROM cfdis WHERE LOWER(uuid) = LOWER($1)
|
||||
`, [TARGET]);
|
||||
console.log(` regla1 (I directo activo): ${t1.rows[0].regla1_directo ? '🔴 TRUE' : '🟢 FALSE'}`);
|
||||
console.log(` regla2 (P paga I activo): ${t1.rows[0].regla2 ? '🔴 TRUE' : '🟢 FALSE'}`);
|
||||
console.log(` regla3 (E ref. I/P activo): ${t1.rows[0].regla3 ? '🔴 TRUE' : '🟢 FALSE'}`);
|
||||
const filtrado = t1.rows[0].regla1_directo || t1.rows[0].regla2 || t1.rows[0].regla3;
|
||||
console.log(` → Si "Considerar activos" OFF → ${filtrado ? '🔴 EXCLUIDO' : '🟢 PASA'}`);
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(e => { console.error(e); process.exit(1); });
|
||||
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); });
|
||||
34
apps/api/scripts/debug-ncs.ts
Normal file
34
apps/api/scripts/debug-ncs.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
async function main() {
|
||||
const t = await prisma.tenant.findFirst({ where: { rfc: 'DESPACHO_MO7JE8BZ_VDOPR' } });
|
||||
if (!t) { console.log('Zorro tenant no encontrado'); return; }
|
||||
const pool = await tenantDb.getPool(t.id, t.databaseName);
|
||||
|
||||
console.log('--- E PUE EMITIDAS (cualquier fecha) ---');
|
||||
const emit = await pool.query(`
|
||||
SELECT EXTRACT(year FROM fecha_emision) as anio,
|
||||
regimen_fiscal_emisor, count(*) as n,
|
||||
SUM(total_mxn)::numeric(14,2) as total
|
||||
FROM cfdis
|
||||
WHERE tipo_comprobante = 'E' AND metodo_pago = 'PUE'
|
||||
AND status NOT IN ('Cancelado','0')
|
||||
GROUP BY 1, 2 ORDER BY 1 DESC, 2
|
||||
`);
|
||||
console.table(emit.rows);
|
||||
|
||||
console.log('\n--- E PUE RECIBIDAS (cualquier fecha) ---');
|
||||
const rec = await pool.query(`
|
||||
SELECT EXTRACT(year FROM fecha_emision) as anio,
|
||||
regimen_fiscal_receptor, count(*) as n,
|
||||
SUM(total_mxn)::numeric(14,2) as total
|
||||
FROM cfdis
|
||||
WHERE tipo_comprobante = 'E' AND metodo_pago = 'PUE'
|
||||
AND status NOT IN ('Cancelado','0')
|
||||
GROUP BY 1, 2 ORDER BY 1 DESC, 2
|
||||
`);
|
||||
console.table(rec.rows);
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(e => { console.error(e); process.exit(1); });
|
||||
67
apps/api/scripts/debug-p-mayo.ts
Normal file
67
apps/api/scripts/debug-p-mayo.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Diseca 2 complementos P de Horux 360 que el usuario espera ver en mayo
|
||||
* pero no aparecen. Verifica fecha_emision vs fecha_pago_p para entender
|
||||
* en qué mes los está sumando el cálculo.
|
||||
*/
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
async function main() {
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
where: { active: true },
|
||||
select: { id: true, rfc: true, nombre: true, databaseName: true },
|
||||
});
|
||||
|
||||
const UUIDS = [
|
||||
'CFACB97E-5426-48D4-A3B9-06B5D160F307',
|
||||
'384CF943-EFB0-475A-B6B6-240E96088B37',
|
||||
];
|
||||
|
||||
// Loop por todos los tenants
|
||||
for (const t of tenants) {
|
||||
const pool = await tenantDb.getPool(t.id, t.databaseName);
|
||||
console.log(`\n>>> Tenant: ${t.rfc} (${t.nombre}) <<<`);
|
||||
|
||||
for (const uuid of UUIDS) {
|
||||
const { rows: [c] } = await pool.query(`
|
||||
SELECT uuid, type, tipo_comprobante, metodo_pago, forma_pago, uso_cfdi,
|
||||
cfdi_tipo_relacion,
|
||||
total_mxn, monto_pago_mxn, iva_traslado_pago_mxn,
|
||||
rfc_emisor, regimen_fiscal_emisor,
|
||||
rfc_receptor, regimen_fiscal_receptor,
|
||||
fecha_emision, fecha_pago_p, status
|
||||
FROM cfdis WHERE LOWER(uuid) = LOWER($1)
|
||||
`, [uuid]);
|
||||
|
||||
console.log(`\n═══ CFDI ${uuid} ═══`);
|
||||
if (!c) { console.log(' (NO ENCONTRADO en BD de Horux 360)'); continue; }
|
||||
|
||||
console.log(` Tipo: ${c.tipo_comprobante} ${c.metodo_pago || ''}`);
|
||||
console.log(` Status: ${c.status}`);
|
||||
console.log(` type (lado): ${c.type}`);
|
||||
console.log(` rfc_emisor: ${c.rfc_emisor} (régimen ${c.regimen_fiscal_emisor})`);
|
||||
console.log(` rfc_receptor: ${c.rfc_receptor} (régimen ${c.regimen_fiscal_receptor})`);
|
||||
console.log(` total_mxn: $${c.total_mxn}`);
|
||||
console.log(` monto_pago_mxn: $${c.monto_pago_mxn}`);
|
||||
console.log(` iva_traslado_pago: $${c.iva_traslado_pago_mxn}`);
|
||||
console.log(` ──────────────────────────────────────`);
|
||||
console.log(` fecha_emision: ${c.fecha_emision}`);
|
||||
console.log(` fecha_pago_p: ${c.fecha_pago_p}`);
|
||||
console.log(` ──────────────────────────────────────`);
|
||||
|
||||
// Análisis: en qué mes "cae" según el cálculo de ingresos (Grupo 1 — FR_PAGO usa fecha_pago_p)
|
||||
const fecPago = c.fecha_pago_p ? new Date(c.fecha_pago_p) : null;
|
||||
const fecEmi = c.fecha_emision ? new Date(c.fecha_emision) : null;
|
||||
if (fecPago) {
|
||||
console.log(` En cálculo Ingresos: APARECE EN ${fecPago.getFullYear()}-${String(fecPago.getMonth() + 1).padStart(2, '0')}`);
|
||||
console.log(` (filtro: fecha_pago_p)`);
|
||||
}
|
||||
if (fecEmi) {
|
||||
console.log(` En filtros UI fecha: se EMITIÓ en ${fecEmi.getFullYear()}-${String(fecEmi.getMonth() + 1).padStart(2, '0')}`);
|
||||
}
|
||||
}
|
||||
} // close tenant loop
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(e => { console.error(e); process.exit(1); });
|
||||
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); });
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user