commit b00b677c54ee54af8640bf1d265c67963085848c Author: consultoria-as Date: Sun May 3 16:47:53 2026 -0600 Initial commit - Horux Despachos NL diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d48700e --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..209e3ef --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4382a4c --- /dev/null +++ b/CLAUDE.md @@ -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_` → 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////`) 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--` 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_` +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 ``** — 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 ` 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. diff --git a/CSF_ejemplos/9f877571-c527-445d-979a-9ab99479d851.pdf b/CSF_ejemplos/9f877571-c527-445d-979a-9ab99479d851.pdf new file mode 100644 index 0000000..51000a9 Binary files /dev/null and b/CSF_ejemplos/9f877571-c527-445d-979a-9ab99479d851.pdf differ diff --git a/CSF_ejemplos/AUZA640701TI9-tax-certificate-1767252856.pdf b/CSF_ejemplos/AUZA640701TI9-tax-certificate-1767252856.pdf new file mode 100644 index 0000000..3eba296 Binary files /dev/null and b/CSF_ejemplos/AUZA640701TI9-tax-certificate-1767252856.pdf differ diff --git a/CSF_ejemplos/CAS2408138W2-tax-certificate-1767252858 (1).pdf b/CSF_ejemplos/CAS2408138W2-tax-certificate-1767252858 (1).pdf new file mode 100644 index 0000000..1d16aec Binary files /dev/null and b/CSF_ejemplos/CAS2408138W2-tax-certificate-1767252858 (1).pdf differ diff --git a/CSF_ejemplos/CBO230302410-tax-certificate-1767252857.pdf b/CSF_ejemplos/CBO230302410-tax-certificate-1767252857.pdf new file mode 100644 index 0000000..5596fa5 Binary files /dev/null and b/CSF_ejemplos/CBO230302410-tax-certificate-1767252857.pdf differ diff --git a/CSF_ejemplos/FAGC961208BXA-tax-certificate-1767252858.pdf b/CSF_ejemplos/FAGC961208BXA-tax-certificate-1767252858.pdf new file mode 100644 index 0000000..01881c6 Binary files /dev/null and b/CSF_ejemplos/FAGC961208BXA-tax-certificate-1767252858.pdf differ diff --git a/CSF_ejemplos/GADM9107165I0-tax-certificate-1767252858.pdf b/CSF_ejemplos/GADM9107165I0-tax-certificate-1767252858.pdf new file mode 100644 index 0000000..20d79d8 Binary files /dev/null and b/CSF_ejemplos/GADM9107165I0-tax-certificate-1767252858.pdf differ diff --git a/CSF_ejemplos/HTS240708LJA-tax-certificate-1767252856.pdf b/CSF_ejemplos/HTS240708LJA-tax-certificate-1767252856.pdf new file mode 100644 index 0000000..0cde464 Binary files /dev/null and b/CSF_ejemplos/HTS240708LJA-tax-certificate-1767252856.pdf differ diff --git a/CSF_ejemplos/ITP020524UW0-tax-certificate-1767252857.pdf b/CSF_ejemplos/ITP020524UW0-tax-certificate-1767252857.pdf new file mode 100644 index 0000000..19ccfd8 Binary files /dev/null and b/CSF_ejemplos/ITP020524UW0-tax-certificate-1767252857.pdf differ diff --git a/CSF_ejemplos/JISJ870518SD7-tax-certificate-1767252856.pdf b/CSF_ejemplos/JISJ870518SD7-tax-certificate-1767252856.pdf new file mode 100644 index 0000000..3b41e6e Binary files /dev/null and b/CSF_ejemplos/JISJ870518SD7-tax-certificate-1767252856.pdf differ diff --git a/CSF_ejemplos/MOMC8311199VA-tax-certificate-1767252858.pdf b/CSF_ejemplos/MOMC8311199VA-tax-certificate-1767252858.pdf new file mode 100644 index 0000000..556964b Binary files /dev/null and b/CSF_ejemplos/MOMC8311199VA-tax-certificate-1767252858.pdf differ diff --git a/CSF_ejemplos/RORD791109L98-tax-certificate-1767252858.pdf b/CSF_ejemplos/RORD791109L98-tax-certificate-1767252858.pdf new file mode 100644 index 0000000..e7f1aea Binary files /dev/null and b/CSF_ejemplos/RORD791109L98-tax-certificate-1767252858.pdf differ diff --git a/CSF_ejemplos/RORE790609168-tax-certificate-1767252857.pdf b/CSF_ejemplos/RORE790609168-tax-certificate-1767252857.pdf new file mode 100644 index 0000000..d2f2a94 Binary files /dev/null and b/CSF_ejemplos/RORE790609168-tax-certificate-1767252857.pdf differ diff --git a/CSF_ejemplos/TOAH680201RA2-tax-certificate-1767252854 (1).pdf b/CSF_ejemplos/TOAH680201RA2-tax-certificate-1767252854 (1).pdf new file mode 100644 index 0000000..8ed954c Binary files /dev/null and b/CSF_ejemplos/TOAH680201RA2-tax-certificate-1767252854 (1).pdf differ diff --git a/CSF_ejemplos/TORA0007099R6-tax-certificate-1767252857.pdf b/CSF_ejemplos/TORA0007099R6-tax-certificate-1767252857.pdf new file mode 100644 index 0000000..da7221f Binary files /dev/null and b/CSF_ejemplos/TORA0007099R6-tax-certificate-1767252857.pdf differ diff --git a/CSF_ejemplos/TORC9611214CA-tax-certificate-1767252856.pdf b/CSF_ejemplos/TORC9611214CA-tax-certificate-1767252856.pdf new file mode 100644 index 0000000..73a685a Binary files /dev/null and b/CSF_ejemplos/TORC9611214CA-tax-certificate-1767252856.pdf differ diff --git a/CSF_ejemplos/TORS980325FH2-tax-certificate-1756664608.pdf b/CSF_ejemplos/TORS980325FH2-tax-certificate-1756664608.pdf new file mode 100644 index 0000000..8ef834f Binary files /dev/null and b/CSF_ejemplos/TORS980325FH2-tax-certificate-1756664608.pdf differ diff --git a/CSF_ejemplos/TPR840604D98-tax-certificate-1767252856.pdf b/CSF_ejemplos/TPR840604D98-tax-certificate-1767252856.pdf new file mode 100644 index 0000000..120406e Binary files /dev/null and b/CSF_ejemplos/TPR840604D98-tax-certificate-1767252856.pdf differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..77dd9d9 --- /dev/null +++ b/README.md @@ -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)** + +--- diff --git a/apps/api/.env.example b/apps/api/.env.example new file mode 100644 index 0000000..e98e866 --- /dev/null +++ b/apps/api/.env.example @@ -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 + +# ----- 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 diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..118b8c4 --- /dev/null +++ b/apps/api/package.json @@ -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" + } +} diff --git a/apps/api/prisma/catalogos-sat-data.ts b/apps/api/prisma/catalogos-sat-data.ts new file mode 100644 index 0000000..40877cb --- /dev/null +++ b/apps/api/prisma/catalogos-sat-data.ts @@ -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' }, +]; diff --git a/apps/api/prisma/eventos-fiscales-data.ts b/apps/api/prisma/eventos-fiscales-data.ts new file mode 100644 index 0000000..d92ee15 --- /dev/null +++ b/apps/api/prisma/eventos-fiscales-data.ts @@ -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); diff --git a/apps/api/prisma/isr-data.ts b/apps/api/prisma/isr-data.ts new file mode 100644 index 0000000..5585c49 --- /dev/null +++ b/apps/api/prisma/isr-data.ts @@ -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 = { + 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 }, + ], +}; diff --git a/apps/api/prisma/migrations/20260414152220_initial_schema_v0_9_2/migration.sql b/apps/api/prisma/migrations/20260414152220_initial_schema_v0_9_2/migration.sql new file mode 100644 index 0000000..17840b0 --- /dev/null +++ b/apps/api/prisma/migrations/20260414152220_initial_schema_v0_9_2/migration.sql @@ -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; + diff --git a/apps/api/prisma/migrations/20260415000057_timbres_paquetes_adicionales/migration.sql b/apps/api/prisma/migrations/20260415000057_timbres_paquetes_adicionales/migration.sql new file mode 100644 index 0000000..932ed19 --- /dev/null +++ b/apps/api/prisma/migrations/20260415000057_timbres_paquetes_adicionales/migration.sql @@ -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; diff --git a/apps/api/prisma/migrations/20260417204528_despacho_fields/migration.sql b/apps/api/prisma/migrations/20260417204528_despacho_fields/migration.sql new file mode 100644 index 0000000..e7531e7 --- /dev/null +++ b/apps/api/prisma/migrations/20260417204528_despacho_fields/migration.sql @@ -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"; diff --git a/apps/api/prisma/migrations/20260417224212_plan_catalogo_tables/migration.sql b/apps/api/prisma/migrations/20260417224212_plan_catalogo_tables/migration.sql new file mode 100644 index 0000000..8980a2e --- /dev/null +++ b/apps/api/prisma/migrations/20260417224212_plan_catalogo_tables/migration.sql @@ -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"); diff --git a/apps/api/prisma/migrations/20260417224614_subscription_addons/migration.sql b/apps/api/prisma/migrations/20260417224614_subscription_addons/migration.sql new file mode 100644 index 0000000..b5a6076 --- /dev/null +++ b/apps/api/prisma/migrations/20260417224614_subscription_addons/migration.sql @@ -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; diff --git a/apps/api/prisma/migrations/20260417225702_connector_heartbeats/migration.sql b/apps/api/prisma/migrations/20260417225702_connector_heartbeats/migration.sql new file mode 100644 index 0000000..c23eba4 --- /dev/null +++ b/apps/api/prisma/migrations/20260417225702_connector_heartbeats/migration.sql @@ -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; diff --git a/apps/api/prisma/migrations/20260418165004_sat_sync_contribuyente_id/migration.sql b/apps/api/prisma/migrations/20260418165004_sat_sync_contribuyente_id/migration.sql new file mode 100644 index 0000000..068873e --- /dev/null +++ b/apps/api/prisma/migrations/20260418165004_sat_sync_contribuyente_id/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "sat_sync_jobs" ADD COLUMN "contribuyente_id" TEXT; diff --git a/apps/api/prisma/migrations/20260421062505_despacho_plan_enum_values/migration.sql b/apps/api/prisma/migrations/20260421062505_despacho_plan_enum_values/migration.sql new file mode 100644 index 0000000..4ab5a7f --- /dev/null +++ b/apps/api/prisma/migrations/20260421062505_despacho_plan_enum_values/migration.sql @@ -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'; diff --git a/apps/api/prisma/migrations/20260422172323_subscription_addons_contribuyente_id/migration.sql b/apps/api/prisma/migrations/20260422172323_subscription_addons_contribuyente_id/migration.sql new file mode 100644 index 0000000..c14d941 --- /dev/null +++ b/apps/api/prisma/migrations/20260422172323_subscription_addons_contribuyente_id/migration.sql @@ -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"); diff --git a/apps/api/prisma/migrations/20260426073942_add_mi_empresa_plan/migration.sql b/apps/api/prisma/migrations/20260426073942_add_mi_empresa_plan/migration.sql new file mode 100644 index 0000000..ed52500 --- /dev/null +++ b/apps/api/prisma/migrations/20260426073942_add_mi_empresa_plan/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "Plan" ADD VALUE 'mi_empresa'; diff --git a/apps/api/prisma/migrations/20260426230000_despacho_plan_prices/migration.sql b/apps/api/prisma/migrations/20260426230000_despacho_plan_prices/migration.sql new file mode 100644 index 0000000..a878322 --- /dev/null +++ b/apps/api/prisma/migrations/20260426230000_despacho_plan_prices/migration.sql @@ -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()); diff --git a/apps/api/prisma/migrations/20260430184123_cleanup_legacy_plans/migration.sql b/apps/api/prisma/migrations/20260430184123_cleanup_legacy_plans/migration.sql new file mode 100644 index 0000000..55620a2 --- /dev/null +++ b/apps/api/prisma/migrations/20260430184123_cleanup_legacy_plans/migration.sql @@ -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'; diff --git a/apps/api/prisma/migrations/20260430195000_extend_despacho_plan_prices_with_limits/migration.sql b/apps/api/prisma/migrations/20260430195000_extend_despacho_plan_prices_with_limits/migration.sql new file mode 100644 index 0000000..f1adb66 --- /dev/null +++ b/apps/api/prisma/migrations/20260430195000_extend_despacho_plan_prices_with_limits/migration.sql @@ -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; diff --git a/apps/api/prisma/migrations/20260430200000_drop_plan_catalogo_orphan/migration.sql b/apps/api/prisma/migrations/20260430200000_drop_plan_catalogo_orphan/migration.sql new file mode 100644 index 0000000..5245db2 --- /dev/null +++ b/apps/api/prisma/migrations/20260430200000_drop_plan_catalogo_orphan/migration.sql @@ -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"; diff --git a/apps/api/prisma/migrations/20260430215000_add_permite_sat_incremental/migration.sql b/apps/api/prisma/migrations/20260430215000_add_permite_sat_incremental/migration.sql new file mode 100644 index 0000000..e49b890 --- /dev/null +++ b/apps/api/prisma/migrations/20260430215000_add_permite_sat_incremental/migration.sql @@ -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'); diff --git a/apps/api/prisma/migrations/20260430230000_add_facturapi_org_key_enc/migration.sql b/apps/api/prisma/migrations/20260430230000_add_facturapi_org_key_enc/migration.sql new file mode 100644 index 0000000..a4268f3 --- /dev/null +++ b/apps/api/prisma/migrations/20260430230000_add_facturapi_org_key_enc/migration.sql @@ -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; diff --git a/apps/api/prisma/migrations/20260501160000_drop_plan_prices_legacy/migration.sql b/apps/api/prisma/migrations/20260501160000_drop_plan_prices_legacy/migration.sql new file mode 100644 index 0000000..e4e5f04 --- /dev/null +++ b/apps/api/prisma/migrations/20260501160000_drop_plan_prices_legacy/migration.sql @@ -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"; diff --git a/apps/api/prisma/migrations/20260501170000_add_subscription_reminder_tracking/migration.sql b/apps/api/prisma/migrations/20260501170000_add_subscription_reminder_tracking/migration.sql new file mode 100644 index 0000000..3262193 --- /dev/null +++ b/apps/api/prisma/migrations/20260501170000_add_subscription_reminder_tracking/migration.sql @@ -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); diff --git a/apps/api/prisma/migrations/20260502170000_add_tenant_fact_preferencias/migration.sql b/apps/api/prisma/migrations/20260502170000_add_tenant_fact_preferencias/migration.sql new file mode 100644 index 0000000..701a751 --- /dev/null +++ b/apps/api/prisma/migrations/20260502170000_add_tenant_fact_preferencias/migration.sql @@ -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); diff --git a/apps/api/prisma/migrations/20260502190000_add_user_login_count_onboarding_dismissed/migration.sql b/apps/api/prisma/migrations/20260502190000_add_user_login_count_onboarding_dismissed/migration.sql new file mode 100644 index 0000000..98b1bab --- /dev/null +++ b/apps/api/prisma/migrations/20260502190000_add_user_login_count_onboarding_dismissed/migration.sql @@ -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); diff --git a/apps/api/prisma/migrations/20260502210000_add_sat_sync_jobs_request_ids_map/migration.sql b/apps/api/prisma/migrations/20260502210000_add_sat_sync_jobs_request_ids_map/migration.sql new file mode 100644 index 0000000..852e725 --- /dev/null +++ b/apps/api/prisma/migrations/20260502210000_add_sat_sync_jobs_request_ids_map/migration.sql @@ -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; diff --git a/apps/api/prisma/migrations/20260502230000_add_sat_sync_jobs_is_custom_range/migration.sql b/apps/api/prisma/migrations/20260502230000_add_sat_sync_jobs_is_custom_range/migration.sql new file mode 100644 index 0000000..aaa3388 --- /dev/null +++ b/apps/api/prisma/migrations/20260502230000_add_sat_sync_jobs_is_custom_range/migration.sql @@ -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; diff --git a/apps/api/prisma/migrations/migration_lock.toml b/apps/api/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..fbffa92 --- /dev/null +++ b/apps/api/prisma/migrations/migration_lock.toml @@ -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" \ No newline at end of file diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma new file mode 100644 index 0000000..e199b29 --- /dev/null +++ b/apps/api/prisma/schema.prisma @@ -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") +} diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts new file mode 100644 index 0000000..77c0b03 --- /dev/null +++ b/apps/api/prisma/seed.ts @@ -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 = { 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(); + }); diff --git a/apps/api/scripts/apply-migration-042.ts b/apps/api/scripts/apply-migration-042.ts new file mode 100644 index 0000000..5cc6534 --- /dev/null +++ b/apps/api/scripts/apply-migration-042.ts @@ -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); }); diff --git a/apps/api/scripts/backfill-cfdi-contribuyente.ts b/apps/api/scripts/backfill-cfdi-contribuyente.ts new file mode 100644 index 0000000..242f421 --- /dev/null +++ b/apps/api/scripts/backfill-cfdi-contribuyente.ts @@ -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 { + 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(); + 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); +}); diff --git a/apps/api/scripts/backfill-cfdis-relaciones.ts b/apps/api/scripts/backfill-cfdis-relaciones.ts new file mode 100644 index 0000000..082705e --- /dev/null +++ b/apps/api/scripts/backfill-cfdis-relaciones.ts @@ -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; + error?: string; +} + +async function backfillTenant( + tenantId: string, + rfc: string, + databaseName: string, +): Promise { + 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 = {}; + 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); +}); diff --git a/apps/api/scripts/backfill-facturapi-cfdis.ts b/apps/api/scripts/backfill-facturapi-cfdis.ts new file mode 100644 index 0000000..fd403e8 --- /dev/null +++ b/apps/api/scripts/backfill-facturapi-cfdis.ts @@ -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); }); diff --git a/apps/api/scripts/backfill-fechas-tz.ts b/apps/api/scripts/backfill-fechas-tz.ts new file mode 100644 index 0000000..b7016b7 --- /dev/null +++ b/apps/api/scripts/backfill-fechas-tz.ts @@ -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 + const m = xml.match(/]*\bFecha="([^"]+)"/); + return m ? m[1] : null; +} + +function extractFechaTimbradoFromXml(xml: string): string | null { + const m = xml.match(/]*\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 { + 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); }); diff --git a/apps/api/scripts/backfill-metricas.ts b/apps/api/scripts/backfill-metricas.ts new file mode 100644 index 0000000..1b6e818 --- /dev/null +++ b/apps/api/scripts/backfill-metricas.ts @@ -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= # 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); +}); diff --git a/apps/api/scripts/backfill-pago-fields.ts b/apps/api/scripts/backfill-pago-fields.ts new file mode 100644 index 0000000..9f79d92 --- /dev/null +++ b/apps/api/scripts/backfill-pago-fields.ts @@ -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); }); diff --git a/apps/api/scripts/backfill-saldo-pendiente.ts b/apps/api/scripts/backfill-saldo-pendiente.ts new file mode 100644 index 0000000..90c72a5 --- /dev/null +++ b/apps/api/scripts/backfill-saldo-pendiente.ts @@ -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 { + 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); +}); diff --git a/apps/api/scripts/bootstrap-horux360-admin.ts b/apps/api/scripts/bootstrap-horux360-admin.ts new file mode 100644 index 0000000..8034d9e --- /dev/null +++ b/apps/api/scripts/bootstrap-horux360-admin.ts @@ -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()); diff --git a/apps/api/scripts/breakdown-gastos.ts b/apps/api/scripts/breakdown-gastos.ts new file mode 100644 index 0000000..6c3b127 --- /dev/null +++ b/apps/api/scripts/breakdown-gastos.ts @@ -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 = {}; + 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); }); diff --git a/apps/api/scripts/breakdown-ingresos.ts b/apps/api/scripts/breakdown-ingresos.ts new file mode 100644 index 0000000..3e6f3ee --- /dev/null +++ b/apps/api/scripts/breakdown-ingresos.ts @@ -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 }> = {}; + 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); }); diff --git a/apps/api/scripts/check-cache-contrib.ts b/apps/api/scripts/check-cache-contrib.ts new file mode 100644 index 0000000..7456911 --- /dev/null +++ b/apps/api/scripts/check-cache-contrib.ts @@ -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); }); diff --git a/apps/api/scripts/check-cache.ts b/apps/api/scripts/check-cache.ts new file mode 100644 index 0000000..5cc8430 --- /dev/null +++ b/apps/api/scripts/check-cache.ts @@ -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); }); diff --git a/apps/api/scripts/check-carlos-emision.ts b/apps/api/scripts/check-carlos-emision.ts new file mode 100644 index 0000000..05150e9 --- /dev/null +++ b/apps/api/scripts/check-carlos-emision.ts @@ -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); +}); diff --git a/apps/api/scripts/check-carlos-lco.ts b/apps/api/scripts/check-carlos-lco.ts new file mode 100644 index 0000000..657894e --- /dev/null +++ b/apps/api/scripts/check-carlos-lco.ts @@ -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); }); diff --git a/apps/api/scripts/check-ieps-inflation.ts b/apps/api/scripts/check-ieps-inflation.ts new file mode 100644 index 0000000..67bf18d --- /dev/null +++ b/apps/api/scripts/check-ieps-inflation.ts @@ -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); }); diff --git a/apps/api/scripts/check-recent-facturapi.ts b/apps/api/scripts/check-recent-facturapi.ts new file mode 100644 index 0000000..f801445 --- /dev/null +++ b/apps/api/scripts/check-recent-facturapi.ts @@ -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 || ''; + const receptor = r.rfc_receptor || ''; + 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); +}); diff --git a/apps/api/scripts/check-rfc-emisor.ts b/apps/api/scripts/check-rfc-emisor.ts new file mode 100644 index 0000000..c0f9b81 --- /dev/null +++ b/apps/api/scripts/check-rfc-emisor.ts @@ -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); }); diff --git a/apps/api/scripts/check-saldo.ts b/apps/api/scripts/check-saldo.ts new file mode 100644 index 0000000..5934aa9 --- /dev/null +++ b/apps/api/scripts/check-saldo.ts @@ -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); }); diff --git a/apps/api/scripts/compare-iva-full.ts b/apps/api/scripts/compare-iva-full.ts new file mode 100644 index 0000000..0138872 --- /dev/null +++ b/apps/api/scripts/compare-iva-full.ts @@ -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); }); diff --git a/apps/api/scripts/compare-iva-gastos.ts b/apps/api/scripts/compare-iva-gastos.ts new file mode 100644 index 0000000..2ad42fb --- /dev/null +++ b/apps/api/scripts/compare-iva-gastos.ts @@ -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); }); diff --git a/apps/api/scripts/count-07-types.ts b/apps/api/scripts/count-07-types.ts new file mode 100644 index 0000000..f4bb22f --- /dev/null +++ b/apps/api/scripts/count-07-types.ts @@ -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); }); diff --git a/apps/api/scripts/count-husberto-07.ts b/apps/api/scripts/count-husberto-07.ts new file mode 100644 index 0000000..a403648 --- /dev/null +++ b/apps/api/scripts/count-husberto-07.ts @@ -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); }); diff --git a/apps/api/scripts/create-carlos.ts b/apps/api/scripts/create-carlos.ts new file mode 100644 index 0000000..fd7c5da --- /dev/null +++ b/apps/api/scripts/create-carlos.ts @@ -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); }); diff --git a/apps/api/scripts/debug-cfdi-activos.ts b/apps/api/scripts/debug-cfdi-activos.ts new file mode 100644 index 0000000..ed3bd58 --- /dev/null +++ b/apps/api/scripts/debug-cfdi-activos.ts @@ -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); }); diff --git a/apps/api/scripts/debug-compensacion-cfdi.ts b/apps/api/scripts/debug-compensacion-cfdi.ts new file mode 100644 index 0000000..4cc437e --- /dev/null +++ b/apps/api/scripts/debug-compensacion-cfdi.ts @@ -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); }); diff --git a/apps/api/scripts/debug-deducciones-husberto.ts b/apps/api/scripts/debug-deducciones-husberto.ts new file mode 100644 index 0000000..1cfa0a5 --- /dev/null +++ b/apps/api/scripts/debug-deducciones-husberto.ts @@ -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); }); diff --git a/apps/api/scripts/debug-drill-buckets.ts b/apps/api/scripts/debug-drill-buckets.ts new file mode 100644 index 0000000..ba3c823 --- /dev/null +++ b/apps/api/scripts/debug-drill-buckets.ts @@ -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); }); diff --git a/apps/api/scripts/debug-i07-ppd.ts b/apps/api/scripts/debug-i07-ppd.ts new file mode 100644 index 0000000..c0fe7fd --- /dev/null +++ b/apps/api/scripts/debug-i07-ppd.ts @@ -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); }); diff --git a/apps/api/scripts/debug-i07.ts b/apps/api/scripts/debug-i07.ts new file mode 100644 index 0000000..30145df --- /dev/null +++ b/apps/api/scripts/debug-i07.ts @@ -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); }); diff --git a/apps/api/scripts/debug-ingresos-horux-may-wider.ts b/apps/api/scripts/debug-ingresos-horux-may-wider.ts new file mode 100644 index 0000000..30ee0a2 --- /dev/null +++ b/apps/api/scripts/debug-ingresos-horux-may-wider.ts @@ -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 = { + 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); }); diff --git a/apps/api/scripts/debug-ingresos-horux-may.ts b/apps/api/scripts/debug-ingresos-horux-may.ts new file mode 100644 index 0000000..927c43a --- /dev/null +++ b/apps/api/scripts/debug-ingresos-horux-may.ts @@ -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 = {}; + + 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); }); diff --git a/apps/api/scripts/debug-ncs.ts b/apps/api/scripts/debug-ncs.ts new file mode 100644 index 0000000..a26ea60 --- /dev/null +++ b/apps/api/scripts/debug-ncs.ts @@ -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); }); diff --git a/apps/api/scripts/debug-p-mayo.ts b/apps/api/scripts/debug-p-mayo.ts new file mode 100644 index 0000000..2f8aa27 --- /dev/null +++ b/apps/api/scripts/debug-p-mayo.ts @@ -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); }); diff --git a/apps/api/scripts/decrypt-fiel.ts b/apps/api/scripts/decrypt-fiel.ts new file mode 100644 index 0000000..c960b0a --- /dev/null +++ b/apps/api/scripts/decrypt-fiel.ts @@ -0,0 +1,82 @@ +/** + * CLI script to decrypt FIEL credentials from filesystem backup. + * Usage: FIEL_ENCRYPTION_KEY= npx tsx scripts/decrypt-fiel.ts + * + * Decrypted files are written to /tmp/horux-fiel-/ 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= npx tsx scripts/decrypt-fiel.ts '); + 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); +}); diff --git a/apps/api/scripts/deep-egresos.ts b/apps/api/scripts/deep-egresos.ts new file mode 100644 index 0000000..20a34c3 --- /dev/null +++ b/apps/api/scripts/deep-egresos.ts @@ -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); }); diff --git a/apps/api/scripts/detail-ingresos.ts b/apps/api/scripts/detail-ingresos.ts new file mode 100644 index 0000000..a03a02b --- /dev/null +++ b/apps/api/scripts/detail-ingresos.ts @@ -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); }); diff --git a/apps/api/scripts/detail-iva-mes.ts b/apps/api/scripts/detail-iva-mes.ts new file mode 100644 index 0000000..ca4df3f --- /dev/null +++ b/apps/api/scripts/detail-iva-mes.ts @@ -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); }); diff --git a/apps/api/scripts/drill-ingresos.ts b/apps/api/scripts/drill-ingresos.ts new file mode 100644 index 0000000..b806024 --- /dev/null +++ b/apps/api/scripts/drill-ingresos.ts @@ -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); }); diff --git a/apps/api/scripts/extract-terminos.mjs b/apps/api/scripts/extract-terminos.mjs new file mode 100644 index 0000000..b238836 --- /dev/null +++ b/apps/api/scripts/extract-terminos.mjs @@ -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); +}); diff --git a/apps/api/scripts/find-contribuyente.ts b/apps/api/scripts/find-contribuyente.ts new file mode 100644 index 0000000..53cea81 --- /dev/null +++ b/apps/api/scripts/find-contribuyente.ts @@ -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 '); 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); }); diff --git a/apps/api/scripts/find-i07-ppd-cases.ts b/apps/api/scripts/find-i07-ppd-cases.ts new file mode 100644 index 0000000..362b115 --- /dev/null +++ b/apps/api/scripts/find-i07-ppd-cases.ts @@ -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); }); diff --git a/apps/api/scripts/find-uuid.ts b/apps/api/scripts/find-uuid.ts new file mode 100644 index 0000000..cb83100 --- /dev/null +++ b/apps/api/scripts/find-uuid.ts @@ -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); }); diff --git a/apps/api/scripts/import-lista-negra.ts b/apps/api/scripts/import-lista-negra.ts new file mode 100644 index 0000000..fe251c1 --- /dev/null +++ b/apps/api/scripts/import-lista-negra.ts @@ -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 = {}; + 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(); + 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()); diff --git a/apps/api/scripts/inspect-cfdi-full.ts b/apps/api/scripts/inspect-cfdi-full.ts new file mode 100644 index 0000000..63024e9 --- /dev/null +++ b/apps/api/scripts/inspect-cfdi-full.ts @@ -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 '); 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); }); diff --git a/apps/api/scripts/inspect-cfdi.ts b/apps/api/scripts/inspect-cfdi.ts new file mode 100644 index 0000000..0d2eb68 --- /dev/null +++ b/apps/api/scripts/inspect-cfdi.ts @@ -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 + */ +import { prisma, tenantDb } from '../src/config/database.js'; + +const rawUuid = process.argv[2]; +if (!rawUuid) { + console.error('Usage: tsx scripts/inspect-cfdi.ts '); + 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); +}); diff --git a/apps/api/scripts/inspect-facturapi-invoice.ts b/apps/api/scripts/inspect-facturapi-invoice.ts new file mode 100644 index 0000000..d9a6ac9 --- /dev/null +++ b/apps/api/scripts/inspect-facturapi-invoice.ts @@ -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); }); diff --git a/apps/api/scripts/inspect-latest-facturapi.ts b/apps/api/scripts/inspect-latest-facturapi.ts new file mode 100644 index 0000000..488eab3 --- /dev/null +++ b/apps/api/scripts/inspect-latest-facturapi.ts @@ -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); }); diff --git a/apps/api/scripts/inspect-pair.ts b/apps/api/scripts/inspect-pair.ts new file mode 100644 index 0000000..11ffa82 --- /dev/null +++ b/apps/api/scripts/inspect-pair.ts @@ -0,0 +1,52 @@ +import { prisma, tenantDb } from '../src/config/database.js'; + +const I_UUID = '5c874749-748f-11f0-96b1-2b9310891836'; +const E_UUID = '7163da3b-748f-11f0-9853-e97a8e1dedd9'; + +async function main() { + const tenants = await prisma.tenant.findMany({ select: { id: true, rfc: true, databaseName: true } }); + + for (const t of tenants) { + let pool; + try { pool = await tenantDb.getPool(t.id, t.databaseName); } catch { continue; } + + const { rows } = await pool.query( + `SELECT uuid, tipo_comprobante, metodo_pago, cfdi_tipo_relacion, cfdis_relacionados, + status, fecha_emision, total_mxn, iva_traslado_mxn, + rfc_emisor, rfc_receptor, contribuyente_id, type + FROM cfdis WHERE LOWER(uuid) IN (LOWER($1), LOWER($2))`, + [I_UUID, E_UUID], + ); + + if (rows.length === 0) continue; + console.log(`\n=== ${t.rfc} ===`); + for (const r of rows) { + const fe = new Date(r.fecha_emision).toISOString().slice(0, 10); + console.log(`\n UUID: ${r.uuid}`); + console.log(` tipo: ${r.tipo_comprobante}/${r.metodo_pago || '?'} rel=${r.cfdi_tipo_relacion ?? 'null'} status=${r.status} type=${r.type}`); + console.log(` fecha: ${fe} total=${r.total_mxn} IVA=${r.iva_traslado_mxn}`); + console.log(` ${r.rfc_emisor} → ${r.rfc_receptor} contrib_id=${r.contribuyente_id}`); + console.log(` cfdis_relacionados: ${r.cfdis_relacionados ?? 'NULL'}`); + } + + // Si están ambos, verificar match de cfdis_relacionados + if (rows.length === 2) { + const i = rows.find((x: any) => x.uuid.toLowerCase() === I_UUID.toLowerCase()); + const e = rows.find((x: any) => x.uuid.toLowerCase() === E_UUID.toLowerCase()); + if (i && e) { + const iRels = (i.cfdis_relacionados || '').split('|').map((u: string) => u.trim().toLowerCase()).filter(Boolean); + const eRels = (e.cfdis_relacionados || '').split('|').map((u: string) => u.trim().toLowerCase()).filter(Boolean); + const overlap = iRels.filter((u: string) => eRels.includes(u)); + console.log(`\n I refs (${iRels.length}): ${iRels.join(', ').substring(0, 200)}`); + console.log(` E refs (${eRels.length}): ${eRels.join(', ').substring(0, 200)}`); + console.log(` Overlap (${overlap.length}): ${overlap.join(', ')}`); + + // Cruz: ¿la E referencia a la I directamente, o viceversa? + if (eRels.includes(I_UUID.toLowerCase())) console.log(` → E.cfdis_relacionados INCLUYE el UUID de I/07 PPD`); + if (iRels.includes(E_UUID.toLowerCase())) console.log(` → I.cfdis_relacionados INCLUYE el UUID de E`); + } + } + } + await prisma.$disconnect(); +} +main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); }); diff --git a/apps/api/scripts/inspect-rfc.ts b/apps/api/scripts/inspect-rfc.ts new file mode 100644 index 0000000..8ba5738 --- /dev/null +++ b/apps/api/scripts/inspect-rfc.ts @@ -0,0 +1,48 @@ +import { prisma, tenantDb } from '../src/config/database.js'; + +const rawRfc = process.argv[2]; +if (!rawRfc) { + console.error('Usage: tsx scripts/inspect-rfc.ts '); + process.exit(1); +} +const rfc = rawRfc.toUpperCase(); + +async function main() { + const tenants = await prisma.tenant.findMany({ + where: { active: true }, + select: { id: true, rfc: true, databaseName: true }, + }); + + for (const t of tenants) { + const pool = await tenantDb.getPool(t.id, t.databaseName); + + const { rows: contrib } = await pool.query( + `SELECT * FROM contribuyentes WHERE UPPER(rfc) = $1`, + [rfc], + ); + if (contrib.length > 0) { + console.log(`\n[${t.rfc}] Contribuyente ${rfc}:`); + console.log(contrib[0]); + } + + const { rows: rfcEntry } = await pool.query( + `SELECT id, rfc, razon_social, regimen_fiscal, codigo_postal FROM rfcs WHERE UPPER(rfc) = $1`, + [rfc], + ); + if (rfcEntry.length > 0) { + console.log(`[${t.rfc}] rfcs table:`, rfcEntry[0]); + } + + if (contrib.length > 0) { + const { rows: org } = await pool.query( + `SELECT facturapi_org_id, csd_uploaded, active FROM facturapi_orgs WHERE contribuyente_id = $1`, + [contrib[0].entidad_id], + ); + if (org.length > 0) console.log(`[${t.rfc}] facturapi_orgs:`, org[0]); + } + } + + await prisma.$disconnect(); +} + +main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); }); diff --git a/apps/api/scripts/invalidate-metricas-all.ts b/apps/api/scripts/invalidate-metricas-all.ts new file mode 100644 index 0000000..e4acf13 --- /dev/null +++ b/apps/api/scripts/invalidate-metricas-all.ts @@ -0,0 +1,159 @@ +/** + * Invalida TODAS las entradas en `metricas_mensuales` — marca para recompute + * cada (contribuyente_id, anio, mes) que tenga datos cacheados. Diseñado para + * usarse después de un cambio de fórmula que afecta resultados históricos + * (ej. 2026-04-23: NC tipo E con TipoRelacion=07 dejan de restar en Grupo 1). + * + * El cron `metricas-invalidations.job` (cada 15min) procesa el backlog. + * Para acelerar: `pnpm --filter @horux/api exec tsx -e "import { runProcessInvalidations } from './src/jobs/metricas-invalidations.job.js'; runProcessInvalidations().then(()=>process.exit(0))"` + * + * Uso: + * pnpm --filter @horux/api exec tsx scripts/invalidate-metricas-all.ts # ejecuta + * pnpm --filter @horux/api exec tsx scripts/invalidate-metricas-all.ts --dry # reporta sin escribir + */ +import { prisma, tenantDb } from '../src/config/database.js'; + +const DRY_RUN = process.argv.includes('--dry') || process.argv.includes('--dry-run'); +const REASON = process.argv.find(a => a.startsWith('--reason='))?.slice(9) || 'FORMULA_CHANGE_E07_GRUPO1'; + +interface PerTenantResult { + tenantId: string; + rfc: string; + databaseName: string; + metricasRows: number; + marcadasNuevas: number; + marcadasUpdate: number; + error?: string; +} + +async function invalidateTenant( + tenantId: string, + rfc: string, + databaseName: string, +): Promise { + const result: PerTenantResult = { + tenantId, + rfc, + databaseName, + metricasRows: 0, + marcadasNuevas: 0, + marcadasUpdate: 0, + }; + + const pool = await tenantDb.getPool(tenantId, databaseName); + + // Cuenta filas existentes en metricas_mensuales para reportar + const { rows: cnt } = await pool.query<{ n: number }>( + `SELECT COUNT(DISTINCT (contribuyente_id, anio, mes))::int AS n FROM metricas_mensuales`, + ); + result.metricasRows = cnt[0]?.n || 0; + if (result.metricasRows === 0) return result; + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + // Insert-or-update: si ya estaba marcada, sobrescribe reason y marcado_at + // para que el cron la re-procese con el motivo correcto. + const { rows: inserted } = await client.query<{ + contribuyente_id: string; + anio: number; + mes: number; + was_new: boolean; + }>( + ` + INSERT INTO metricas_invalidaciones (contribuyente_id, anio, mes, reason) + SELECT DISTINCT contribuyente_id, anio, mes, $1 AS reason + FROM metricas_mensuales + ON CONFLICT (contribuyente_id, anio, mes) DO UPDATE + SET reason = EXCLUDED.reason, marcado_at = now() + RETURNING contribuyente_id, anio, mes, (xmax = 0) AS was_new + `, + [REASON], + ); + + result.marcadasNuevas = inserted.filter(r => r.was_new).length; + result.marcadasUpdate = inserted.length - result.marcadasNuevas; + + if (DRY_RUN) { + await client.query('ROLLBACK'); + } else { + await client.query('COMMIT'); + } + } catch (err: any) { + await client.query('ROLLBACK').catch(() => {}); + result.error = err?.message || String(err); + } finally { + client.release(); + } + + return result; +} + +async function main() { + console.log(`=== Invalidate metricas_mensuales ${DRY_RUN ? '(DRY RUN — no writes)' : ''} ===`); + console.log(`Reason: ${REASON}\n`); + + const tenants = await prisma.tenant.findMany({ + where: { active: true }, + select: { id: true, rfc: true, databaseName: true }, + orderBy: { rfc: 'asc' }, + }); + + console.log(`Tenants activos: ${tenants.length}\n`); + + const results: PerTenantResult[] = []; + for (const t of tenants) { + process.stdout.write(`[${t.rfc}] (${t.databaseName}) ... `); + try { + const r = await invalidateTenant(t.id, t.rfc, t.databaseName); + results.push(r); + if (r.error) { + console.log(`ERROR: ${r.error}`); + } else if (r.metricasRows === 0) { + console.log(`sin cache (skip)`); + } else { + console.log( + `cache=${r.metricasRows} (contrib,año,mes), marcadas=${r.marcadasNuevas + r.marcadasUpdate} (nuevas=${r.marcadasNuevas}, re-marcadas=${r.marcadasUpdate})`, + ); + } + } catch (err: any) { + console.log(`FATAL: ${err?.message || err}`); + results.push({ + tenantId: t.id, + rfc: t.rfc, + databaseName: t.databaseName, + metricasRows: 0, + marcadasNuevas: 0, + marcadasUpdate: 0, + error: err?.message || String(err), + }); + } + } + + const totalMetricas = results.reduce((s, r) => s + r.metricasRows, 0); + const totalMarcadas = results.reduce((s, r) => s + r.marcadasNuevas + r.marcadasUpdate, 0); + const tenantsTouched = results.filter(r => r.marcadasNuevas + r.marcadasUpdate > 0).length; + const tenantsFailed = results.filter(r => r.error).length; + + console.log(`\n=== Resumen ===`); + console.log(` Tenants procesados: ${results.length}`); + console.log(` Tenants con cache: ${tenantsTouched}`); + console.log(` Filas cache total: ${totalMetricas}`); + console.log(` Invalidaciones: ${totalMarcadas}${DRY_RUN ? ' (rolled back)' : ''}`); + if (tenantsFailed > 0) console.log(` Tenants con error: ${tenantsFailed}`); + + if (!DRY_RUN && totalMarcadas > 0) { + console.log(`\nCron metricas-invalidations procesará el backlog en <=15 min.`); + console.log(`Para disparar manual: runProcessInvalidations() desde un tsx -e ad-hoc.`); + } + + await prisma.$disconnect(); + process.exit(tenantsFailed > 0 ? 1 : 0); +} + +main().catch(async (err) => { + console.error('Fatal:', err); + await prisma.$disconnect().catch(() => {}); + process.exit(1); +}); diff --git a/apps/api/scripts/list-contribuyentes.ts b/apps/api/scripts/list-contribuyentes.ts new file mode 100644 index 0000000..53c48f8 --- /dev/null +++ b/apps/api/scripts/list-contribuyentes.ts @@ -0,0 +1,26 @@ +import { prisma, tenantDb } from '../src/config/database.js'; + +async function main() { + const tenants = await prisma.tenant.findMany({ where: { active: true }, select: { id: true, rfc: true, databaseName: true } }); + for (const t of tenants) { + const pool = await tenantDb.getPool(t.id, t.databaseName); + // descubrir tablas con 'entidad' o 'contribuyente' en el nombre + const { rows: tbls } = await pool.query(`SELECT table_name FROM information_schema.tables WHERE table_schema='public' AND (table_name LIKE '%entidad%' OR table_name LIKE '%contribuyente%') ORDER BY table_name`); + console.log(`\n[${t.rfc}] tablas:`, tbls.map((r: any) => r.table_name).join(', ')); + + // Join con rfcs si existe + try { + const { rows } = await pool.query( + `SELECT c.entidad_id, c.rfc, r.razon_social, c.regimen_fiscal, c.codigo_postal + FROM contribuyentes c + LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc) + ORDER BY r.razon_social NULLS LAST, c.rfc`, + ); + for (const r of rows) console.log(' ', r); + } catch (e: any) { + console.log(' ERR:', e.message); + } + } + await prisma.$disconnect(); +} +main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); }); diff --git a/apps/api/scripts/migrate-tenants.ts b/apps/api/scripts/migrate-tenants.ts new file mode 100644 index 0000000..9a10f19 --- /dev/null +++ b/apps/api/scripts/migrate-tenants.ts @@ -0,0 +1,33 @@ +/** + * Eager tenant migration script. + * Run: pnpm --filter @horux/api db:migrate-tenants + * Or: pnpm db:migrate-tenants (from monorepo root via Turborepo) + * + * Applies pending SQL migrations to all active tenant databases. + */ +import { migrateAll } from '../src/config/tenant-migrations.js'; + +async function main() { + console.log('=== Tenant Schema Migration (Eager) ===\n'); + + const start = Date.now(); + const result = await migrateAll(); + const elapsed = ((Date.now() - start) / 1000).toFixed(1); + + console.log(`\n=== Done in ${elapsed}s ===`); + console.log(` Migrated: ${result.success}`); + console.log(` Up-to-date: ${result.skipped}`); + console.log(` Failed: ${result.failed}`); + + if (result.failed > 0) { + console.error('\nSome tenants failed migration. Check logs above.'); + process.exit(1); + } + + process.exit(0); +} + +main().catch((err) => { + console.error('Fatal error:', err); + process.exit(1); +}); diff --git a/apps/api/scripts/otf-ingresos.ts b/apps/api/scripts/otf-ingresos.ts new file mode 100644 index 0000000..03b980e --- /dev/null +++ b/apps/api/scripts/otf-ingresos.ts @@ -0,0 +1,27 @@ +process.env.METRICAS_BYPASS_CACHE = '1'; +import { prisma, tenantDb } from '../src/config/database.js'; +import { calcularIngresosPorRegimen } from '../src/services/dashboard.service.js'; + +const tenantRfc = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG'; +const contribuyenteId = process.argv[3] || 'b3761db6-0b8d-4251-8078-4ddc31e9c75b'; +const yearMonth = process.argv[4] || '2025-05'; + +async function main() { + const tenant = await prisma.tenant.findFirst({ where: { rfc: tenantRfc }, select: { id: true, databaseName: true } }); + if (!tenant) return; + const pool = await tenantDb.getPool(tenant.id, tenant.databaseName); + + const [anio, mes] = yearMonth.split('-').map(Number); + const lastDay = new Date(anio, mes, 0).getDate(); + const fi = `${yearMonth}-01`; + const ff = `${yearMonth}-${String(lastDay).padStart(2, '0')}`; + + const r = await calcularIngresosPorRegimen(pool, tenant.id, fi, ff, undefined, undefined, false, contribuyenteId); + console.log(`\n=== Ingresos ${yearMonth} contrib=${contribuyenteId} (BYPASS_CACHE=1) ===`); + console.log(`Total: ${r.total.toFixed(2)}`); + for (const p of r.porRegimen) { + console.log(` ${p.regimenClave} (${p.regimenDescripcion}): ${p.monto.toFixed(2)}`); + } + await prisma.$disconnect(); +} +main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); }); diff --git a/apps/api/scripts/preview-emails.mjs b/apps/api/scripts/preview-emails.mjs new file mode 100644 index 0000000..8a87023 --- /dev/null +++ b/apps/api/scripts/preview-emails.mjs @@ -0,0 +1,164 @@ +#!/usr/bin/env node +/** + * Genera los 8 templates de email como archivos HTML estáticos en + * `apps/api/email-previews/` para revisar el diseño en el navegador + * sin necesidad de SMTP configurado. + * + * Uso: + * pnpm email:preview + * + * Tras correr, abre `apps/api/email-previews/index.html` para ver + * el listado con links a cada template. + */ +import { writeFileSync, mkdirSync, rmSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, '..'); +const OUT_DIR = resolve(ROOT, 'email-previews'); + +// Datos de ejemplo realistas para cada template +const SAMPLES = { + 'welcome.html': { + label: 'Bienvenida', + fixture: { nombre: 'Carlos Hernández', email: 'carlos@empresa.com', tempPassword: 'a3f2c891' }, + importPath: '../src/services/email/templates/welcome.ts', + fnName: 'welcomeEmail', + }, + 'password-reset.html': { + label: 'Recuperación de contraseña', + fixture: { nombre: 'Carlos Hernández', resetUrl: 'https://horuxfin.com/reset-password?token=a8e4f...' }, + importPath: '../src/services/email/templates/password-reset.ts', + fnName: 'passwordResetEmail', + }, + 'payment-confirmed.html': { + label: 'Pago confirmado', + fixture: { nombre: 'Carlos Hernández', amount: 780, plan: 'Business + IA', date: new Date().toLocaleDateString('es-MX') }, + importPath: '../src/services/email/templates/payment-confirmed.ts', + fnName: 'paymentConfirmedEmail', + }, + 'payment-failed.html': { + label: 'Pago rechazado', + fixture: { nombre: 'Carlos Hernández', amount: 780, plan: 'Business + IA' }, + importPath: '../src/services/email/templates/payment-failed.ts', + fnName: 'paymentFailedEmail', + }, + 'subscription-cancelled.html': { + label: 'Suscripción cancelada', + fixture: { nombre: 'Carlos Hernández', plan: 'Business + IA' }, + importPath: '../src/services/email/templates/subscription-cancelled.ts', + fnName: 'subscriptionCancelledEmail', + }, + 'subscription-expiring.html': { + label: 'Suscripción por vencer', + fixture: { nombre: 'Carlos Hernández', plan: 'Business + IA', expiresAt: '15 de mayo, 2026' }, + importPath: '../src/services/email/templates/subscription-expiring.ts', + fnName: 'subscriptionExpiringEmail', + }, + 'fiel-notification.html': { + label: 'e.firma cargada (admin)', + fixture: { clienteNombre: 'Empresa Demo SA de CV', clienteRfc: 'EDE123456AB1' }, + importPath: '../src/services/email/templates/fiel-notification.ts', + fnName: 'fielNotificationEmail', + }, + 'weekly-update.html': { + label: 'Actualización semanal', + fixture: { + nombre: 'Carlos Hernández', + empresa: 'Empresa Demo SA de CV', + periodoLabel: 'Abril 2026', + kpis: { + ingresos: 285430.50, + egresos: 142900.00, + utilidad: 142530.50, + margen: 49.9, + ivaBalance: 18420.00, + ivaAFavorAcumulado: 32100.00, + cfdisEmitidos: 47, + cfdisRecibidos: 23, + }, + alertas: [ + { titulo: 'Cliente en lista negra', mensaje: '1 cliente con situación SAT "Definitivo".', prioridad: 'alta' }, + { titulo: 'Concentración alta de proveedores', mensaje: 'IHH = 6,840. Más del 50% del gasto en 1 proveedor.', prioridad: 'media' }, + { titulo: 'Pago en efectivo', mensaje: '3 facturas recibidas con forma de pago "01-Efectivo" este mes.', prioridad: 'baja' }, + ], + discrepanciasPorMes: [ + { label: 'Abril 2026', count: 2 }, + { label: 'Marzo 2026', count: 5 }, + { label: 'Febrero 2026', count: 0 }, + { label: 'Enero 2026', count: 1 }, + ], + fechaGeneracion: new Date().toLocaleString('es-MX', { dateStyle: 'long', timeStyle: 'short' }), + }, + importPath: '../src/services/email/templates/weekly-update.ts', + fnName: 'weeklyUpdateEmail', + }, + 'new-client-admin.html': { + label: 'Nuevo cliente registrado (admin)', + fixture: { + clienteNombre: 'Empresa Demo SA de CV', + clienteRfc: 'EDE123456AB1', + adminEmail: 'admin@empresademo.com', + adminNombre: 'Carlos Hernández', + tempPassword: 'a3f2c891', + databaseName: 'horux_ede123456ab1', + plan: 'mi_empresa_plus', + }, + importPath: '../src/services/email/templates/new-client-admin.ts', + fnName: 'newClientAdminEmail', + }, +}; + +async function main() { + // Limpia output previo y recrea + try { rmSync(OUT_DIR, { recursive: true, force: true }); } catch {} + mkdirSync(OUT_DIR, { recursive: true }); + + const generated = []; + for (const [filename, sample] of Object.entries(SAMPLES)) { + const modPath = resolve(__dirname, sample.importPath); + const mod = await import(pathToFileURL(modPath).href); + const fn = mod[sample.fnName]; + if (typeof fn !== 'function') { + console.error(`[email:preview] FAIL: ${sample.fnName} no exportada en ${modPath}`); + continue; + } + const html = fn(sample.fixture); + const outPath = resolve(OUT_DIR, filename); + writeFileSync(outPath, html, 'utf8'); + generated.push({ filename, label: sample.label }); + console.log(`[email:preview] ✓ ${filename}`); + } + + // Index navegable + const indexHtml = ` +Email previews — Horux 360 + +

Email previews — Horux 360

+

Generados desde los templates en apps/api/src/services/email/templates/ con datos de ejemplo. Cada link abre el HTML renderizado tal como llegaría al inbox del cliente.

+
    + ${generated.map(g => `
  • ${g.label} (${g.filename})
  • `).join('\n ')} +
+

Si modificas un template, vuelve a correr pnpm email:preview para regenerar.

+`; + + writeFileSync(resolve(OUT_DIR, 'index.html'), indexHtml, 'utf8'); + console.log(`\n[email:preview] ${generated.length} templates generados.`); + console.log(`[email:preview] Abre: ${resolve(OUT_DIR, 'index.html')}`); +} + +main().catch(err => { + console.error('[email:preview] FAIL:', err); + process.exit(1); +}); diff --git a/apps/api/scripts/process-metricas-now.ts b/apps/api/scripts/process-metricas-now.ts new file mode 100644 index 0000000..280d752 --- /dev/null +++ b/apps/api/scripts/process-metricas-now.ts @@ -0,0 +1,32 @@ +/** + * Dispara manualmente el procesamiento de `metricas_invalidaciones` para todos + * los tenants. Útil tras un `invalidate-metricas-all.ts` para no esperar al + * cron (cada 15 min). + * + * Uso: + * pnpm --filter @horux/api exec tsx scripts/process-metricas-now.ts + */ +import { prisma } from '../src/config/database.js'; +import { processAllTenantsInvalidations } from '../src/services/metricas-compute.service.js'; + +async function main() { + console.log('=== Procesar metricas_invalidaciones (all tenants) ===\n'); + const start = Date.now(); + const r = await processAllTenantsInvalidations(); + const elapsed = ((Date.now() - start) / 1000).toFixed(1); + console.log( + `\nTenants revisados: ${r.tenantsRevisados}\n` + + `Invalidaciones procesadas: ${r.totalProcesadas}\n` + + `Filas metricas_mensuales escritas: ${r.totalFilasEscritas}\n` + + `Errores: ${r.totalErrores}\n` + + `Tiempo: ${elapsed}s`, + ); + await prisma.$disconnect(); + process.exit(r.totalErrores > 0 ? 1 : 0); +} + +main().catch(async (err) => { + console.error('Fatal:', err); + await prisma.$disconnect().catch(() => {}); + process.exit(1); +}); diff --git a/apps/api/scripts/refresh-metricas-cache.ts b/apps/api/scripts/refresh-metricas-cache.ts new file mode 100644 index 0000000..074ec36 --- /dev/null +++ b/apps/api/scripts/refresh-metricas-cache.ts @@ -0,0 +1,59 @@ +/** + * Limpia el cache `metricas_mensuales` de TODOS los tenants activos. + * + * Ejecutar después de cambios fiscales en las fórmulas de ingresos/deducciones + * (dashboard.service.ts) — los valores ya escritos en el cache reflejan la + * fórmula vieja y muestran datos incorrectos para meses pasados hasta ser + * recomputados. + * + * Estrategia: TRUNCATE (vía DELETE) por tenant. La próxima consulta a un + * período pasado cae al path on-the-fly y rehidrata el cache con la fórmula + * vigente. No bloquea uso normal — solo aumenta latencia de la primera lectura. + * + * Idempotente. Por-tenant try/catch para que un tenant que falla no tumbe el + * resto. + */ +import { prisma, tenantDb } from '../src/config/database.js'; + +async function main() { + const tenants = await prisma.tenant.findMany({ + where: { active: true }, + select: { id: true, nombre: true, rfc: true, databaseName: true }, + orderBy: { rfc: 'asc' }, + }); + + console.log(`[Cache Refresh] Iterando ${tenants.length} tenant(s) activo(s)...\n`); + + let totalRows = 0; + let okTenants = 0; + let failedTenants = 0; + + for (const t of tenants) { + try { + const pool = await tenantDb.getPool(t.id, t.databaseName); + const result = await pool.query('DELETE FROM metricas_mensuales'); + const rows = result.rowCount ?? 0; + totalRows += rows; + okTenants++; + console.log(`✓ ${t.rfc.padEnd(15)} ${t.nombre.padEnd(40)} → ${rows.toLocaleString('es-MX')} filas borradas`); + } catch (err: any) { + failedTenants++; + console.error(`✗ ${t.rfc.padEnd(15)} ${t.nombre.padEnd(40)} → ERROR: ${err.message || err}`); + } + } + + console.log(`\n[Cache Refresh] Completado:`); + console.log(` Tenants OK: ${okTenants}`); + console.log(` Tenants fallidos: ${failedTenants}`); + console.log(` Total filas: ${totalRows.toLocaleString('es-MX')}`); + console.log(`\nLa próxima consulta a un período en cache lo recomputará on-demand`); + console.log(`con las fórmulas vigentes en dashboard.service.ts.`); + + await prisma.$disconnect(); + process.exit(failedTenants > 0 ? 1 : 0); +} + +main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/apps/api/scripts/set-horux-custom.ts b/apps/api/scripts/set-horux-custom.ts new file mode 100644 index 0000000..009fd88 --- /dev/null +++ b/apps/api/scripts/set-horux-custom.ts @@ -0,0 +1,100 @@ +/** + * Configura la suscripción del tenant Horux 360 (HTS240708LJA) como Plan Custom: + * - amount: $10 + * - currentPeriodEnd: hoy + 300 días + * - status: authorized + * + * Idempotente — actualiza la suscripción existente o crea una nueva si no hay. + * Resetea `lastReminderDay`/`lastReminderSentAt` para que el cron de avisos + * arranque limpio respecto al nuevo período. + */ +import { prisma } from '../src/config/database.js'; + +async function main() { + const RFC = 'HTS240708LJA'; + const AMOUNT = 10; + const DAYS_AHEAD = 7; + + const tenant = await prisma.tenant.findUnique({ where: { rfc: RFC } }); + if (!tenant) { + console.error(`Tenant ${RFC} no encontrado.`); + process.exit(1); + } + + const now = new Date(); + const periodEnd = new Date(now.getTime() + DAYS_AHEAD * 24 * 60 * 60 * 1000); + + const existing = await prisma.subscription.findFirst({ + where: { tenantId: tenant.id }, + orderBy: { createdAt: 'desc' }, + }); + + console.log('Tenant:', { id: tenant.id, nombre: tenant.nombre, plan: tenant.plan }); + console.log('Subscription previa:', existing ? { + id: existing.id, + plan: existing.plan, + status: existing.status, + amount: existing.amount.toString(), + currentPeriodEnd: existing.currentPeriodEnd, + } : null); + + let sub; + if (existing) { + sub = await prisma.subscription.update({ + where: { id: existing.id }, + data: { + plan: 'custom', + amount: AMOUNT, + status: 'authorized', + currentPeriodStart: now, + currentPeriodEnd: periodEnd, + // Limpiar pending/upgrade residuales del estado anterior. + pendingPlan: null, + pendingFrequency: null, + pendingEffectiveAt: null, + upgradePreferenceId: null, + upgradeTargetPlan: null, + upgradeTargetAmount: null, + // Reset del tracker de avisos — período nuevo, ningún bucket notificado. + lastReminderDay: null, + lastReminderSentAt: null, + }, + }); + } else { + sub = await prisma.subscription.create({ + data: { + tenantId: tenant.id, + plan: 'custom', + amount: AMOUNT, + status: 'authorized', + frequency: 'monthly', + currentPeriodStart: now, + currentPeriodEnd: periodEnd, + }, + }); + } + + // El tenant también tiene un campo `plan` propio — alinearlo con la sub. + if (tenant.plan !== 'custom') { + await prisma.tenant.update({ where: { id: tenant.id }, data: { plan: 'custom' } }); + console.log(`Tenant.plan actualizado: ${tenant.plan} → custom`); + } + + console.log('Subscription final:', { + id: sub.id, + plan: sub.plan, + status: sub.status, + amount: sub.amount.toString(), + currentPeriodStart: sub.currentPeriodStart, + currentPeriodEnd: sub.currentPeriodEnd, + }); + + console.log(`\n✓ Plan Custom activo. Próximo cobro: ${periodEnd.toLocaleDateString('es-MX', { dateStyle: 'long' })} ($${AMOUNT})`); + + await prisma.$disconnect(); +} + +main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/apps/api/scripts/setup-despachos-db.ts b/apps/api/scripts/setup-despachos-db.ts new file mode 100644 index 0000000..3d5fd2a --- /dev/null +++ b/apps/api/scripts/setup-despachos-db.ts @@ -0,0 +1,71 @@ +import { PrismaClient } from '@prisma/client'; +import bcrypt from 'bcryptjs'; + +const prisma = new PrismaClient(); + +async function main() { + console.log('Setting up horux_despachos database...'); + + // Create admin user + const hash = await bcrypt.hash('Admin12345!', 12); + + const user = await prisma.user.upsert({ + where: { email: 'ivan@horuxfin.com' }, + update: {}, + create: { + email: 'ivan@horuxfin.com', + passwordHash: hash, + nombre: 'Ivan Admin', + }, + }); + console.log('✅ User created:', user.email); + + // Find or create tenant + let tenant = await prisma.tenant.findFirst(); + if (!tenant) { + tenant = await prisma.tenant.create({ + data: { + nombre: 'Despacho Demo', + rfc: 'DDE250101AAA', + plan: 'trial', + databaseName: 'horux_dde250101aaa', + verticalProfile: 'CONTABLE', + dbMode: 'MANAGED', + trialEndsAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + }, + }); + console.log('✅ Tenant created:', tenant.nombre); + } else { + console.log('✅ Tenant exists:', tenant.nombre); + } + + // Create membership + await prisma.tenantMembership.upsert({ + where: { userId_tenantId: { userId: user.id, tenantId: tenant.id } }, + update: {}, + create: { + userId: user.id, + tenantId: tenant.id, + rolId: 1, + isOwner: true, + }, + }); + console.log('✅ Membership created (owner)'); + + // Set lastTenantId + await prisma.user.update({ + where: { id: user.id }, + data: { lastTenantId: tenant.id }, + }); + + console.log('\n🎉 Setup complete!'); + console.log('Login: ivan@horuxfin.com / Admin12345!'); + console.log('Tenant:', tenant.nombre, `(${tenant.rfc})`); +} + +main() + .catch((e) => { + console.error('Setup failed:', e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/apps/api/scripts/sweep-stale-sat-jobs.ts b/apps/api/scripts/sweep-stale-sat-jobs.ts new file mode 100644 index 0000000..0d0e632 --- /dev/null +++ b/apps/api/scripts/sweep-stale-sat-jobs.ts @@ -0,0 +1,47 @@ +/** + * CLI wrapper del watchdog. La lógica vive en + * `src/services/sat/sweep-stale-jobs.service.ts` para que también se pueda + * correr desde un cron (`sat-sync.job.ts`) sin duplicar código. + * + * Uso: + * pnpm --filter @horux/api exec tsx scripts/sweep-stale-sat-jobs.ts # dry-run + * pnpm --filter @horux/api exec tsx scripts/sweep-stale-sat-jobs.ts --apply # ejecuta + * STALE_RUNNING_HOURS=2 pnpm --filter @horux/api exec tsx scripts/sweep-stale-sat-jobs.ts + */ +import { prisma } from '../src/config/database.js'; +import { sweepStaleSatJobs } from '../src/services/sat/sweep-stale-jobs.service.js'; + +async function main() { + const apply = process.argv.includes('--apply'); + const pendingHours = Number(process.env.STALE_PENDING_HOURS || 12); + const runningHours = Number(process.env.STALE_RUNNING_HOURS || 4); + const mode = apply ? 'APPLY' : 'DRY-RUN'; + console.log(`=== SAT stale-jobs watchdog [${mode}] ===`); + console.log(` pending: nextRetryAt < now − ${pendingHours}h`); + console.log(` running: startedAt < now − ${runningHours}h`); + console.log(); + + const result = await sweepStaleSatJobs({ apply, pendingHours, runningHours }); + + console.log(`Encontrados:`); + console.log(` pending stale: ${result.pendingFound}`); + console.log(` running stale: ${result.runningFound}`); + + for (const e of result.entries) { + console.log(` ─ ${e.id} tenant=${e.tenantId} kind=${e.kind} edad=${e.ageHours}h`); + } + + if (!apply) { + console.log(`\n[DRY-RUN] No se aplicaron cambios. Pasa --apply para marcar como failed.`); + } else { + console.log(`\nMarcados como failed: pending=${result.pendingMarked} running=${result.runningMarked}`); + } + + await prisma.$disconnect(); +} + +main().catch(async (err) => { + console.error('Fatal:', err); + await prisma.$disconnect().catch(() => {}); + process.exit(1); +}); diff --git a/apps/api/scripts/test-emails.ts b/apps/api/scripts/test-emails.ts new file mode 100644 index 0000000..908c055 --- /dev/null +++ b/apps/api/scripts/test-emails.ts @@ -0,0 +1,96 @@ +import { emailService } from '../src/services/email/email.service.js'; + +const recipients = ['ivan@horuxfin.com', 'carlos@horuxfin.com']; + +async function sendAllSamples() { + for (const to of recipients) { + console.log(`\n=== Enviando a ${to} ===`); + + // 1. Welcome + console.log('1/6 Bienvenida...'); + await emailService.sendWelcome(to, { + nombre: 'Ivan Alcaraz', + email: 'ivan@horuxfin.com', + tempPassword: 'TempPass123!', + }); + + // 2. FIEL notification (goes to ADMIN_EMAIL, but we override for test) + console.log('2/6 Notificación FIEL...'); + // Send directly since sendFielNotification goes to admin + const { fielNotificationEmail } = await import('../src/services/email/templates/fiel-notification.js'); + const { createTransport } = await import('nodemailer'); + const { env } = await import('../src/config/env.js'); + const transport = createTransport({ + host: env.SMTP_HOST, + port: parseInt(env.SMTP_PORT), + secure: false, + auth: { user: env.SMTP_USER, pass: env.SMTP_PASS }, + }); + const fielHtml = fielNotificationEmail({ + clienteNombre: 'Horux 360', + clienteRfc: 'CAS200101XXX', + }); + await transport.sendMail({ + from: env.SMTP_FROM, + to, + subject: '[Horux 360] subió su FIEL (MUESTRA)', + html: fielHtml, + }); + + // 3. Payment confirmed + console.log('3/6 Pago confirmado...'); + await emailService.sendPaymentConfirmed(to, { + nombre: 'Ivan Alcaraz', + amount: 1499, + plan: 'Enterprise', + date: '16 de marzo de 2026', + }); + + // 4. Payment failed + console.log('4/6 Pago fallido...'); + const { paymentFailedEmail } = await import('../src/services/email/templates/payment-failed.js'); + const failedHtml = paymentFailedEmail({ + nombre: 'Ivan Alcaraz', + amount: 1499, + plan: 'Enterprise', + }); + await transport.sendMail({ + from: env.SMTP_FROM, + to, + subject: 'Problema con tu pago - Horux360 (MUESTRA)', + html: failedHtml, + }); + + // 5. Subscription expiring + console.log('5/6 Suscripción por vencer...'); + await emailService.sendSubscriptionExpiring(to, { + nombre: 'Ivan Alcaraz', + plan: 'Enterprise', + expiresAt: '21 de marzo de 2026', + }); + + // 6. Subscription cancelled + console.log('6/6 Suscripción cancelada...'); + const { subscriptionCancelledEmail } = await import('../src/services/email/templates/subscription-cancelled.js'); + const cancelledHtml = subscriptionCancelledEmail({ + nombre: 'Ivan Alcaraz', + plan: 'Enterprise', + }); + await transport.sendMail({ + from: env.SMTP_FROM, + to, + subject: 'Suscripción cancelada - Horux360 (MUESTRA)', + html: cancelledHtml, + }); + + console.log(`Listo: 6 correos enviados a ${to}`); + } + + console.log('\n=== Todos los correos enviados ==='); + process.exit(0); +} + +sendAllSamples().catch((err) => { + console.error('Error:', err); + process.exit(1); +}); diff --git a/apps/api/scripts/validate-dashboard-impuestos.ts b/apps/api/scripts/validate-dashboard-impuestos.ts new file mode 100644 index 0000000..4c992d1 --- /dev/null +++ b/apps/api/scripts/validate-dashboard-impuestos.ts @@ -0,0 +1,97 @@ +/** + * Valida la alineación dashboard ≡ impuestos tras refactor de getResumenIva. + * Para 5 muestras aleatorias por contribuyente, compara: + * dashboard.calcularIvaBalancePorRegimen().total vs + * impuestos.getResumenIva().resultado + * + * Deben coincidir céntimo por céntimo (Resultado = Trasladado − Acreditable − Retenido, + * usando los mismos 6 buckets del dashboard). + * + * Uso: + * pnpm --filter @horux/api exec tsx scripts/validate-dashboard-impuestos.ts + * METRICAS_BYPASS_CACHE=1 pnpm --filter @horux/api exec tsx scripts/validate-dashboard-impuestos.ts + */ +import { prisma, tenantDb } from '../src/config/database.js'; +import * as dashboard from '../src/services/dashboard.service.js'; +import { getResumenIva } from '../src/services/impuestos.service.js'; + +const TOL = 0.01; + +function cmp(a: number, b: number): boolean { return Math.abs(a - b) <= TOL; } +function fmt(n: number): string { + return n.toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +} + +async function main() { + console.log('=== Validación dashboard.balance ≡ impuestos.resultado ==='); + console.log(` BYPASS_CACHE=${process.env.METRICAS_BYPASS_CACHE === '1' ? 'YES' : 'no'}\n`); + + const tenants = await prisma.tenant.findMany({ + where: { active: true }, + select: { id: true, rfc: true, databaseName: true }, + }); + + let total = 0; + let pass = 0; + let fail = 0; + + for (const t of tenants) { + const pool = await tenantDb.getPool(t.id, t.databaseName); + const { rows: contribs } = await pool.query<{ entidad_id: string; nombre: string }>( + `SELECT c.entidad_id, eg.nombre + FROM contribuyentes c + JOIN entidades_gestionadas eg ON eg.id = c.entidad_id + WHERE EXISTS (SELECT 1 FROM metricas_mensuales m WHERE m.contribuyente_id = c.entidad_id)`, + ); + if (contribs.length === 0) continue; + console.log(`[${t.rfc}] ${contribs.length} contribuyentes`); + + for (const c of contribs) { + const { rows: samples } = await pool.query<{ anio: number; mes: number }>( + `SELECT anio, mes FROM ( + SELECT DISTINCT anio, mes FROM metricas_mensuales WHERE contribuyente_id = $1 + ) t + ORDER BY random() LIMIT 5`, + [c.entidad_id], + ); + console.log(` ${c.nombre}:`); + + for (const s of samples) { + total++; + const fi = `${s.anio}-${String(s.mes).padStart(2, '0')}-01`; + const lastDay = new Date(s.anio, s.mes, 0).getDate(); + const ff = `${s.anio}-${String(s.mes).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`; + + const bal = await dashboard.calcularIvaBalancePorRegimen( + pool, t.id, fi, ff, [], undefined, false, c.entidad_id, + ); + const resumen = await getResumenIva(pool, fi, ff, t.id, false, c.entidad_id); + + const mesLabel = `${s.anio}-${String(s.mes).padStart(2, '0')}`; + if (cmp(bal.total, resumen.resultado)) { + pass++; + console.log(` ✓ ${mesLabel} balance=$${fmt(bal.total)} resultado=$${fmt(resumen.resultado)}`); + } else { + fail++; + const delta = bal.total - resumen.resultado; + console.log(` ✗ ${mesLabel} balance=$${fmt(bal.total)} resultado=$${fmt(resumen.resultado)} Δ=$${fmt(delta)}`); + console.log(` T=$${fmt(resumen.trasladado)} A=$${fmt(resumen.acreditable)} R=$${fmt(resumen.retenido)}`); + } + } + } + } + + console.log(`\n=== Resumen ===`); + console.log(` Muestras: ${total}`); + console.log(` PASS: ${pass}`); + console.log(` FAIL: ${fail}`); + + await prisma.$disconnect(); + process.exit(fail > 0 ? 1 : 0); +} + +main().catch(async (err) => { + console.error('Fatal:', err); + await prisma.$disconnect().catch(() => {}); + process.exit(1); +}); diff --git a/apps/api/scripts/validate-gastos.ts b/apps/api/scripts/validate-gastos.ts new file mode 100644 index 0000000..ecf8e64 --- /dev/null +++ b/apps/api/scripts/validate-gastos.ts @@ -0,0 +1,115 @@ +/** + * Compara Gastos del Dashboard vs Drill-down para un mes/contribuyente. + * Identifica discrepancias y rompe el detalle por lado (factura/pago/NC). + * + * Uso: tsx scripts/validate-gastos.ts + */ +import { prisma, tenantDb } from '../src/config/database.js'; +import { calcularEgresosPorRegimen } from '../src/services/dashboard.service.js'; + +const tenantRfcArg = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG'; +const contribuyenteId = process.argv[3] || 'd745a915-6a23-4818-944b-a7e1e18e536a'; +const yearMonth = process.argv[4] || '2025-02'; + +async function main() { + const tenant = await prisma.tenant.findFirst({ + where: { rfc: tenantRfcArg, active: true }, + select: { id: true, rfc: true, databaseName: true }, + }); + if (!tenant) { console.error('Tenant not found'); process.exit(1); } + + const pool = await tenantDb.getPool(tenant.id, tenant.databaseName); + + const [anio, mes] = yearMonth.split('-').map(Number); + const lastDay = new Date(anio, mes, 0).getDate(); + const fi = `${yearMonth}-01`; + const ff = `${yearMonth}-${String(lastDay).padStart(2, '0')}`; + + console.log(`\n=== Contribuyente ${contribuyenteId} — ${fi} a ${ff} ===\n`); + + // 1. Dashboard (calcularEgresosPorRegimen) + const dashboard = await calcularEgresosPorRegimen( + pool, tenant.id, fi, ff, undefined, undefined, false, contribuyenteId, + ); + console.log('DASHBOARD calcularEgresosPorRegimen:'); + console.log(` total: ${dashboard.total.toFixed(2)}`); + for (const r of dashboard.porRegimen) { + console.log(` ${r.regimenClave} (${r.regimenDescripcion}): ${r.monto.toFixed(2)}`); + } + + // 2. Drill-down query (simulated — bucket=gastos uniforme) + const IMP_TRAS = `COALESCE(iva_traslado_mxn,0) + COALESCE(ieps_traslado_mxn,0) + COALESCE(impuestos_locales_trasladado_mxn,0)`; + const IMP_TRAS_PAGO = `COALESCE(iva_traslado_pago_mxn,0) + COALESCE(ieps_traslado_pago_mxn,0)`; + const EXCL_MONTO = `COALESCE((SELECT SUM(COALESCE(cc.importe_mxn,0) - COALESCE(cc.descuento_mxn,0)) FROM cfdi_conceptos cc WHERE cc.cfdi_id = cfdis.id AND cc.clave_prod_serv IN ('84121603','93161608','85101501','85121800')), 0)`; + + // bucket=gastos: RECIBIDO I PUE + RECIBIDO P + RECIBIDO E PUE (excl 07) + // Sumamos tomando en cuenta el signo (E resta) + const { rows: drillRows } = await pool.query( + `SELECT + type, tipo_comprobante, metodo_pago, + COALESCE(cfdi_tipo_relacion, '') AS tipo_rel, + COUNT(*)::int AS n, + SUM(total_mxn) AS total_bruto, + SUM(COALESCE(total_mxn,0) - (${IMP_TRAS}) - (${EXCL_MONTO})) AS total_neto, + SUM(COALESCE(monto_pago_mxn,0) - (${IMP_TRAS_PAGO})) AS pago_neto + FROM cfdis + WHERE ( + (type = 'RECIBIDO' AND tipo_comprobante = 'I' AND metodo_pago = 'PUE' + AND COALESCE(cfdi_tipo_relacion, '') <> '07') + OR (type = 'RECIBIDO' AND tipo_comprobante = 'P') + OR (type = 'RECIBIDO' AND tipo_comprobante = 'E' AND metodo_pago = 'PUE' + AND COALESCE(cfdi_tipo_relacion, '') <> '07') + ) + AND regimen_fiscal_receptor IN ('605','606','612','621','625','626','601','603','607','608','610','611','614','615','620','622','623','624') + AND status NOT IN ('Cancelado','0') + AND ((tipo_comprobante='P' AND fecha_pago_p >= $1::date AND fecha_pago_p < ($2::date + interval '1 day')) + OR (tipo_comprobante!='P' AND fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day'))) + AND contribuyente_id = $3 + GROUP BY type, tipo_comprobante, metodo_pago, tipo_rel + ORDER BY tipo_comprobante, metodo_pago`, + [fi, ff, contribuyenteId], + ); + + console.log(`\nDRILL-DOWN bucket=gastos (filas del drill por bucket):`); + let drillSumaFacturas = 0, drillSumaPagos = 0, drillSumaNC = 0; + for (const r of drillRows) { + const tc = r.tipo_comprobante; + const valor = tc === 'P' ? Number(r.pago_neto) : Number(r.total_neto); + console.log(` ${r.type} ${tc} ${r.metodo_pago || '-'} rel=${r.tipo_rel || '-'} n=${r.n} total_bruto=${Number(r.total_bruto).toFixed(2)} valor_neto=${valor.toFixed(2)}`); + if (tc === 'I') drillSumaFacturas += valor; + else if (tc === 'P') drillSumaPagos += valor; + else if (tc === 'E') drillSumaNC += valor; + } + const drillTotal = drillSumaFacturas + drillSumaPagos - drillSumaNC; + console.log(` → facturas=${drillSumaFacturas.toFixed(2)} pagos=${drillSumaPagos.toFixed(2)} NC=${drillSumaNC.toFixed(2)}`); + console.log(` → drill total = ${drillTotal.toFixed(2)}`); + + // 3. Comparación + const delta = dashboard.total - drillTotal; + console.log(`\n=== COMPARATIVA ===`); + console.log(` Dashboard: ${dashboard.total.toFixed(2)}`); + console.log(` Drill-down: ${drillTotal.toFixed(2)}`); + console.log(` Delta: ${delta.toFixed(2)}`); + + if (Math.abs(delta) < 0.01) { + console.log(` ✓ CUADRAN`); + } else { + console.log(` ✗ NO CUADRAN — investigar`); + } + + // 4. Régimenes del receptor que aparecen vs los ignorados + const { rows: regsReceptor } = await pool.query( + `SELECT DISTINCT regimen_fiscal_receptor + FROM cfdis + WHERE contribuyente_id = $1 + AND type = 'RECIBIDO' + AND fecha_emision >= $2::date AND fecha_emision < ($3::date + interval '1 day') + ORDER BY regimen_fiscal_receptor`, + [contribuyenteId, fi, ff], + ); + console.log(`\nRegímenes en CFDIs RECIBIDOS del periodo:`, regsReceptor.map(r => r.regimen_fiscal_receptor).join(', ')); + + await prisma.$disconnect(); +} + +main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); }); diff --git a/apps/api/scripts/validate-ingresos.ts b/apps/api/scripts/validate-ingresos.ts new file mode 100644 index 0000000..c053b31 --- /dev/null +++ b/apps/api/scripts/validate-ingresos.ts @@ -0,0 +1,39 @@ +/** + * Paridad dashboard vs drill para INGRESOS de un contribuyente en un año. + * Similar a validate-gastos pero para el lado emisor. + */ +import { prisma, tenantDb } from '../src/config/database.js'; +import { calcularIngresosPorRegimen } from '../src/services/dashboard.service.js'; + +const tenantRfc = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG'; +const contribuyenteId = process.argv[3] || '414b22a8-c6e2-4f39-be0f-7537a848107e'; +const año = Number(process.argv[4] || '2025'); + +async function main() { + const tenant = await prisma.tenant.findFirst({ where: { rfc: tenantRfc }, select: { id: true, databaseName: true } }); + if (!tenant) { console.error('Tenant not found'); process.exit(1); } + const pool = await tenantDb.getPool(tenant.id, tenant.databaseName); + + console.log(`\n=== Ingresos ${año} Contribuyente ${contribuyenteId} ===\n`); + console.log(`mes | total por régimen | total mes`); + + let totalAño = 0; + for (let m = 1; m <= 12; m++) { + const lastDay = new Date(año, m, 0).getDate(); + const mm = String(m).padStart(2, '0'); + const fi = `${año}-${mm}-01`; + const ff = `${año}-${mm}-${String(lastDay).padStart(2, '0')}`; + + const ingresos = await calcularIngresosPorRegimen( + pool, tenant.id, fi, ff, undefined, undefined, false, contribuyenteId, + ); + + const porReg = ingresos.porRegimen.map(r => `${r.regimenClave}:${r.monto.toFixed(2)}`).join(' / '); + console.log(`${mm} | ${porReg || '(sin datos)'} | ${ingresos.total.toFixed(2)}`); + totalAño += ingresos.total; + } + console.log(`\nTotal año: ${totalAño.toFixed(2)}`); + + await prisma.$disconnect(); +} +main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); }); diff --git a/apps/api/scripts/validate-metricas.ts b/apps/api/scripts/validate-metricas.ts new file mode 100644 index 0000000..2dd7e17 --- /dev/null +++ b/apps/api/scripts/validate-metricas.ts @@ -0,0 +1,160 @@ +/** + * Validación Tanda A: para cada contribuyente con datos en metricas_mensuales, + * toma 5 filas al azar y compara contra el cálculo on-the-fly usando los + * servicios canónicos (dashboard, impuestos). Reporta PASS/FAIL por celda. + * + * Uso: + * pnpm --filter @horux/api exec tsx scripts/validate-metricas.ts + */ +import { prisma, tenantDb } from '../src/config/database.js'; +import { + calcularIngresosPorRegimen, + calcularEgresosPorRegimen, +} from '../src/services/dashboard.service.js'; +import { getResumenIva } from '../src/services/impuestos.service.js'; + +const TOL = 0.01; // tolerancia de $0.01 para redondeo decimal + +interface StoredRow { + contribuyente_id: string; + anio: number; + mes: number; + regimen_fiscal: string | null; + ingresos_cobrados: string; + egresos_pagados: string; + iva_trasladado_total: string; + iva_acreditable: string; + iva_retenido_cobrado: string; + iva_resultado: string; + cfdis_emitidos_count: number; + cfdis_recibidos_count: number; + cfdis_cancelados_count: number; +} + +function cmp(a: number, b: number): boolean { + return Math.abs(a - b) <= TOL; +} + +function fmt(n: number): string { + return n.toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +} + +async function validateRow( + tenantId: string, + row: StoredRow, +): Promise<{ pass: boolean; diffs: string[] }> { + const tenant = await prisma.tenant.findUnique({ + where: { id: tenantId }, + select: { databaseName: true }, + }); + if (!tenant) return { pass: false, diffs: ['tenant no encontrado'] }; + + const pool = await tenantDb.getPool(tenantId, tenant.databaseName); + const fi = `${row.anio}-${String(row.mes).padStart(2, '0')}-01`; + const lastDay = new Date(row.anio, row.mes, 0).getDate(); + const ff = `${row.anio}-${String(row.mes).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`; + + // Ejecutamos secuencial para evitar interferencia entre queries bajo el pool + // limit del tenant (max 3 conexiones). Con Promise.all concurrente, algunas + // queries compartidas de getResumenIva devolvían valores parciales. + const ingresos = await calcularIngresosPorRegimen(pool, tenantId, fi, ff, [], undefined, false, row.contribuyente_id); + const egresos = await calcularEgresosPorRegimen(pool, tenantId, fi, ff, [], undefined, false, row.contribuyente_id); + const resumenIva = await getResumenIva(pool, fi, ff, tenantId, false, row.contribuyente_id); + + const reg = row.regimen_fiscal; + const ingOtf = ingresos.porRegimen.find(r => r.regimenClave === reg)?.monto || 0; + const egrOtf = egresos.porRegimen.find(r => r.regimenClave === reg)?.monto || 0; + const trasOtf = resumenIva.trasladadoPorRegimen.find(r => r.regimenClave === reg)?.monto || 0; + const acrOtf = resumenIva.acreditablePorRegimen.find(r => r.regimenClave === reg)?.monto || 0; + const retOtf = resumenIva.retenidoPorRegimen.find(r => r.regimenClave === reg)?.monto || 0; + const resOtf = trasOtf - acrOtf - retOtf; + + const diffs: string[] = []; + const ingStored = Number(row.ingresos_cobrados); + const egrStored = Number(row.egresos_pagados); + const trasStored = Number(row.iva_trasladado_total); + const acrStored = Number(row.iva_acreditable); + const retStored = Number(row.iva_retenido_cobrado); + const resStored = Number(row.iva_resultado); + + if (!cmp(ingStored, ingOtf)) diffs.push(`ingresos: tabla=${fmt(ingStored)} vs otf=${fmt(ingOtf)}`); + if (!cmp(egrStored, egrOtf)) diffs.push(`egresos: tabla=${fmt(egrStored)} vs otf=${fmt(egrOtf)}`); + if (!cmp(trasStored, trasOtf)) diffs.push(`ivaTras: tabla=${fmt(trasStored)} vs otf=${fmt(trasOtf)}`); + if (!cmp(acrStored, acrOtf)) diffs.push(`ivaAcr: tabla=${fmt(acrStored)} vs otf=${fmt(acrOtf)}`); + if (!cmp(retStored, retOtf)) diffs.push(`ivaRet: tabla=${fmt(retStored)} vs otf=${fmt(retOtf)}`); + if (!cmp(resStored, resOtf)) diffs.push(`ivaResultado: tabla=${fmt(resStored)} vs otf=${fmt(resOtf)}`); + + return { pass: diffs.length === 0, diffs }; +} + +async function main() { + console.log('=== Validación metricas_mensuales (5 muestras aleatorias por contribuyente) ===\n'); + + const tenants = await prisma.tenant.findMany({ + where: { active: true }, + select: { id: true, rfc: true, databaseName: true }, + }); + + let totalMuestras = 0; + let totalPass = 0; + let totalFail = 0; + + for (const t of tenants) { + const pool = await tenantDb.getPool(t.id, t.databaseName); + const { rows: contribs } = await pool.query<{ entidad_id: string; nombre: string }>( + `SELECT c.entidad_id, eg.nombre + FROM contribuyentes c + JOIN entidades_gestionadas eg ON eg.id = c.entidad_id + WHERE EXISTS ( + SELECT 1 FROM metricas_mensuales m WHERE m.contribuyente_id = c.entidad_id + )`, + ); + + if (contribs.length === 0) continue; + console.log(`\n[${t.rfc}] ${contribs.length} contribuyentes con datos`); + + for (const c of contribs) { + const { rows: samples } = await pool.query( + `SELECT contribuyente_id::text, anio, mes, regimen_fiscal, + ingresos_cobrados, egresos_pagados, + iva_trasladado_total, iva_acreditable, iva_retenido_cobrado, iva_resultado, + cfdis_emitidos_count, cfdis_recibidos_count, cfdis_cancelados_count + FROM metricas_mensuales + WHERE contribuyente_id = $1 + ORDER BY random() + LIMIT 5`, + [c.entidad_id], + ); + + console.log(` ${c.nombre} (${samples.length} muestras):`); + for (const s of samples) { + totalMuestras++; + const { pass, diffs } = await validateRow(t.id, s); + const mesLabel = `${s.anio}-${String(s.mes).padStart(2, '0')}`; + const reg = s.regimen_fiscal || 'null'; + if (pass) { + totalPass++; + console.log(` ✓ ${mesLabel} reg=${reg} ingresos=$${fmt(Number(s.ingresos_cobrados))}`); + } else { + totalFail++; + console.log(` ✗ ${mesLabel} reg=${reg} DIFFS:`); + for (const d of diffs) console.log(` - ${d}`); + } + } + } + } + + console.log(`\n=== Resumen ===`); + console.log(` Muestras totales: ${totalMuestras}`); + console.log(` PASS: ${totalPass}`); + console.log(` FAIL: ${totalFail}`); + + await prisma.$disconnect(); + process.exit(totalFail > 0 ? 1 : 0); +} + +main().catch(async (err) => { + console.error('Fatal:', err); + await prisma.$disconnect().catch(() => {}); + process.exit(1); +}); diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts new file mode 100644 index 0000000..6c5c3c9 --- /dev/null +++ b/apps/api/src/app.ts @@ -0,0 +1,112 @@ +import express, { type Express } from 'express'; +import cors from 'cors'; +import helmet from 'helmet'; +import { env, getCorsOrigins } from './config/env.js'; +import { errorMiddleware } from './middlewares/error.middleware.js'; +import { authRoutes } from './routes/auth.routes.js'; +import { dashboardRoutes } from './routes/dashboard.routes.js'; +import { cfdiRoutes } from './routes/cfdi.routes.js'; +import { impuestosRoutes } from './routes/impuestos.routes.js'; +import { exportRoutes } from './routes/export.routes.js'; +import { alertasRoutes } from './routes/alertas.routes.js'; +import { notificationPreferencesRoutes } from './routes/notification-preferences.routes.js'; +import { tareasRoutes } from './routes/tareas.routes.js'; +import { papeleriaRoutes } from './routes/papeleria.routes.js'; +import { despachoStatsRoutes } from './routes/despacho-stats.routes.js'; +import { calendarioRoutes } from './routes/calendario.routes.js'; +import { reportesRoutes } from './routes/reportes.routes.js'; +import { usuariosRoutes } from './routes/usuarios.routes.js'; +import { tenantsRoutes } from './routes/tenants.routes.js'; +import fielRoutes from './routes/fiel.routes.js'; +import satRoutes from './routes/sat.routes.js'; +import { webhookRoutes } from './routes/webhook.routes.js'; +import { subscriptionRoutes } from './routes/subscription.routes.js'; +import { regimenRoutes } from './routes/regimen.routes.js'; +import { bancosRoutes } from './routes/bancos.routes.js'; +import { conciliacionRoutes } from './routes/conciliacion.routes.js'; +import { facturacionRoutes } from './routes/facturacion.routes.js'; +import { catalogosRoutes } from './routes/catalogos.routes.js'; +import { documentosRoutes } from './routes/documentos.routes.js'; +import { auditLogRoutes } from './routes/audit-log.routes.js'; +import { platformStaffRoutes } from './routes/platform-staff.routes.js'; +import despachoRoutes from './routes/despacho.routes.js'; +import contribuyenteRoutes from './routes/contribuyente.routes.js'; +import carteraRoutes from './routes/cartera.routes.js'; +import planCatalogoRoutes from './routes/plan-catalogo.routes.js'; +import connectorRoutes from './routes/connector.routes.js'; +import adminDashboardRoutes from './routes/admin-dashboard.routes.js'; +import adminImpersonateRoutes from './routes/admin-impersonate.routes.js'; +import adminClientesRoutes from './routes/admin-clientes.routes.js'; +import adminAddonsRoutes from './routes/admin-addons.routes.js'; +import despachoAuditRoutes from './routes/despacho-audit.routes.js'; +import metricasRoutes from './routes/metricas.routes.js'; + +const app: Express = express(); + +// Security. Helmet default incluye un CSP restrictivo que puede chocar con el +// frontend cuando éste embebe recursos propios (ej: /terminos embebe el PDF de +// /legal/). Dejamos CSP off en el API y centralizamos los headers de seguridad +// en next.config del web (X-Frame-Options, CSP frame-ancestors, HSTS, nosniff, +// Referrer-Policy) que es quien sirve la UI. El API solo responde JSON y +// archivos binarios (PDFs, XMLs) — no tiene contenido HTML que requiera CSP. +app.use(helmet({ + contentSecurityPolicy: false, + crossOriginResourcePolicy: { policy: 'cross-origin' }, // permite /legal/*.pdf embebido +})); +app.use(cors({ + origin: getCorsOrigins(), + credentials: true, +})); + +// Body parsing - 10MB default, bulk CFDI route has its own higher limit +app.use(express.json({ limit: '10mb' })); +app.use(express.urlencoded({ extended: true, limit: '10mb' })); + +// Health check +app.get('/health', (req, res) => { + res.json({ status: 'ok', timestamp: new Date().toISOString() }); +}); + +// API Routes +app.use('/api/auth', authRoutes); +app.use('/api/dashboard', dashboardRoutes); +app.use('/api/cfdi', cfdiRoutes); +app.use('/api/impuestos', impuestosRoutes); +app.use('/api/export', exportRoutes); +app.use('/api/alertas', alertasRoutes); +app.use('/api/notificaciones', notificationPreferencesRoutes); +app.use('/api/tareas', tareasRoutes); +app.use('/api/papeleria', papeleriaRoutes); +app.use('/api/despachos', despachoStatsRoutes); +app.use('/api/calendario', calendarioRoutes); +app.use('/api/reportes', reportesRoutes); +app.use('/api/usuarios', usuariosRoutes); +app.use('/api/tenants', tenantsRoutes); +app.use('/api/fiel', fielRoutes); +app.use('/api/sat', satRoutes); +app.use('/api/webhooks', webhookRoutes); +app.use('/api/subscriptions', subscriptionRoutes); +app.use('/api/regimenes', regimenRoutes); +app.use('/api/bancos', bancosRoutes); +app.use('/api/conciliacion', conciliacionRoutes); +app.use('/api/facturacion', facturacionRoutes); +app.use('/api/catalogos', catalogosRoutes); +app.use('/api/documentos', documentosRoutes); +app.use('/api/audit-log', auditLogRoutes); +app.use('/api/platform-staff', platformStaffRoutes); +app.use('/api/despachos', despachoRoutes); +app.use('/api/contribuyentes', contribuyenteRoutes); +app.use('/api/carteras', carteraRoutes); +app.use('/api/planes', planCatalogoRoutes); +app.use('/api/connector', connectorRoutes); +app.use('/api/admin/dashboard', adminDashboardRoutes); +app.use('/api/admin/impersonate', adminImpersonateRoutes); +app.use('/api/admin/clientes', adminClientesRoutes); +app.use('/api/admin/addons', adminAddonsRoutes); +app.use('/api/despacho/audit-log', despachoAuditRoutes); +app.use('/api/metricas', metricasRoutes); + +// Error handling +app.use(errorMiddleware); + +export { app }; diff --git a/apps/api/src/auth/passwords.ts b/apps/api/src/auth/passwords.ts new file mode 100644 index 0000000..2713c42 --- /dev/null +++ b/apps/api/src/auth/passwords.ts @@ -0,0 +1 @@ +export { hashPassword, verifyPassword } from '@horux/core'; diff --git a/apps/api/src/auth/tokens.ts b/apps/api/src/auth/tokens.ts new file mode 100644 index 0000000..9d951e4 --- /dev/null +++ b/apps/api/src/auth/tokens.ts @@ -0,0 +1,30 @@ +import { + generateAccessToken as coreGenerateAccessToken, + generateRefreshToken as coreGenerateRefreshToken, + verifyToken as coreVerifyToken, + decodeToken, + type TokenConfig, +} from '@horux/core'; +import type { JWTPayload } from '@horux/shared'; +import { env } from '../config/env.js'; + +const tokenConfig: TokenConfig = { + secret: env.JWT_SECRET, + accessExpiresIn: env.JWT_EXPIRES_IN, + refreshExpiresIn: env.JWT_REFRESH_EXPIRES_IN, +}; + +export function generateAccessToken(payload: Omit): string { + return coreGenerateAccessToken(payload, tokenConfig); +} + +export function generateRefreshToken(payload: Omit): string { + return coreGenerateRefreshToken(payload, tokenConfig); +} + +export function verifyToken(token: string): JWTPayload { + return coreVerifyToken(token, tokenConfig.secret); +} + +export { decodeToken }; +export type { JWTPayload }; diff --git a/apps/api/src/config/database.ts b/apps/api/src/config/database.ts new file mode 100644 index 0000000..3f6b162 --- /dev/null +++ b/apps/api/src/config/database.ts @@ -0,0 +1,234 @@ +import { PrismaClient } from '@prisma/client'; +import { Pool, type PoolConfig } from 'pg'; +import { env } from './env.js'; +import { migrate } from './tenant-migrations.js'; + +// =========================================== +// Prisma Client (central database: horux360) +// =========================================== + +declare global { + var prisma: PrismaClient | undefined; +} + +export const prisma = globalThis.prisma || new PrismaClient({ + log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'], +}); + +if (process.env.NODE_ENV !== 'production') { + globalThis.prisma = prisma; +} + +// =========================================== +// TenantConnectionManager (per-tenant DBs) +// =========================================== + +interface PoolEntry { + pool: Pool; + lastAccess: Date; +} + +function parseDatabaseUrl(url: string) { + const parsed = new URL(url); + return { + host: parsed.hostname, + port: parseInt(parsed.port || '5432'), + user: decodeURIComponent(parsed.username), + password: decodeURIComponent(parsed.password), + }; +} + +class TenantConnectionManager { + private pools: Map = new Map(); + private cleanupInterval: NodeJS.Timeout | null = null; + private dbConfig: { host: string; port: number; user: string; password: string }; + private migratedPools: Set = new Set(); + + constructor() { + this.dbConfig = parseDatabaseUrl(env.DATABASE_URL); + this.cleanupInterval = setInterval(() => this.cleanupIdlePools(), 60_000); + } + + /** + * Get or create a connection pool for a tenant's database. + * Runs lazy migrations on first access (or after pool invalidation). + */ + async getPool( + tenantId: string, + databaseName: string, + connectionOverride?: { host: string; port: number; user: string; password: string }, + ): Promise { + let pool: Pool; + + const entry = this.pools.get(tenantId); + if (entry) { + entry.lastAccess = new Date(); + pool = entry.pool; + } else { + const poolConfig: PoolConfig = { + host: connectionOverride?.host ?? this.dbConfig.host, + port: connectionOverride?.port ?? this.dbConfig.port, + user: connectionOverride?.user ?? this.dbConfig.user, + password: connectionOverride?.password ?? this.dbConfig.password, + database: databaseName, + max: 3, + idleTimeoutMillis: 300_000, + connectionTimeoutMillis: 10_000, + }; + + pool = new Pool(poolConfig); + + pool.on('error', (err) => { + console.error(`[TenantDB] Pool error for tenant ${tenantId} (${databaseName}):`, err.message); + }); + + this.pools.set(tenantId, { pool, lastAccess: new Date() }); + } + + if (!this.migratedPools.has(tenantId)) { + try { + await migrate(pool, databaseName); + } catch (err) { + console.error(`[TenantDB] Migration error for tenant ${tenantId} (${databaseName}):`, err); + } + this.migratedPools.add(tenantId); + } + + return pool; + } + + /** + * Create a new database for a tenant with all required tables and indexes. + */ + async provisionDatabase(rfc: string, overrideDatabaseName?: string): Promise { + const databaseName = overrideDatabaseName || `horux_${rfc.toLowerCase().replace(/[^a-z0-9]/g, '')}`; + + const adminPool = new Pool({ + ...this.dbConfig, + database: 'postgres', + max: 1, + }); + + try { + const exists = await adminPool.query( + `SELECT 1 FROM pg_database WHERE datname = $1`, + [databaseName] + ); + + if (exists.rows.length > 0) { + throw new Error(`Database ${databaseName} already exists`); + } + + await adminPool.query(`CREATE DATABASE "${databaseName}"`); + + const tenantPool = new Pool({ + ...this.dbConfig, + database: databaseName, + max: 1, + }); + + try { + await migrate(tenantPool, databaseName); + } finally { + await tenantPool.end(); + } + + return databaseName; + } finally { + await adminPool.end(); + } + } + + /** + * Soft-delete: rename database so it can be recovered. + */ + async deprovisionDatabase(databaseName: string): Promise { + // Close any active pool for this tenant + for (const [tenantId, entry] of this.pools.entries()) { + // We check pool config to match the database + if ((entry.pool as any).options?.database === databaseName) { + await entry.pool.end().catch(() => {}); + this.pools.delete(tenantId); + } + } + + const timestamp = Date.now(); + const adminPool = new Pool({ + ...this.dbConfig, + database: 'postgres', + max: 1, + }); + + try { + await adminPool.query(` + SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid() + `, [databaseName]); + + await adminPool.query( + `ALTER DATABASE "${databaseName}" RENAME TO "${databaseName}_deleted_${timestamp}"` + ); + } finally { + await adminPool.end(); + } + } + + /** + * Invalidate (close and remove) a specific tenant's pool. + */ + invalidatePool(tenantId: string): void { + const entry = this.pools.get(tenantId); + if (entry) { + entry.pool.end().catch(() => {}); + this.pools.delete(tenantId); + } + this.migratedPools.delete(tenantId); + } + + /** + * Remove idle pools (not accessed in last 5 minutes). + */ + private cleanupIdlePools(): void { + const now = Date.now(); + const maxIdle = 5 * 60 * 1000; + + for (const [tenantId, entry] of this.pools.entries()) { + if (now - entry.lastAccess.getTime() > maxIdle) { + entry.pool.end().catch((err) => + console.error(`[TenantDB] Error closing idle pool for ${tenantId}:`, err.message) + ); + this.pools.delete(tenantId); + } + } + } + + /** + * Graceful shutdown: close all pools. + */ + async shutdown(): Promise { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + } + + const closePromises = Array.from(this.pools.values()).map((entry) => + entry.pool.end() + ); + await Promise.all(closePromises); + this.pools.clear(); + } + + /** + * Get stats about active pools. + */ + getStats(): { activePools: number; tenantIds: string[] } { + return { + activePools: this.pools.size, + tenantIds: Array.from(this.pools.keys()), + }; + } + +} + +// Singleton instance +export const tenantDb = new TenantConnectionManager(); diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts new file mode 100644 index 0000000..93a3a0a --- /dev/null +++ b/apps/api/src/config/env.ts @@ -0,0 +1,89 @@ +import { z } from 'zod'; +import { config } from 'dotenv'; +import { resolve } from 'path'; + +// Load .env file from the api package root +config({ path: resolve(process.cwd(), '.env') }); + +const envSchema = z.object({ + NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), + PORT: z.string().default('4000'), + DATABASE_URL: z.string(), + JWT_SECRET: z.string().min(32), + JWT_EXPIRES_IN: z.string().default('15m'), + JWT_REFRESH_EXPIRES_IN: z.string().default('7d'), + CORS_ORIGIN: z.string().default('http://localhost:3000'), + + // Frontend URL (for MercadoPago back_url, emails, etc.) + FRONTEND_URL: z.string().default('https://horuxfin.com'), + + // FIEL encryption (separate from JWT to allow independent rotation) + FIEL_ENCRYPTION_KEY: z.string().min(32), + FIEL_STORAGE_PATH: z.string().default('/var/horux/fiel'), + + // MercadoPago + MP_ACCESS_TOKEN: z.string().optional(), + // Token sandbox (TEST-...) para pruebas locales sin cobro real. Conviven con + // el de prod para no estar swap-eando manualmente. Solo se usa cuando + // MP_USE_SANDBOX=true. + MP_ACCESS_TOKEN_SANDBOX: z.string().optional(), + // Toggle global: cuando true, todas las llamadas a MP usan + // MP_ACCESS_TOKEN_SANDBOX. Default false → usa MP_ACCESS_TOKEN (prod). + MP_USE_SANDBOX: z.string().transform(v => v === 'true' || v === '1').default('false'), + MP_WEBHOOK_SECRET: z.string().optional(), + MP_NOTIFICATION_URL: z.string().optional(), + // Solo dev/staging: override del payer_email enviado a MercadoPago. Útil cuando + // el owner del tenant tiene el mismo email vinculado al MP_ACCESS_TOKEN + // (vendedor) — MP rechaza con "Payer and collector cannot be the same user". + // Al setearlo, todas las llamadas a MP usan este email como payer en lugar del + // owner real. Production: dejar vacío. (string vacío se trata como undefined + // para que prod pueda dejar la línea declarada sin valor sin romper Zod.) + MP_TEST_PAYER_EMAIL: z.preprocess( + v => (v === '' ? undefined : v), + z.string().email().optional(), + ), + + // SMTP (Gmail Workspace) + SMTP_HOST: z.string().default('smtp.gmail.com'), + SMTP_PORT: z.string().default('587'), + SMTP_USER: z.string().optional(), + SMTP_PASS: z.string().optional(), + SMTP_FROM: z.string().default('Horux360 '), + + // Admin notification email + ADMIN_EMAIL: z.string().default('carlos@horuxfin.com'), + + // Facturapi + FACTURAPI_USER_KEY: z.string().optional(), + + // Cloudflare Tunnel (connector BYO-DB) + CLOUDFLARE_API_TOKEN: z.string().optional(), + CLOUDFLARE_ACCOUNT_ID: z.string().optional(), + CLOUDFLARE_TUNNEL_DOMAIN: z.string().default('tunnel.horux.mx'), + + // KMS for encrypting DB connection strings and connector tokens + CONNECTOR_ENCRYPTION_KEY: z.string().optional(), + + // Metabase (auto-registro de BDs tenant en Metabase para BI) + METABASE_URL: z.string().optional(), + METABASE_USERNAME: z.string().optional(), + METABASE_PASSWORD: z.string().optional(), + METABASE_PG_HOST: z.string().optional(), + METABASE_PG_PORT: z.string().optional(), + METABASE_PG_USER: z.string().optional(), + METABASE_PG_PASSWORD: z.string().optional(), +}); + +const parsed = envSchema.safeParse(process.env); + +if (!parsed.success) { + console.error('❌ Invalid environment variables:', parsed.error.flatten().fieldErrors); + process.exit(1); +} + +export const env = parsed.data; + +// Parse CORS origins (comma-separated) into array +export function getCorsOrigins(): string[] { + return env.CORS_ORIGIN.split(',').map(origin => origin.trim()); +} diff --git a/apps/api/src/config/tenant-migrations.ts b/apps/api/src/config/tenant-migrations.ts new file mode 100644 index 0000000..df3cc43 --- /dev/null +++ b/apps/api/src/config/tenant-migrations.ts @@ -0,0 +1,143 @@ +import { Pool } from 'pg'; +import { readdir, readFile } from 'fs/promises'; +import { join } from 'path'; +import { prisma } from './database.js'; +import { env } from './env.js'; + +const MIGRATIONS_DIR = join(__dirname, '..', 'migrations', 'tenant'); + +export interface MigrationFile { + version: number; + name: string; + sql: string; +} + +export async function getMigrationFiles(): Promise { + let files: string[]; + + try { + files = await readdir(MIGRATIONS_DIR); + } catch (err: any) { + if (err.code === 'ENOENT') { + console.warn(`[Migrations] Directory not found: ${MIGRATIONS_DIR}`); + return []; + } + throw err; + } + + const pattern = /^(\d{3})_(.+)\.sql$/; + const migrations: MigrationFile[] = []; + + for (const file of files) { + const match = pattern.exec(file); + if (!match) continue; + + const version = parseInt(match[1], 10); + const name = file; + const sql = await readFile(join(MIGRATIONS_DIR, file), 'utf8'); + + migrations.push({ version, name, sql }); + } + + migrations.sort((a, b) => a.version - b.version); + return migrations; +} + +export async function migrate(pool: Pool, label?: string): Promise { + const prefix = label ? `[Migrations] (${label})` : '[Migrations]'; + + // Ensure schema_migrations table exists + await pool.query(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + name VARCHAR(255) NOT NULL, + applied_at TIMESTAMP DEFAULT NOW() + ); + `); + + // Get already-applied versions + const { rows } = await pool.query<{ version: number }>( + 'SELECT version FROM schema_migrations ORDER BY version' + ); + const appliedVersions = new Set(rows.map((r) => r.version)); + + // Get all migration files + const migrationFiles = await getMigrationFiles(); + const pending = migrationFiles.filter((m) => !appliedVersions.has(m.version)); + + if (pending.length === 0) { + return 0; + } + + console.log(`${prefix} Applying ${pending.length} pending migration(s)...`); + + for (const migration of pending) { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + await client.query(migration.sql); + await client.query( + 'INSERT INTO schema_migrations (version, name) VALUES ($1, $2)', + [migration.version, migration.name] + ); + await client.query('COMMIT'); + console.log(`${prefix} Applied: ${migration.name}`); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + } + + return pending.length; +} + +export async function migrateAll(): Promise<{ + success: number; + failed: number; + skipped: number; +}> { + const tenants = await prisma.tenant.findMany({ + where: { active: true }, + select: { id: true, rfc: true, databaseName: true }, + }); + + let success = 0; + let failed = 0; + let skipped = 0; + + for (const tenant of tenants) { + const parsed = new URL(env.DATABASE_URL); + const pool = new Pool({ + host: parsed.hostname, + port: parseInt(parsed.port || '5432'), + user: decodeURIComponent(parsed.username), + password: decodeURIComponent(parsed.password), + database: tenant.databaseName, + max: 1, + }); + + try { + const applied = await migrate(pool, tenant.rfc); + if (applied > 0) { + success++; + } else { + skipped++; + } + } catch (err: any) { + failed++; + console.error( + `[Migrations] (${tenant.rfc}) Failed: ${err.message}` + ); + } finally { + await pool.end(); + } + } + + console.log( + `[Migrations] Summary — success: ${success}, skipped: ${skipped}, failed: ${failed}` + ); + + return { success, failed, skipped }; +} diff --git a/apps/api/src/constants/obligaciones-fiscales.ts b/apps/api/src/constants/obligaciones-fiscales.ts new file mode 100644 index 0000000..6cb8662 --- /dev/null +++ b/apps/api/src/constants/obligaciones-fiscales.ts @@ -0,0 +1,84 @@ +export interface ObligacionFiscal { + id: string; + nombre: string; + fundamento: string; + frecuencia: 'mensual' | 'bimestral' | 'trimestral' | 'anual' | 'eventual'; + fechaLimite: string; + aplica: 'PM' | 'PF' | 'ambos'; + regimenes: string[] | null; // null = all regimes + condicion: string | null; + categoria: string; + recomendadaPorDefecto: boolean; +} + +export const OBLIGACIONES_CATALOGO: ObligacionFiscal[] = [ + // === FEDERALES MENSUALES (día 17) === + { id: 'isr-provisional', nombre: 'Pago provisional de ISR', fundamento: 'Art. 14 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Federal mensual', recomendadaPorDefecto: true }, + { id: 'iva-mensual', nombre: 'Pago mensual definitivo de IVA', fundamento: 'Art. 5-D LIVA', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Federal mensual', recomendadaPorDefecto: true }, + { id: 'ret-isr-sueldos', nombre: 'Retenciones de ISR por sueldos y salarios', fundamento: 'Art. 96 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'Facturas emitidas tipo N', categoria: 'Federal mensual', recomendadaPorDefecto: false }, + { id: 'ret-isr-asimilados', nombre: 'Retenciones de ISR por asimilados a salarios', fundamento: 'Art. 94 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'Facturas emitidas tipo N', categoria: 'Federal mensual', recomendadaPorDefecto: false }, + { id: 'ret-isr-honorarios', nombre: 'Retenciones de ISR por honorarios y arrendamiento a PF', fundamento: 'Art. 106/116 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'PM que contrate PF', categoria: 'Federal mensual', recomendadaPorDefecto: false }, + { id: 'ret-iva', nombre: 'Retenciones de IVA (servicios, fletes, outsourcing)', fundamento: 'Art. 1-A LIVA', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'Según supuesto', categoria: 'Federal mensual', recomendadaPorDefecto: false }, + { id: 'ieps', nombre: 'Pago definitivo de IEPS', fundamento: 'Art. 5 LIEPS', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'Productores/importadores', categoria: 'Federal mensual', recomendadaPorDefecto: false }, + + // === INFORMATIVAS MENSUALES === + { id: 'diot', nombre: 'DIOT (Declaración Informativa de Operaciones con Terceros)', fundamento: 'Art. 32 LIVA', frecuencia: 'mensual', fechaLimite: 'Último día del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'PF con ingresos > $4M y todas las PM, excepto RESICO', categoria: 'Informativa mensual', recomendadaPorDefecto: false }, + { id: 'cont-balanza', nombre: 'Contabilidad Electrónica - Balanza de comprobación', fundamento: 'CFF Art. 28', frecuencia: 'mensual', fechaLimite: 'Día 3 del segundo mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'PF con ingresos > $4M y todas las PM, excepto RESICO', categoria: 'Informativa mensual', recomendadaPorDefecto: false }, + { id: 'cont-catalogo', nombre: 'Contabilidad Electrónica - Catálogo de cuentas', fundamento: 'CFF Art. 28', frecuencia: 'eventual', fechaLimite: 'Cuando haya modificación', aplica: 'ambos', regimenes: null, condicion: 'PF con ingresos > $4M y todas las PM, excepto RESICO', categoria: 'Informativa mensual', recomendadaPorDefecto: false }, + + // === RESICO PM === + { id: 'isr-resico-pm', nombre: 'Pago provisional ISR RESICO-PM', fundamento: 'Art. 206 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: ['626'], condicion: null, categoria: 'RESICO PM', recomendadaPorDefecto: true }, + + // === RESICO PF === + { id: 'isr-resico-pf', nombre: 'Pago mensual ISR RESICO PF (1%-2.5%)', fundamento: 'Art. 113-E LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PF', regimenes: ['626'], condicion: null, categoria: 'RESICO PF', recomendadaPorDefecto: true }, + + // === ANUALES PM === + { id: 'anual-isr-pm', nombre: 'Declaración Anual de ISR PM', fundamento: 'Art. 76 LISR', frecuencia: 'anual', fechaLimite: '31 de marzo', aplica: 'PM', regimenes: null, condicion: null, categoria: 'Anual', recomendadaPorDefecto: true }, + { id: 'issif', nombre: 'ISSIF (Información sobre Situación Fiscal)', fundamento: 'CFF Art. 32-H', frecuencia: 'anual', fechaLimite: 'Con la declaración anual', aplica: 'PM', regimenes: null, condicion: null, categoria: 'Anual', recomendadaPorDefecto: false }, + { id: 'dictamen-fiscal', nombre: 'Dictamen Fiscal', fundamento: 'CFF Art. 32-A', frecuencia: 'anual', fechaLimite: '15 de mayo', aplica: 'PM', regimenes: null, condicion: 'Ingresos > $1,855M o grupos', categoria: 'Anual', recomendadaPorDefecto: false }, + { id: 'dim', nombre: 'DIM - Declaraciones Informativas Múltiples', fundamento: 'CFF', frecuencia: 'anual', fechaLimite: '15 de febrero', aplica: 'PM', regimenes: null, condicion: null, categoria: 'Anual', recomendadaPorDefecto: false }, + + // === ANUALES PF === + { id: 'anual-isr-pf', nombre: 'Declaración Anual PF', fundamento: 'Art. 150 LISR', frecuencia: 'anual', fechaLimite: '30 de abril', aplica: 'PF', regimenes: null, condicion: null, categoria: 'Anual', recomendadaPorDefecto: true }, + + // === SEGURIDAD SOCIAL === + { id: 'imss-cuotas', nombre: 'Cuotas obrero-patronales IMSS', fundamento: 'LSS', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', recomendadaPorDefecto: false }, + { id: 'infonavit', nombre: 'Aportaciones INFONAVIT + amortizaciones', fundamento: 'LINFONAVIT', frecuencia: 'bimestral', fechaLimite: 'Día 17 del mes siguiente al bimestre', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', recomendadaPorDefecto: false }, + { id: 'sar-retiro', nombre: 'SAR / Retiro', fundamento: 'LSS', frecuencia: 'bimestral', fechaLimite: 'Día 17 del mes siguiente al bimestre', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', recomendadaPorDefecto: false }, + { id: 'prima-riesgo', nombre: 'Determinación Prima de Riesgo de Trabajo', fundamento: 'LSS Art. 74', frecuencia: 'anual', fechaLimite: 'Febrero', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', recomendadaPorDefecto: false }, + + // === ESTATALES === + { id: 'isn', nombre: 'ISN - Impuesto Sobre Nómina', fundamento: 'Ley estatal', frecuencia: 'mensual', fechaLimite: 'Varía por estado (CDMX día 17)', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Estatal', recomendadaPorDefecto: false }, +]; + +/** + * Returns recommended obligations for a contribuyente based on: + * - PM vs PF (RFC length: 12 = PM, 13 = PF) + * - Specific regímenes + * - Whether they have nómina CFDIs (type N) + */ +export function getRecomendaciones(rfc: string, regimenes: string[], tieneNomina: boolean): ObligacionFiscal[] { + const esPM = rfc.length === 12; + const tipo = esPM ? 'PM' : 'PF'; + + return OBLIGACIONES_CATALOGO.filter(ob => { + // Filter by PM/PF + if (ob.aplica !== 'ambos' && ob.aplica !== tipo) return false; + + // Filter by régimen if specified + if (ob.regimenes && ob.regimenes.length > 0) { + if (!regimenes.some(r => ob.regimenes!.includes(r))) return false; + } + + // Always recommend IVA + ISR + if (ob.recomendadaPorDefecto) return true; + + // Recommend nómina obligations if they have type N + if (tieneNomina && ob.condicion?.includes('tipo N')) return true; + + // Recommend nómina-related social security if has employees + if (tieneNomina && ob.condicion?.includes('empleados')) return true; + + return false; + }); +} diff --git a/apps/api/src/controllers/activos-fijos.controller.ts b/apps/api/src/controllers/activos-fijos.controller.ts new file mode 100644 index 0000000..908d7bc --- /dev/null +++ b/apps/api/src/controllers/activos-fijos.controller.ts @@ -0,0 +1,87 @@ +import type { Request, Response, NextFunction } from 'express'; +import { z } from 'zod'; +import { AppError } from '../middlewares/error.middleware.js'; +import * as activosFijosService from '../services/activos-fijos.service.js'; + +function effectiveTenantId(req: Request): string { + return req.viewingTenantId || req.user!.tenantId; +} + +const listSchema = z.object({ + año: z.string().regex(/^\d{4}$/), + mes: z.string().regex(/^\d{1,2}$/), + contribuyenteId: z.string().uuid().optional(), + estado: z.enum(['todos', 'activos', 'baja', 'agotados']).optional(), +}); + +export async function list(req: Request, res: Response, next: NextFunction) { + try { + const q = listSchema.parse(req.query); + const data = await activosFijosService.listActivosFijos( + req.tenantPool!, + effectiveTenantId(req), + parseInt(q.año, 10), + parseInt(q.mes, 10), + q.contribuyenteId ?? null, + q.estado, + ); + res.json(data); + } catch (error) { + if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message)); + next(error); + } +} + +const bajaSchema = z.object({ + fechaBaja: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + motivo: z.enum(['venta', 'desecho', 'otro']), + comentario: z.string().max(2000).nullable().optional(), +}); + +export async function darDeBaja(req: Request, res: Response, next: NextFunction) { + try { + const cfdiId = parseInt(String(req.params.cfdiId), 10); + if (isNaN(cfdiId)) return next(new AppError(400, 'cfdiId inválido')); + const data = bajaSchema.parse(req.body); + await activosFijosService.darDeBaja( + req.tenantPool!, + cfdiId, + data.fechaBaja, + data.motivo, + req.user!.userId, + data.comentario ?? null, + ); + res.status(201).json({ ok: true }); + } catch (error) { + if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message)); + next(error); + } +} + +const usosExcluidosSchema = z.object({ + contribuyenteId: z.string().uuid(), + usos: z.array(z.string().regex(/^I0[1-8]$/)), +}); + +export async function setUsosExcluidos(req: Request, res: Response, next: NextFunction) { + try { + const { contribuyenteId, usos } = usosExcluidosSchema.parse(req.body); + const saved = await activosFijosService.setUsosExcluidos(req.tenantPool!, contribuyenteId, usos); + res.json({ usosExcluidos: saved }); + } catch (error) { + if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message)); + next(error); + } +} + +export async function revertirBaja(req: Request, res: Response, next: NextFunction) { + try { + const cfdiId = parseInt(String(req.params.cfdiId), 10); + if (isNaN(cfdiId)) return next(new AppError(400, 'cfdiId inválido')); + const ok = await activosFijosService.revertirBaja(req.tenantPool!, cfdiId); + if (!ok) return next(new AppError(404, 'Activo no estaba dado de baja')); + res.status(204).send(); + } catch (error) { + next(error); + } +} diff --git a/apps/api/src/controllers/admin-addons.controller.ts b/apps/api/src/controllers/admin-addons.controller.ts new file mode 100644 index 0000000..0ca13ea --- /dev/null +++ b/apps/api/src/controllers/admin-addons.controller.ts @@ -0,0 +1,86 @@ +import type { Request, Response, NextFunction } from 'express'; +import { z } from 'zod'; +import { prisma } from '../config/database.js'; +import { isPlatformStaff } from '../utils/platform-admin.js'; +import { AppError } from '../middlewares/error.middleware.js'; +import { auditFromReq } from '../utils/audit.js'; + +async function requireStaff(req: Request) { + if (!req.user?.userId) throw new AppError(401, 'No autenticado'); + const isStaff = await isPlatformStaff(req.user.userId); + if (!isStaff) throw new AppError(403, 'Acceso restringido a staff de plataforma'); +} + +const updateSchema = z.object({ + nombre: z.string().min(1).max(200).optional(), + precio: z.number().nonnegative().optional(), + active: z.boolean().optional(), +}); + +/** Lista todo el catálogo de add-ons (incluye inactivos). */ +export async function listCatalogo(req: Request, res: Response, next: NextFunction) { + try { + await requireStaff(req); + const items = await prisma.planAddonCatalogo.findMany({ + orderBy: { codename: 'asc' }, + include: { + _count: { select: { subscriptionAddons: { where: { status: { in: ['authorized', 'pending'] } } } } }, + }, + }); + return res.json({ + data: items.map(i => ({ + id: i.id, + codename: i.codename, + nombre: i.nombre, + verticalProfile: i.verticalProfile, + precio: Number(i.precio), + frecuencia: i.frecuencia, + active: i.active, + delta: i.delta, + createdAt: i.createdAt.toISOString(), + suscripcionesActivas: i._count.subscriptionAddons, + })), + }); + } catch (err) { return next(err); } +} + +export async function updateCatalogoItem(req: Request, res: Response, next: NextFunction) { + try { + await requireStaff(req); + const id = String(req.params.id); + const data = updateSchema.parse(req.body); + const before = await prisma.planAddonCatalogo.findUnique({ where: { id } }); + if (!before) throw new AppError(404, 'Add-on no encontrado'); + + const updated = await prisma.planAddonCatalogo.update({ + where: { id }, + data: { + ...(data.nombre !== undefined ? { nombre: data.nombre } : {}), + ...(data.precio !== undefined ? { precio: data.precio } : {}), + ...(data.active !== undefined ? { active: data.active } : {}), + }, + }); + + auditFromReq(req, 'addon.catalogo_updated', { + entityType: 'PlanAddonCatalogo', + entityId: id, + metadata: { + codename: before.codename, + before: { nombre: before.nombre, precio: Number(before.precio), active: before.active }, + after: { nombre: updated.nombre, precio: Number(updated.precio), active: updated.active }, + }, + }); + + return res.json({ + id: updated.id, + codename: updated.codename, + nombre: updated.nombre, + precio: Number(updated.precio), + frecuencia: updated.frecuencia, + active: updated.active, + }); + } catch (err: any) { + if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message)); + return next(err); + } +} diff --git a/apps/api/src/controllers/admin-clientes.controller.ts b/apps/api/src/controllers/admin-clientes.controller.ts new file mode 100644 index 0000000..8b3baa2 --- /dev/null +++ b/apps/api/src/controllers/admin-clientes.controller.ts @@ -0,0 +1,46 @@ +import type { Request, Response, NextFunction } from 'express'; +import * as svc from '../services/admin-clientes.service.js'; +import { isPlatformStaff } from '../utils/platform-admin.js'; +import { AppError } from '../middlewares/error.middleware.js'; + +async function requireStaff(req: Request) { + if (!req.user?.userId) throw new AppError(401, 'No autenticado'); + const isStaff = await isPlatformStaff(req.user.userId); + if (!isStaff) throw new AppError(403, 'Acceso restringido a staff de plataforma'); +} + +/** + * Stats de gestión de clientes. + * + * Query params: + * - `from` (YYYY-MM-DD): inicio del rango. Default: primer día del mes en curso. + * - `to` (YYYY-MM-DD): fin del rango. Default: último día del mes en curso. + */ +export async function getStats(req: Request, res: Response, next: NextFunction) { + try { + await requireStaff(req); + const now = new Date(); + const defaultFrom = new Date(now.getFullYear(), now.getMonth(), 1); + const defaultTo = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999); + + const fromStr = String(req.query.from || '').trim(); + const toStr = String(req.query.to || '').trim(); + const from = fromStr ? new Date(fromStr + 'T00:00:00') : defaultFrom; + const to = toStr ? new Date(toStr + 'T23:59:59.999') : defaultTo; + if (isNaN(from.getTime()) || isNaN(to.getTime())) { + return next(new AppError(400, 'Rango de fechas inválido')); + } + const stats = await svc.getClientesStats({ from, to }); + return res.json(stats); + } catch (err) { return next(err); } +} + +export async function listUsuarios(req: Request, res: Response, next: NextFunction) { + try { + await requireStaff(req); + const tenantId = String(req.params.tenantId || ''); + if (!tenantId) return next(new AppError(400, 'tenantId requerido')); + const usuarios = await svc.getTenantUsuarios(tenantId); + return res.json({ data: usuarios }); + } catch (err) { return next(err); } +} diff --git a/apps/api/src/controllers/admin-dashboard.controller.ts b/apps/api/src/controllers/admin-dashboard.controller.ts new file mode 100644 index 0000000..a7ea4bc --- /dev/null +++ b/apps/api/src/controllers/admin-dashboard.controller.ts @@ -0,0 +1,36 @@ +import type { Request, Response, NextFunction } from 'express'; +import * as dashService from '../services/admin-dashboard.service.js'; +import { isPlatformStaff } from '../utils/platform-admin.js'; +import { AppError } from '../middlewares/error.middleware.js'; + +async function requireStaff(req: Request) { + if (!req.user?.userId) throw new AppError(401, 'No autenticado'); + const isStaff = await isPlatformStaff(req.user.userId); + if (!isStaff) throw new AppError(403, 'Acceso restringido a staff de plataforma'); +} + +export async function getMetrics(req: Request, res: Response, next: NextFunction) { + try { + await requireStaff(req); + const metrics = await dashService.getDashboardMetrics(); + return res.json(metrics); + } catch (err) { return next(err); } +} + +export async function listDespachos(req: Request, res: Response, next: NextFunction) { + try { + await requireStaff(req); + const { vertical, status, search } = req.query as Record; + const despachos = await dashService.listAllDespachos({ vertical, status, search }); + return res.json({ data: despachos }); + } catch (err) { return next(err); } +} + +export async function getActivity(req: Request, res: Response, next: NextFunction) { + try { + await requireStaff(req); + const limit = Math.min(Number(req.query.limit) || 20, 100); + const activity = await dashService.getRecentActivity(limit); + return res.json({ data: activity }); + } catch (err) { return next(err); } +} diff --git a/apps/api/src/controllers/admin-impersonate.controller.ts b/apps/api/src/controllers/admin-impersonate.controller.ts new file mode 100644 index 0000000..6469f7e --- /dev/null +++ b/apps/api/src/controllers/admin-impersonate.controller.ts @@ -0,0 +1,77 @@ +import type { Request, Response, NextFunction } from 'express'; +import { z } from 'zod'; +import { prisma } from '../config/database.js'; +import { hasPlatformRole } from '../utils/platform-admin.js'; +import { auditLog } from '../utils/audit.js'; +import { AppError } from '../middlewares/error.middleware.js'; + +const impersonateSchema = z.object({ + despachoId: z.string().uuid('ID de despacho inválido'), + motivo: z.string().min(5, 'Motivo es obligatorio (mínimo 5 caracteres)'), +}); + +export async function startImpersonation(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user?.userId) return next(new AppError(401, 'No autenticado')); + + const canImpersonate = await hasPlatformRole(req.user.userId, 'platform_admin') || + await hasPlatformRole(req.user.userId, 'platform_ti') || + await hasPlatformRole(req.user.userId, 'platform_support'); + if (!canImpersonate) return next(new AppError(403, 'No tienes permisos para impersonar')); + + const { despachoId, motivo } = impersonateSchema.parse(req.body); + + const tenant = await prisma.tenant.findUnique({ + where: { id: despachoId }, + select: { id: true, nombre: true, rfc: true, active: true }, + }); + if (!tenant) return next(new AppError(404, 'Despacho no encontrado')); + if (!tenant.active) return next(new AppError(403, 'Despacho inactivo')); + + await auditLog({ + userId: req.user.userId, + tenantId: despachoId, + action: 'admin.impersonate_start', + entityType: 'tenant', + entityId: despachoId, + metadata: { + motivo, + adminEmail: req.user.email, + despachoNombre: tenant.nombre, + despachoRfc: tenant.rfc, + ip: req.ip, + userAgent: req.headers['user-agent'], + }, + }); + + return res.json({ + despachoId: tenant.id, + nombre: tenant.nombre, + rfc: tenant.rfc, + message: 'Impersonación iniciada. Usa el header X-View-Tenant para acceder.', + }); + } catch (err: any) { + if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message)); + return next(err); + } +} + +export async function stopImpersonation(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user?.userId) return next(new AppError(401, 'No autenticado')); + + const despachoId = req.body.despachoId as string | undefined; + + await auditLog({ + userId: req.user.userId, + tenantId: despachoId || undefined, + action: 'admin.impersonate_end', + metadata: { + adminEmail: req.user.email, + ip: req.ip, + }, + }); + + return res.json({ message: 'Impersonación finalizada' }); + } catch (err) { return next(err); } +} diff --git a/apps/api/src/controllers/alertas.controller.ts b/apps/api/src/controllers/alertas.controller.ts new file mode 100644 index 0000000..6e058ab --- /dev/null +++ b/apps/api/src/controllers/alertas.controller.ts @@ -0,0 +1,506 @@ +import type { Request, Response, NextFunction } from 'express'; +import { z } from 'zod'; +import * as alertasService from '../services/alertas.service.js'; +import { generarAlertasAutomaticas, SOSPECHOSA_TIPO_RELACION_WHERE_EXPORT } from '../services/alertas-auto.service.js'; +import { sincronizarAlertasManuales, getAlertasManualesPendientes, resolverAlerta } from '../services/alertas-manuales.service.js'; +import { getRegimenesActivosClavesEfectivos } from '../services/regimen.service.js'; +import { prisma } from '../config/database.js'; +import { AppError } from '../middlewares/error.middleware.js'; + +const createAlertaSchema = z.object({ + tipo: z.enum(['vencimiento', 'discrepancia', 'iva_favor', 'declaracion', 'limite_cfdi', 'custom']), + titulo: z.string().min(1).max(200), + mensaje: z.string().min(1).max(2000), + prioridad: z.enum(['alta', 'media', 'baja']), + fechaVencimiento: z.string().optional(), +}); + +const updateAlertaSchema = z.object({ + leida: z.boolean().optional(), + resuelta: z.boolean().optional(), +}); + +export async function getAlertas(req: Request, res: Response, next: NextFunction) { + try { + const { leida, resuelta, prioridad } = req.query; + const alertas = await alertasService.getAlertas(req.tenantPool!, { + leida: leida === 'true' ? true : leida === 'false' ? false : undefined, + resuelta: resuelta === 'true' ? true : resuelta === 'false' ? false : undefined, + prioridad: prioridad as string, + }); + res.json(alertas); + } catch (error) { + next(error); + } +} + +export async function getAlerta(req: Request, res: Response, next: NextFunction) { + try { + const alerta = await alertasService.getAlertaById(req.tenantPool!, parseInt(String(req.params.id))); + if (!alerta) { + return res.status(404).json({ message: 'Alerta no encontrada' }); + } + res.json(alerta); + } catch (error) { + next(error); + } +} + +export async function createAlerta(req: Request, res: Response, next: NextFunction) { + try { + const data = createAlertaSchema.parse(req.body); + const alerta = await alertasService.createAlerta(req.tenantPool!, data); + res.status(201).json(alerta); + } catch (error) { + if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message)); + next(error); + } +} + +export async function updateAlerta(req: Request, res: Response, next: NextFunction) { + try { + const data = updateAlertaSchema.parse(req.body); + const alerta = await alertasService.updateAlerta(req.tenantPool!, parseInt(String(req.params.id)), data); + res.json(alerta); + } catch (error) { + if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message)); + next(error); + } +} + +export async function deleteAlerta(req: Request, res: Response, next: NextFunction) { + try { + await alertasService.deleteAlerta(req.tenantPool!, parseInt(String(req.params.id))); + res.status(204).send(); + } catch (error) { + next(error); + } +} + +export async function getStats(req: Request, res: Response, next: NextFunction) { + try { + const stats = await alertasService.getStats(req.tenantPool!); + res.json(stats); + } catch (error) { + next(error); + } +} + +export async function markAllAsRead(req: Request, res: Response, next: NextFunction) { + try { + await alertasService.markAllAsRead(req.tenantPool!); + res.json({ success: true }); + } catch (error) { + next(error); + } +} + +export async function getManualesPendientes(req: Request, res: Response, next: NextFunction) { + try { + const contribuyenteId = req.query.contribuyenteId as string | undefined; + // Sincronizar primero (crear alertas para eventos vencidos nuevos) + await sincronizarAlertasManuales(req.tenantPool!, req.user!.tenantId, contribuyenteId || null); + // Devolver pendientes (filtered by contribuyente or user role) + const alertas = await getAlertasManualesPendientes( + req.tenantPool!, + contribuyenteId || null, + req.user!.userId, + req.user!.role, + ); + res.json(alertas); + } catch (error) { + next(error); + } +} + +export async function resolverAlertaManual(req: Request, res: Response, next: NextFunction) { + try { + await resolverAlerta(req.tenantPool!, String(req.params.id)); + res.json({ success: true }); + } catch (error) { + next(error); + } +} + +export async function getAlertasAutomaticas(req: Request, res: Response, next: NextFunction) { + try { + const contribuyenteId = req.query.contribuyenteId as string | undefined; + const alertas = await generarAlertasAutomaticas(req.tenantPool!, req.user!.tenantId, contribuyenteId || null); + res.json(alertas); + } catch (error) { + next(error); + } +} + +// Drill-down: Clientes en lista negra +export async function getListaNegraClientes(req: Request, res: Response, next: NextFunction) { + try { + const contribuyenteId = req.query.contribuyenteId as string | undefined; + const cf = contribuyenteId + ? `AND contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'` + : ''; + + const listaRfcs = await prisma.listaNegra.findMany({ + where: { situacion: { in: ['Definitivo', 'Presunto'] } }, + select: { rfc: true, nombre: true, situacion: true }, + }); + const rfcMap = new Map(listaRfcs.map(l => [l.rfc, l])); + + const { rows } = await req.tenantPool!.query(` + SELECT rfc_receptor as rfc, nombre_receptor as nombre, + COUNT(*)::int as cantidad, SUM(total_mxn) as total + FROM cfdis + WHERE type = 'EMITIDO' AND status NOT IN ('Cancelado', '0') AND tipo_comprobante = 'I' + ${cf} + GROUP BY rfc_receptor, nombre_receptor + ORDER BY total DESC + `); + + const result = rows + .filter((r: any) => rfcMap.has(r.rfc)) + .map((r: any) => ({ + rfc: r.rfc, + nombre: r.nombre, + cantidad: r.cantidad, + total: Number(r.total), + situacionSat: rfcMap.get(r.rfc)!.situacion, + })); + + res.json(result); + } catch (error) { + next(error); + } +} + +// Drill-down: Proveedores en lista negra +export async function getListaNegraProveedores(req: Request, res: Response, next: NextFunction) { + try { + const contribuyenteId = req.query.contribuyenteId as string | undefined; + const cf = contribuyenteId + ? `AND contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'` + : ''; + + const listaRfcs = await prisma.listaNegra.findMany({ + where: { situacion: { in: ['Definitivo', 'Presunto'] } }, + select: { rfc: true, nombre: true, situacion: true }, + }); + const rfcMap = new Map(listaRfcs.map(l => [l.rfc, l])); + + const { rows } = await req.tenantPool!.query(` + SELECT rfc_emisor as rfc, nombre_emisor as nombre, + COUNT(*)::int as cantidad, SUM(total_mxn) as total + FROM cfdis + WHERE type = 'RECIBIDO' AND status NOT IN ('Cancelado', '0') AND tipo_comprobante = 'I' + ${cf} + GROUP BY rfc_emisor, nombre_emisor + ORDER BY total DESC + `); + + const result = rows + .filter((r: any) => rfcMap.has(r.rfc)) + .map((r: any) => ({ + rfc: r.rfc, + nombre: r.nombre, + cantidad: r.cantidad, + total: Number(r.total), + situacionSat: rfcMap.get(r.rfc)!.situacion, + })); + + res.json(result); + } catch (error) { + next(error); + } +} + +// Drill-down: Concentración de clientes +export async function getConcentracionClientes(req: Request, res: Response, next: NextFunction) { + try { + const contribuyenteId = req.query.contribuyenteId as string | undefined; + const cf = contribuyenteId + ? `AND contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'` + : ''; + + const { rows } = await req.tenantPool!.query(` + SELECT rfc_receptor as rfc, nombre_receptor as nombre, + COUNT(*)::int as cantidad, + SUM(total_mxn) as total + FROM cfdis + WHERE type = 'EMITIDO' AND tipo_comprobante = 'I' + AND status NOT IN ('Cancelado', '0') AND total_mxn > 0 + ${cf} + GROUP BY rfc_receptor, nombre_receptor + ORDER BY total DESC + `); + + const totalGeneral = rows.reduce((s: number, r: any) => s + Number(r.total), 0); + const result = rows.map((r: any) => ({ + rfc: r.rfc, + nombre: r.nombre, + cantidad: r.cantidad, + total: Number(r.total), + participacion: totalGeneral > 0 ? Math.round((Number(r.total) / totalGeneral) * 10000) / 100 : 0, + })); + + res.json(result); + } catch (error) { + next(error); + } +} + +// Drill-down: Concentración de proveedores +export async function getConcentracionProveedores(req: Request, res: Response, next: NextFunction) { + try { + const contribuyenteId = req.query.contribuyenteId as string | undefined; + const cf = contribuyenteId + ? `AND contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'` + : ''; + + const { rows } = await req.tenantPool!.query(` + SELECT rfc_emisor as rfc, nombre_emisor as nombre, + COUNT(*)::int as cantidad, + SUM(total_mxn) as total + FROM cfdis + WHERE type = 'RECIBIDO' AND tipo_comprobante = 'I' + AND status NOT IN ('Cancelado', '0') AND total_mxn > 0 + ${cf} + GROUP BY rfc_emisor, nombre_emisor + ORDER BY total DESC + `); + + const totalGeneral = rows.reduce((s: number, r: any) => s + Number(r.total), 0); + const result = rows.map((r: any) => ({ + rfc: r.rfc, + nombre: r.nombre, + cantidad: r.cantidad, + total: Number(r.total), + participacion: totalGeneral > 0 ? Math.round((Number(r.total) / totalGeneral) * 10000) / 100 : 0, + })); + + res.json(result); + } catch (error) { + next(error); + } +} + +// Drill-down: CFDIs con discrepancia de régimen +export async function getDiscrepanciaRegimen(req: Request, res: Response, next: NextFunction) { + try { + const contribuyenteId = req.query.contribuyenteId as string | undefined; + const activos = await getRegimenesActivosClavesEfectivos(req.user!.tenantId, req.tenantPool!, contribuyenteId); + if (activos.length === 0) return res.json([]); + + const cf = contribuyenteId + ? `AND contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'` + : ''; + + const { rows } = await req.tenantPool!.query(` + SELECT id, uuid, type, fecha_emision as "fechaEmision", + rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor", + rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor", + total_mxn as "totalMxn", regimen_fiscal_receptor as "regimenReceptor" + FROM cfdis + WHERE type = 'RECIBIDO' + AND status = 'Vigente' + AND fecha_cancelacion IS NULL + AND regimen_fiscal_receptor IS NOT NULL + AND regimen_fiscal_receptor != ALL($1) + AND id NOT IN (SELECT cfdi_id FROM cfdi_descartados WHERE tipo_alerta = 'discrepancia-regimen') + ${cf} + ORDER BY fecha_emision DESC + `, [activos]); + + res.json(rows); + } catch (error) { + next(error); + } +} + +// Drill-down: CFDIs cancelados +export async function getCancelados(req: Request, res: Response, next: NextFunction) { + try { + const contribuyenteId = req.query.contribuyenteId as string | undefined; + const cf = contribuyenteId + ? `AND contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'` + : ''; + + const hace5 = new Date(); + hace5.setFullYear(hace5.getFullYear() - 5); + + const { rows } = await req.tenantPool!.query(` + SELECT id, uuid, type, fecha_emision as "fechaEmision", + rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor", + rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor", + total_mxn as "totalMxn", fecha_cancelacion as "fechaCancelacion" + FROM cfdis + WHERE status IN ('Cancelado', '0') + AND fecha_emision >= $1::date + ${cf} + ORDER BY fecha_emision DESC + `, [hace5.toISOString().split('T')[0]]); + + res.json(rows); + } catch (error) { + next(error); + } +} + +// Drill-down: Facturas de periodos anteriores canceladas este mes +export async function getCancelacionesPeriodoAnterior(req: Request, res: Response, next: NextFunction) { + try { + const contribuyenteId = req.query.contribuyenteId as string | undefined; + const cf = contribuyenteId + ? `AND contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'` + : ''; + + const ahora = new Date(); + const inicioMes = `${ahora.getFullYear()}-${String(ahora.getMonth() + 1).padStart(2, '0')}-01`; + + const { rows } = await req.tenantPool!.query(` + SELECT id, uuid, type, fecha_emision as "fechaEmision", + rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor", + rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor", + total_mxn as "totalMxn", tipo_comprobante as "tipoComprobante", + fecha_cancelacion as "fechaCancelacion" + FROM cfdis + WHERE status IN ('Cancelado', '0') + AND fecha_cancelacion >= $1::date + AND fecha_emision < $1::date + ${cf} + ORDER BY fecha_cancelacion DESC + `, [inicioMes]); + + res.json(rows); + } catch (error) { + next(error); + } +} + +// Drill-down: CFDIs con pago en efectivo +export async function getEfectivo(req: Request, res: Response, next: NextFunction) { + try { + const contribuyenteId = req.query.contribuyenteId as string | undefined; + const cf = contribuyenteId + ? `AND contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'` + : ''; + + const { rows } = await req.tenantPool!.query(` + SELECT id, uuid, type, fecha_emision as "fechaEmision", + rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor", + rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor", + total_mxn as "totalMxn", forma_pago as "formaPago" + FROM cfdis + WHERE status NOT IN ('Cancelado', '0') AND tipo_comprobante = 'I' + AND forma_pago = '01' + ${cf} + ORDER BY fecha_emision DESC + `); + + res.json(rows); + } catch (error) { + next(error); + } +} + +// Drill-down: CFDIs tipo E con TipoRelacion sospechoso (debería ser 07) +export async function getTipoRelacionSospechosa(req: Request, res: Response, next: NextFunction) { + try { + const contribuyenteId = req.query.contribuyenteId as string | undefined; + const cf = contribuyenteId + ? `AND c.contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'` + : ''; + + const { rows } = await req.tenantPool!.query(` + SELECT c.id, c.uuid, c.type, c.fecha_emision AS "fechaEmision", + c.rfc_emisor AS "rfcEmisor", c.nombre_emisor AS "nombreEmisor", + c.rfc_receptor AS "rfcReceptor", c.nombre_receptor AS "nombreReceptor", + c.total_mxn AS "totalMxn", + c.tipo_comprobante AS "tipoComprobante", + c.cfdi_tipo_relacion AS "cfdiTipoRelacion", + c.cfdis_relacionados AS "cfdisRelacionados" + FROM cfdis c + WHERE ${SOSPECHOSA_TIPO_RELACION_WHERE_EXPORT} + ${cf} + ORDER BY c.fecha_emision DESC + `); + res.json(rows); + } catch (error) { + next(error); + } +} + +// ── Descarte de CFDIs de alertas ── + +export async function descartarCfdis(req: Request, res: Response, next: NextFunction) { + try { + const { cfdiIds, tipoAlerta } = z.object({ + cfdiIds: z.array(z.number().int()), + tipoAlerta: z.string().min(1), + }).parse(req.body); + + for (const cfdiId of cfdiIds) { + await req.tenantPool!.query( + `INSERT INTO cfdi_descartados (cfdi_id, tipo_alerta, descartado_por) + VALUES ($1, $2, $3) ON CONFLICT (cfdi_id, tipo_alerta) DO NOTHING`, + [cfdiId, tipoAlerta, req.user!.email], + ); + } + res.json({ descartados: cfdiIds.length }); + } catch (error) { + if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message)); + next(error); + } +} + +export async function restaurarDescartados(req: Request, res: Response, next: NextFunction) { + try { + const { cfdiIds, tipoAlerta } = z.object({ + cfdiIds: z.array(z.number().int()).optional(), + tipoAlerta: z.string().min(1), + }).parse(req.body); + + if (cfdiIds && cfdiIds.length > 0) { + await req.tenantPool!.query( + `DELETE FROM cfdi_descartados WHERE tipo_alerta = $1 AND cfdi_id = ANY($2)`, + [tipoAlerta, cfdiIds], + ); + } else { + await req.tenantPool!.query( + `DELETE FROM cfdi_descartados WHERE tipo_alerta = $1`, + [tipoAlerta], + ); + } + res.json({ success: true }); + } catch (error) { + if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message)); + next(error); + } +} + +export async function getDescartados(req: Request, res: Response, next: NextFunction) { + try { + const tipoAlerta = req.query.tipoAlerta as string; + if (!tipoAlerta) return next(new AppError(400, 'tipoAlerta requerido')); + const contribuyenteId = req.query.contribuyenteId as string | undefined; + const cf = contribuyenteId + ? `AND c.contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'` + : ''; + + // JOIN con cfdis para devolver datos completos (mismo shape que el + // drill-down activo, para que el frontend pueda reutilizar el componente). + const { rows } = await req.tenantPool!.query(` + SELECT c.id, c.uuid, c.type, c.fecha_emision AS "fechaEmision", + c.rfc_emisor AS "rfcEmisor", c.nombre_emisor AS "nombreEmisor", + c.rfc_receptor AS "rfcReceptor", c.nombre_receptor AS "nombreReceptor", + c.total_mxn AS "totalMxn", + c.regimen_fiscal_receptor AS "regimenReceptor", + d.descartado_por AS "descartadoPor", + d.created_at AS "descartadoEn" + FROM cfdi_descartados d + JOIN cfdis c ON c.id = d.cfdi_id + WHERE d.tipo_alerta = $1 + ${cf} + ORDER BY d.created_at DESC + `, [tipoAlerta]); + res.json({ data: rows }); + } catch (error) { next(error); } +} diff --git a/apps/api/src/controllers/audit-log.controller.ts b/apps/api/src/controllers/audit-log.controller.ts new file mode 100644 index 0000000..fbc0efc --- /dev/null +++ b/apps/api/src/controllers/audit-log.controller.ts @@ -0,0 +1,87 @@ +import type { Request, Response, NextFunction } from 'express'; +import { prisma } from '../config/database.js'; +import { isGlobalAdmin } from '../utils/global-admin.js'; + +async function requireGlobalAdmin(req: Request, res: Response): Promise { + const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role); + if (!isAdmin) { + res.status(403).json({ message: 'Solo el administrador global puede consultar el audit log' }); + } + return isAdmin; +} + +/** + * Lista eventos de audit con filtros opcionales. Admin global only. + * + * Query params: + * action — filtra por action prefix (ej: "subscription." matches todas las subs) + * tenantId — filtra a un tenant específico + * userId — filtra a un user específico + * from, to — rango de fechas (ISO) + * page, limit — paginación (default: 1, 50; max limit 200) + */ +export async function listAuditLog(req: Request, res: Response, next: NextFunction) { + try { + if (!(await requireGlobalAdmin(req, res))) return; + + const action = String(req.query.action || '').trim(); + const tenantId = String(req.query.tenantId || '').trim(); + const userId = String(req.query.userId || '').trim(); + const from = String(req.query.from || '').trim(); + const to = String(req.query.to || '').trim(); + const page = Math.max(1, parseInt(String(req.query.page || '1'), 10) || 1); + const limit = Math.min(200, Math.max(1, parseInt(String(req.query.limit || '50'), 10) || 50)); + + const where: any = {}; + if (action) where.action = { startsWith: action }; + if (tenantId) where.tenantId = tenantId; + if (userId) where.userId = userId; + if (from || to) { + where.createdAt = {}; + if (from) where.createdAt.gte = new Date(from); + if (to) where.createdAt.lte = new Date(to); + } + + const [total, rows] = await Promise.all([ + prisma.auditLog.count({ where }), + prisma.auditLog.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * limit, + take: limit, + }), + ]); + + // Enriquecer con user.email y tenant.nombre para display + const userIds = [...new Set(rows.map(r => r.userId).filter(Boolean))] as string[]; + const tenantIds = [...new Set(rows.map(r => r.tenantId).filter(Boolean))] as string[]; + + const [users, tenants] = await Promise.all([ + userIds.length + ? prisma.user.findMany({ where: { id: { in: userIds } }, select: { id: true, email: true, nombre: true } }) + : [], + tenantIds.length + ? prisma.tenant.findMany({ where: { id: { in: tenantIds } }, select: { id: true, nombre: true, rfc: true } }) + : [], + ]); + + const userMap = new Map(users.map(u => [u.id, u])); + const tenantMap = new Map(tenants.map(t => [t.id, t])); + + const data = rows.map(r => ({ + ...r, + user: r.userId ? userMap.get(r.userId) || null : null, + tenant: r.tenantId ? tenantMap.get(r.tenantId) || null : null, + })); + + res.json({ + data, + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }); + } catch (error) { + next(error); + } +} diff --git a/apps/api/src/controllers/auth.controller.ts b/apps/api/src/controllers/auth.controller.ts new file mode 100644 index 0000000..02029b2 --- /dev/null +++ b/apps/api/src/controllers/auth.controller.ts @@ -0,0 +1,203 @@ +import type { Request, Response, NextFunction } from 'express'; +import { z } from 'zod'; +import * as authService from '../services/auth.service.js'; +import { AppError } from '../middlewares/error.middleware.js'; + +const registerSchema = z.object({ + empresa: z.object({ + nombre: z.string().min(2, 'Nombre de empresa requerido'), + rfc: z.string().min(12).max(13, 'RFC inválido'), + }), + usuario: z.object({ + nombre: z.string().min(2, 'Nombre requerido'), + email: z.string().email('Email inválido'), + password: z.string().min(8, 'La contraseña debe tener al menos 8 caracteres'), + }), +}); + +const loginSchema = z.object({ + email: z.string().email('Email inválido'), + password: z.string().min(1, 'Contraseña requerida'), +}); + +export async function register(req: Request, res: Response, next: NextFunction) { + try { + const data = registerSchema.parse(req.body); + const result = await authService.register(data); + res.status(201).json(result); + } catch (error) { + if (error instanceof z.ZodError) { + return next(new AppError(400, error.errors[0].message)); + } + next(error); + } +} + +export async function login(req: Request, res: Response, next: NextFunction) { + try { + const data = loginSchema.parse(req.body); + const result = await authService.login(data); + res.json(result); + } catch (error) { + if (error instanceof z.ZodError) { + return next(new AppError(400, error.errors[0].message)); + } + next(error); + } +} + +export async function refresh(req: Request, res: Response, next: NextFunction) { + try { + const { refreshToken } = req.body; + if (!refreshToken) { + throw new AppError(400, 'Refresh token requerido'); + } + const tokens = await authService.refreshTokens(refreshToken); + res.json(tokens); + } catch (error) { + next(error); + } +} + +export async function logout(req: Request, res: Response, next: NextFunction) { + try { + const { refreshToken } = req.body; + if (refreshToken) { + await authService.logout(refreshToken); + } + res.json({ message: 'Sesión cerrada exitosamente' }); + } catch (error) { + next(error); + } +} + +export async function me(req: Request, res: Response, next: NextFunction) { + try { + res.json({ user: req.user }); + } catch (error) { + next(error); + } +} + +const passwordResetRequestSchema = z.object({ + email: z.string().email('Email inválido'), +}); + +const passwordResetConfirmSchema = z.object({ + token: z.string().min(10, 'Token inválido'), + newPassword: z.string().min(8, 'La contraseña debe tener al menos 8 caracteres'), +}); + +/** + * Solicita recuperación de contraseña. Responde 200 siempre (anti-enumeration), + * independiente de si el email existe o no. + */ +export async function requestPasswordReset(req: Request, res: Response, next: NextFunction) { + try { + const { email } = passwordResetRequestSchema.parse(req.body); + // Dispara async — no esperamos resultado para preservar timing constante + await authService.requestPasswordReset(email); + res.json({ + message: 'Si el email existe en nuestro sistema, recibirás un enlace para restablecer tu contraseña.', + }); + } catch (error) { + if (error instanceof z.ZodError) { + return next(new AppError(400, error.errors[0].message)); + } + next(error); + } +} + +/** + * Confirma recuperación con token + nueva contraseña. + */ +export async function confirmPasswordReset(req: Request, res: Response, next: NextFunction) { + try { + const { token, newPassword } = passwordResetConfirmSchema.parse(req.body); + await authService.confirmPasswordReset(token, newPassword); + res.json({ message: 'Contraseña actualizada exitosamente. Por favor inicia sesión con tu nueva contraseña.' }); + } catch (error) { + if (error instanceof z.ZodError) { + return next(new AppError(400, error.errors[0].message)); + } + next(error); + } +} + +const changePasswordSchema = z.object({ + currentPassword: z.string().min(1, 'Contraseña actual requerida'), + newPassword: z.string().min(8, 'La contraseña debe tener al menos 8 caracteres'), +}); + +/** + * Cambia la contraseña del user autenticado. Requiere contraseña actual. + * Tras cambio: todas las sesiones del user quedan invalidadas (incluyendo esta). + */ +export async function changePassword(req: Request, res: Response, next: NextFunction) { + try { + const { currentPassword, newPassword } = changePasswordSchema.parse(req.body); + await authService.changePassword({ + userId: req.user!.userId, + currentPassword, + newPassword, + }); + res.json({ + message: 'Contraseña actualizada. Por seguridad, todas tus sesiones fueron cerradas. Inicia sesión de nuevo.', + }); + } catch (error) { + if (error instanceof z.ZodError) { + return next(new AppError(400, error.errors[0].message)); + } + next(error); + } +} + +/** + * "Cerrar todas las sesiones" — invalida todos los tokens del user actual. + */ +export async function logoutAll(req: Request, res: Response, next: NextFunction) { + try { + await authService.logoutAllSessions(req.user!.userId); + res.json({ message: 'Todas tus sesiones fueron cerradas. Inicia sesión de nuevo.' }); + } catch (error) { + next(error); + } +} + +const switchTenantSchema = z.object({ + tenantId: z.string().uuid('tenantId inválido'), + refreshToken: z.string().min(1, 'refreshToken requerido'), +}); + +/** + * Cambia el tenant activo del user (requiere membership válida). Emite un par + * nuevo de tokens apuntando al tenant destino y revoca el refresh token actual. + */ +export async function switchTenant(req: Request, res: Response, next: NextFunction) { + try { + const { tenantId, refreshToken } = switchTenantSchema.parse(req.body); + const result = await authService.switchTenant({ + userId: req.user!.userId, + currentRefreshToken: refreshToken, + targetTenantId: tenantId, + }); + res.json(result); + } catch (error) { + if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message)); + next(error); + } +} + +/** + * Marca el onboarding como dismissed para el user actual. Idempotente — si ya + * estaba dismissed, conserva el timestamp original. La UI lo invoca cuando el + * user completa todos los pasos requeridos del onboarding. + */ +export async function dismissOnboarding(req: Request, res: Response, next: NextFunction) { + try { + const result = await authService.dismissOnboarding(req.user!.userId); + res.json(result); + } catch (error) { + next(error); + } +} diff --git a/apps/api/src/controllers/bancos.controller.ts b/apps/api/src/controllers/bancos.controller.ts new file mode 100644 index 0000000..a77b21d --- /dev/null +++ b/apps/api/src/controllers/bancos.controller.ts @@ -0,0 +1,62 @@ +import type { Request, Response, NextFunction } from 'express'; +import { z } from 'zod'; +import * as bancosService from '../services/bancos.service.js'; +import { AppError } from '../middlewares/error.middleware.js'; + +const createSchema = z.object({ + banco: z.string().min(1, 'banco requerido').max(100), + terminacionCuenta: z.string().min(1).max(4, 'terminacionCuenta max 4 digitos'), +}); + +const updateSchema = z.object({ + banco: z.string().min(1).max(100).optional(), + terminacionCuenta: z.string().min(1).max(4).optional(), +}); + +export async function getBancos(req: Request, res: Response, next: NextFunction) { + try { + const contribuyenteId = (req.query.contribuyenteId as string) || null; + const bancos = await bancosService.getBancos(req.tenantPool!, contribuyenteId); + res.json(bancos); + } catch (error) { next(error); } +} + +export async function createBanco(req: Request, res: Response, next: NextFunction) { + try { + if (req.user!.role !== 'owner') return res.status(403).json({ message: 'No autorizado' }); + const data = createSchema.parse(req.body); + const contribuyenteId = req.body.contribuyenteId as string | undefined; + const result = await bancosService.createBanco(req.tenantPool!, { ...data, contribuyenteId }); + res.status(201).json(result); + } catch (error) { + if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message)); + next(error); + } +} + +export async function updateBanco(req: Request, res: Response, next: NextFunction) { + try { + if (req.user!.role !== 'owner') return res.status(403).json({ message: 'No autorizado' }); + const id = parseInt(String(req.params.id)); + const data = updateSchema.parse(req.body); + const result = await bancosService.updateBanco(req.tenantPool!, id, data); + res.json(result); + } catch (error) { + if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message)); + next(error); + } +} + +export async function deleteBanco(req: Request, res: Response, next: NextFunction) { + try { + if (req.user!.role !== 'owner') return res.status(403).json({ message: 'No autorizado' }); + const id = parseInt(String(req.params.id)); + await bancosService.deleteBanco(req.tenantPool!, id); + res.json({ message: 'Banco eliminado' }); + } catch (error: any) { + if (error.message?.includes('conciliaciones')) { + return res.status(400).json({ message: error.message }); + } + next(error); + } +} diff --git a/apps/api/src/controllers/calendario.controller.ts b/apps/api/src/controllers/calendario.controller.ts new file mode 100644 index 0000000..84ea05d --- /dev/null +++ b/apps/api/src/controllers/calendario.controller.ts @@ -0,0 +1,175 @@ +import type { Request, Response, NextFunction } from 'express'; +import { z } from 'zod'; +import { generarEventosFiscales, generarEventosDesdeObligaciones } from '../services/calendario-fiscal.service.js'; +import * as recordatoriosService from '../services/recordatorios.service.js'; +import { getEventosTareasParaCalendario } from '../services/tareas.service.js'; +import { AppError } from '../middlewares/error.middleware.js'; +import { isDespachoTenant } from '@horux/shared'; +import { prisma } from '../config/database.js'; + +const createRecordatorioSchema = z.object({ + titulo: z.string().min(1).max(200), + descripcion: z.string().max(2000).default(''), + fechaLimite: z.string().min(8), // ISO date o yyyy-mm-dd + notas: z.string().max(2000).optional(), + privado: z.boolean().optional(), +}); + +const updateRecordatorioSchema = z.object({ + titulo: z.string().min(1).max(200).optional(), + descripcion: z.string().max(2000).optional(), + fechaLimite: z.string().min(8).optional(), + notas: z.string().max(2000).optional(), + privado: z.boolean().optional(), + completado: z.boolean().optional(), +}); + +function effectiveTenantId(req: Request): string { + return req.viewingTenantId || req.user!.tenantId; +} + +// Forma compatible con EventoFiscal (sin metadata interna como tareaId/periodoId). +function eventoTareaShape(t: import('../services/tareas.service.js').TareaEventoCalendario) { + return { + titulo: t.titulo, + descripcion: t.descripcion, + tipo: 'tarea' as const, + fechaLimite: t.fechaLimite, + recurrencia: t.recurrencia, + completado: t.completado, + notas: t.notas, + // Metadata adicional para el frontend (links, modales) + tareaId: t.tareaId, + periodoId: t.periodoId, + }; +} + +export async function getEventosGenerados(req: Request, res: Response, next: NextFunction) { + try { + const año = parseInt(req.query.año as string) || new Date().getFullYear(); + const tenantId = effectiveTenantId(req); + + let fiscales; + + // Determine tenant type by looking up the RFC from the central DB + const tenantRecord = await prisma.tenant.findUnique({ + where: { id: tenantId }, + select: { rfc: true }, + }); + const isDespacho = isDespachoTenant(tenantRecord?.rfc); + if (isDespacho) { + const contribuyenteId = (req.query.contribuyenteId as string) || null; + fiscales = await generarEventosDesdeObligaciones(req.tenantPool!, contribuyenteId, año); + } else { + // Horux360: use static catalog as before + fiscales = await generarEventosFiscales(tenantId, año); + } + + // Recordatorios custom — always included regardless of tenant type + const custom = await recordatoriosService.getRecordatorios( + req.tenantPool!, + req.user!.userId, + año + ); + + // Tareas operativas (despacho) — solo si hay contribuyente y rol no es cliente. + // El usuario tipo cliente no debe ver tareas internas del despacho. + let tareas: ReturnType[] = []; + const contribuyenteIdParam = (req.query.contribuyenteId as string) || null; + if (contribuyenteIdParam && req.user?.role !== 'cliente') { + const tareasRaw = await getEventosTareasParaCalendario( + req.tenantPool!, + contribuyenteIdParam, + año, + ); + tareas = tareasRaw.map(eventoTareaShape); + } + + // Merge y ordenar por fecha + const todos = [...fiscales, ...custom, ...tareas].sort((a, b) => + a.fechaLimite.localeCompare(b.fechaLimite) + ); + + res.json(todos); + } catch (error) { + next(error); + } +} + +export async function createRecordatorio(req: Request, res: Response, next: NextFunction) { + try { + if (!['owner', 'cfo', 'contador', 'supervisor', 'auxiliar'].includes(req.user!.role)) { + return res.status(403).json({ message: 'Solo admin y contador pueden crear recordatorios' }); + } + + const data = createRecordatorioSchema.parse(req.body); + + const evento = await recordatoriosService.createRecordatorio( + req.tenantPool!, + req.user!.userId, + { ...data, tipo: 'custom', recurrencia: 'unica' } + ); + + res.status(201).json(evento); + } catch (error) { + if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message)); + next(error); + } +} + +export async function updateRecordatorio(req: Request, res: Response, next: NextFunction) { + try { + if (!['owner', 'cfo', 'contador', 'supervisor', 'auxiliar'].includes(req.user!.role)) { + return res.status(403).json({ message: 'Solo admin y contador pueden editar recordatorios' }); + } + + const id = parseInt(String(req.params.id)); + if (isNaN(id)) { + return res.status(400).json({ message: 'ID inválido' }); + } + + const data = updateRecordatorioSchema.parse(req.body); + const evento = await recordatoriosService.updateRecordatorio( + req.tenantPool!, + req.user!.userId, + id, + data + ); + + if (!evento) { + return res.status(404).json({ message: 'Recordatorio no encontrado' }); + } + + res.json(evento); + } catch (error) { + if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message)); + next(error); + } +} + +export async function deleteRecordatorio(req: Request, res: Response, next: NextFunction) { + try { + if (!['owner', 'cfo', 'contador', 'supervisor', 'auxiliar'].includes(req.user!.role)) { + return res.status(403).json({ message: 'Solo admin y contador pueden eliminar recordatorios' }); + } + + const id = parseInt(String(req.params.id)); + if (isNaN(id)) { + return res.status(400).json({ message: 'ID inválido' }); + } + + const deleted = await recordatoriosService.deleteRecordatorio( + req.tenantPool!, + req.user!.userId, + id + ); + + if (!deleted) { + return res.status(404).json({ message: 'Recordatorio no encontrado' }); + } + + res.status(204).send(); + } catch (error) { + next(error); + } +} diff --git a/apps/api/src/controllers/cartera.controller.ts b/apps/api/src/controllers/cartera.controller.ts new file mode 100644 index 0000000..032d6e5 --- /dev/null +++ b/apps/api/src/controllers/cartera.controller.ts @@ -0,0 +1,277 @@ +import type { Request, Response, NextFunction } from 'express'; +import { z } from 'zod'; +import * as carteraService from '../services/cartera.service.js'; +import { AppError } from '../middlewares/error.middleware.js'; + +const createSchema = z.object({ + nombre: z.string().min(1, 'Nombre requerido'), + descripcion: z.string().optional(), + supervisorUserId: z.string().uuid().optional(), // Owner can assign to a supervisor +}); + +const createSubcarteraSchema = z.object({ + nombre: z.string().min(1, 'Nombre requerido'), + descripcion: z.string().optional(), + auxiliarUserId: z.string().uuid('Auxiliar requerido'), +}); + +const updateSchema = z.object({ + nombre: z.string().min(1).optional(), + descripcion: z.string().optional(), + supervisorUserId: z.string().uuid().optional(), +}); + +/** + * Permission helpers: + * - Owner: sees all, edits all + * - Supervisor: sees carteras assigned to them (by owner) + carteras they created. + * Can only edit/delete carteras THEY created. Cannot edit owner-created ones. + * Can only add contribuyentes that are already assigned to them. + * - Auxiliar: sees subcarteras where they're assigned. Read-only. + */ + +function isOwner(req: Request): boolean { + return req.user!.role === 'owner'; +} + +function isSupervisor(req: Request): boolean { + return req.user!.role === 'supervisor'; +} + +/** Check if a supervisor created this cartera (vs owner assigned it to them) */ +async function supervisorCreatedCartera(req: Request, cartera: carteraService.CarteraRow): Promise { + // A cartera was created by the supervisor if supervisorUserId === the supervisor's userId + // AND the cartera was not created by the owner assigning it. + // We use a heuristic: if the supervisor_user_id matches and createdBy is not tracked, + // we assume the supervisor can edit their own carteras. + // For now: supervisor can edit carteras where they are the supervisor. + // Owner-created carteras also have supervisorUserId set to the supervisor — + // so we need another way to distinguish. + // Solution: we'll add a 'created_by' concept. For now, let supervisor edit all carteras + // assigned to them (both owner-created and self-created). + // The user said: "Las que crea el owner, solo las puede ver el supervisor, pero no las puede editar" + // This requires tracking who created the cartera. Let's use a simple approach: + // check if the owner's userId matches the request user. + return cartera.supervisorUserId === req.user!.userId; +} + +export async function list(req: Request, res: Response, next: NextFunction) { + try { + const role = req.user!.role; + const userId = req.user!.userId; + + if (isOwner(req)) { + // Owner sees all top-level carteras + const rows = await carteraService.listCarteras(req.tenantPool!); + return res.json({ data: rows }); + } + + if (isSupervisor(req)) { + // Supervisor sees carteras assigned to them + const rows = await carteraService.listCarteras(req.tenantPool!, userId); + return res.json({ data: rows }); + } + + // Auxiliar: sees subcarteras where they're assigned + const { rows } = await req.tenantPool!.query( + `SELECT c.id, c.supervisor_user_id AS "supervisorUserId", + c.auxiliar_user_id AS "auxiliarUserId", c.parent_id AS "parentId", + c.nombre, c.descripcion, c.created_at AS "createdAt", + (SELECT count(*) FROM cartera_entidades ce WHERE ce.cartera_id = c.id)::int AS "entidadesCount", + 0 AS "subcarterasCount" + FROM carteras c + WHERE c.auxiliar_user_id = $1 + ORDER BY c.nombre`, + [userId], + ); + return res.json({ data: rows }); + } catch (err) { return next(err); } +} + +export async function getById(req: Request, res: Response, next: NextFunction) { + try { + const row = await carteraService.getCarteraById(req.tenantPool!, String(req.params.id)); + if (!row) return next(new AppError(404, 'Cartera no encontrada')); + // Auxiliar can only see their own subcarteras + if (req.user!.role === 'auxiliar' && row.auxiliarUserId !== req.user!.userId) { + return next(new AppError(403, 'No autorizado')); + } + return res.json(row); + } catch (err) { return next(err); } +} + +export async function create(req: Request, res: Response, next: NextFunction) { + try { + const data = createSchema.parse(req.body); + const supervisorUserId = data.supervisorUserId || req.user!.userId; + const row = await carteraService.createCartera(req.tenantPool!, { + supervisorUserId, + nombre: data.nombre, + descripcion: data.descripcion, + }); + return res.status(201).json(row); + } catch (err: any) { + if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message)); + return next(err); + } +} + +export async function update(req: Request, res: Response, next: NextFunction) { + try { + const cartera = await carteraService.getCarteraById(req.tenantPool!, String(req.params.id)); + if (!cartera) return next(new AppError(404, 'Cartera no encontrada')); + + // Supervisor cannot edit carteras (owner-assigned are read-only for them) + // Only owner can edit top-level carteras + if (isSupervisor(req)) { + return next(new AppError(403, 'Solo el owner puede editar carteras')); + } + + const data = updateSchema.parse(req.body); + const row = await carteraService.updateCartera(req.tenantPool!, String(req.params.id), data); + return res.json(row); + } catch (err: any) { + if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message)); + return next(err); + } +} + +export async function remove(req: Request, res: Response, next: NextFunction) { + try { + const cartera = await carteraService.getCarteraById(req.tenantPool!, String(req.params.id)); + if (!cartera) return next(new AppError(404, 'Cartera no encontrada')); + + if (isSupervisor(req)) { + return next(new AppError(403, 'Solo el owner puede eliminar carteras')); + } + + await carteraService.deleteCartera(req.tenantPool!, String(req.params.id)); + return res.json({ message: 'Cartera eliminada' }); + } catch (err) { return next(err); } +} + +// Subcarteras +export async function listSubcarteras(req: Request, res: Response, next: NextFunction) { + try { + const rows = await carteraService.listSubcarteras(req.tenantPool!, String(req.params.id)); + return res.json({ data: rows }); + } catch (err) { return next(err); } +} + +export async function createSubcartera(req: Request, res: Response, next: NextFunction) { + try { + const parent = await carteraService.getCarteraById(req.tenantPool!, String(req.params.id)); + if (!parent) return next(new AppError(404, 'Cartera padre no encontrada')); + + // Supervisor can create subcarteras within their own carteras + if (isSupervisor(req) && parent.supervisorUserId !== req.user!.userId) { + return next(new AppError(403, 'No autorizado')); + } + + const data = createSubcarteraSchema.parse(req.body); + const row = await carteraService.createSubcartera(req.tenantPool!, { + parentId: String(req.params.id), + auxiliarUserId: data.auxiliarUserId, + nombre: data.nombre, + descripcion: data.descripcion, + }); + return res.status(201).json(row); + } catch (err: any) { + if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message)); + return next(err); + } +} + +// Entidades +export async function addEntidad(req: Request, res: Response, next: NextFunction) { + try { + const cartera = await carteraService.getCarteraById(req.tenantPool!, String(req.params.id)); + if (!cartera) return next(new AppError(404, 'Cartera no encontrada')); + + if (isSupervisor(req)) { + // For subcarteras: check the parent's supervisor + const supervisorId = cartera.supervisorUserId + || (cartera.parentId ? (await carteraService.getCarteraById(req.tenantPool!, cartera.parentId))?.supervisorUserId : null); + if (supervisorId !== req.user!.userId) { + return next(new AppError(403, 'No autorizado')); + } + } + + const { entidadId } = z.object({ entidadId: z.string().uuid() }).parse(req.body); + await carteraService.addEntidadToCartera(req.tenantPool!, String(req.params.id), entidadId); + return res.json({ message: 'Entidad agregada a cartera' }); + } catch (err: any) { + if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message)); + return next(err); + } +} + +export async function removeEntidad(req: Request, res: Response, next: NextFunction) { + try { + const cartera = await carteraService.getCarteraById(req.tenantPool!, String(req.params.id)); + if (!cartera) return next(new AppError(404, 'Cartera no encontrada')); + + if (isSupervisor(req)) { + const supervisorId = cartera.supervisorUserId + || (cartera.parentId ? (await carteraService.getCarteraById(req.tenantPool!, cartera.parentId))?.supervisorUserId : null); + if (supervisorId !== req.user!.userId) { + return next(new AppError(403, 'No autorizado')); + } + } + + await carteraService.removeEntidadFromCartera(req.tenantPool!, String(req.params.id), String(req.params.entidadId)); + return res.json({ message: 'Entidad removida de cartera' }); + } catch (err) { return next(err); } +} + +export async function getEntidades(req: Request, res: Response, next: NextFunction) { + try { + const ids = await carteraService.getCarteraEntidades(req.tenantPool!, String(req.params.id)); + return res.json({ data: ids }); + } catch (err) { return next(err); } +} + +// Auxiliares +export async function getAuxiliares(req: Request, res: Response, next: NextFunction) { + try { + const ids = await carteraService.getCarteraAuxiliares(req.tenantPool!, String(req.params.id)); + return res.json({ data: ids }); + } catch (err) { return next(err); } +} + +export async function addAuxiliar(req: Request, res: Response, next: NextFunction) { + try { + const { auxiliarUserId } = z.object({ auxiliarUserId: z.string().uuid() }).parse(req.body); + await carteraService.addAuxiliarToCartera(req.tenantPool!, String(req.params.id), auxiliarUserId); + return res.json({ message: 'Auxiliar agregado a cartera' }); + } catch (err: any) { + if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message)); + return next(err); + } +} + +export async function removeAuxiliar(req: Request, res: Response, next: NextFunction) { + try { + await carteraService.removeAuxiliarFromCartera(req.tenantPool!, String(req.params.id), String(req.params.auxiliarUserId)); + return res.json({ message: 'Auxiliar removido de cartera' }); + } catch (err) { return next(err); } +} + +// Supervisores available (for dropdown) +export async function getSupervisores(req: Request, res: Response, next: NextFunction) { + try { + const supervisores = await carteraService.getSupervisores(req.tenantPool!, req.user!.tenantId); + return res.json({ data: supervisores }); + } catch (err) { return next(err); } +} + +// Auxiliares of a supervisor +export async function getAuxiliaresDelSupervisor(req: Request, res: Response, next: NextFunction) { + try { + const supervisorId = isOwner(req) + ? String(req.params.supervisorId || req.user!.userId) + : req.user!.userId; + const rows = await carteraService.getAuxiliaresDelSupervisor(req.tenantPool!, supervisorId); + return res.json({ data: rows }); + } catch (err) { return next(err); } +} diff --git a/apps/api/src/controllers/catalogos.controller.ts b/apps/api/src/controllers/catalogos.controller.ts new file mode 100644 index 0000000..c20a3a6 --- /dev/null +++ b/apps/api/src/controllers/catalogos.controller.ts @@ -0,0 +1,108 @@ +import type { Request, Response, NextFunction } from 'express'; +import { prisma } from '../config/database.js'; + +export async function getFormasPago(req: Request, res: Response, next: NextFunction) { + try { + const data = await prisma.catFormaPago.findMany({ orderBy: { clave: 'asc' } }); + res.json(data); + } catch (error) { next(error); } +} + +export async function getMetodosPago(req: Request, res: Response, next: NextFunction) { + try { + const data = await prisma.catMetodoPago.findMany({ orderBy: { clave: 'asc' } }); + res.json(data); + } catch (error) { next(error); } +} + +export async function getUsosCfdi(req: Request, res: Response, next: NextFunction) { + try { + const data = await prisma.catUsoCfdi.findMany({ orderBy: { clave: 'asc' } }); + res.json(data); + } catch (error) { next(error); } +} + +export async function getMonedas(req: Request, res: Response, next: NextFunction) { + try { + const data = await prisma.catMoneda.findMany({ orderBy: { clave: 'asc' } }); + res.json(data); + } catch (error) { next(error); } +} + +export async function getClavesUnidad(req: Request, res: Response, next: NextFunction) { + try { + const data = await prisma.catClaveUnidad.findMany({ orderBy: { descripcion: 'asc' } }); + res.json(data); + } catch (error) { next(error); } +} + +export async function searchClaveProdServ(req: Request, res: Response, next: NextFunction) { + try { + const q = (req.query.q as string || '').trim(); + if (q.length < 2) { + return res.json([]); + } + + // Buscar por clave o descripción + // Primero buscar por clave, luego por texto + const data = await prisma.catClaveProdServ.findMany({ + where: { + OR: [ + { clave: { startsWith: q } }, + { descripcion: { contains: q, mode: 'insensitive' } }, + ], + }, + take: 20, + orderBy: { clave: 'asc' }, + }); + + // Si no hay resultados, intentar sin acentos + if (data.length === 0) { + const normalized = q.normalize('NFD').replace(/[\u0300-\u036f]/g, ''); + if (normalized !== q) { + const fallback = await prisma.catClaveProdServ.findMany({ + where: { descripcion: { contains: normalized, mode: 'insensitive' } }, + take: 20, + orderBy: { clave: 'asc' }, + }); + return res.json(fallback); + } + + // Buscar con variantes comunes de acentos + const withAccents = normalized + .replace(/a/gi, '[aá]').replace(/e/gi, '[eé]') + .replace(/i/gi, '[ií]').replace(/o/gi, '[oó]').replace(/u/gi, '[uú]') + .replace(/n/gi, '[nñ]'); + + // Usar raw SQL con regex para búsqueda flexible + const rows: any[] = await prisma.$queryRawUnsafe( + `SELECT id, clave, descripcion FROM cat_clave_prod_serv WHERE descripcion ~* $1 ORDER BY clave LIMIT 20`, + withAccents + ); + return res.json(rows); + } + + res.json(data); + } catch (error) { next(error); } +} + +export async function getObjetosImp(req: Request, res: Response, next: NextFunction) { + try { + const data = await prisma.catObjetoImp.findMany({ orderBy: { clave: 'asc' } }); + res.json(data); + } catch (error) { next(error); } +} + +export async function getTiposRelacion(req: Request, res: Response, next: NextFunction) { + try { + const data = await prisma.catTipoRelacion.findMany({ orderBy: { clave: 'asc' } }); + res.json(data); + } catch (error) { next(error); } +} + +export async function getExportaciones(req: Request, res: Response, next: NextFunction) { + try { + const data = await prisma.catExportacion.findMany({ orderBy: { clave: 'asc' } }); + res.json(data); + } catch (error) { next(error); } +} diff --git a/apps/api/src/controllers/cfdi.controller.ts b/apps/api/src/controllers/cfdi.controller.ts new file mode 100644 index 0000000..687c880 --- /dev/null +++ b/apps/api/src/controllers/cfdi.controller.ts @@ -0,0 +1,530 @@ +import type { Request, Response, NextFunction } from 'express'; +import * as cfdiService from '../services/cfdi.service.js'; +import { AppError } from '../middlewares/error.middleware.js'; +import { GRUPO_PF_EMPRESARIAL, GRUPO_PM_OTROS } from '../services/dashboard.service.js'; +import { getRegimenesIgnoradosClaves } from '../services/regimen.service.js'; +import { resolveContribuyenteContext } from '../utils/contribuyente-context.js'; +import { buildExtraFilters } from '../services/_shared/cfdi-filters.js'; +import type { CfdiFilters } from '@horux/shared'; + +export async function getCfdis(req: Request, res: Response, next: NextFunction) { + try { + if (!req.tenantPool) { + return next(new AppError(400, 'Tenant no configurado')); + } + + const filters: CfdiFilters = { + tipo: req.query.tipo as any, + tipoComprobante: req.query.tipoComprobante as any, + estado: req.query.estado as any, + fechaInicio: req.query.fechaInicio as string, + fechaFin: req.query.fechaFin as string, + rfc: req.query.rfc as string, + emisor: req.query.emisor as string, + receptor: req.query.receptor as string, + search: req.query.search as string, + contribuyenteId: req.query.contribuyenteId as string, + page: parseInt(req.query.page as string) || 1, + // Cap defensivo: paginación normal usa 20-100; export pide 10000. + // Más de eso se rechaza para no agotar memoria del proceso. + limit: Math.min(parseInt(req.query.limit as string) || 20, 10_000), + }; + + const result = await cfdiService.getCfdis(req.tenantPool, filters); + res.json(result); + } catch (error) { + next(error); + } +} + +export async function getCfdiById(req: Request, res: Response, next: NextFunction) { + try { + if (!req.tenantPool) { + return next(new AppError(400, 'Tenant no configurado')); + } + + const cfdi = await cfdiService.getCfdiById(req.tenantPool, String(req.params.id)); + + if (!cfdi) { + return next(new AppError(404, 'CFDI no encontrado')); + } + + res.json(cfdi); + } catch (error) { + next(error); + } +} + +export async function getXml(req: Request, res: Response, next: NextFunction) { + try { + if (!req.tenantPool) { + return next(new AppError(400, 'Tenant no configurado')); + } + + const xml = await cfdiService.getXmlById(req.tenantPool, String(req.params.id)); + + if (!xml) { + return next(new AppError(404, 'XML no encontrado para este CFDI')); + } + + res.set('Content-Type', 'application/xml'); + res.set('Content-Disposition', `attachment; filename="cfdi-${req.params.id}.xml"`); + res.send(xml); + } catch (error) { + next(error); + } +} + +export async function listConceptos(req: Request, res: Response, next: NextFunction) { + try { + if (!req.tenantPool) return next(new AppError(400, 'Tenant no configurado')); + + const filters: CfdiFilters & { + uuidLike?: string; + claveProdServ?: string; + descripcionConcepto?: string; + orderBy?: 'fecha' | 'importe'; + orderDir?: 'asc' | 'desc'; + } = { + tipo: req.query.tipo as any, + tipoComprobante: req.query.tipoComprobante as any, + estado: req.query.estado as any, + fechaInicio: req.query.fechaInicio as string, + fechaFin: req.query.fechaFin as string, + rfc: req.query.rfc as string, + emisor: req.query.emisor as string, + receptor: req.query.receptor as string, + search: req.query.search as string, + contribuyenteId: req.query.contribuyenteId as string, + page: parseInt(req.query.page as string) || 1, + limit: Math.min(parseInt(req.query.limit as string) || 50, 10_000), + uuidLike: req.query.uuidLike as string, + claveProdServ: req.query.claveProdServ as string, + descripcionConcepto: req.query.descripcionConcepto as string, + orderBy: req.query.orderBy as 'fecha' | 'importe', + orderDir: req.query.orderDir as 'asc' | 'desc', + }; + + const result = await cfdiService.getConceptosList(req.tenantPool, filters); + res.json(result); + } catch (error) { next(error); } +} + +export async function getConceptos(req: Request, res: Response, next: NextFunction) { + try { + if (!req.tenantPool) { + return next(new AppError(400, 'Tenant no configurado')); + } + + const conceptos = await cfdiService.getConceptos(req.tenantPool, String(req.params.id)); + res.json(conceptos); + } catch (error) { + next(error); + } +} + +export async function drillDown(req: Request, res: Response, next: NextFunction) { + try { + if (!req.tenantPool) { + return next(new AppError(400, 'Tenant no configurado')); + } + + const { + fechaInicio, fechaFin, type, tipoComprobante, metodoPago, + regimenEmisor, regimenReceptor, status, contribuyenteId, + bucket, considerarActivos, considerarNCs, + } = req.query; + + // Default true (consistente con el resto del sistema). Solo false si la URL + // pasa explícitamente '0' o 'false'. Sin estos toggles, el drill ignoraba + // el filtro de "Considerar activos" y mostraba CFDIs que la card sí estaba + // excluyendo del total. + const considerarActivosBool = considerarActivos !== '0' && considerarActivos !== 'false'; + const considerarNCsBool = considerarNCs !== '0' && considerarNCs !== 'false'; + const extra = buildExtraFilters(considerarActivosBool, considerarNCsBool); + + let where = 'WHERE 1=1'; + const params: any[] = []; + let pi = 1; + + // `bucket` expande la combinación (type, tipo_comprobante, metodo_pago, + // régimen) exactamente igual a la fórmula de KPIs/tarjetas — para que + // el drill-down cuadre línea a línea con el total del header. + // + // Reglas por bucket (alineado con dashboard.service y impuestos.service): + // ingresos: 3 grupos de régimen del emisor con fórmulas distintas. + // Grupo 1 (PF Empresarial 606/612/621/625/626): EMIT I PUE + EMIT P + // Grupo 2 (Sueldos 605, recibido como N): RECIB N PUE con receptor=605 + // Grupo 3 (PM y otros): EMIT I PUE+PPD + // gastos: uniforme todos los regímenes del receptor + // RECIB I PUE + RECIB P + // causado (IVA): EMIT I PUE + EMIT P + EMIT E PUE (excl. E/07) + // acreditable (IVA): RECIB I PUE + RECIB P + RECIB E PUE (excl. E/07) + // + // Las E PUE NO entran en ingresos/gastos — viven en sus propios drills + // ("NCs Emitidas" / "NCs Recibidas"). En IVA causado/acreditable sí + // entran, ya que el IVA de las NCs sí se acredita/cancela. + // + // Régimenes "ignorados" por el tenant se excluyen en todos los buckets. + // Las NC que restan se muestran como filas con signo (frontend las resta + // del total del header). Si `bucket` se pasa, se ignoran filtros + // type/tipoComprobante/metodoPago de entrada. + const bucketStr = typeof bucket === 'string' ? bucket.toLowerCase() : ''; + const bucketApplied = bucketStr === 'ingresos' || bucketStr === 'gastos' || + bucketStr === 'causado' || bucketStr === 'acreditable' || + bucketStr === 'ncs_emitidas' || bucketStr === 'ncs_recibidas' || + bucketStr === 'no_deducibles_efectivo'; + + // Régimenes ignorados por el tenant (configurable en /regimenes). Se + // excluyen del lado correspondiente según el bucket. + const ignorados = req.user?.tenantId + ? await getRegimenesIgnoradosClaves(req.user.tenantId) + : []; + + // Resolver condiciones esEmisor/esReceptor basadas en RFC del contribuyente. + // Reemplaza `type = 'EMITIDO/RECIBIDO' AND contribuyente_id = X` por un + // filtro por RFC — fuente de verdad cuando dos contribuyentes del tenant + // se facturan entre sí (type/contribuyente_id pueden ser inconsistentes). + const contribIdStr = typeof contribuyenteId === 'string' ? contribuyenteId : undefined; + const cfdiCtx = req.user?.tenantId + ? await resolveContribuyenteContext(req.tenantPool, req.user.tenantId, contribIdStr) + : null; + const esEmisor = cfdiCtx?.esEmisor || `type = 'EMITIDO'`; + const esReceptor = cfdiCtx?.esReceptor || `type = 'RECIBIDO'`; + + const NO_IGNORADO_EMISOR = ignorados.length > 0 + ? `AND (regimen_fiscal_emisor IS NULL OR regimen_fiscal_emisor NOT IN (${ignorados.map(r => `'${r}'`).join(',')}))` + : ''; + const NO_IGNORADO_RECEPTOR = ignorados.length > 0 + ? `AND (regimen_fiscal_receptor IS NULL OR regimen_fiscal_receptor NOT IN (${ignorados.map(r => `'${r}'`).join(',')}))` + : ''; + + const g1 = GRUPO_PF_EMPRESARIAL.map(r => `'${r}'`).join(','); + const g3 = GRUPO_PM_OTROS.map(r => `'${r}'`).join(','); + // Conjunto canónico de regímenes que el dashboard considera (excluye 616 + // extranjero y otros fuera del catálogo). El drill debe respetarlo para + // cuadrar con los KPIs/tarjetas. + const TODOS_REGS = [...GRUPO_PF_EMPRESARIAL, '605', ...GRUPO_PM_OTROS] + .map(r => `'${r}'`) + .join(','); + const E_NO_ANTICIPO = `COALESCE(cfdi_tipo_relacion, '') <> '07'`; + + if (bucketStr === 'ingresos') { + // 3 grupos con fórmulas distintas. Filtro por RFC (esEmisor/esReceptor). + // Las E PUE se exhiben en su propia card "NCs Emitidas" — no entran aquí. + // I/07 PPD compensación: cuando el contribuyente emite I/07 PPD con E + // relacionada en mismo mes, el cálculo aporta el valor de la E. La I/07 + // PPD aparece en el drill (parte del Grupo 1 universe vía I PPD), pero + // las E ya no. + where += ` AND ( + ( -- Grupo 1 PF Empresarial + ${esEmisor} + AND regimen_fiscal_emisor IN (${g1}) + AND ( + (tipo_comprobante = 'I' AND metodo_pago = 'PUE') + OR (tipo_comprobante = 'P') + ) + ) + OR ( -- Grupo 2 Sueldos: nómina recibida 605 + ${esReceptor} + AND tipo_comprobante = 'N' + AND metodo_pago = 'PUE' + AND regimen_fiscal_receptor = '605' + ) + OR ( -- Grupo 3 PM y otros + ${esEmisor} + AND regimen_fiscal_emisor IN (${g3}) + AND tipo_comprobante = 'I' AND metodo_pago IN ('PUE','PPD') + ) + ) ${NO_IGNORADO_EMISOR.replace('regimen_fiscal_emisor', `CASE WHEN ${esEmisor} THEN regimen_fiscal_emisor ELSE regimen_fiscal_receptor END`)}`; + } else if (bucketStr === 'gastos') { + // Las E PUE se exhiben en su propia card "NCs Recibidas" — no entran aquí. + where += ` AND ( + ${esReceptor} AND ( + (tipo_comprobante = 'I' AND metodo_pago = 'PUE') + OR (tipo_comprobante = 'P') + ) + AND regimen_fiscal_receptor IN (${TODOS_REGS}) + ) ${NO_IGNORADO_RECEPTOR}`; + } else if (bucketStr === 'causado') { + where += ` AND ( + ${esEmisor} AND ( + (tipo_comprobante = 'I' AND metodo_pago = 'PUE') + OR (tipo_comprobante = 'P') + OR (tipo_comprobante = 'E' AND metodo_pago = 'PUE' AND ${E_NO_ANTICIPO}) + ) + AND regimen_fiscal_emisor IN (${TODOS_REGS}) + ) ${NO_IGNORADO_EMISOR}`; + } else if (bucketStr === 'ncs_emitidas') { + // E PUE emitidas por el contribuyente, por régimen del emisor. + // Mirror del card "NCs Emitidas" en /impuestos > ISR. + // Sin restringir a TODOS_REGS — el calcular function tampoco lo hace + // (acepta cualquier régimen no-NULL no-ignorado, incluyendo 616 + // Extranjero, etc.). Si el contador filtró regímenes ignorados, el + // NO_IGNORADO_EMISOR ya los excluye. + where += ` AND ( + ${esEmisor} + AND tipo_comprobante = 'E' AND metodo_pago = 'PUE' + AND regimen_fiscal_emisor IS NOT NULL + ) ${NO_IGNORADO_EMISOR}`; + } else if (bucketStr === 'ncs_recibidas') { + // E PUE recibidas por el contribuyente, por régimen del receptor. + // Mirror del card "NCs Recibidas" en /impuestos > ISR. + where += ` AND ( + ${esReceptor} + AND tipo_comprobante = 'E' AND metodo_pago = 'PUE' + AND regimen_fiscal_receptor IS NOT NULL + ) ${NO_IGNORADO_RECEPTOR}`; + } else if (bucketStr === 'no_deducibles_efectivo') { + // Art. 27 fracción III LISR — facturas recibidas pagadas en efectivo + // (forma_pago='01') con monto > $2,000. Mirror del card "No Deducibles". + // I PUE: comparación con total_mxn. P: con monto_pago_mxn. + where += ` AND ( + ${esReceptor} + 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 regimen_fiscal_receptor IS NOT NULL + ) ${NO_IGNORADO_RECEPTOR}`; + } else if (bucketStr === 'acreditable') { + where += ` AND ( + ${esReceptor} AND ( + (tipo_comprobante = 'I' AND metodo_pago = 'PUE') + OR (tipo_comprobante = 'P') + OR (tipo_comprobante = 'E' AND metodo_pago = 'PUE' AND ${E_NO_ANTICIPO}) + ) + AND regimen_fiscal_receptor IN (${TODOS_REGS}) + ) ${NO_IGNORADO_RECEPTOR}`; + } + + // Fecha efectiva: para CFDIs tipo P (complementos de pago) usa fecha_pago_p + // (cuándo el cliente cobró) en vez de fecha_emision (cuándo se emitió el + // complemento). Así el drill-down es coherente con los KPIs — un P emitido + // en mayo que cobró una PPD de noviembre aparece en noviembre, no en mayo. + const FECHA_EFECTIVA = `CASE WHEN tipo_comprobante = 'P' THEN fecha_pago_p ELSE fecha_emision END`; + if (fechaInicio) { + where += ` AND ${FECHA_EFECTIVA} >= $${pi++}::date`; + params.push(fechaInicio); + } + if (fechaFin) { + where += ` AND ${FECHA_EFECTIVA} < ($${pi++}::date + interval '1 day')`; + params.push(fechaFin); + } + if (!bucketApplied) { + if (type) { + where += ` AND type = $${pi++}`; + params.push(type); + } + // tipoComprobante acepta valor único ('I') o CSV ('I,P'). Cuando la lista + // incluye P, el filtro metodoPago NO se aplica a los P (que no tienen), + // para que un drill-down "Ingresos del Mes" muestre I PUE + todos los P. + const tiposList = tipoComprobante + ? (tipoComprobante as string).split(',').map(t => t.trim()).filter(Boolean) + : []; + const includesP = tiposList.includes('P'); + if (tiposList.length === 1) { + where += ` AND tipo_comprobante = $${pi++}`; + params.push(tiposList[0]); + } else if (tiposList.length > 1) { + where += ` AND tipo_comprobante = ANY($${pi++})`; + params.push(tiposList); + } + if (metodoPago) { + const metodos = (metodoPago as string).split(','); + if (includesP) { + // P no tiene metodo_pago: el filtro aplica solo a los no-P + where += ` AND (tipo_comprobante = 'P' OR metodo_pago = ANY($${pi++}))`; + params.push(metodos); + } else { + where += ` AND metodo_pago = ANY($${pi++})`; + params.push(metodos); + } + } + } + if (regimenEmisor) { + where += ` AND regimen_fiscal_emisor = $${pi++}`; + params.push(regimenEmisor); + } + if (regimenReceptor) { + where += ` AND regimen_fiscal_receptor = $${pi++}`; + params.push(regimenReceptor); + } + if (status) { + if (status === 'vigente') { + where += ` AND status NOT IN ('Cancelado', '0')`; + } else { + where += ` AND status IN ('Cancelado', '0')`; + } + } + if (contribuyenteId && !bucketApplied) { + // Solo aplica cuando NO hay bucket (drill crudo, sin semantic de lado). + // Con bucket, esEmisor/esReceptor ya restringen por RFC del contribuyente. + // Sin bucket, filtramos inclusivo: contribuyente_id O RFC en cualquier lado. + if (cfdiCtx) { + where += ` AND ${cfdiCtx.contribFilter.replace(/^AND /, '')}`; + } + } + + // Aplica filtros de "Considerar activos" / "Considerar NCs" — alineado + // con los KPIs/cards. Sin esto el drill mostraba CFDIs que la card había + // excluido (ej. P que paga una I de activo con uso_cfdi=I03). + where += extra; + + const { rows } = await req.tenantPool.query(` + SELECT id, uuid, type, tipo_comprobante as "tipoComprobante", + fecha_emision as "fechaEmision", status, + rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor", + rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor", + subtotal, subtotal_mxn as "subtotalMxn", + total, total_mxn as "totalMxn", + moneda, metodo_pago as "metodoPago", + iva_traslado_mxn as "ivaTrasladoMxn", + iva_retencion_mxn as "ivaRetencionMxn", + isr_retencion_mxn as "isrRetencionMxn", + monto_pago_mxn as "montoPagoMxn", + regimen_fiscal_emisor as "regimenEmisor", + regimen_fiscal_receptor as "regimenReceptor" + FROM cfdis + ${where} + ORDER BY fecha_emision DESC + LIMIT 500 + `, params); + + res.json(rows); + } catch (error) { + next(error); + } +} + +export async function getEmisores(req: Request, res: Response, next: NextFunction) { + try { + if (!req.tenantPool) { + return next(new AppError(400, 'Tenant no configurado')); + } + + const search = (req.query.search as string) || ''; + if (search.length < 2) { + return res.json([]); + } + + const contribuyenteId = req.query.contribuyenteId as string | undefined; + const emisores = await cfdiService.getEmisores(req.tenantPool, search, 10, contribuyenteId); + res.json(emisores); + } catch (error) { + next(error); + } +} + +export async function getReceptores(req: Request, res: Response, next: NextFunction) { + try { + if (!req.tenantPool) { + return next(new AppError(400, 'Tenant no configurado')); + } + + const search = (req.query.search as string) || ''; + if (search.length < 2) { + return res.json([]); + } + + const contribuyenteId = req.query.contribuyenteId as string | undefined; + const receptores = await cfdiService.getReceptores(req.tenantPool, search, 10, contribuyenteId); + res.json(receptores); + } catch (error) { + next(error); + } +} + +export async function getResumen(req: Request, res: Response, next: NextFunction) { + try { + if (!req.tenantPool) { + return next(new AppError(400, 'Tenant no configurado')); + } + + const año = parseInt(req.query.año as string) || new Date().getFullYear(); + const mes = parseInt(req.query.mes as string) || new Date().getMonth() + 1; + const contribuyenteId = req.query.contribuyenteId as string | undefined; + + const resumen = await cfdiService.getResumenCfdis(req.tenantPool, año, mes, contribuyenteId); + res.json(resumen); + } catch (error) { + next(error); + } +} + +export async function createCfdi(req: Request, res: Response, next: NextFunction) { + try { + if (!req.tenantPool) { + return next(new AppError(400, 'Tenant no configurado')); + } + + if (!['owner', 'contador'].includes(req.user!.role)) { + return next(new AppError(403, 'No tienes permisos para agregar CFDIs')); + } + + const cfdi = await cfdiService.createCfdi(req.tenantPool, req.body); + res.status(201).json(cfdi); + } catch (error: any) { + if (error.message?.includes('duplicate')) { + return next(new AppError(409, 'Este CFDI ya existe (UUID duplicado)')); + } + next(error); + } +} + +export async function createManyCfdis(req: Request, res: Response, next: NextFunction) { + try { + if (!req.tenantPool) { + return next(new AppError(400, 'Tenant no configurado')); + } + + if (!['owner', 'contador'].includes(req.user!.role)) { + return next(new AppError(403, 'No tienes permisos para agregar CFDIs')); + } + + if (!Array.isArray(req.body.cfdis)) { + return next(new AppError(400, 'Se requiere un array de CFDIs')); + } + + const batchInfo = { + batchNumber: req.body.batchNumber || 1, + totalBatches: req.body.totalBatches || 1, + totalFiles: req.body.totalFiles || req.body.cfdis.length + }; + + console.log(`[CFDI Bulk] Lote ${batchInfo.batchNumber}/${batchInfo.totalBatches} - ${req.body.cfdis.length} CFDIs`); + + const result = await cfdiService.createManyCfdisBatch(req.tenantPool, req.body.cfdis); + + res.status(201).json({ + message: `Lote ${batchInfo.batchNumber} procesado`, + batchNumber: batchInfo.batchNumber, + totalBatches: batchInfo.totalBatches, + inserted: result.inserted, + duplicates: result.duplicates, + errors: result.errors, + errorMessages: result.errorMessages.slice(0, 5) + }); + } catch (error: any) { + console.error('[CFDI Bulk Error]', error.message, error.stack); + next(new AppError(400, error.message || 'Error al procesar CFDIs')); + } +} + +export async function deleteCfdi(req: Request, res: Response, next: NextFunction) { + try { + if (!req.tenantPool) { + return next(new AppError(400, 'Tenant no configurado')); + } + + if (!['owner', 'contador'].includes(req.user!.role)) { + return next(new AppError(403, 'No tienes permisos para eliminar CFDIs')); + } + + await cfdiService.deleteCfdi(req.tenantPool, String(req.params.id)); + res.status(204).send(); + } catch (error) { + next(error); + } +} diff --git a/apps/api/src/controllers/conciliacion.controller.ts b/apps/api/src/controllers/conciliacion.controller.ts new file mode 100644 index 0000000..eb14c1e --- /dev/null +++ b/apps/api/src/controllers/conciliacion.controller.ts @@ -0,0 +1,58 @@ +import type { Request, Response, NextFunction } from 'express'; +import * as conciliacionService from '../services/conciliacion.service.js'; +import { prisma } from '../config/database.js'; + +export async function getCfdis(req: Request, res: Response, next: NextFunction) { + try { + const { tipo, fechaInicio, fechaFin, regimen, estado, contribuyenteId } = req.query; + if (!tipo) return res.status(400).json({ message: 'tipo es requerido (EMITIDO|RECIBIDO)' }); + + const data = await conciliacionService.getCfdisConConciliacion(req.tenantPool!, { + tipo: tipo as string, + fechaInicio: fechaInicio as string, + fechaFin: fechaFin as string, + regimen: regimen as string, + estado: estado as string, + contribuyenteId: contribuyenteId as string | undefined, + }); + res.json(data); + } catch (error) { next(error); } +} + +export async function conciliar(req: Request, res: Response, next: NextFunction) { + try { + if (!['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'].includes(req.user!.role)) { + return res.status(403).json({ message: 'No autorizado' }); + } + + const { cfdiIds, fechaDePago, idBanco } = req.body; + if (!cfdiIds?.length || !fechaDePago || !idBanco) { + return res.status(400).json({ message: 'cfdiIds, fechaDePago e idBanco son requeridos' }); + } + + const tenant = await prisma.tenant.findUnique({ + where: { id: req.user!.tenantId }, + select: { createdAt: true }, + }); + const tenantCreatedYear = tenant ? tenant.createdAt.getFullYear() : new Date().getFullYear(); + + const count = await conciliacionService.conciliar(req.tenantPool!, { cfdiIds, fechaDePago, idBanco }, tenantCreatedYear); + res.json({ message: `${count} CFDIs conciliados`, count }); + } catch (error: any) { + if (error.message && !error.message.includes('Internal')) { + return res.status(400).json({ message: error.message }); + } + next(error); + } +} + +export async function desconciliar(req: Request, res: Response, next: NextFunction) { + try { + if (!['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'].includes(req.user!.role)) { + return res.status(403).json({ message: 'No autorizado' }); + } + const id = parseInt(String(req.params.id)); + await conciliacionService.desconciliar(req.tenantPool!, id); + res.json({ message: 'CFDI desconciliado' }); + } catch (error) { next(error); } +} diff --git a/apps/api/src/controllers/connector.controller.ts b/apps/api/src/controllers/connector.controller.ts new file mode 100644 index 0000000..8e41ed1 --- /dev/null +++ b/apps/api/src/controllers/connector.controller.ts @@ -0,0 +1,58 @@ +import type { Request, Response, NextFunction } from 'express'; +import { z } from 'zod'; +import * as connectorService from '../services/connector.service.js'; +import { AppError } from '../middlewares/error.middleware.js'; + +const heartbeatSchema = z.object({ + version: z.string(), + uptimeSeconds: z.number().optional().default(0), + postgresPingMs: z.number().optional().default(0), + pgVersion: z.string().optional(), + lastMigration: z.string().optional(), + status: z.string().optional(), + errorMsg: z.string().optional(), +}); + +// Called by the connector Docker container, NOT by browser users +export async function heartbeat(req: Request, res: Response, next: NextFunction) { + try { + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith('Bearer ')) { + return res.status(401).json({ message: 'Token requerido' }); + } + + const token = authHeader.split(' ')[1]; + const tenantId = await connectorService.verifyConnectorToken(token); + if (!tenantId) { + return res.status(401).json({ message: 'Token inválido' }); + } + + const data = heartbeatSchema.parse(req.body); + await connectorService.recordHeartbeat(tenantId, data); + + return res.json({ ok: true }); + } catch (err: any) { + if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message)); + return next(err); + } +} + +// Called by authenticated tenant owner to provision or check connector +export async function provision(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.viewingTenantId || req.user!.tenantId; + const result = await connectorService.provisionConnector(tenantId); + return res.status(201).json(result); + } catch (err: any) { + if (err.message?.includes('no encontrado')) return next(new AppError(404, err.message)); + return next(err); + } +} + +export async function status(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.viewingTenantId || req.user!.tenantId; + const result = await connectorService.getConnectorStatus(tenantId); + return res.json(result); + } catch (err) { return next(err); } +} diff --git a/apps/api/src/controllers/contribuyente-config.controller.ts b/apps/api/src/controllers/contribuyente-config.controller.ts new file mode 100644 index 0000000..8d49172 --- /dev/null +++ b/apps/api/src/controllers/contribuyente-config.controller.ts @@ -0,0 +1,95 @@ +import type { Request, Response, NextFunction } from 'express'; +import { AppError } from '../middlewares/error.middleware.js'; +import * as fielService from '../services/contribuyente-fiel.service.js'; +import * as facturapiService from '../services/contribuyente-facturapi.service.js'; +import { getContribuyenteById } from '../services/contribuyente.service.js'; + +// ========== FIEL ========== + +export async function uploadFiel(req: Request, res: Response, next: NextFunction) { + try { + const { cerFile, keyFile, password } = req.body; + if (!cerFile || !keyFile || !password) { + return next(new AppError(400, 'cerFile, keyFile y password son requeridos')); + } + const contribuyenteId = String(req.params.id); + const contrib = await getContribuyenteById(req.tenantPool!, contribuyenteId); + if (!contrib) return next(new AppError(404, 'Contribuyente no encontrado')); + + const result = await fielService.uploadFielContribuyente(req.tenantPool!, contribuyenteId, cerFile, keyFile, password); + if (!result.success) { + console.error('[FIEL Upload] Failed:', result.message); + return res.status(400).json({ message: result.message }); + } + return res.json(result); + } catch (err: any) { + console.error('[FIEL Upload] Exception:', err.message || err); + return next(err); + } +} + +export async function fielStatus(req: Request, res: Response, next: NextFunction) { + try { + const contribuyenteId = String(req.params.id); + const status = await fielService.getFielStatusContribuyente(req.tenantPool!, contribuyenteId); + return res.json(status); + } catch (err) { return next(err); } +} + +export async function deleteFiel(req: Request, res: Response, next: NextFunction) { + try { + const contribuyenteId = String(req.params.id); + // Delete from per-contribuyente table (tenant BD) + await req.tenantPool!.query( + 'UPDATE fiel_contribuyente SET is_active = false WHERE contribuyente_id = $1', + [contribuyenteId] + ); + // Also try to deactivate legacy FIEL if it matches this contribuyente's RFC + const { rows } = await req.tenantPool!.query('SELECT rfc FROM contribuyentes WHERE entidad_id = $1', [contribuyenteId]); + if (rows[0]?.rfc) { + const { prisma } = await import('../config/database.js'); + await prisma.fielCredential.updateMany({ + where: { rfc: rows[0].rfc }, + data: { isActive: false }, + }).catch(() => {}); + } + return res.json({ message: 'FIEL eliminada' }); + } catch (err) { return next(err); } +} + +// ========== FACTURAPI ========== + +export async function createOrg(req: Request, res: Response, next: NextFunction) { + try { + const contribuyenteId = String(req.params.id); + const contrib = await getContribuyenteById(req.tenantPool!, contribuyenteId); + if (!contrib) return next(new AppError(404, 'Contribuyente no encontrado')); + + const result = await facturapiService.createOrgContribuyente(req.tenantPool!, contribuyenteId, contrib.nombre); + return res.status(201).json(result); + } catch (err: any) { + if (err.message?.includes('ya tiene')) return next(new AppError(409, err.message)); + return next(err); + } +} + +export async function orgStatus(req: Request, res: Response, next: NextFunction) { + try { + const contribuyenteId = String(req.params.id); + const status = await facturapiService.getOrgStatusContribuyente(req.tenantPool!, contribuyenteId); + return res.json(status); + } catch (err) { return next(err); } +} + +export async function uploadCsd(req: Request, res: Response, next: NextFunction) { + try { + const { cerFile, keyFile, password } = req.body; + if (!cerFile || !keyFile || !password) { + return next(new AppError(400, 'cerFile, keyFile y password son requeridos')); + } + const contribuyenteId = String(req.params.id); + const result = await facturapiService.uploadCsdContribuyente(req.tenantPool!, contribuyenteId, cerFile, keyFile, password); + if (!result.success) return res.status(400).json({ message: result.message }); + return res.json(result); + } catch (err) { return next(err); } +} diff --git a/apps/api/src/controllers/contribuyente.controller.ts b/apps/api/src/controllers/contribuyente.controller.ts new file mode 100644 index 0000000..a0ca37f --- /dev/null +++ b/apps/api/src/controllers/contribuyente.controller.ts @@ -0,0 +1,148 @@ +import type { Request, Response, NextFunction } from 'express'; +import { z } from 'zod'; +import * as contribuyenteService from '../services/contribuyente.service.js'; +import { AppError } from '../middlewares/error.middleware.js'; +import { getEntidadesVisibles } from '../utils/entidades-visibles.js'; +import { adjustDespachoOverage } from '../services/payment/addon.service.js'; +import { prisma } from '../config/database.js'; + +/** + * Límite duro de contribuyentes mientras el despacho está en trial gratuito. + * Una vez expira el trial (`trialEndsAt < now`) este límite deja de aplicar y + * el plan vigente toma el control. + */ +const TRIAL_MAX_CONTRIBUYENTES = 5; + +/** + * Cuenta contribuyentes activos del tenant actual. Usado para ajustar el + * overage de Business Control / Enterprise tras crear o desactivar un RFC, + * y para enforce el límite del trial. + */ +async function countActiveContribuyentes(pool: import('pg').Pool): Promise { + const { rows: [{ cnt }] } = await pool.query<{ cnt: string }>( + `SELECT COUNT(*)::text AS cnt FROM entidades_gestionadas + WHERE active = true AND tipo = 'CONTRIBUYENTE'`, + ); + return Number(cnt) || 0; +} + +const createSchema = z.object({ + rfc: z.string().regex(/^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$/i, 'RFC inválido'), + razonSocial: z.string().min(2, 'Razón social requerida'), + regimenFiscal: z.string().length(3).optional(), + codigoPostal: z.string().regex(/^\d{5}$/).optional(), + domicilio: z.record(z.unknown()).optional(), + supervisorUserId: z.string().uuid().optional(), +}); + +const updateSchema = createSchema.partial(); + +export async function list(req: Request, res: Response, next: NextFunction) { + try { + const visibleIds = await getEntidadesVisibles(req.tenantPool!, req.user!.userId, req.user!.role); + const rows = await contribuyenteService.listContribuyentes(req.tenantPool!, visibleIds); + return res.json({ data: rows }); + } catch (err) { return next(err); } +} + +export async function getById(req: Request, res: Response, next: NextFunction) { + try { + const row = await contribuyenteService.getContribuyenteById(req.tenantPool!, String(req.params.id)); + if (!row) return next(new AppError(404, 'Contribuyente no encontrado')); + return res.json(row); + } catch (err) { return next(err); } +} + +export async function create(req: Request, res: Response, next: NextFunction) { + try { + const data = createSchema.parse(req.body); + + // Trial gate: durante el periodo de prueba (trialEndsAt > now) el despacho + // no puede gestionar más de TRIAL_MAX_CONTRIBUYENTES RFCs activos. Cuando + // el trial expira, deja de aplicar y el límite del plan vigente toma el control. + const tenant = await prisma.tenant.findUnique({ + where: { id: req.user!.tenantId }, + select: { trialEndsAt: true }, + }); + const isTrialActive = tenant?.trialEndsAt ? tenant.trialEndsAt > new Date() : false; + if (isTrialActive) { + const activeCount = await countActiveContribuyentes(req.tenantPool!); + if (activeCount >= TRIAL_MAX_CONTRIBUYENTES) { + return next(new AppError( + 403, + `Durante el periodo de prueba puedes gestionar hasta ${TRIAL_MAX_CONTRIBUYENTES} contribuyentes. Contrata un plan para agregar más.`, + )); + } + } + + const row = await contribuyenteService.createContribuyente(req.tenantPool!, data); + + // Ajuste de overage despacho: si el tenant pasa de 100 a 101+ RFCs, crea + // el addon y devuelve paymentUrl para que el frontend redirija al usuario. + // Fail-soft: si falla el addon, el contribuyente queda creado y se loguea. + let overage: Awaited> | null = null; + try { + const activeCount = await countActiveContribuyentes(req.tenantPool!); + overage = await adjustDespachoOverage(req.user!.tenantId, activeCount); + } catch (err: any) { + console.error('[Contribuyente] Overage adjust failed (non-blocking):', err.message || err); + } + + return res.status(201).json({ ...row, overage }); + } catch (err: any) { + if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message)); + if (err.code === '23505') return next(new AppError(409, 'Ya existe un contribuyente con este RFC')); + return next(err); + } +} + +export async function update(req: Request, res: Response, next: NextFunction) { + try { + const data = updateSchema.parse(req.body); + const row = await contribuyenteService.updateContribuyente(req.tenantPool!, String(req.params.id), data); + if (!row) return next(new AppError(404, 'Contribuyente no encontrado')); + return res.json(row); + } catch (err: any) { + if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message)); + return next(err); + } +} + +export async function deactivate(req: Request, res: Response, next: NextFunction) { + try { + const ok = await contribuyenteService.deactivateContribuyente(req.tenantPool!, String(req.params.id)); + if (!ok) return next(new AppError(404, 'Contribuyente no encontrado')); + + // Ajuste de overage despacho: si el count baja, reduce quantity del + // addon (updatePreapprovalAmount) o cancela el preapproval si pasa al límite. + let overage: Awaited> | null = null; + try { + const activeCount = await countActiveContribuyentes(req.tenantPool!); + overage = await adjustDespachoOverage(req.user!.tenantId, activeCount); + } catch (err: any) { + console.error('[Contribuyente] Overage adjust failed (non-blocking):', err.message || err); + } + + return res.json({ message: 'Contribuyente desactivado', overage }); + } catch (err) { return next(err); } +} + +export async function backfill(req: Request, res: Response, next: NextFunction) { + try { + const total = await contribuyenteService.backfillAllContribuyentes(req.tenantPool!); + return res.json({ message: `${total} CFDIs asignados a contribuyentes`, total }); + } catch (err) { return next(err); } +} + +export async function addClienteAcceso(req: Request, res: Response, next: NextFunction) { + try { + const { userId } = req.body; + if (!userId || typeof userId !== 'string') return next(new AppError(400, 'userId requerido')); + const entidadId = String(req.params.id); + await req.tenantPool!.query( + 'INSERT INTO cliente_accesos (user_id, entidad_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', + [userId, entidadId], + ); + return res.json({ message: 'Acceso otorgado' }); + } catch (err) { return next(err); } +} diff --git a/apps/api/src/controllers/dashboard.controller.ts b/apps/api/src/controllers/dashboard.controller.ts new file mode 100644 index 0000000..03c08c4 --- /dev/null +++ b/apps/api/src/controllers/dashboard.controller.ts @@ -0,0 +1,108 @@ +import type { Request, Response, NextFunction } from 'express'; +import * as dashboardService from '../services/dashboard.service.js'; +import { generarAlertasAutomaticas } from '../services/alertas-auto.service.js'; +import { getAlertasManualesPendientes } from '../services/alertas-manuales.service.js'; +import { AppError } from '../middlewares/error.middleware.js'; + +function getDefaultRange() { + const now = new Date(); + const y = now.getFullYear(); + const m = now.getMonth() + 1; + const lastDay = new Date(y, m, 0).getDate(); + return { + fechaInicio: `${y}-${String(m).padStart(2, '0')}-01`, + fechaFin: `${y}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`, + año: y, + mes: m, + }; +} + +function parseConciliacion(req: Request): boolean { + return req.query.conciliacion === 'true' || req.query.conciliacion === '1'; +} + +export async function getKpis(req: Request, res: Response, next: NextFunction) { + try { + if (!req.tenantPool) { + return next(new AppError(400, 'Tenant no configurado')); + } + + const defaults = getDefaultRange(); + const fechaInicio = (req.query.fechaInicio as string) || defaults.fechaInicio; + const fechaFin = (req.query.fechaFin as string) || defaults.fechaFin; + const conciliacion = parseConciliacion(req); + const contribuyenteId = (req.query.contribuyenteId as string) || null; + + const tenantId = req.viewingTenantId || req.user!.tenantId; + const kpis = await dashboardService.getKpis(req.tenantPool, fechaInicio, fechaFin, tenantId, conciliacion, contribuyenteId); + res.json(kpis); + } catch (error) { + next(error); + } +} + +export async function getIngresosEgresos(req: Request, res: Response, next: NextFunction) { + try { + if (!req.tenantPool) { + return next(new AppError(400, 'Tenant no configurado')); + } + + const año = parseInt(req.query.año as string) || new Date().getFullYear(); + const conciliacion = parseConciliacion(req); + const contribuyenteId = (req.query.contribuyenteId as string) || null; + const tenantId = req.viewingTenantId || req.user!.tenantId; + + const data = await dashboardService.getIngresosEgresos(req.tenantPool, año, tenantId, conciliacion, contribuyenteId); + res.json(data); + } catch (error) { + next(error); + } +} + +export async function getRegimenesDelPeriodo(req: Request, res: Response, next: NextFunction) { + try { + if (!req.tenantPool) { + return next(new AppError(400, 'Tenant no configurado')); + } + + const defaults = getDefaultRange(); + const fechaInicio = (req.query.fechaInicio as string) || defaults.fechaInicio; + const fechaFin = (req.query.fechaFin as string) || defaults.fechaFin; + const conciliacion = parseConciliacion(req); + const contribuyenteId = (req.query.contribuyenteId as string) || null; + + const tenantId = req.viewingTenantId || req.user?.tenantId; + const regimenes = await dashboardService.getRegimenesDelPeriodo(req.tenantPool, fechaInicio, fechaFin, conciliacion, contribuyenteId, tenantId); + res.json(regimenes); + } catch (error) { + next(error); + } +} + +export async function getAlertas(req: Request, res: Response, next: NextFunction) { + try { + if (!req.tenantPool) { + return next(new AppError(400, 'Tenant no configurado')); + } + + const limit = parseInt(req.query.limit as string) || 5; + const tenantId = req.viewingTenantId || req.user!.tenantId; + const contribuyenteId = (req.query.contribuyenteId as string) || null; + + // Combinar alertas persistidas (manuales, filtered by role) + automáticas (calculadas) + const [manuales, automaticas] = await Promise.all([ + getAlertasManualesPendientes(req.tenantPool, contribuyenteId, req.user!.userId, req.user!.role), + generarAlertasAutomaticas(req.tenantPool, tenantId, contribuyenteId), + ]); + + // Unir, ordenar por prioridad, y limitar + const prioridadOrden: Record = { alta: 1, media: 2, baja: 3 }; + const alertas = [...automaticas, ...manuales] + .sort((a, b) => (prioridadOrden[a.prioridad] || 3) - (prioridadOrden[b.prioridad] || 3)) + .slice(0, limit); + + res.json(alertas); + } catch (error) { + next(error); + } +} diff --git a/apps/api/src/controllers/despacho-audit.controller.ts b/apps/api/src/controllers/despacho-audit.controller.ts new file mode 100644 index 0000000..cc72dc7 --- /dev/null +++ b/apps/api/src/controllers/despacho-audit.controller.ts @@ -0,0 +1,67 @@ +import type { Request, Response, NextFunction } from 'express'; +import { prisma } from '../config/database.js'; +import { AppError } from '../middlewares/error.middleware.js'; + +export async function getDespachoAuditLog(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) return next(new AppError(401, 'No autenticado')); + + const tenantId = req.viewingTenantId || req.user.tenantId; + + // Only owner or cfo can see audit log of their despacho + if (req.user.role !== 'owner' && req.user.role !== 'cfo') { + return next(new AppError(403, 'Solo el dueño puede ver el registro de accesos')); + } + + const from = req.query.from + ? new Date(req.query.from as string) + : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + const to = req.query.to ? new Date(req.query.to as string) : new Date(); + const limit = Math.min(Number(req.query.limit) || 50, 200); + + const logs = await prisma.auditLog.findMany({ + where: { + tenantId, + action: { startsWith: 'admin.' }, + createdAt: { gte: from, lte: to }, + }, + orderBy: { createdAt: 'desc' }, + take: limit, + }); + + // Enrich with admin user info + const userIds = [...new Set(logs.filter(l => l.userId).map(l => l.userId!))]; + const users = + userIds.length > 0 + ? await prisma.user.findMany({ + where: { id: { in: userIds } }, + select: { id: true, nombre: true, email: true }, + }) + : []; + const userMap = new Map(users.map(u => [u.id, u])); + + const enriched = logs.map(log => ({ + id: log.id, + action: log.action, + timestamp: log.createdAt.toISOString(), + admin: log.userId + ? { + nombre: userMap.get(log.userId)?.nombre ?? 'Desconocido', + email: userMap.get(log.userId)?.email ?? '', + } + : null, + motivo: (log.metadata as any)?.motivo ?? null, + ip: (log.metadata as any)?.ip ?? null, + details: log.metadata, + })); + + return res.json({ + data: enriched, + total: enriched.length, + from: from.toISOString(), + to: to.toISOString(), + }); + } catch (err) { + return next(err); + } +} diff --git a/apps/api/src/controllers/despacho-stats.controller.ts b/apps/api/src/controllers/despacho-stats.controller.ts new file mode 100644 index 0000000..24c18e2 --- /dev/null +++ b/apps/api/src/controllers/despacho-stats.controller.ts @@ -0,0 +1,67 @@ +import type { Request, Response, NextFunction } from 'express'; +import { AppError } from '../middlewares/error.middleware.js'; +import * as despachoService from '../services/despacho-stats.service.js'; + +function effectiveTenantId(req: Request): string { + return req.viewingTenantId || req.user!.tenantId; +} + +const ROLES_OWNER = new Set(['owner', 'cfo']); +const ROLES_SUPERVISORY = new Set(['owner', 'cfo', 'supervisor']); +const ROLES_ASIGNADOS = new Set(['owner', 'cfo', 'supervisor', 'auxiliar']); + +export async function getContribuyentesStats(req: Request, res: Response, next: NextFunction) { + try { + if (!ROLES_OWNER.has(req.user!.role)) { + throw new AppError(403, 'Solo owner puede ver estas métricas'); + } + const tenantId = effectiveTenantId(req); + const año = req.query.año ? parseInt(String(req.query.año), 10) : undefined; + const mes = req.query.mes ? parseInt(String(req.query.mes), 10) : undefined; + const stats = await despachoService.getContribuyentesStats(req.tenantPool!, tenantId, año, mes); + res.json(stats); + } catch (error) { + next(error); + } +} + +export async function getMisAsignados(req: Request, res: Response, next: NextFunction) { + try { + if (!ROLES_ASIGNADOS.has(req.user!.role)) { + throw new AppError(403, 'No tienes contribuyentes asignados'); + } + const año = req.query.año ? parseInt(String(req.query.año), 10) : undefined; + const mes = req.query.mes ? parseInt(String(req.query.mes), 10) : undefined; + const data = await despachoService.getMisAsignados( + req.tenantPool!, + req.user!.userId, + req.user!.role, + año, + mes, + ); + res.json(data); + } catch (error) { + next(error); + } +} + +export async function getEquipoStats(req: Request, res: Response, next: NextFunction) { + try { + if (!ROLES_SUPERVISORY.has(req.user!.role)) { + throw new AppError(403, 'Solo owner y supervisor pueden ver al equipo'); + } + const año = req.query.año ? parseInt(String(req.query.año), 10) : undefined; + const mes = req.query.mes ? parseInt(String(req.query.mes), 10) : undefined; + const data = await despachoService.getEquipoStats( + req.tenantPool!, + req.user!.userId, + req.user!.role, + effectiveTenantId(req), + año, + mes, + ); + res.json(data); + } catch (error) { + next(error); + } +} diff --git a/apps/api/src/controllers/despacho.controller.ts b/apps/api/src/controllers/despacho.controller.ts new file mode 100644 index 0000000..96ce909 --- /dev/null +++ b/apps/api/src/controllers/despacho.controller.ts @@ -0,0 +1,98 @@ +import type { Request, Response, NextFunction } from 'express'; +import { z } from 'zod'; +import { signupDespacho } from '../services/despacho.service.js'; +import { AppError } from '../middlewares/error.middleware.js'; +import { prisma } from '../config/database.js'; + +const signupSchema = z.object({ + despacho: z.object({ + nombre: z.string().min(2, 'Nombre del despacho requerido'), + regimenFiscal: z.string().optional(), + codigoPostal: z.string().regex(/^\d{5}$/, 'Código postal inválido').optional(), + verticalProfile: z.enum(['CONTABLE', 'JURIDICO', 'ARQUITECTURA']), + plan: z.enum(['trial', 'mi_empresa', 'mi_empresa_plus', 'business_control', 'business_cloud']).optional().default('trial'), + // Solo aplica a mi_empresa y mi_empresa_plus (los otros pagados son + // anuales fijos). Default annual sesga el cash-flow del negocio. + frequency: z.enum(['monthly', 'annual']).optional().default('annual'), + }), + owner: z.object({ + nombre: z.string().min(2, 'Nombre del owner requerido'), + email: z.string().email('Email inválido'), + password: z.string().min(10, 'La contraseña debe tener al menos 10 caracteres'), + }), +}); + +export async function getMyPlan(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.user!.tenantId; + const tenant = await prisma.tenant.findUnique({ + where: { id: tenantId }, + select: { dbMode: true, trialEndsAt: true, verticalProfile: true, plan: true }, + }); + + if (!tenant) { + return next(new AppError(404, 'Tenant no encontrado')); + } + + const now = new Date(); + const isTrialActive = tenant.trialEndsAt ? tenant.trialEndsAt > now : false; + + // Mapea según trialEndsAt + tenant.plan (no dbMode). dbMode era proxy + // antes de la introducción de Mi Empresa / Mi Empresa+ — para esos + // planes, dbMode también es MANAGED y reportar `business_cloud` daba + // mapeo equivocado. tenant.plan es la fuente de verdad post-migración + // 20260426073942 (que añadió mi_empresa y mi_empresa_plus al enum). + let currentPlan: string; + if (isTrialActive) { + currentPlan = 'trial'; + } else { + currentPlan = String(tenant.plan); + } + + // Estado de suscripción activa (si hay) — alimenta la UI con el monto + // recurrente actual, fecha de próxima renovación y si el primer pago + // (cuando aplica dualidad firstYear) ya fue completado. + const subscription = await prisma.subscription.findFirst({ + where: { tenantId, status: { in: ['authorized', 'pending', 'paused', 'trial'] } }, + orderBy: { createdAt: 'desc' }, + select: { + status: true, amount: true, plan: true, + currentPeriodStart: true, currentPeriodEnd: true, + }, + }); + + return res.json({ + plan: currentPlan, + dbMode: tenant.dbMode, + trialEndsAt: tenant.trialEndsAt?.toISOString() ?? null, + isTrialActive, + subscription: subscription + ? { + status: subscription.status, + plan: subscription.plan, + amount: Number(subscription.amount), + currentPeriodStart: subscription.currentPeriodStart?.toISOString() ?? null, + currentPeriodEnd: subscription.currentPeriodEnd?.toISOString() ?? null, + } + : null, + }); + } catch (error) { + return next(error); + } +} + +export async function signup(req: Request, res: Response, next: NextFunction) { + try { + const data = signupSchema.parse(req.body); + const result = await signupDespacho(data); + return res.status(201).json(result); + } catch (error: any) { + if (error instanceof z.ZodError) { + return next(new AppError(400, error.errors[0].message)); + } + if (error.message?.includes('Ya existe')) { + return next(new AppError(409, error.message)); + } + return next(error); + } +} diff --git a/apps/api/src/controllers/documentos.controller.ts b/apps/api/src/controllers/documentos.controller.ts new file mode 100644 index 0000000..2fea881 --- /dev/null +++ b/apps/api/src/controllers/documentos.controller.ts @@ -0,0 +1,333 @@ +import type { Request, Response, NextFunction } from 'express'; +import { z } from 'zod'; +import { getOpiniones, getOpinionPdf, consultarOpinion, consultarOpinionContribuyente } from '../services/opinion-cumplimiento.service.js'; +import * as declaracionesService from '../services/declaraciones.service.js'; +import * as constanciaService from '../services/constancia.service.js'; +import * as extrasService from '../services/documentos-extras.service.js'; +import { notifyDocumentoSubido } from '../services/notify-upload.service.js'; +import { AppError } from '../middlewares/error.middleware.js'; + +const MESES = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre']; + +function effectiveTenantId(req: Request): string { + return req.viewingTenantId || req.user!.tenantId; +} + +export async function listarOpiniones(req: Request, res: Response, next: NextFunction) { + try { + const contribuyenteId = req.query.contribuyenteId as string | undefined; + let rfc: string | undefined; + if (contribuyenteId) { + const { rows } = await req.tenantPool!.query( + 'SELECT rfc FROM contribuyentes WHERE entidad_id = $1', + [contribuyenteId], + ); + rfc = rows[0]?.rfc; + } + const opiniones = await getOpiniones(req.tenantPool!, 5, rfc); + res.json(opiniones); + } catch (error) { + next(error); + } +} + +export async function descargarPdf(req: Request, res: Response, next: NextFunction) { + try { + const id = parseInt(String(req.params.id)); + if (isNaN(id)) return res.status(400).json({ error: 'ID inválido' }); + + const pdf = await getOpinionPdf(req.tenantPool!, id); + if (!pdf) return res.status(404).json({ error: 'Opinión no encontrada' }); + + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename="opinion_cumplimiento_${id}.pdf"`); + res.send(pdf); + } catch (error) { + next(error); + } +} + +export async function consultarManual(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = effectiveTenantId(req); + const contribuyenteId = req.query.contribuyenteId as string | undefined; + + let opinion; + if (contribuyenteId) { + opinion = await consultarOpinionContribuyente(req.tenantPool!, contribuyenteId); + } else { + opinion = await consultarOpinion(tenantId); + } + res.json(opinion); + } catch (error: any) { + if (error.message?.includes('FIEL')) { + return res.status(400).json({ error: error.message }); + } + next(error); + } +} + +// ============================================================================ +// Declaraciones provisionales +// ============================================================================ + +const ROLES_UPLOAD = ['owner', 'cfo', 'contador', 'auxiliar']; + +function canUpload(req: Request): boolean { + return ROLES_UPLOAD.includes(req.user!.role); +} + +const createDeclaracionSchema = z.object({ + año: z.number().int().min(2020).max(2100), + mes: z.number().int().min(1).max(12), + tipo: z.enum(['normal', 'complementaria']), + periodicidad: z.enum(['mensual', 'bimestral', 'trimestral', 'semestral', 'anual']).optional(), + impuestos: z.array(z.enum(['IVA', 'ISR', 'IEPS', 'SUELDOS', 'DIOT', 'OTRO'])).min(1, 'Selecciona al menos un impuesto'), + montoPago: z.number().min(0).optional(), + pdfBase64: z.string().min(100), + pdfFilename: z.string().min(1).max(255), + ligaPagoBase64: z.string().min(100).optional(), + ligaPagoFilename: z.string().min(1).max(255).optional(), + notas: z.string().max(2000).optional(), +}).refine( + d => !d.ligaPagoBase64 || !!d.ligaPagoFilename, + { message: 'Si incluyes liga de pago, también debes mandar su nombre de archivo', path: ['ligaPagoFilename'] }, +); + +export async function listarDeclaraciones(req: Request, res: Response, next: NextFunction) { + try { + const fechaDesde = req.query.fechaDesde as string | undefined; + const fechaHasta = req.query.fechaHasta as string | undefined; + const contribuyenteId = typeof req.query.contribuyenteId === 'string' && req.query.contribuyenteId + ? req.query.contribuyenteId + : null; + const data = await declaracionesService.listDeclaraciones(req.tenantPool!, fechaDesde, fechaHasta, contribuyenteId); + res.json(data); + } catch (error) { next(error); } +} + +export async function crearDeclaracion(req: Request, res: Response, next: NextFunction) { + try { + if (!canUpload(req)) return res.status(403).json({ message: 'No tienes permiso para subir declaraciones' }); + const data = createDeclaracionSchema.parse(req.body); + const contribuyenteId = req.body.contribuyenteId as string | undefined; + const result = await declaracionesService.createDeclaracion(req.tenantPool!, { + ...data, + creadoPor: req.user!.email, + creadoPorUserId: req.user!.userId, + contribuyenteId, + }); + + // Notificación fire-and-forget a owners del despacho + supervisor del RFC. + // No bloquea la respuesta ni falla la creación si SMTP no está configurado. + notifyDocumentoSubido({ + pool: req.tenantPool!, + tenantId: req.user!.tenantId, + contribuyenteId: contribuyenteId ?? null, + subidoPor: req.user!.email, + kind: 'declaracion', + declaracion: { + periodo: `${MESES[data.mes - 1]} ${data.año}`, + tipo: data.tipo, + impuestos: data.impuestos as string[], + montoPago: data.montoPago ?? null, + }, + }).catch((err: any) => console.error('[notifyDocumentoSubido declaracion]', err?.message || err)); + + res.status(201).json(result); + } catch (error: any) { + if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message)); + if (error?.message?.includes('Ya existe') || error?.message?.includes('normal')) { + return next(new AppError(400, error.message)); + } + next(error); + } +} + +const comprobantePagoSchema = z.object({ + pdfBase64: z.string().min(100), + pdfFilename: z.string().min(1).max(255), +}); + +export async function subirComprobantePago(req: Request, res: Response, next: NextFunction) { + try { + if (!canUpload(req)) return res.status(403).json({ message: 'No tienes permiso para subir comprobantes' }); + const id = parseInt(String(req.params.id)); + if (isNaN(id)) return next(new AppError(400, 'id inválido')); + const data = comprobantePagoSchema.parse(req.body); + const result = await declaracionesService.uploadComprobantePago(req.tenantPool!, id, { + ...data, + uploadedByUserId: req.user!.userId, + }); + res.json(result); + } catch (error: any) { + if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message)); + if (error?.message?.includes('no encontrada')) { + return next(new AppError(404, error.message)); + } + next(error); + } +} + +export async function descargarDeclaracionPdf(req: Request, res: Response, next: NextFunction) { + try { + const id = parseInt(String(req.params.id)); + if (isNaN(id)) return next(new AppError(400, 'id inválido')); + const v = req.params.variant; + const variant: 'declaracion' | 'liga' | 'pago' = v === 'pago' ? 'pago' : v === 'liga' ? 'liga' : 'declaracion'; + const pdf = await declaracionesService.getDeclaracionPdf(req.tenantPool!, id, variant); + if (!pdf) return res.status(404).json({ message: 'PDF no encontrado' }); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename="${pdf.filename}"`); + res.send(pdf.buffer); + } catch (error) { next(error); } +} + +// ============================================================================ +// Constancia de Situación Fiscal +// ============================================================================ + +export async function listarConstancias(req: Request, res: Response, next: NextFunction) { + try { + const contribuyenteId = req.query.contribuyenteId as string | undefined; + let rfc: string | undefined; + if (contribuyenteId) { + const { rows } = await req.tenantPool!.query( + 'SELECT rfc FROM contribuyentes WHERE entidad_id = $1', + [contribuyenteId], + ); + rfc = rows[0]?.rfc; + } + const data = await constanciaService.listConstancias(req.tenantPool!, 12, rfc); + res.json(data); + } catch (error) { next(error); } +} + +export async function descargarConstanciaPdf(req: Request, res: Response, next: NextFunction) { + try { + const id = parseInt(String(req.params.id)); + if (isNaN(id)) return next(new AppError(400, 'id inválido')); + const pdf = await constanciaService.getConstanciaPdf(req.tenantPool!, id); + if (!pdf) return res.status(404).json({ message: 'Constancia no encontrada' }); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename="constancia_${id}.pdf"`); + res.send(pdf); + } catch (error) { next(error); } +} + +export async function consultarConstanciaManual(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = effectiveTenantId(req); + const contribuyenteId = req.query.contribuyenteId as string | undefined; + + let constancia; + if (contribuyenteId) { + constancia = await constanciaService.consultarConstanciaContribuyente(req.tenantPool!, contribuyenteId); + } else { + constancia = await constanciaService.consultarConstancia(tenantId); + } + res.json(constancia); + } catch (error: any) { + if (error.message?.includes('FIEL')) return res.status(400).json({ error: error.message }); + next(error); + } +} + +export async function eliminarDeclaracion(req: Request, res: Response, next: NextFunction) { + try { + if (!canUpload(req)) return res.status(403).json({ message: 'No tienes permiso para eliminar declaraciones' }); + const id = parseInt(String(req.params.id)); + if (isNaN(id)) return next(new AppError(400, 'id inválido')); + await declaracionesService.deleteDeclaracion(req.tenantPool!, id); + res.status(204).send(); + } catch (error: any) { + if (error?.message?.includes('no encontrada')) { + return next(new AppError(404, error.message)); + } + next(error); + } +} + +// ============================================================================ +// Documentos Extras — PDFs libres (acuses, contratos, poderes, estados, etc.) +// ============================================================================ + +const createExtraSchema = z.object({ + nombre: z.string().min(1, 'Nombre requerido').max(255), + descripcion: z.string().max(2000).optional(), + categoria: z.string().max(100).optional(), + pdfBase64: z.string().min(100, 'PDF requerido'), + pdfFilename: z.string().min(1).max(255), +}); + +export async function listarExtras(req: Request, res: Response, next: NextFunction) { + try { + const contribuyenteId = req.query.contribuyenteId as string | undefined; + const categoria = req.query.categoria as string | undefined; + const data = await extrasService.listExtras(req.tenantPool!, contribuyenteId, categoria); + res.json(data); + } catch (error) { next(error); } +} + +export async function crearExtra(req: Request, res: Response, next: NextFunction) { + try { + if (!canUpload(req)) return res.status(403).json({ message: 'No tienes permiso para subir documentos' }); + const data = createExtraSchema.parse(req.body); + const contribuyenteId = req.body.contribuyenteId as string | undefined; + const result = await extrasService.createExtra(req.tenantPool!, { + ...data, + contribuyenteId: contribuyenteId ?? null, + subidoPor: req.user!.email, + }); + + // Notificación fire-and-forget a owners del despacho + supervisor del RFC. + notifyDocumentoSubido({ + pool: req.tenantPool!, + tenantId: req.user!.tenantId, + contribuyenteId: contribuyenteId ?? null, + subidoPor: req.user!.email, + kind: 'extra', + extra: { + nombre: data.nombre, + descripcion: data.descripcion ?? null, + categoria: data.categoria ?? null, + }, + }).catch((err: any) => console.error('[notifyDocumentoSubido extra]', err?.message || err)); + + res.status(201).json(result); + } catch (error: any) { + if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message)); + next(error); + } +} + +export async function descargarExtraPdf(req: Request, res: Response, next: NextFunction) { + try { + const id = parseInt(String(req.params.id)); + if (isNaN(id)) return next(new AppError(400, 'id inválido')); + const pdf = await extrasService.getExtraPdf(req.tenantPool!, id); + if (!pdf) return next(new AppError(404, 'Documento no encontrado')); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename="${pdf.filename}"`); + res.send(pdf.buffer); + } catch (error) { next(error); } +} + +export async function eliminarExtra(req: Request, res: Response, next: NextFunction) { + try { + if (!canUpload(req)) return res.status(403).json({ message: 'No tienes permiso para eliminar documentos' }); + const id = parseInt(String(req.params.id)); + if (isNaN(id)) return next(new AppError(400, 'id inválido')); + const ok = await extrasService.deleteExtra(req.tenantPool!, id); + if (!ok) return next(new AppError(404, 'Documento no encontrado')); + res.status(204).send(); + } catch (error) { next(error); } +} + +export async function listarCategoriasExtras(req: Request, res: Response, next: NextFunction) { + try { + const contribuyenteId = req.query.contribuyenteId as string | undefined; + const data = await extrasService.listCategorias(req.tenantPool!, contribuyenteId); + res.json(data); + } catch (error) { next(error); } +} diff --git a/apps/api/src/controllers/export.controller.ts b/apps/api/src/controllers/export.controller.ts new file mode 100644 index 0000000..d4e7a60 --- /dev/null +++ b/apps/api/src/controllers/export.controller.ts @@ -0,0 +1,42 @@ +import type { Request, Response, NextFunction } from 'express'; +import * as exportService from '../services/export.service.js'; + +export async function exportCfdis(req: Request, res: Response, next: NextFunction) { + try { + const { tipo, estado, fechaInicio, fechaFin } = req.query; + const buffer = await exportService.exportCfdisToExcel(req.tenantPool!, { + tipo: tipo as string, + estado: estado as string, + fechaInicio: fechaInicio as string, + fechaFin: fechaFin as string, + }); + + res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + res.setHeader('Content-Disposition', `attachment; filename=cfdis-${Date.now()}.xlsx`); + res.send(buffer); + } catch (error) { + next(error); + } +} + +export async function exportReporte(req: Request, res: Response, next: NextFunction) { + try { + const { tipo, fechaInicio, fechaFin } = req.query; + const now = new Date(); + const inicio = (fechaInicio as string) || `${now.getFullYear()}-01-01`; + const fin = (fechaFin as string) || now.toISOString().split('T')[0]; + + const buffer = await exportService.exportReporteToExcel( + req.tenantPool!, + tipo as 'estado-resultados' | 'flujo-efectivo', + inicio, + fin + ); + + res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + res.setHeader('Content-Disposition', `attachment; filename=${tipo}-${Date.now()}.xlsx`); + res.send(buffer); + } catch (error) { + next(error); + } +} diff --git a/apps/api/src/controllers/facturacion.controller.ts b/apps/api/src/controllers/facturacion.controller.ts new file mode 100644 index 0000000..eef2503 --- /dev/null +++ b/apps/api/src/controllers/facturacion.controller.ts @@ -0,0 +1,789 @@ +import type { Request, Response, NextFunction } from 'express'; +import type { Pool } from 'pg'; +import { z } from 'zod'; +import * as facturapiService from '../services/facturapi.service.js'; +import { + createInvoiceContribuyente, + cancelInvoiceContribuyente, + downloadPdfContribuyente, + downloadXmlContribuyente, + sendInvoiceByEmailContribuyente, +} from '../services/contribuyente-facturapi.service.js'; +import { parseXml } from '../services/sat/sat-parser.service.js'; +import * as tenantsService from '../services/tenants.service.js'; +import { prisma } from '../config/database.js'; +import { AppError } from '../middlewares/error.middleware.js'; +import { hasPlatformRole } from '../utils/platform-admin.js'; +import { auditFromReq } from '../utils/audit.js'; + +function effectiveTenantId(req: Request): string { + return req.viewingTenantId || req.user!.tenantId; +} + +/** + * Detecta si un mensaje de error del SAT (propagado por Facturapi) indica + * que el CSD aún no está en la Lista de Contribuyentes Obligados (LCO). + * El SAT tarda 24-72h en propagar un CSD nuevo; durante esa ventana todo + * intento de emisión falla. Cuando se detecta este patrón se marca la + * org con `last_lco_rejection_at` para que el frontend muestre un banner. + */ +function isLcoRejection(errorMessage: string): boolean { + if (!errorMessage) return false; + const msg = errorMessage.toLowerCase(); + return ( + /no se encontr.*rfc.*lco/.test(msg) || + /rfc.*no.*registrado.*lco/.test(msg) || + /lista.*contribuyentes.*obligados/.test(msg) || + /csd.*no.*registrad/.test(msg) || + msg.includes('lco') + ); +} + +/** + * Registra el timestamp del rechazo LCO en la fila correspondiente de + * `facturapi_orgs`. Fire-and-forget: un fallo aquí no bloquea la + * propagación del error al frontend. + */ +async function markLcoRejection( + pool: import('pg').Pool, + contribuyenteId: string | undefined, +): Promise { + try { + if (contribuyenteId) { + await pool.query( + `UPDATE facturapi_orgs SET last_lco_rejection_at = NOW() WHERE contribuyente_id = $1`, + [contribuyenteId], + ); + } + // Nota: Horux360 single-tenant usaría `tenants.facturapi_org_id` en + // BD central; en el fork multi-contribuyente solo marcamos la fila + // por-contribuyente. Si el user emite desde el org del tenant (sin + // contribuyenteId), el banner no aplicaría aquí. + } catch (e: any) { + console.error('[facturacion.markLcoRejection] falló UPDATE:', e?.message || e); + } +} + +// ── Organización ── + +export async function getOrgStatus(req: Request, res: Response, next: NextFunction) { + try { + const status = await facturapiService.getOrganizationStatus(effectiveTenantId(req)); + res.json(status); + } catch (error) { next(error); } +} + +export async function createOrg(req: Request, res: Response, next: NextFunction) { + try { + const result = await facturapiService.createOrganization(effectiveTenantId(req)); + res.status(201).json(result); + } catch (error) { next(error); } +} + +// ── CSD ── + +export async function uploadCsd(req: Request, res: Response, next: NextFunction) { + try { + const { cerFile, keyFile, password } = req.body; + if (!cerFile || !keyFile || !password) { + return res.status(400).json({ message: 'cerFile, keyFile y password son requeridos' }); + } + const result = await facturapiService.uploadCsd(effectiveTenantId(req), cerFile, keyFile, password); + if (!result.success) return res.status(400).json({ message: result.message }); + res.json(result); + } catch (error) { next(error); } +} + +// ── Emisión ── + +export async function emitir(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = effectiveTenantId(req); + const contribuyenteId = req.body.contribuyenteId as string | undefined; + + // ── Validar CFDIs relacionados antes de consumir timbre ── + // En Live, SAT rechaza si el UUID relacionado no existe, está cancelado, + // o el rfc_receptor no coincide con el customer.taxId del CFDI nuevo. + // Catch temprano con error legible en vez de un 500 oscuro de Facturapi. + const relatedDocs: Array<{ relationship: string; uuids: string[] }> = req.body.relatedDocuments || []; + const customerRfc = req.body.customer?.taxId?.toUpperCase()?.trim(); + if (relatedDocs.length > 0 && customerRfc && req.tenantPool) { + const allUuids = relatedDocs + .flatMap(r => r.uuids || []) + .filter(u => typeof u === 'string' && u.trim() !== ''); + for (const uuid of allUuids) { + const { rows } = await req.tenantPool.query( + `SELECT rfc_receptor, status FROM cfdis WHERE LOWER(uuid) = LOWER($1) LIMIT 1`, + [uuid.trim()], + ); + if (rows.length === 0) { + throw new AppError(400, `El CFDI relacionado con UUID ${uuid} no existe en el sistema.`); + } + const rel = rows[0]; + if (rel.status === 'Cancelado' || rel.status === '0') { + throw new AppError(400, `El CFDI relacionado con UUID ${uuid} está cancelado.`); + } + const rfcReceptorRel = (rel.rfc_receptor || '').toUpperCase().trim(); + if (rfcReceptorRel !== customerRfc) { + throw new AppError( + 400, + `El CFDI relacionado con UUID ${uuid} no corresponde al RFC del receptor de esta factura. ` + + `RFC esperado: ${customerRfc}. RFC del receptor del CFDI relacionado: ${rfcReceptorRel}.`, + ); + } + } + } + + // Reservar timbre — si falla emisión en Facturapi, revertimos abajo + const consumedTimbre = await facturapiService.consumeTimbre(tenantId); + + // Emitir factura en Facturapi + // Si hay contribuyenteId, usar la org Facturapi del contribuyente (tenant BD). + // Si no, usar la org del tenant (BD central). + let invoice; + try { + if (contribuyenteId) { + invoice = await createInvoiceContribuyente(req.tenantPool!, contribuyenteId, req.body); + } else { + invoice = await facturapiService.createInvoice(tenantId, req.body); + } + } catch (err: any) { + // SAT nunca selló → revertir el timbre reservado (fire-and-forget; no bloquear la respuesta + // de error si el refund falla, solo loggear la inconsistencia) + facturapiService.refundTimbre(tenantId, consumedTimbre).catch(refundErr => { + console.error('[facturacion.emitir] Falló refund de timbre tras rechazo Facturapi:', { + tenantId, + consumedTimbre, + refundError: refundErr?.message || String(refundErr), + }); + }); + // Loggea el payload que causó el rechazo para diagnóstico server-side + console.error('[facturacion.emitir] Rechazo al crear factura:', { + tenantId, + contribuyenteId: contribuyenteId || null, + type: req.body?.type, + items: req.body?.items?.map((it: any) => ({ + description: it.description, + taxes: it.taxes, + })), + error: err?.message || String(err), + }); + // Detectar rechazo por CSD aún no propagado a la LCO y marcar la org + // para que el frontend muestre banner informativo durante 24h. + if (isLcoRejection(err?.message || '')) { + await markLcoRejection(req.tenantPool!, contribuyenteId); + } + // Propaga el mensaje real (Facturapi suele explicar la validación) + throw new AppError(400, err?.message || 'Error al emitir factura'); + } + + // Guardar en tabla cfdis del tenant. + // El response de `invoices.create` de Facturapi NO incluye `issuer`/`subtotal`/`taxes` + // como campos top-level (usa `issuer_info` y los impuestos viven dentro de `items[*].product.taxes`). + // La forma más fiable y consistente con el sync SAT es descargar el XML timbrado y + // reutilizar el mismo parser que ya procesa los CFDIs descargados del SAT. + const pool = req.tenantPool!; + const xmlBuffer = contribuyenteId + ? await downloadXmlContribuyente(pool, contribuyenteId, invoice.id) + : await facturapiService.downloadXml(tenantId, invoice.id); + const xmlString = xmlBuffer.toString('utf-8'); + const parsed = parseXml(xmlString, 'emitidos'); + if (!parsed) { + throw new AppError(500, `Factura ${invoice.uuid} emitida en Facturapi pero el XML no pudo parsearse`); + } + + const fecha = parsed.fechaEmision; + const year = String(fecha.getFullYear()); + const month = String(fecha.getMonth() + 1).padStart(2, '0'); + + // Upsert RFCs desde datos del XML (fuente autoritativa — igual al sync SAT) + 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], + ); + const { rows: [receptorRow] } = await pool.query( + `INSERT INTO rfcs (rfc, razon_social, regimen_fiscal, codigo_postal) VALUES ($1, $2, $3, $4) + 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, + codigo_postal = CASE WHEN $4 IS NOT NULL AND $4 != '' THEN $4 ELSE rfcs.codigo_postal END + RETURNING id`, + [parsed.rfcReceptor, parsed.nombreReceptor || null, parsed.regimenFiscalReceptor || null, req.body.customer?.zip || null], + ); + + // Para CFDIs tipo P (complemento de pago) parseamos `fechaPagoP`. SAT + // permite múltiples pagos por complemento — el parser concatena las fechas + // con '|'; aquí tomamos la primera (suficiente para el cálculo fiscal, + // donde fecha_pago_p drives el período de devengo). + const fechaPagoP = parsed.fechaPagoP + ? new Date(String(parsed.fechaPagoP).split('|')[0]) + : null; + + await pool.query(` + INSERT INTO cfdis ( + year, month, type, uuid, serie, folio, status, fecha_emision, fecha_cert_sat, + rfc_emisor_id, rfc_emisor, nombre_emisor, regimen_fiscal_emisor, + rfc_receptor_id, rfc_receptor, nombre_receptor, regimen_fiscal_receptor, + subtotal, subtotal_mxn, total, total_mxn, + moneda, tipo_comprobante, metodo_pago, forma_pago, uso_cfdi, + iva_traslado, iva_traslado_mxn, + iva_retencion, iva_retencion_mxn, + monto_pago, monto_pago_mxn, + fecha_pago_p, + iva_traslado_pago, iva_traslado_pago_mxn, + iva_retencion_pago, iva_retencion_pago_mxn, + ieps_traslado_pago, ieps_traslado_pago_mxn, + source, facturapi_id, + contribuyente_id, xml_original + ) VALUES ( + $1, $2, 'EMITIDO', $3, $4, $5, 'Vigente', $6, $7, + $8, $9, $10, $11, + $12, $13, $14, $15, + $16, $16, $17, $17, + $18, $19, $20, $21, $22, + $23, $23, + $24, $24, + $25, $25, + $26, + $27, $27, + $28, $28, + $29, $29, + 'facturapi', $30, + $31, $32 + ) + `, [ + year, month, parsed.uuid, parsed.serie, parsed.folio, fecha, parsed.fechaCertSat, + emisorRow.id, parsed.rfcEmisor, parsed.nombreEmisor, parsed.regimenFiscalEmisor, + receptorRow.id, parsed.rfcReceptor, parsed.nombreReceptor, parsed.regimenFiscalReceptor, + parsed.subtotal, parsed.total, + parsed.moneda, parsed.tipoComprobante, parsed.metodoPago, parsed.formaPago, parsed.usoCfdi, + parsed.ivaTraslado, + parsed.ivaRetencion, + parsed.montoPago, + fechaPagoP, + parsed.ivaTrasladoPago, + parsed.ivaRetencionPago, + parsed.iepsTrasladoPago, + invoice.id, + contribuyenteId ?? null, xmlString, + ]); + + // Enviar por email si el receptor tiene email — ruteado a la org correcta + const customerEmail = req.body.customer?.email; + if (customerEmail) { + const sendPromise = contribuyenteId + ? sendInvoiceByEmailContribuyente(req.tenantPool!, contribuyenteId, invoice.id, customerEmail) + : facturapiService.sendInvoiceByEmail(tenantId, invoice.id, customerEmail); + sendPromise.catch(err => console.error('[Facturapi] Error enviando email:', err.message)); + } + + res.status(201).json({ + id: invoice.id, + uuid: invoice.uuid, + total: invoice.total, + status: invoice.status, + }); + } catch (error: any) { + // Los errores de emisión ya hacen refund dentro del inner catch. + // Aquí solo propagamos — incluye errores del INSERT post-emisión (CFDI ya sellado, + // no refund) y errores de validación de timbre (ocurrieron antes del consume). + next(error); + } +} + +// Estado LCO: si hubo un rechazo del SAT por CSD no propagado en las últimas 24h, +// el frontend muestra un banner informativo en la pantalla de emisión. +export async function getLcoStatus(req: Request, res: Response, next: NextFunction) { + try { + const contribuyenteId = req.query.contribuyenteId as string | undefined; + if (!contribuyenteId) { + return res.json({ hasRecentLcoRejection: false, rejectedAt: null }); + } + + const { rows } = await req.tenantPool!.query<{ last_lco_rejection_at: Date | null }>( + `SELECT last_lco_rejection_at FROM facturapi_orgs WHERE contribuyente_id = $1 AND active = true`, + [contribuyenteId], + ); + + const rejectedAt = rows[0]?.last_lco_rejection_at || null; + const hasRecentLcoRejection = + rejectedAt !== null && Date.now() - new Date(rejectedAt).getTime() < 24 * 60 * 60 * 1000; + + res.json({ hasRecentLcoRejection, rejectedAt }); + } catch (error) { + next(error); + } +} + +// ── Cancelación ── + +export async function cancelar(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = effectiveTenantId(req); + const { uuid } = req.params; + const { motive, substitution } = req.body; + + const pool = req.tenantPool!; + const { rows } = await pool.query( + `SELECT facturapi_id, contribuyente_id FROM cfdis WHERE uuid = $1 AND source = 'facturapi'`, + [uuid] + ); + + if (rows.length === 0 || !rows[0].facturapi_id) { + return res.status(404).json({ message: 'CFDI no encontrado o no fue emitido por Facturapi' }); + } + + const facturapiId = rows[0].facturapi_id; + const cfdiContribuyenteId = rows[0].contribuyente_id as string | null; + + const result = cfdiContribuyenteId + ? await cancelInvoiceContribuyente(pool, cfdiContribuyenteId, facturapiId, motive || '02', substitution) + : await facturapiService.cancelInvoice(tenantId, facturapiId, motive || '02', substitution); + + // Capturamos la fecha del CFDI antes del UPDATE para saber qué mes marcar + // como invalidado (la cancelación afecta las métricas del mes del CFDI, + // no del mes actual). + const { rows: fechas } = await pool.query<{ fecha_emision: Date; fecha_pago_p: Date | null; tipo_comprobante: string }>( + `SELECT fecha_emision, fecha_pago_p, tipo_comprobante FROM cfdis WHERE uuid = $1`, + [uuid], + ); + + await pool.query( + `UPDATE cfdis SET status = 'Cancelado', fecha_cancelacion = NOW(), actualizado_en = NOW() WHERE uuid = $1`, + [uuid] + ); + + // Invalidar métricas del mes afectado (usa fecha_pago_p para P, fecha_emision para el resto) + if (cfdiContribuyenteId && fechas[0]) { + const f = fechas[0]; + const fechaContable = f.tipo_comprobante === 'P' && f.fecha_pago_p ? f.fecha_pago_p : f.fecha_emision; + const { markForInvalidation } = await import('../services/metricas.service.js'); + await markForInvalidation( + pool, + cfdiContribuyenteId, + fechaContable.getFullYear(), + fechaContable.getMonth() + 1, + 'CFDI_CANCEL', + ).catch(err => console.warn('[Cancelar] markForInvalidation falló:', err?.message || err)); + } + + res.json({ message: 'CFDI cancelado', result }); + } catch (error) { next(error); } +} + +// ── Descargas ── + +async function resolveCfdiContribuyenteId( + pool: Pool, + facturapiId: string, +): Promise { + const { rows } = await pool.query<{ contribuyente_id: string | null }>( + `SELECT contribuyente_id FROM cfdis WHERE facturapi_id = $1 LIMIT 1`, + [facturapiId], + ); + return rows[0]?.contribuyente_id ?? null; +} + +export async function downloadPdf(req: Request, res: Response, next: NextFunction) { + try { + const id = String(req.params.id); + const pool = req.tenantPool!; + const cfdiContribuyenteId = await resolveCfdiContribuyenteId(pool, id); + const buffer = cfdiContribuyenteId + ? await downloadPdfContribuyente(pool, cfdiContribuyenteId, id) + : await facturapiService.downloadPdf(effectiveTenantId(req), id); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename=factura-${id}.pdf`); + res.send(buffer); + } catch (error) { next(error); } +} + +export async function downloadXml(req: Request, res: Response, next: NextFunction) { + try { + const id = String(req.params.id); + const pool = req.tenantPool!; + const cfdiContribuyenteId = await resolveCfdiContribuyenteId(pool, id); + const buffer = cfdiContribuyenteId + ? await downloadXmlContribuyente(pool, cfdiContribuyenteId, id) + : await facturapiService.downloadXml(effectiveTenantId(req), id); + res.setHeader('Content-Type', 'application/xml'); + res.setHeader('Content-Disposition', `attachment; filename=factura-${id}.xml`); + res.send(buffer); + } catch (error) { next(error); } +} + +// ── Timbres ── + +export async function getTimbres(req: Request, res: Response, next: NextFunction) { + try { + const status = await facturapiService.getTimbreStatus(effectiveTenantId(req)); + res.json(status); + } catch (error) { next(error); } +} + +// ── Personalización (logo, color) ── + +export async function getCustomization(req: Request, res: Response, next: NextFunction) { + try { + const data = await facturapiService.getCustomization(effectiveTenantId(req)); + res.json(data || {}); + } catch (error) { next(error); } +} + +export async function uploadLogo(req: Request, res: Response, next: NextFunction) { + try { + const { logo } = req.body; // base64 + if (!logo) return res.status(400).json({ message: 'Logo es requerido (base64)' }); + const result = await facturapiService.uploadLogo(effectiveTenantId(req), logo); + if (!result.success) return res.status(400).json({ message: result.message }); + res.json(result); + } catch (error) { next(error); } +} + +export async function updateColor(req: Request, res: Response, next: NextFunction) { + try { + const { color } = req.body; + if (!color) return res.status(400).json({ message: 'Color es requerido' }); + const result = await facturapiService.updateColor(effectiveTenantId(req), color); + if (!result.success) return res.status(400).json({ message: result.message }); + res.json(result); + } catch (error) { next(error); } +} + +// ── Datos fiscales del tenant ── + +// Schema Zod para preferencias de auto-facturación +const PreferenciasFacturacionSchema = z.object({ + factPreferencia: z.enum(['publico_general', 'mis_datos']).optional(), + factUsoCfdi: z.string().min(2).max(5).optional(), + factRegimenPreferido: z.string().max(3).nullable().optional(), +}); + +export async function getPreferenciasFacturacion(req: Request, res: Response, next: NextFunction) { + try { + const data = await tenantsService.getPreferenciasFacturacion(effectiveTenantId(req)); + res.json(data); + } catch (error) { next(error); } +} + +export async function updatePreferenciasFacturacion(req: Request, res: Response, next: NextFunction) { + try { + const parsed = PreferenciasFacturacionSchema.parse(req.body); + const data = await tenantsService.updatePreferenciasFacturacion(effectiveTenantId(req), parsed); + res.json(data); + } catch (error: any) { + if (error?.name === 'ZodError') { + return next(new AppError(400, error.errors[0].message)); + } + next(error); + } +} + +export async function getDatosFiscales(req: Request, res: Response, next: NextFunction) { + try { + const data = await tenantsService.getDatosFiscales(effectiveTenantId(req)); + res.json(data || {}); + } catch (error) { next(error); } +} + +export async function updateDatosFiscales(req: Request, res: Response, next: NextFunction) { + try { + if (req.user!.role !== 'owner') { + return res.status(403).json({ message: 'Solo el dueño puede actualizar datos fiscales' }); + } + const data = await tenantsService.updateDatosFiscales(effectiveTenantId(req), req.body); + res.json(data); + } catch (error) { next(error); } +} + +// ── Búsqueda de conceptos previos ── + +export async function searchConceptos(req: Request, res: Response, next: NextFunction) { + try { + const q = (req.query.q as string || '').trim(); + const tipo = (req.query.tipo as string || 'todos'); // emitidos, recibidos, todos + const contribuyenteId = (req.query.contribuyenteId as string || '').replace(/[^a-f0-9-]/gi, ''); + const pool = req.tenantPool!; + + let whereType = ''; + if (tipo === 'emitidos') { + whereType = `AND c.type = 'EMITIDO'`; + } else if (tipo === 'recibidos') { + whereType = `AND c.type = 'RECIBIDO' AND c.uso_cfdi = 'G01'`; + } else { + whereType = `AND (c.type = 'EMITIDO' OR (c.type = 'RECIBIDO' AND c.uso_cfdi = 'G01'))`; + } + + const whereContrib = contribuyenteId ? `AND c.contribuyente_id = '${contribuyenteId}'` : ''; + + let whereSearch = ''; + const params: any[] = []; + if (q.length >= 2) { + params.push(`%${q}%`); + whereSearch = `AND (cc.descripcion ILIKE $1 OR cc.clave_prod_serv ILIKE $1)`; + } + + const { rows } = await pool.query(` + SELECT DISTINCT ON (cc.clave_prod_serv, cc.descripcion) + cc.clave_prod_serv as "claveProdServ", + cc.descripcion, + cc.clave_unidad as "claveUnidad", + cc.unidad, + cc.valor_unitario_mxn as "valorUnitario", + cc.importe_mxn as "importe", + cc.iva_traslado_mxn as "ivaTraslado", + cc.isr_retencion_mxn as "isrRetencion", + cc.iva_retencion_mxn as "ivaRetencion", + c.type as "tipoCfdi", + c.rfc_emisor as "rfcEmisor", + c.nombre_emisor as "nombreEmisor", + c.rfc_receptor as "rfcReceptor", + c.nombre_receptor as "nombreReceptor", + c.fecha_emision as "fechaEmision" + FROM cfdi_conceptos cc + JOIN cfdis c ON cc.cfdi_id = c.id + WHERE c.status NOT IN ('Cancelado', '0') + ${whereType} + ${whereContrib} + ${whereSearch} + ORDER BY cc.clave_prod_serv, cc.descripcion, c.fecha_emision DESC + LIMIT 30 + `, params); + + res.json(rows); + } catch (error) { next(error); } +} + +// ── CFDIs PPD pendientes ── + +export async function getCfdisPpdPendientes(req: Request, res: Response, next: NextFunction) { + try { + const rfc = (req.query.rfc as string || '').trim().toUpperCase(); + if (rfc.length < 3) return res.json([]); + + const contribuyenteId = (req.query.contribuyenteId as string || '').trim(); + const pool = req.tenantPool!; + + // Buscar CFDIs emitidos PPD vigentes para este RFC receptor con saldo > 0. + // Usamos `saldo_pendiente_mxn` denormalizado (utils/saldo.ts §13) que ya + // considera pagos P + NCs no-07 + anticipos aplicados. Es la fuente de + // verdad del sistema — recalcular con subquery solo sobre pagos P + // sobreestima el saldo cuando hay NCs/anticipos. + // En multi-RFC con contribuyente activo, filtra por contribuyente_id — + // solo los PPDs emitidos por el contribuyente activo. Sin contribuyenteId, + // retorna todos los del tenant (compat con flujos sin contribuyente activo). + const params: any[] = [rfc]; + let contribFilter = ''; + if (contribuyenteId) { + params.push(contribuyenteId); + contribFilter = ` AND c.contribuyente_id = $${params.length}`; + } + const { rows } = await pool.query(` + SELECT + c.uuid, c.serie, c.folio, c.total_mxn as "totalMxn", + c.fecha_emision as "fechaEmision", + c.rfc_receptor as "rfcReceptor", + c.nombre_receptor as "nombreReceptor", + c.iva_traslado_mxn as "ivaTrasladoMxn", + c.saldo_pendiente_mxn as "saldoPendiente" + FROM cfdis c + WHERE c.type = 'EMITIDO' + AND c.metodo_pago = 'PPD' + AND c.tipo_comprobante = 'I' + AND c.status NOT IN ('Cancelado', '0') + AND c.rfc_receptor = $1${contribFilter} + AND COALESCE(c.saldo_pendiente_mxn, 0) > 0 + ORDER BY c.fecha_emision DESC + LIMIT 20 + `, params); + + res.json(rows); + } catch (error) { next(error); } +} + +// ── CFDIs relacionables ── +// Devuelve CFDIs emitidos por el contribuyente activo cuyo rfc_receptor +// coincide con el de la nueva factura. Usado por el dropdown de la sección +// "CFDIs Relacionados" en facturación tipo I y E. +// +// Filtros aplicados: +// - contribuyente_id = caller (multi-RFC: solo CFDIs del contribuyente activo) +// - rfc_receptor = rfc del receptor de la factura nueva +// - tipo_comprobante IN ('I','E') — los relacionables habituales +// - status NOT IN ('Cancelado','0') — solo vigentes (SAT rechaza relacionar cancelados) + +export async function getCfdisRelacionables(req: Request, res: Response, next: NextFunction) { + try { + const rfcReceptor = (req.query.rfcReceptor as string || '').trim().toUpperCase(); + const contribuyenteId = (req.query.contribuyenteId as string || '').trim(); + if (rfcReceptor.length < 12) return res.json([]); + if (!contribuyenteId) return res.json([]); + + const pool = req.tenantPool!; + const { rows } = await pool.query(` + SELECT + uuid, + serie, + folio, + total_mxn AS "totalMxn", + fecha_emision AS "fechaEmision", + tipo_comprobante AS "tipoComprobante", + metodo_pago AS "metodoPago" + FROM cfdis + WHERE contribuyente_id = $1 + AND rfc_receptor = $2 + AND tipo_comprobante IN ('I', 'E') + AND status NOT IN ('Cancelado', '0') + ORDER BY fecha_emision DESC + LIMIT 50 + `, [contribuyenteId, rfcReceptor]); + + res.json(rows); + } catch (error) { next(error); } +} + +// ── Búsqueda de RFCs ── + +export async function searchRfcs(req: Request, res: Response, next: NextFunction) { + try { + const q = (req.query.q as string || '').trim(); + if (q.length < 3) return res.json([]); + + const contribuyenteId = (req.query.contribuyenteId as string || '').trim(); + const pool = req.tenantPool!; + + // RFC del tenant despacho para excluirlo (no se factura a sí mismo) + const tenantId = effectiveTenantId(req); + const tenant = await prisma.tenant.findUnique({ + where: { id: tenantId }, + select: { rfc: true }, + }); + const tenantRfc = tenant?.rfc || ''; + + // En multi-RFC con contribuyente activo, filtrar a contrapartes con las + // que ese contribuyente ha tenido CFDIs (emisor o receptor). Sin + // contribuyenteId, retornar el catálogo completo (compat con flujos + // legacy / admin global sin contribuyente seleccionado). + let rows; + if (contribuyenteId) { + ({ rows } = await pool.query(` + SELECT DISTINCT r.id, r.rfc, + r.razon_social as "razonSocial", + r.regimen_fiscal as "regimenFiscal", + r.codigo_postal as "codigoPostal" + FROM rfcs r + WHERE r.rfc != $1 + AND (r.rfc ILIKE $2 OR r.razon_social ILIKE $2) + AND EXISTS ( + SELECT 1 FROM cfdis c + WHERE c.contribuyente_id = $3 + AND (c.rfc_emisor_id = r.id OR c.rfc_receptor_id = r.id) + ) + ORDER BY r.razon_social + LIMIT 10 + `, [tenantRfc, `%${q}%`, contribuyenteId])); + } else { + ({ rows } = await pool.query(` + SELECT id, rfc, razon_social as "razonSocial", + regimen_fiscal as "regimenFiscal", + codigo_postal as "codigoPostal" + FROM rfcs + WHERE rfc != $1 + AND (rfc ILIKE $2 OR razon_social ILIKE $2) + ORDER BY razon_social + LIMIT 10 + `, [tenantRfc, `%${q}%`])); + } + + res.json(rows); + } catch (error) { next(error); } +} + +// ── Timbres adicionales: catálogo + compra ── + +export async function getPaquetesCatalogo(req: Request, res: Response, next: NextFunction) { + try { + const catalogo = await facturapiService.listPaquetesCatalogo(); + res.json(catalogo); + } catch (error) { next(error); } +} + +const comprarPaqueteSchema = z.object({ + catalogoId: z.number().int().positive(), +}); + +// Admin global: catálogo completo incluyendo inactivos + edit +export async function getPaquetesCatalogoAdmin(req: Request, res: Response, next: NextFunction) { + try { + if (!(await hasPlatformRole(req.user!.userId, 'platform_admin'))) { + return res.status(403).json({ message: 'Solo admin global puede ver el catálogo completo' }); + } + const catalogo = await facturapiService.listAllPaquetesCatalogo(); + res.json(catalogo); + } catch (error) { next(error); } +} + +const updatePaqueteSchema = z.object({ + precio: z.number().positive().optional(), + active: z.boolean().optional(), +}); + +export async function updatePaqueteCatalogo(req: Request, res: Response, next: NextFunction) { + try { + if (!(await hasPlatformRole(req.user!.userId, 'platform_admin'))) { + return res.status(403).json({ message: 'Solo admin global puede editar el catálogo' }); + } + const id = parseInt(String(req.params.id)); + if (isNaN(id)) return next(new AppError(400, 'id inválido')); + + const data = updatePaqueteSchema.parse(req.body); + const before = await facturapiService.listAllPaquetesCatalogo().then(r => r.find(p => p.id === id)); + const updated = await facturapiService.updatePaqueteCatalogo({ id, ...data }); + + auditFromReq(req, 'timbres.catalogo_updated', { + entityType: 'TimbrePaqueteCatalogo', + entityId: String(id), + metadata: { + cantidad: updated.cantidad, + from: { precio: before?.precio, active: before?.active }, + to: { precio: updated.precio, active: updated.active }, + }, + }); + + res.json(updated); + } catch (error: any) { + if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message)); + if (error?.message?.includes('precio') || error?.message?.includes('actualizar')) { + return next(new AppError(400, error.message)); + } + next(error); + } +} + +export async function comprarPaquete(req: Request, res: Response, next: NextFunction) { + try { + if (!['owner', 'cfo'].includes(req.user!.role)) { + return res.status(403).json({ message: 'Solo owner/cfo pueden comprar timbres adicionales' }); + } + const { catalogoId } = comprarPaqueteSchema.parse(req.body); + const result = await facturapiService.iniciarCompraPaquete({ + tenantId: effectiveTenantId(req), + catalogoId, + callerEmail: req.user!.email, + }); + res.json(result); + } catch (error: any) { + if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message)); + // Errores de negocio esperados → 400 con mensaje para el usuario + const msg = error?.message || ''; + if (msg.includes('no disponible') || msg.includes('dueño') || msg.includes('email') || msg.includes('MercadoPago')) { + return next(new AppError(400, msg)); + } + console.error('[comprarPaquete] Error no esperado:', error); + next(error); + } +} diff --git a/apps/api/src/controllers/fiel.controller.ts b/apps/api/src/controllers/fiel.controller.ts new file mode 100644 index 0000000..a55b287 --- /dev/null +++ b/apps/api/src/controllers/fiel.controller.ts @@ -0,0 +1,136 @@ +import type { Request, Response } from 'express'; +import { uploadFiel, getFielStatus, deleteFiel } from '../services/fiel.service.js'; +import type { FielUploadRequest } from '@horux/shared'; +import type { Pool } from 'pg'; + +/** + * Crea recordatorios automáticos de vencimiento de e.firma en el calendario. + * 60 días, 30 días y 7 días antes del vencimiento. + * Elimina recordatorios previos de e.firma antes de crear nuevos. + */ +async function crearRecordatoriosEfirma( + pool: Pool, + userId: string, + validUntil: string, +): Promise { + const vencimiento = new Date(validUntil); + const PREFIJO = '[e.firma]'; + + // Eliminar recordatorios previos de e.firma para evitar duplicados al re-subir + await pool.query( + `DELETE FROM recordatorios WHERE titulo LIKE $1`, + [`${PREFIJO}%`] + ); + + const recordatorios = [ + { dias: 60, titulo: `${PREFIJO} Tu e.firma vence en 60 días` }, + { dias: 30, titulo: `${PREFIJO} Tu e.firma vence en 30 días` }, + { dias: 7, titulo: `${PREFIJO} Tu e.firma vence en 7 días — ¡Renueva pronto!` }, + ]; + + for (const { dias, titulo } of recordatorios) { + const fecha = new Date(vencimiento); + fecha.setDate(fecha.getDate() - dias); + + // Solo crear si la fecha no ha pasado + if (fecha > new Date()) { + await pool.query( + `INSERT INTO recordatorios (titulo, descripcion, fecha_limite, privado, creado_por) + VALUES ($1, $2, $3, false, $4)`, + [ + titulo, + `La e.firma (FIEL) vence el ${vencimiento.toLocaleDateString('es-MX')}. Renueva en el portal del SAT.`, + fecha.toISOString().split('T')[0], + userId, + ] + ); + } + } +} + +function effectiveTenantId(req: Request): string { + return req.viewingTenantId || req.user!.tenantId; +} + +/** + * Sube y configura las credenciales FIEL + */ +export async function upload(req: Request, res: Response): Promise { + try { + const tenantId = effectiveTenantId(req); + + const { cerFile, keyFile, password } = req.body as FielUploadRequest; + + if (!cerFile || !keyFile || !password) { + res.status(400).json({ error: 'cerFile, keyFile y password son requeridos' }); + return; + } + + // Validate file sizes (typical .cer/.key files are under 10KB, base64 ~33% larger) + const MAX_FILE_SIZE = 50_000; // 50KB base64 ≈ ~37KB binary + if (cerFile.length > MAX_FILE_SIZE || keyFile.length > MAX_FILE_SIZE) { + res.status(400).json({ error: 'Los archivos FIEL son demasiado grandes (máx 50KB)' }); + return; + } + + if (password.length > 256) { + res.status(400).json({ error: 'Contraseña FIEL demasiado larga' }); + return; + } + + const result = await uploadFiel(tenantId, cerFile, keyFile, password); + + if (!result.success) { + res.status(400).json({ error: result.message }); + return; + } + + // Crear recordatorios de vencimiento en el calendario + if (result.status?.validUntil && req.tenantPool) { + crearRecordatoriosEfirma(req.tenantPool, req.user!.userId, result.status.validUntil) + .catch(err => console.error('[FIEL] Error creando recordatorios de vencimiento:', err)); + } + + res.json({ + message: result.message, + status: result.status, + }); + } catch (error: any) { + console.error('[FIEL Controller] Error en upload:', error); + res.status(500).json({ error: 'Error interno del servidor' }); + } +} + +/** + * Obtiene el estado de la FIEL configurada + */ +export async function status(req: Request, res: Response): Promise { + try { + const tenantId = effectiveTenantId(req); + const fielStatus = await getFielStatus(tenantId); + res.json(fielStatus); + } catch (error: any) { + console.error('[FIEL Controller] Error en status:', error); + res.status(500).json({ error: 'Error interno del servidor' }); + } +} + +/** + * Elimina las credenciales FIEL + */ +export async function remove(req: Request, res: Response): Promise { + try { + const tenantId = effectiveTenantId(req); + const deleted = await deleteFiel(tenantId); + + if (!deleted) { + res.status(404).json({ error: 'No hay FIEL configurada' }); + return; + } + + res.json({ message: 'FIEL eliminada correctamente' }); + } catch (error: any) { + console.error('[FIEL Controller] Error en remove:', error); + res.status(500).json({ error: 'Error interno del servidor' }); + } +} diff --git a/apps/api/src/controllers/impuestos.controller.ts b/apps/api/src/controllers/impuestos.controller.ts new file mode 100644 index 0000000..1619997 --- /dev/null +++ b/apps/api/src/controllers/impuestos.controller.ts @@ -0,0 +1,171 @@ +import type { Request, Response, NextFunction } from 'express'; +import * as impuestosService from '../services/impuestos.service.js'; +import { prisma } from '../config/database.js'; +import { AppError } from '../middlewares/error.middleware.js'; + +function parseConciliacion(req: Request): boolean { + return req.query.conciliacion === 'true' || req.query.conciliacion === '1'; +} + +function parseFlag(req: Request, key: string, defaultValue = true): boolean { + const v = req.query[key]; + if (v === undefined || v === null) return defaultValue; + return v === 'true' || v === '1'; +} + +function effectiveTenantId(req: Request): string { + return req.viewingTenantId || req.user!.tenantId; +} + +export async function getIvaMensual(req: Request, res: Response, next: NextFunction) { + try { + if (!req.tenantPool) { + return next(new AppError(400, 'Tenant no configurado')); + } + + const año = parseInt(req.query.año as string) || new Date().getFullYear(); + const conciliacion = parseConciliacion(req); + const contribuyenteId = (req.query.contribuyenteId as string) || null; + const considerarActivos = parseFlag(req, 'considerarActivos', true); + const considerarNCs = parseFlag(req, 'considerarNCs', true); + const data = await impuestosService.getIvaMensual(req.tenantPool, año, effectiveTenantId(req), conciliacion, contribuyenteId, considerarActivos, considerarNCs); + res.json(data); + } catch (error) { + next(error); + } +} + +export async function getIsrMensual(req: Request, res: Response, next: NextFunction) { + try { + if (!req.tenantPool) { + return next(new AppError(400, 'Tenant no configurado')); + } + const año = parseInt(req.query.año as string) || new Date().getFullYear(); + const conciliacion = parseConciliacion(req); + const contribuyenteId = (req.query.contribuyenteId as string) || null; + const regimenClave = (req.query.regimenClave as string) || null; + const considerarActivos = parseFlag(req, 'considerarActivos', true); + const considerarNCs = parseFlag(req, 'considerarNCs', true); + const data = await impuestosService.getIsrMensual(req.tenantPool, año, effectiveTenantId(req), conciliacion, contribuyenteId, regimenClave, considerarActivos, considerarNCs); + res.json(data); + } catch (error) { + next(error); + } +} + +export async function getResumenIva(req: Request, res: Response, next: NextFunction) { + try { + if (!req.tenantPool) { + return next(new AppError(400, 'Tenant no configurado')); + } + + const now = new Date(); + const y = now.getFullYear(); + const m = now.getMonth() + 1; + const lastDay = new Date(y, m, 0).getDate(); + const fechaInicio = (req.query.fechaInicio as string) || `${y}-${String(m).padStart(2, '0')}-01`; + const fechaFin = (req.query.fechaFin as string) || `${y}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`; + const conciliacion = parseConciliacion(req); + const contribuyenteId = (req.query.contribuyenteId as string) || null; + const considerarActivos = parseFlag(req, 'considerarActivos', true); + const considerarNCs = parseFlag(req, 'considerarNCs', true); + + const resumen = await impuestosService.getResumenIva(req.tenantPool, fechaInicio, fechaFin, effectiveTenantId(req), conciliacion, contribuyenteId, considerarActivos, considerarNCs); + res.json(resumen); + } catch (error) { + next(error); + } +} + +export async function getResumenIsr(req: Request, res: Response, next: NextFunction) { + try { + if (!req.tenantPool) { + return next(new AppError(400, 'Tenant no configurado')); + } + + const now = new Date(); + const y = now.getFullYear(); + const m = now.getMonth() + 1; + const lastDay = new Date(y, m, 0).getDate(); + const fechaInicio = (req.query.fechaInicio as string) || `${y}-${String(m).padStart(2, '0')}-01`; + const fechaFin = (req.query.fechaFin as string) || `${y}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`; + const conciliacion = parseConciliacion(req); + const contribuyenteId = (req.query.contribuyenteId as string) || null; + const considerarActivos = parseFlag(req, 'considerarActivos', true); + const considerarNCs = parseFlag(req, 'considerarNCs', true); + + const resumen = await impuestosService.getResumenIsr(req.tenantPool, fechaInicio, fechaFin, effectiveTenantId(req), conciliacion, contribuyenteId, considerarActivos, considerarNCs); + res.json(resumen); + } catch (error) { + next(error); + } +} + +export async function getResumenIsrDesglosado(req: Request, res: Response, next: NextFunction) { + try { + if (!req.tenantPool) { + return next(new AppError(400, 'Tenant no configurado')); + } + + // fechaFin define mes_final + año. Default: último día del mes corriente. + const now = new Date(); + const y = now.getFullYear(); + const m = now.getMonth() + 1; + const lastDay = new Date(y, m, 0).getDate(); + const fechaFin = (req.query.fechaFin as string) || `${y}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`; + const conciliacion = parseConciliacion(req); + const contribuyenteId = (req.query.contribuyenteId as string) || null; + const considerarActivos = parseFlag(req, 'considerarActivos', true); + const considerarNCs = parseFlag(req, 'considerarNCs', true); + + const desglose = await impuestosService.getResumenIsrDesglosado( + req.tenantPool, + fechaFin, + effectiveTenantId(req), + conciliacion, + contribuyenteId, + considerarActivos, + considerarNCs, + ); + res.json(desglose); + } catch (error) { + next(error); + } +} + +export async function getCoeficiente(req: Request, res: Response, next: NextFunction) { + try { + const anio = parseInt(req.query.anio as string) || new Date().getFullYear(); + const tenantId = effectiveTenantId(req); + const row = await prisma.coeficienteUtilidad.findUnique({ + where: { tenantId_anio: { tenantId, anio } }, + }); + res.json({ anio, coeficiente: row ? Number(row.coeficiente) : null }); + } catch (error) { + next(error); + } +} + +export async function setCoeficiente(req: Request, res: Response, next: NextFunction) { + try { + if (req.user!.role !== 'owner') { + return res.status(403).json({ message: 'Solo el dueño puede configurar el coeficiente' }); + } + + const { anio, coeficiente } = req.body; + if (!anio || coeficiente === undefined || coeficiente === null) { + return res.status(400).json({ message: 'anio y coeficiente son requeridos' }); + } + + const tenantId = effectiveTenantId(req); + const row = await prisma.coeficienteUtilidad.upsert({ + where: { tenantId_anio: { tenantId, anio } }, + update: { coeficiente }, + create: { tenantId, anio, coeficiente }, + }); + + res.json({ anio: row.anio, coeficiente: Number(row.coeficiente) }); + } catch (error) { + next(error); + } +} diff --git a/apps/api/src/controllers/metricas.controller.ts b/apps/api/src/controllers/metricas.controller.ts new file mode 100644 index 0000000..1190daa --- /dev/null +++ b/apps/api/src/controllers/metricas.controller.ts @@ -0,0 +1,25 @@ +import type { Request, Response, NextFunction } from 'express'; +import { getMetricasMensuales } from '../services/metricas.service.js'; +import { AppError } from '../middlewares/error.middleware.js'; + +export async function getMensuales(req: Request, res: Response, next: NextFunction) { + try { + const contribuyenteId = req.query.contribuyenteId as string; + const anio = Number(req.query.anio); + + if (!contribuyenteId || !anio) { + return next(new AppError(400, 'contribuyenteId y anio son requeridos')); + } + + const regimenFiscal = req.query.regimen as string | undefined; + const metricas = await getMetricasMensuales(req.tenantPool!, contribuyenteId, anio, regimenFiscal); + + const currentYear = new Date().getFullYear(); + return res.json({ + data: metricas, + source: anio < currentYear ? 'cold' : 'hot', + anio, + contribuyenteId, + }); + } catch (err) { return next(err); } +} diff --git a/apps/api/src/controllers/notification-preferences.controller.ts b/apps/api/src/controllers/notification-preferences.controller.ts new file mode 100644 index 0000000..cda54d4 --- /dev/null +++ b/apps/api/src/controllers/notification-preferences.controller.ts @@ -0,0 +1,33 @@ +import type { Request, Response, NextFunction } from 'express'; +import { z } from 'zod'; +import { AppError } from '../middlewares/error.middleware.js'; +import { + EMAIL_TYPES, + getEmailPreferencesPorContribuyente, + setContribuyenteEmailPreferences, +} from '../services/notification-preferences.service.js'; + +export async function listPreferences(req: Request, res: Response, next: NextFunction) { + try { + const data = await getEmailPreferencesPorContribuyente(req.tenantPool!); + res.json({ emailTypes: EMAIL_TYPES, data }); + } catch (error) { + next(error); + } +} + +const updateSchema = z.object({ + contribuyenteId: z.string().uuid(), + preferences: z.record(z.string(), z.boolean()), +}); + +export async function updatePreferences(req: Request, res: Response, next: NextFunction) { + try { + const { contribuyenteId, preferences } = updateSchema.parse(req.body); + const updated = await setContribuyenteEmailPreferences(req.tenantPool!, contribuyenteId, preferences); + res.json({ contribuyenteId, preferences: updated }); + } catch (error) { + if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message)); + next(error); + } +} diff --git a/apps/api/src/controllers/obligaciones.controller.ts b/apps/api/src/controllers/obligaciones.controller.ts new file mode 100644 index 0000000..8291649 --- /dev/null +++ b/apps/api/src/controllers/obligaciones.controller.ts @@ -0,0 +1,111 @@ +import type { Request, Response, NextFunction } from 'express'; +import { z } from 'zod'; +import * as obligacionesService from '../services/obligaciones.service.js'; +import { AppError } from '../middlewares/error.middleware.js'; + +export async function getCatalogo(req: Request, res: Response, next: NextFunction) { + try { + return res.json({ data: obligacionesService.getCatalogo() }); + } catch (err) { return next(err); } +} + +export async function getObligaciones(req: Request, res: Response, next: NextFunction) { + try { + const contribuyenteId = String(req.params.id); + const rows = await obligacionesService.getObligaciones(req.tenantPool!, contribuyenteId); + return res.json({ data: rows }); + } catch (err) { return next(err); } +} + +export async function initRecomendaciones(req: Request, res: Response, next: NextFunction) { + try { + const contribuyenteId = String(req.params.id); + const { rfc, regimenes, tieneNomina } = req.body; + if (!rfc) return next(new AppError(400, 'rfc requerido')); + const count = await obligacionesService.initRecomendaciones( + req.tenantPool!, contribuyenteId, rfc, regimenes || [], tieneNomina ?? false + ); + return res.json({ message: `${count} obligaciones recomendadas agregadas`, count }); + } catch (err) { return next(err); } +} + +export async function addObligacion(req: Request, res: Response, next: NextFunction) { + try { + const contribuyenteId = String(req.params.id); + const schema = z.object({ + catalogoId: z.string().optional(), + nombre: z.string().min(2), + fundamento: z.string().optional(), + frecuencia: z.string().optional(), + fechaLimite: z.string().optional(), + categoria: z.string().optional(), + }); + const data = schema.parse(req.body); + const row = await obligacionesService.addObligacion(req.tenantPool!, contribuyenteId, data); + return res.status(201).json(row); + } catch (err: any) { + if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message)); + return next(err); + } +} + +export async function removeObligacion(req: Request, res: Response, next: NextFunction) { + try { + const ok = await obligacionesService.removeObligacion(req.tenantPool!, String(req.params.obligacionId)); + if (!ok) return next(new AppError(404, 'Obligación no encontrada')); + return res.json({ message: 'Obligación desactivada' }); + } catch (err) { return next(err); } +} + +export async function restoreObligacion(req: Request, res: Response, next: NextFunction) { + try { + const ok = await obligacionesService.restoreObligacion(req.tenantPool!, String(req.params.obligacionId)); + if (!ok) return next(new AppError(404, 'Obligación no encontrada')); + return res.json({ message: 'Obligación restaurada' }); + } catch (err) { return next(err); } +} + +export async function completeObligacion(req: Request, res: Response, next: NextFunction) { + try { + const periodo = req.body.periodo || new Date().toISOString().substring(0, 7); + const ok = await obligacionesService.completeObligacion(req.tenantPool!, String(req.params.obligacionId), req.user!.userId, periodo); + if (!ok) return next(new AppError(404, 'Obligación no encontrada')); + return res.json({ message: 'Obligación marcada como completada' }); + } catch (err) { return next(err); } +} + +export async function uncompleteObligacion(req: Request, res: Response, next: NextFunction) { + try { + const ok = await obligacionesService.uncompleteObligacion(req.tenantPool!, String(req.params.obligacionId)); + if (!ok) return next(new AppError(404, 'Obligación no encontrada')); + return res.json({ message: 'Obligación desmarcada' }); + } catch (err) { return next(err); } +} + +export async function getObligacionesPorPeriodo(req: Request, res: Response, next: NextFunction) { + try { + const contribuyenteId = String(req.params.id); + const periodo = (req.query.periodo as string) || new Date().toISOString().substring(0, 7); + const incluirAtrasados = req.query.atrasados !== 'false'; + const rows = await obligacionesService.getObligacionesPorPeriodo(req.tenantPool!, contribuyenteId, periodo, incluirAtrasados); + return res.json({ data: rows, periodo }); + } catch (err) { return next(err); } +} + +export async function completePeriodo(req: Request, res: Response, next: NextFunction) { + try { + const { periodo, notas } = req.body; + if (!periodo) return next(new AppError(400, 'periodo requerido (YYYY-MM)')); + await obligacionesService.completePeriodo(req.tenantPool!, String(req.params.obligacionId), periodo, req.user!.userId, notas); + return res.json({ message: 'Obligación completada para el periodo' }); + } catch (err) { return next(err); } +} + +export async function uncompletePeriodo(req: Request, res: Response, next: NextFunction) { + try { + const periodo = req.body.periodo || req.query.periodo; + if (!periodo) return next(new AppError(400, 'periodo requerido')); + await obligacionesService.uncompletePeriodo(req.tenantPool!, String(req.params.obligacionId), periodo as string); + return res.json({ message: 'Completación removida' }); + } catch (err) { return next(err); } +} diff --git a/apps/api/src/controllers/papeleria.controller.ts b/apps/api/src/controllers/papeleria.controller.ts new file mode 100644 index 0000000..6812301 --- /dev/null +++ b/apps/api/src/controllers/papeleria.controller.ts @@ -0,0 +1,263 @@ +import type { Request, Response, NextFunction } from 'express'; +import { z } from 'zod'; +import { AppError } from '../middlewares/error.middleware.js'; +import * as papeleriaService from '../services/papeleria.service.js'; +import { emailService } from '../services/email/email.service.js'; +import { getTenantOwnerEmails, getUserEmailById } from '../utils/memberships.js'; +import { env } from '../config/env.js'; +import { prisma } from '../config/database.js'; + +function rejectClienteRole(req: Request): void { + if (req.user?.role === 'cliente') { + throw new AppError(403, 'Papelería no disponible para usuarios cliente'); + } +} + +function effectiveTenantId(req: Request): string { + return req.viewingTenantId || req.user!.tenantId; +} + +const uploadSchema = z.object({ + contribuyenteId: z.string().uuid(), + nombre: z.string().min(1).max(255), + descripcion: z.string().max(2000).nullable().optional(), + anio: z.number().int().min(2000).max(2100), + mes: z.number().int().min(1).max(12), + requiereAprobacion: z.boolean(), + archivoBase64: z.string().min(1), + archivoFilename: z.string().min(1).max(255), + archivoMime: z.string().min(1).max(100), +}); + +export async function upload(req: Request, res: Response, next: NextFunction) { + try { + rejectClienteRole(req); + const data = uploadSchema.parse(req.body); + const archivo = Buffer.from(data.archivoBase64, 'base64'); + + const item = await papeleriaService.uploadPapeleria(req.tenantPool!, { + contribuyenteId: data.contribuyenteId, + nombre: data.nombre, + descripcion: data.descripcion ?? null, + anio: data.anio, + mes: data.mes, + requiereAprobacion: data.requiereAprobacion, + archivo, + archivoFilename: data.archivoFilename, + archivoMime: data.archivoMime, + subidoPor: req.user!.userId, + }); + + // Notificación a aprobadores si la papelería requiere aprobación. + if (item.requiereAprobacion) { + notifyAprobacionRequerida(req, item).catch(err => + console.error('[papeleria.upload] notify aprobadores failed:', err?.message || err), + ); + } + + res.status(201).json(item); + } catch (error: any) { + if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message)); + if (error?.message?.startsWith('Formato no permitido') || error?.message?.startsWith('Archivo excede')) { + return next(new AppError(400, error.message)); + } + next(error); + } +} + +const listSchema = z.object({ + contribuyenteId: z.string().uuid(), + anio: z.string().regex(/^\d{4}$/).optional(), + mes: z.string().regex(/^\d{1,2}$/).optional(), + estado: z.enum(['pendiente', 'aprobado', 'rechazado', 'sin_aprobacion']).optional(), +}); + +export async function list(req: Request, res: Response, next: NextFunction) { + try { + rejectClienteRole(req); + const q = listSchema.parse(req.query); + const items = await papeleriaService.listPapeleria(req.tenantPool!, { + contribuyenteId: q.contribuyenteId, + anio: q.anio ? parseInt(q.anio, 10) : undefined, + mes: q.mes ? parseInt(q.mes, 10) : undefined, + estado: q.estado, + }); + res.json(items); + } catch (error) { + if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message)); + next(error); + } +} + +export async function download(req: Request, res: Response, next: NextFunction) { + try { + rejectClienteRole(req); + const id = parseInt(String(req.params.id), 10); + if (isNaN(id)) return next(new AppError(400, 'ID inválido')); + const file = await papeleriaService.downloadArchivo(req.tenantPool!, id); + if (!file) return next(new AppError(404, 'Documento no encontrado')); + res.setHeader('Content-Type', file.mime); + res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(file.filename)}"`); + res.send(file.archivo); + } catch (error) { + next(error); + } +} + +export async function aprobar(req: Request, res: Response, next: NextFunction) { + try { + rejectClienteRole(req); + const id = parseInt(String(req.params.id), 10); + if (isNaN(id)) return next(new AppError(400, 'ID inválido')); + const item = await papeleriaService.aprobar( + req.tenantPool!, id, req.user!.userId, req.user!.role, + ); + if (!item) return next(new AppError(404, 'Documento no encontrado o no requiere aprobación')); + notifyDecisionAuxiliar(req, item).catch(err => + console.error('[papeleria.aprobar] notify auxiliar failed:', err?.message || err), + ); + res.json(item); + } catch (error: any) { + if (error?.message?.startsWith('Solo owner')) return next(new AppError(403, error.message)); + next(error); + } +} + +const rechazarSchema = z.object({ comentario: z.string().max(2000).nullable().optional() }); + +export async function rechazar(req: Request, res: Response, next: NextFunction) { + try { + rejectClienteRole(req); + const id = parseInt(String(req.params.id), 10); + if (isNaN(id)) return next(new AppError(400, 'ID inválido')); + const { comentario } = rechazarSchema.parse(req.body); + const item = await papeleriaService.rechazar( + req.tenantPool!, id, req.user!.userId, req.user!.role, comentario ?? null, + ); + if (!item) return next(new AppError(404, 'Documento no encontrado o no requiere aprobación')); + notifyDecisionAuxiliar(req, item).catch(err => + console.error('[papeleria.rechazar] notify auxiliar failed:', err?.message || err), + ); + res.json(item); + } catch (error: any) { + if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message)); + if (error?.message?.startsWith('Solo owner')) return next(new AppError(403, error.message)); + next(error); + } +} + +export async function eliminar(req: Request, res: Response, next: NextFunction) { + try { + rejectClienteRole(req); + const id = parseInt(String(req.params.id), 10); + if (isNaN(id)) return next(new AppError(400, 'ID inválido')); + const ok = await papeleriaService.eliminar(req.tenantPool!, id); + if (!ok) return next(new AppError(404, 'Documento no encontrado')); + res.status(204).send(); + } catch (error) { + next(error); + } +} + +// ─── Notificaciones ─── + +/** + * Notifica a owners y supervisores cuando una papelería requiere aprobación. + * Owners se obtienen de tenant_memberships (BD central). Supervisores se + * resuelven leyendo carteras del tenant. + */ +async function notifyAprobacionRequerida( + req: Request, + item: papeleriaService.PapeleriaItem, +): Promise { + const tenantId = effectiveTenantId(req); + + // Owners del despacho + const recipients = new Set(await getTenantOwnerEmails(tenantId)); + + // Supervisores: cualquier user con rol 'supervisor' o 'cfo' que pertenezca a este tenant. + // Buscamos vía tenant_memberships + roles. + const supervisores = await prisma.tenantMembership.findMany({ + where: { tenantId, active: true, rol: { nombre: { in: ['supervisor', 'cfo'] } } }, + include: { user: { select: { email: true, active: true } } }, + }); + for (const m of supervisores) { + if (m.user.active && m.user.email) recipients.add(m.user.email); + } + + // No notificarse a sí mismo + recipients.delete(req.user!.email); + + if (recipients.size === 0) return; + + const tenant = await prisma.tenant.findUnique({ + where: { id: tenantId }, + select: { nombre: true }, + }); + + const { rows } = await req.tenantPool!.query<{ rfc: string; nombre: string }>( + `SELECT c.rfc, eg.nombre FROM contribuyentes c + JOIN entidades_gestionadas eg ON eg.id = c.entidad_id + WHERE c.entidad_id = $1`, + [item.contribuyenteId], + ); + if (rows.length === 0) return; + + const link = `${env.FRONTEND_URL}/documentos`; + const meses = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic']; + const periodo = `${meses[item.mes - 1]} ${item.anio}`; + + for (const to of recipients) { + try { + await emailService.sendPapeleriaAprobacionRequerida(to, { + contribuyenteRfc: rows[0].rfc, + contribuyenteNombre: rows[0].nombre, + despachoNombre: tenant?.nombre, + nombreDocumento: item.nombre, + descripcion: item.descripcion, + periodo, + subidoPor: req.user!.email, + link, + }); + } catch (err: any) { + console.error(`[Email] papeleria-aprobacion a ${to}:`, err?.message || err); + } + } +} + +/** + * Notifica al uploader (auxiliar) cuando un documento que él subió fue + * aprobado o rechazado. Solo manda si quien aprobó/rechazó NO es el mismo + * uploader (caso edge: owner sube su propia papelería). + */ +async function notifyDecisionAuxiliar( + req: Request, + item: papeleriaService.PapeleriaItem, +): Promise { + if (item.subidoPor === req.user!.userId) return; + const auxiliarEmail = await getUserEmailById(item.subidoPor); + if (!auxiliarEmail) return; + + const { rows } = await req.tenantPool!.query<{ rfc: string; nombre: string }>( + `SELECT c.rfc, eg.nombre FROM contribuyentes c + JOIN entidades_gestionadas eg ON eg.id = c.entidad_id + WHERE c.entidad_id = $1`, + [item.contribuyenteId], + ); + if (rows.length === 0) return; + + const link = `${env.FRONTEND_URL}/documentos`; + const meses = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic']; + const periodo = `${meses[item.mes - 1]} ${item.anio}`; + + await emailService.sendPapeleriaDecision(auxiliarEmail, { + contribuyenteRfc: rows[0].rfc, + contribuyenteNombre: rows[0].nombre, + nombreDocumento: item.nombre, + estado: item.estado as 'aprobado' | 'rechazado', + revisor: req.user!.email, + comentario: item.comentarioRechazo, + periodo, + link, + }); +} diff --git a/apps/api/src/controllers/plan-catalogo.controller.ts b/apps/api/src/controllers/plan-catalogo.controller.ts new file mode 100644 index 0000000..dbb4c59 --- /dev/null +++ b/apps/api/src/controllers/plan-catalogo.controller.ts @@ -0,0 +1,92 @@ +import type { Request, Response, NextFunction } from 'express'; +import { z } from 'zod'; +import * as planService from '../services/plan-catalogo.service.js'; +import { prisma } from '../config/database.js'; +import { invalidateDespachoPlanCache } from '../services/plan-catalogo.service.js'; +import { canEditPrices } from '../utils/platform-admin.js'; +import { AppError } from '../middlewares/error.middleware.js'; + +export async function getPlans(req: Request, res: Response, next: NextFunction) { + try { + const vertical = req.query.vertical as string | undefined; + const plans = await planService.listPlans(vertical); + return res.json({ data: plans }); + } catch (err) { return next(err); } +} + +export async function getAddons(req: Request, res: Response, next: NextFunction) { + try { + const vertical = req.query.vertical as string | undefined; + const addons = await planService.listAddons(vertical); + return res.json({ data: addons }); + } catch (err) { return next(err); } +} + +export async function getPlan(req: Request, res: Response, next: NextFunction) { + try { + const plan = await planService.getPlanByCodename(String(req.params.codename)); + if (!plan) return res.status(404).json({ message: 'Plan no encontrado' }); + return res.json(plan); + } catch (err) { return next(err); } +} + +// ============================================================================ +// Catálogo despacho — limits + precios editables por admin global +// ============================================================================ + +/** GET /api/planes/despacho — devuelve los 6 planes con limits + precios. */ +export async function listDespachoCatalogo(_req: Request, res: Response, next: NextFunction) { + try { + const plans = await planService.getAllDespachoPlanLimits(); + return res.json({ data: plans }); + } catch (err) { return next(err); } +} + +const updateSchema = z.object({ + nombre: z.string().min(1).max(50).optional(), + monthly: z.number().nullable().optional(), + firstYear: z.number().nullable().optional(), + renewal: z.number().nullable().optional(), + permiteMonthly: z.boolean().optional(), + maxRfcs: z.number().int().optional(), + maxUsers: z.number().int().optional(), + timbresIncluidosMes: z.number().int().nonnegative().optional(), + dbMode: z.enum(['BYO', 'MANAGED']).optional(), + permiteServidorBackup: z.boolean().optional(), + permiteSatIncremental: z.boolean().optional(), +}); + +/** PATCH /api/planes/despacho/:plan — actualiza precios/limits. Solo admin con canEditPrices. */ +export async function updateDespachoCatalogo(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user || !(await canEditPrices(req.user.userId))) { + throw new AppError(403, 'Solo admin global puede editar el catálogo'); + } + const plan = String(req.params.plan); + const data = updateSchema.parse(req.body); + + const existing = await prisma.despachoPlanPrice.findUnique({ where: { plan } }); + if (!existing) throw new AppError(404, `Plan '${plan}' no encontrado`); + + const updated = await prisma.despachoPlanPrice.update({ where: { plan }, data }); + invalidateDespachoPlanCache(); + + return res.json({ + plan: updated.plan, + nombre: updated.nombre, + monthly: updated.monthly !== null ? Number(updated.monthly) : null, + firstYear: updated.firstYear !== null ? Number(updated.firstYear) : null, + renewal: updated.renewal !== null ? Number(updated.renewal) : null, + permiteMonthly: updated.permiteMonthly, + maxRfcs: updated.maxRfcs, + maxUsers: updated.maxUsers, + timbresIncluidosMes: updated.timbresIncluidosMes, + dbMode: updated.dbMode, + permiteServidorBackup: updated.permiteServidorBackup, + permiteSatIncremental: updated.permiteSatIncremental, + }); + } catch (err) { + if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message)); + return next(err); + } +} diff --git a/apps/api/src/controllers/platform-staff.controller.ts b/apps/api/src/controllers/platform-staff.controller.ts new file mode 100644 index 0000000..b358e01 --- /dev/null +++ b/apps/api/src/controllers/platform-staff.controller.ts @@ -0,0 +1,187 @@ +import type { Request, Response, NextFunction } from 'express'; +import { prisma } from '../config/database.js'; +import { hasPlatformRole, invalidatePlatformRolesCache, type PlatformRole } from '../utils/platform-admin.js'; +import { auditFromReq } from '../utils/audit.js'; + +const VALID_ROLES: PlatformRole[] = ['platform_admin', 'platform_ti', 'platform_support', 'platform_sales', 'platform_finance']; +const SUPERSET_ROLES: PlatformRole[] = ['platform_admin', 'platform_ti']; + +async function requirePlatformAdmin(req: Request, res: Response): Promise { + const ok = await hasPlatformRole(req.user?.userId, 'platform_admin'); + if (!ok) { + res.status(403).json({ message: 'Solo platform_admin puede gestionar staff' }); + } + return ok; +} + +/** + * Lista users que tienen al menos un platform role + users candidatos a serlo. + * Admin global (platform_admin) only. + */ +export async function listStaff(req: Request, res: Response, next: NextFunction) { + try { + if (!(await requirePlatformAdmin(req, res))) return; + + // Todos los users con al menos un platform role + const roles = await prisma.userPlatformRole.findMany({ + include: { + user: { + select: { + id: true, email: true, nombre: true, active: true, + // Tenant principal del staff: el primer membership owner por joinedAt + // ASC. Se incluye solo para mostrar contexto en la UI admin. + memberships: { + where: { active: true, isOwner: true }, + orderBy: { joinedAt: 'asc' }, + take: 1, + include: { tenant: { select: { id: true, nombre: true, rfc: true } } }, + }, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + }); + + // Agrupa por user + const byUser = new Map(); + for (const r of roles) { + const existing = byUser.get(r.userId); + if (existing) { + existing.roles.push(r.role); + } else { + const { memberships, ...userBase } = r.user; + byUser.set(r.userId, { + ...userBase, + tenant: memberships[0]?.tenant ?? null, + roles: [r.role], + }); + } + } + res.json(Array.from(byUser.values())); + } catch (error) { + next(error); + } +} + +/** + * Busca users por email (para agregar nuevos staff). Admin global only. + */ +export async function searchUsers(req: Request, res: Response, next: NextFunction) { + try { + if (!(await requirePlatformAdmin(req, res))) return; + + const q = String(req.query.q || '').trim(); + if (q.length < 2) return res.json([]); + + const users = await prisma.user.findMany({ + where: { + OR: [ + { email: { contains: q, mode: 'insensitive' } }, + { nombre: { contains: q, mode: 'insensitive' } }, + ], + }, + select: { + id: true, email: true, nombre: true, active: true, + memberships: { + where: { active: true, isOwner: true }, + orderBy: { joinedAt: 'asc' }, + take: 1, + include: { tenant: { select: { id: true, nombre: true, rfc: true } } }, + }, + }, + take: 10, + }); + res.json(users.map(u => { + const { memberships, ...rest } = u; + return { ...rest, tenant: memberships[0]?.tenant ?? null }; + })); + } catch (error) { + next(error); + } +} + +/** + * Asigna un rol a un user. Idempotente (si ya existe, no duplica). + */ +export async function grantRole(req: Request, res: Response, next: NextFunction) { + try { + if (!(await requirePlatformAdmin(req, res))) return; + + const { userId, role } = req.body; + if (!userId || typeof userId !== 'string') { + return res.status(400).json({ message: 'userId requerido' }); + } + if (!VALID_ROLES.includes(role)) { + return res.status(400).json({ message: `role inválido. Valores: ${VALID_ROLES.join(', ')}` }); + } + + const user = await prisma.user.findUnique({ where: { id: userId }, select: { id: true, email: true } }); + if (!user) return res.status(404).json({ message: 'Usuario no encontrado' }); + + await prisma.userPlatformRole.upsert({ + where: { userId_role: { userId, role } }, + create: { userId, role, createdBy: req.user!.userId }, + update: {}, + }); + + invalidatePlatformRolesCache(userId); + + auditFromReq(req, 'platform_role.granted', { + entityType: 'User', + entityId: userId, + metadata: { role, targetEmail: user.email }, + }); + + res.json({ ok: true }); + } catch (error) { + next(error); + } +} + +/** + * Quita un rol a un user. Protección: no puedes quitarte tu propio `platform_admin` + * si eres el último admin (evita bootstrap problem — nadie queda con acceso). + */ +export async function revokeRole(req: Request, res: Response, next: NextFunction) { + try { + if (!(await requirePlatformAdmin(req, res))) return; + + const { userId, role } = req.body; + if (!userId || typeof userId !== 'string') { + return res.status(400).json({ message: 'userId requerido' }); + } + if (!VALID_ROLES.includes(role)) { + return res.status(400).json({ message: 'role inválido' }); + } + + // Protección: no quitar tu último rol superset (admin o TI) — evita bootstrap problem + if (SUPERSET_ROLES.includes(role) && userId === req.user!.userId) { + const supersetCount = await prisma.userPlatformRole.count({ + where: { role: { in: SUPERSET_ROLES } }, + }); + if (supersetCount <= 1) { + return res.status(400).json({ + message: 'No puedes quitar tu propio rol superset — serías el último con acceso transversal. Asigna platform_admin o platform_ti a otro usuario primero.', + }); + } + } + + const user = await prisma.user.findUnique({ where: { id: userId }, select: { email: true } }); + + await prisma.userPlatformRole.deleteMany({ + where: { userId, role }, + }); + + invalidatePlatformRolesCache(userId); + + auditFromReq(req, 'platform_role.revoked', { + entityType: 'User', + entityId: userId, + metadata: { role, targetEmail: user?.email }, + }); + + res.json({ ok: true }); + } catch (error) { + next(error); + } +} diff --git a/apps/api/src/controllers/regimen.controller.ts b/apps/api/src/controllers/regimen.controller.ts new file mode 100644 index 0000000..cad5357 --- /dev/null +++ b/apps/api/src/controllers/regimen.controller.ts @@ -0,0 +1,70 @@ +import type { Request, Response, NextFunction } from 'express'; +import * as regimenService from '../services/regimen.service.js'; + +/** Resuelve el tenantId efectivo (impersonación o propio) */ +function effectiveTenantId(req: Request): string { + return req.viewingTenantId || req.user!.tenantId; +} + +export async function getAllRegimenes(req: Request, res: Response, next: NextFunction) { + try { + const regimenes = await regimenService.getAllRegimenes(); + res.json(regimenes); + } catch (error) { + next(error); + } +} + +export async function getActivos(req: Request, res: Response, next: NextFunction) { + try { + const activos = await regimenService.getRegimenesActivos(effectiveTenantId(req)); + res.json(activos); + } catch (error) { + next(error); + } +} + +export async function setActivos(req: Request, res: Response, next: NextFunction) { + try { + if (req.user!.role !== 'owner') { + return res.status(403).json({ message: 'Solo el dueño puede configurar regímenes' }); + } + + const { regimenIds } = req.body; + if (!Array.isArray(regimenIds)) { + return res.status(400).json({ message: 'regimenIds debe ser un array' }); + } + + const result = await regimenService.setRegimenesActivos(effectiveTenantId(req), regimenIds); + res.json(result); + } catch (error) { + next(error); + } +} + +export async function getIgnorados(req: Request, res: Response, next: NextFunction) { + try { + const ignorados = await regimenService.getRegimenesIgnorados(effectiveTenantId(req)); + res.json(ignorados); + } catch (error) { + next(error); + } +} + +export async function setIgnorados(req: Request, res: Response, next: NextFunction) { + try { + if (req.user!.role !== 'owner') { + return res.status(403).json({ message: 'Solo el dueño puede configurar regímenes' }); + } + + const { regimenIds } = req.body; + if (!Array.isArray(regimenIds)) { + return res.status(400).json({ message: 'regimenIds debe ser un array' }); + } + + const result = await regimenService.setRegimenesIgnorados(effectiveTenantId(req), regimenIds); + res.json(result); + } catch (error) { + next(error); + } +} diff --git a/apps/api/src/controllers/reportes.controller.ts b/apps/api/src/controllers/reportes.controller.ts new file mode 100644 index 0000000..6d44e22 --- /dev/null +++ b/apps/api/src/controllers/reportes.controller.ts @@ -0,0 +1,85 @@ +import type { Request, Response, NextFunction } from 'express'; +import * as reportesService from '../services/reportes.service.js'; + +export async function getEstadoResultados(req: Request, res: Response, next: NextFunction) { + try { + const { fechaInicio, fechaFin, contribuyenteId } = req.query; + const now = new Date(); + const inicio = (fechaInicio as string) || `${now.getFullYear()}-01-01`; + const fin = (fechaFin as string) || now.toISOString().split('T')[0]; + + const data = await reportesService.getEstadoResultados(req.tenantPool!, inicio, fin, req.user!.tenantId, contribuyenteId as string | undefined || null); + res.json(data); + } catch (error) { + console.error('[reportes] Error en getEstadoResultados:', error); + next(error); + } +} + +export async function getFlujoEfectivo(req: Request, res: Response, next: NextFunction) { + try { + const { fechaInicio, fechaFin, contribuyenteId } = req.query; + const now = new Date(); + const inicio = (fechaInicio as string) || `${now.getFullYear()}-01-01`; + const fin = (fechaFin as string) || now.toISOString().split('T')[0]; + + const data = await reportesService.getFlujoEfectivo(req.tenantPool!, inicio, fin, contribuyenteId as string | undefined || null); + res.json(data); + } catch (error) { + next(error); + } +} + +export async function getComparativo(req: Request, res: Response, next: NextFunction) { + try { + const { contribuyenteId } = req.query; + const año = parseInt(req.query.año as string) || new Date().getFullYear(); + const data = await reportesService.getComparativo(req.tenantPool!, año, contribuyenteId as string | undefined || null); + res.json(data); + } catch (error) { + next(error); + } +} + +export async function getCuentasXPagar(req: Request, res: Response, next: NextFunction) { + try { + const { fechaInicio, fechaFin, regimen, contribuyenteId } = req.query; + const now = new Date(); + const inicio = (fechaInicio as string) || `${now.getFullYear()}-01-01`; + const fin = (fechaFin as string) || now.toISOString().split('T')[0]; + + const data = await reportesService.getCuentasXPagar(req.tenantPool!, inicio, fin, regimen as string, contribuyenteId as string | undefined || null); + res.json(data); + } catch (error) { + next(error); + } +} + +export async function getCuentasXCobrar(req: Request, res: Response, next: NextFunction) { + try { + const { fechaInicio, fechaFin, regimen, contribuyenteId } = req.query; + const now = new Date(); + const inicio = (fechaInicio as string) || `${now.getFullYear()}-01-01`; + const fin = (fechaFin as string) || now.toISOString().split('T')[0]; + + const data = await reportesService.getCuentasXCobrar(req.tenantPool!, inicio, fin, regimen as string, contribuyenteId as string | undefined || null); + res.json(data); + } catch (error) { + next(error); + } +} + +export async function getConcentradoRfc(req: Request, res: Response, next: NextFunction) { + try { + const { fechaInicio, fechaFin, tipo, contribuyenteId } = req.query; + const now = new Date(); + const inicio = (fechaInicio as string) || `${now.getFullYear()}-01-01`; + const fin = (fechaFin as string) || now.toISOString().split('T')[0]; + const tipoRfc = (tipo as 'cliente' | 'proveedor') || 'cliente'; + + const data = await reportesService.getConcentradoRfc(req.tenantPool!, inicio, fin, tipoRfc, contribuyenteId as string | undefined || null); + res.json(data); + } catch (error) { + next(error); + } +} diff --git a/apps/api/src/controllers/sat.controller.ts b/apps/api/src/controllers/sat.controller.ts new file mode 100644 index 0000000..fbb1aaa --- /dev/null +++ b/apps/api/src/controllers/sat.controller.ts @@ -0,0 +1,168 @@ +import type { Request, Response } from 'express'; +import { + startSync, + getSyncStatus, + getSyncHistory, + retryJob, +} from '../services/sat/sat.service.js'; +import { getJobInfo, runSatSyncJobManually } from '../jobs/sat-sync.job.js'; +import type { StartSyncRequest } from '@horux/shared'; +import { isGlobalAdmin } from '../utils/global-admin.js'; + +function effectiveTenantId(req: Request): string { + return req.viewingTenantId || req.user!.tenantId; +} + +/** + * Inicia una sincronización manual + */ +export async function start(req: Request, res: Response): Promise { + try { + const tenantId = effectiveTenantId(req); + const { type, dateFrom, dateTo } = req.body as StartSyncRequest; + const contribuyenteId = req.body.contribuyenteId as string | undefined; + + const jobId = await startSync( + tenantId, + type || 'daily', + dateFrom ? new Date(dateFrom) : undefined, + dateTo ? new Date(dateTo) : undefined, + contribuyenteId || undefined + ); + + res.json({ + jobId, + message: 'Sincronización iniciada', + }); + } catch (error: any) { + console.error('[SAT Controller] Error en start:', error); + + if (error.message.includes('FIEL') || error.message.includes('sincronización en curso')) { + res.status(400).json({ error: error.message }); + return; + } + + res.status(500).json({ error: 'Error interno del servidor' }); + } +} + +/** + * Obtiene el estado actual de sincronización + */ +export async function status(req: Request, res: Response): Promise { + try { + const tenantId = effectiveTenantId(req); + const contribuyenteId = req.query.contribuyenteId as string | undefined; + const syncStatus = await getSyncStatus(tenantId, contribuyenteId || undefined); + res.json(syncStatus); + } catch (error: any) { + console.error('[SAT Controller] Error en status:', error); + res.status(500).json({ error: 'Error interno del servidor' }); + } +} + +/** + * Obtiene el historial de sincronizaciones + */ +export async function history(req: Request, res: Response): Promise { + try { + const tenantId = effectiveTenantId(req); + const page = parseInt(req.query.page as string) || 1; + const limit = parseInt(req.query.limit as string) || 10; + const contribuyenteId = req.query.contribuyenteId as string | undefined; + + const result = await getSyncHistory(tenantId, page, limit, contribuyenteId || undefined); + res.json({ + ...result, + page, + limit, + }); + } catch (error: any) { + console.error('[SAT Controller] Error en history:', error); + res.status(500).json({ error: 'Error interno del servidor' }); + } +} + +/** + * Obtiene detalle de un job específico + */ +export async function jobDetail(req: Request, res: Response): Promise { + try { + const tenantId = effectiveTenantId(req); + const { id } = req.params; + const { jobs } = await getSyncHistory(tenantId, 1, 100); + const job = jobs.find(j => j.id === id); + + if (!job) { + res.status(404).json({ error: 'Job no encontrado' }); + return; + } + + res.json(job); + } catch (error: any) { + console.error('[SAT Controller] Error en jobDetail:', error); + res.status(500).json({ error: 'Error interno del servidor' }); + } +} + +/** + * Reintenta un job fallido + */ +export async function retry(req: Request, res: Response): Promise { + try { + const id = req.params.id as string; + const newJobId = await retryJob(id); + + res.json({ + jobId: newJobId, + message: 'Job reintentado', + }); + } catch (error: any) { + console.error('[SAT Controller] Error en retry:', error); + + if (error.message.includes('no encontrado') || error.message.includes('Solo se pueden')) { + res.status(400).json({ error: error.message }); + return; + } + + res.status(500).json({ error: 'Error interno del servidor' }); + } +} + +/** + * Obtiene información del job programado (solo admin global) + */ +export async function cronInfo(req: Request, res: Response): Promise { + try { + if (!(await isGlobalAdmin(req.user!.tenantId, req.user!.role))) { + res.status(403).json({ error: 'Solo el administrador global puede ver info del cron' }); + return; + } + const info = getJobInfo(); + res.json(info); + } catch (error: any) { + console.error('[SAT Controller] Error en cronInfo:', error); + res.status(500).json({ error: 'Error interno del servidor' }); + } +} + +/** + * Ejecuta el job de sincronización manualmente (solo admin global) + */ +export async function runCron(req: Request, res: Response): Promise { + try { + if (!(await isGlobalAdmin(req.user!.tenantId, req.user!.role))) { + res.status(403).json({ error: 'Solo el administrador global puede ejecutar el cron' }); + return; + } + // Ejecutar en background + runSatSyncJobManually().catch(err => + console.error('[SAT Controller] Error ejecutando cron manual:', err) + ); + + res.json({ message: 'Job de sincronización iniciado' }); + } catch (error: any) { + console.error('[SAT Controller] Error en runCron:', error); + res.status(500).json({ error: 'Error interno del servidor' }); + } +} diff --git a/apps/api/src/controllers/subscription.controller.ts b/apps/api/src/controllers/subscription.controller.ts new file mode 100644 index 0000000..39d6d3a --- /dev/null +++ b/apps/api/src/controllers/subscription.controller.ts @@ -0,0 +1,359 @@ +import type { Request, Response, NextFunction } from 'express'; +import * as subscriptionService from '../services/payment/subscription.service.js'; +import { listActiveAddons, subscribeAddon, cancelAddon } from '../services/payment/addon.service.js'; +import { isGlobalAdmin } from '../utils/global-admin.js'; +import { auditFromReq } from '../utils/audit.js'; + +async function requireGlobalAdmin(req: Request, res: Response): Promise { + const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role); + if (!isAdmin) { + res.status(403).json({ message: 'Solo el administrador global puede gestionar suscripciones' }); + } + return isAdmin; +} + +/** + * Permite si el usuario es admin global O si está consultando su propio tenant. + * Úsalo para endpoints de lectura/acción sobre la suscripción del mismo tenant + * del usuario (ver estado, generar link de pago pendiente). + */ +async function requireOwnTenantOrGlobalAdmin(req: Request, res: Response, targetTenantId: string): Promise { + if (targetTenantId === req.user!.tenantId) return true; + const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role); + if (!isAdmin) { + res.status(403).json({ message: 'Solo puedes gestionar la suscripción de tu propio tenant' }); + } + return isAdmin; +} + +// (getPlans + updatePlanPrice eliminados — modelo PlanPrice legacy dropeado +// en migración 20260501160000_drop_plan_prices_legacy. Catálogo despacho +// vive en `despacho_plan_prices` editado vía /api/planes/despacho.) + +export async function getAllSubscriptions(req: Request, res: Response, next: NextFunction) { + try { + if (!(await requireGlobalAdmin(req, res))) return; + + const { prisma } = await import('../config/database.js'); + const subscriptions = await prisma.subscription.findMany({ + include: { + tenant: { + select: { id: true, nombre: true, rfc: true, plan: true, active: true }, + }, + }, + orderBy: { createdAt: 'desc' }, + }); + + res.json(subscriptions); + } catch (error) { + next(error); + } +} + +export async function getSubscription(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = String(req.params.tenantId); + if (!(await requireOwnTenantOrGlobalAdmin(req, res, tenantId))) return; + + const subscription = await subscriptionService.getActiveSubscription(tenantId); + if (!subscription) { + return res.status(404).json({ message: 'No se encontró suscripción' }); + } + res.json(subscription); + } catch (error) { + next(error); + } +} + +export async function generatePaymentLink(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = String(req.params.tenantId); + if (!(await requireOwnTenantOrGlobalAdmin(req, res, tenantId))) return; + + const result = await subscriptionService.generatePaymentLink(tenantId); + res.json(result); + } catch (error) { + next(error); + } +} + +export async function markAsPaid(req: Request, res: Response, next: NextFunction) { + try { + if (!(await requireGlobalAdmin(req, res))) return; + + const tenantId = String(req.params.tenantId); + const { amount } = req.body; + + if (!amount || amount <= 0) { + return res.status(400).json({ message: 'Monto inválido' }); + } + + const payment = await subscriptionService.markAsPaidManually(tenantId, amount); + res.json(payment); + } catch (error) { + next(error); + } +} + +export async function getPayments(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = String(req.params.tenantId); + if (!(await requireOwnTenantOrGlobalAdmin(req, res, tenantId))) return; + + const payments = await subscriptionService.getPaymentHistory(tenantId); + res.json(payments); + } catch (error) { + next(error); + } +} + +// ============================================================================ +// Self-serve endpoints (actúan sobre el tenant del usuario autenticado) +// ============================================================================ + +type FrequencyInput = 'monthly' | 'annual'; +const VALID_PLANS = [ + 'business_control', 'business_cloud', + 'mi_empresa', 'mi_empresa_plus', +] as const; +// Planes despacho que se cobran SOLO anual. Mi Empresa y Mi Empresa+ aceptan +// monthly o annual (annual con descuento ~17% — paga 10 meses); Business +// Control y Enterprise siguen exclusivamente anuales. +const DESPACHO_ONLY_ANNUAL = new Set([ + 'business_control', 'business_cloud', +]); + +function validatePlanFrequency(body: any): { plan: typeof VALID_PLANS[number]; frequency: FrequencyInput } | { error: string } { + const plan = body?.plan; + const frequency = body?.frequency; + if (!plan || !VALID_PLANS.includes(plan)) { + return { error: `plan inválido. Valores aceptados: ${VALID_PLANS.join(', ')}` }; + } + if (frequency !== 'monthly' && frequency !== 'annual') { + return { error: `frequency inválida. Debe ser 'monthly' o 'annual'` }; + } + if (DESPACHO_ONLY_ANNUAL.has(plan) && frequency !== 'annual') { + return { error: `El plan ${plan} solo está disponible con frecuencia anual` }; + } + return { plan, frequency }; +} + +export async function startMyTrial(req: Request, res: Response, next: NextFunction) { + try { + const parsed = validatePlanFrequency(req.body); + if ('error' in parsed) return res.status(400).json({ message: parsed.error }); + + const result = await subscriptionService.startTrial({ + tenantId: req.user!.tenantId, + plan: parsed.plan, + frequency: parsed.frequency, + ownerUserId: req.user!.userId, + }); + res.status(201).json(result); + } catch (error: any) { + if ( + error.message?.includes('ya usó') || + error.message?.includes('Ya existe') || + error.message?.includes('no se puede') || + error.message?.includes('Ya consumiste') || + error.message?.includes('ya consumió') + ) { + return res.status(400).json({ message: error.message }); + } + next(error); + } +} + +export async function subscribeMe(req: Request, res: Response, next: NextFunction) { + try { + const parsed = validatePlanFrequency(req.body); + if ('error' in parsed) return res.status(400).json({ message: parsed.error }); + + const result = await subscriptionService.subscribe({ + tenantId: req.user!.tenantId, + plan: parsed.plan, + frequency: parsed.frequency, + payerEmail: req.user!.email, + }); + res.status(201).json(result); + } catch (error: any) { + const msg: string = error?.message || ''; + if (msg.includes('Ya existe') || msg.includes('custom')) { + return res.status(400).json({ message: msg }); + } + if (msg.includes('MercadoPago no está configurado')) { + return res.status(503).json({ message: msg }); + } + // Otros errores de MP al crear preapproval (monto inválido, email inválido, etc.) + if (msg.includes('Unauthorized access') || error?.status === 401) { + return res.status(503).json({ + message: 'MercadoPago rechazó la solicitud. Verifica que MP_ACCESS_TOKEN sea válido y esté vigente.', + }); + } + next(error); + } +} + +export async function changeMyPlan(req: Request, res: Response, next: NextFunction) { + try { + const parsed = validatePlanFrequency(req.body); + if ('error' in parsed) return res.status(400).json({ message: parsed.error }); + + const result = await subscriptionService.scheduleChange({ + tenantId: req.user!.tenantId, + newPlan: parsed.plan, + newFrequency: parsed.frequency, + }); + res.json(result); + } catch (error: any) { + if (error.message?.includes('iguales') || error.message?.includes('No hay') || error.message?.includes('custom')) { + return res.status(400).json({ message: error.message }); + } + next(error); + } +} + +export async function cancelMySubscription(req: Request, res: Response, next: NextFunction) { + try { + const result = await subscriptionService.cancelSubscription(req.user!.tenantId); + res.json(result); + } catch (error: any) { + if (error.message?.includes('No hay')) { + return res.status(400).json({ message: error.message }); + } + next(error); + } +} + +/** + * Reactiva suscripción cancelada que aún está dentro de su período pagado. + * Crea un preapproval nuevo en MP con start_date al final del período actual. + * Retorna paymentUrl para que el usuario autorice. + */ +export async function reactivateMe(req: Request, res: Response, next: NextFunction) { + try { + const result = await subscriptionService.reactivateSubscription({ + tenantId: req.user!.tenantId, + payerEmail: req.user!.email, + }); + res.status(201).json(result); + } catch (error: any) { + const msg: string = error?.message || ''; + if (msg.includes('No hay') || msg.includes('vencido') || msg.includes('custom')) { + return res.status(400).json({ message: msg }); + } + if (msg.includes('MercadoPago no está configurado')) { + return res.status(503).json({ message: msg }); + } + if (msg.includes('Unauthorized access') || error?.status === 401) { + return res.status(503).json({ + message: 'MercadoPago rechazó la solicitud. Verifica que MP_ACCESS_TOKEN sea válido.', + }); + } + next(error); + } +} + +/** + * Inicia un upgrade con cobro prorateado inmediato. Body: `{ plan }`. + * La frecuencia actual se preserva — para cambiar frecuencia usa `/me/change`. + * Retorna `{ checkoutUrl, proratedAmount }` — el cliente debe abrir la URL para que + * el usuario pague en MP. Al confirmarse el pago (webhook), se aplica el plan nuevo. + */ +export async function upgradeMe(req: Request, res: Response, next: NextFunction) { + try { + const plan = req.body?.plan; + if (!plan || !VALID_PLANS.includes(plan)) { + return res.status(400).json({ message: `plan inválido. Valores: ${VALID_PLANS.join(', ')}` }); + } + + const result = await subscriptionService.initiateUpgrade({ + tenantId: req.user!.tenantId, + newPlan: plan, + payerEmail: req.user!.email, + }); + res.status(201).json(result); + } catch (error: any) { + const msg: string = error?.message || ''; + if ( + msg.includes('No hay suscripción') || + msg.includes('en curso') || + msg.includes('no es un upgrade') || + msg.includes('días restantes') || + msg.includes('custom') || + msg.includes('Precio no configurado') + ) { + return res.status(400).json({ message: msg }); + } + if (msg.includes('MercadoPago no está configurado')) { + return res.status(503).json({ message: msg }); + } + if (msg.includes('Unauthorized access') || error?.status === 401) { + return res.status(503).json({ + message: 'MercadoPago rechazó la solicitud. Verifica que MP_ACCESS_TOKEN sea válido.', + }); + } + next(error); + } +} + +export async function cancelMyPendingUpgrade(req: Request, res: Response, next: NextFunction) { + try { + await subscriptionService.cancelPendingUpgrade(req.user!.tenantId); + res.json({ ok: true }); + } catch (error: any) { + if (error.message?.includes('No hay upgrade')) { + return res.status(400).json({ message: error.message }); + } + next(error); + } +} + +// ============================================================================ +// Addon endpoints (self-serve) +// ============================================================================ + +export async function getMyAddons(req: Request, res: Response, next: NextFunction) { + try { + // Query param `contribuyenteId` opcional: filtra al contribuyente específico. + // Sin param → retorna todos los add-ons del tenant (incluye los de todos los RFCs). + const contribuyenteId = typeof req.query.contribuyenteId === 'string' && req.query.contribuyenteId + ? req.query.contribuyenteId + : undefined; + const result = await listActiveAddons(req.user!.tenantId, contribuyenteId); + return res.json(result); + } catch (err) { return next(err); } +} + +export async function addMyAddon(req: Request, res: Response, next: NextFunction) { + try { + const { addonCodename, quantity, contribuyenteId } = req.body; + if (!addonCodename) return res.status(400).json({ message: 'addonCodename requerido' }); + + const result = await subscribeAddon({ + tenantId: req.user!.tenantId, + addonCodename, + quantity: quantity || 1, + payerEmail: req.user!.email, + contribuyenteId: typeof contribuyenteId === 'string' ? contribuyenteId : null, + }); + return res.status(201).json(result); + } catch (err: any) { + if (err.message?.includes('no disponible') || err.message?.includes('Ya tienes')) { + return res.status(409).json({ message: err.message }); + } + return next(err); + } +} + +export async function cancelMyAddon(req: Request, res: Response, next: NextFunction) { + try { + await cancelAddon(req.user!.tenantId, String(req.params.addonId)); + return res.json({ message: 'Addon cancelado' }); + } catch (err: any) { + if (err.message?.includes('no encontrado')) { + return res.status(404).json({ message: err.message }); + } + return next(err); + } +} diff --git a/apps/api/src/controllers/tareas.controller.ts b/apps/api/src/controllers/tareas.controller.ts new file mode 100644 index 0000000..c37803b --- /dev/null +++ b/apps/api/src/controllers/tareas.controller.ts @@ -0,0 +1,177 @@ +import type { Request, Response, NextFunction } from 'express'; +import { z } from 'zod'; +import { AppError } from '../middlewares/error.middleware.js'; +import * as tareasService from '../services/tareas.service.js'; +import { emailService } from '../services/email/email.service.js'; +import { getUserEmailById } from '../utils/memberships.js'; +import { env } from '../config/env.js'; +import { prisma } from '../config/database.js'; + +/** + * Bloquea a usuarios rol `cliente` de cualquier endpoint de tareas. + * El cliente no debe ver tareas operativas internas del despacho. + */ +function rejectClienteRole(req: Request): void { + if (req.user?.role === 'cliente') { + throw new AppError(403, 'Tareas no disponibles para usuarios cliente'); + } +} + +const RECURRENCIAS = ['semanal', 'quincenal', 'mensual', 'bimestral', 'trimestral', 'semestral', 'anual'] as const; + +const tareaSchema = z.object({ + nombre: z.string().min(1).max(200), + descripcion: z.string().max(1000).nullable().optional(), + recurrencia: z.enum(RECURRENCIAS), + diaSemana: z.number().int().min(1).max(7).nullable().optional(), + diaMes: z.number().int().min(1).max(31).nullable().optional(), + soloSupervisorCompleta: z.boolean().optional(), + orden: z.number().int().optional(), +}); + +export async function listTareas(req: Request, res: Response, next: NextFunction) { + try { + rejectClienteRole(req); + const contribuyenteId = req.query.contribuyenteId as string | undefined; + if (!contribuyenteId) return next(new AppError(400, 'contribuyenteId requerido')); + const tareas = await tareasService.listTareasConPeriodoActual(req.tenantPool!, contribuyenteId); + res.json(tareas); + } catch (error) { + next(error); + } +} + +export async function createTarea(req: Request, res: Response, next: NextFunction) { + try { + rejectClienteRole(req); + const contribuyenteId = req.query.contribuyenteId as string | undefined; + if (!contribuyenteId) return next(new AppError(400, 'contribuyenteId requerido')); + const data = tareaSchema.parse(req.body); + const tarea = await tareasService.createTarea(req.tenantPool!, contribuyenteId, data); + res.status(201).json(tarea); + } catch (error) { + if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message)); + next(error); + } +} + +export async function updateTarea(req: Request, res: Response, next: NextFunction) { + try { + rejectClienteRole(req); + const data = tareaSchema.partial().parse(req.body); + const updated = await tareasService.updateTarea(req.tenantPool!, String(req.params.id), data); + if (!updated) return next(new AppError(404, 'Tarea no encontrada')); + res.json(updated); + } catch (error) { + if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message)); + next(error); + } +} + +export async function deleteTarea(req: Request, res: Response, next: NextFunction) { + try { + rejectClienteRole(req); + const ok = await tareasService.deleteTarea(req.tenantPool!, String(req.params.id)); + if (!ok) return next(new AppError(404, 'Tarea no encontrada')); + res.status(204).send(); + } catch (error) { + next(error); + } +} + +export async function completarPeriodo(req: Request, res: Response, next: NextFunction) { + try { + rejectClienteRole(req); + const { notas } = z.object({ notas: z.string().max(1000).nullable().optional() }).parse(req.body); + const result = await tareasService.completarPeriodo( + req.tenantPool!, + String(req.params.id), + req.user!.userId, + req.user!.role, + notas ?? null, + ); + if (!result) return next(new AppError(404, 'Periodo no encontrado')); + + // Notificar al auxiliar de la cartera SOLO cuando una tarea con + // solo_supervisor_completa=true fue marcada como completada por + // un supervisor/owner. Fire-and-forget — no bloquea la respuesta. + if (result.tarea.soloSupervisorCompleta) { + notifyAuxiliarTareaCompletada(req, result).catch(err => + console.error('[tareas.completar] notify auxiliar failed:', err?.message || err), + ); + } + res.json(result); + } catch (error: any) { + if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message)); + if (error?.message?.startsWith('Solo supervisor')) return next(new AppError(403, error.message)); + next(error); + } +} + +async function notifyAuxiliarTareaCompletada( + req: Request, + result: { periodo: tareasService.TareaPeriodo; tarea: tareasService.TareaCatalogo }, +): Promise { + const auxiliarUserId = await tareasService.getAuxiliarUserIdDeContribuyente( + req.tenantPool!, + result.tarea.contribuyenteId, + ); + if (!auxiliarUserId) return; + if (auxiliarUserId === req.user!.userId) return; // no notificarse a sí mismo + const auxiliarEmail = await getUserEmailById(auxiliarUserId); + if (!auxiliarEmail) return; + + // Datos del contribuyente y supervisor para el email + const { rows } = await req.tenantPool!.query<{ rfc: string; nombre: string }>( + `SELECT c.rfc, eg.nombre + FROM contribuyentes c + JOIN entidades_gestionadas eg ON eg.id = c.entidad_id + WHERE c.entidad_id = $1`, + [result.tarea.contribuyenteId], + ); + if (rows.length === 0) return; + + const auxiliarNombre = (await prisma.user.findUnique({ + where: { id: auxiliarUserId }, + select: { nombre: true }, + }))?.nombre || 'Auxiliar'; + + const fechaLimite = result.periodo.fechaLimite instanceof Date + ? result.periodo.fechaLimite.toLocaleDateString('es-MX', { dateStyle: 'long' }) + : new Date(String(result.periodo.fechaLimite)).toLocaleDateString('es-MX', { dateStyle: 'long' }); + + await emailService.sendTareaCompletada(auxiliarEmail, { + destinatarioNombre: auxiliarNombre, + contribuyenteNombre: rows[0].nombre, + contribuyenteRfc: rows[0].rfc, + tareaNombre: result.tarea.nombre, + tareaDescripcion: result.tarea.descripcion, + completadaPor: req.user!.email, + notas: result.periodo.notas, + fechaLimite, + link: `${env.FRONTEND_URL}/configuracion/obligaciones`, + }); +} + +export async function descompletarPeriodo(req: Request, res: Response, next: NextFunction) { + try { + rejectClienteRole(req); + const ok = await tareasService.descompletarPeriodo(req.tenantPool!, String(req.params.id)); + if (!ok) return next(new AppError(404, 'Periodo no encontrado')); + res.status(204).send(); + } catch (error) { + next(error); + } +} + +export async function seedDefaults(req: Request, res: Response, next: NextFunction) { + try { + rejectClienteRole(req); + const contribuyenteId = req.query.contribuyenteId as string | undefined; + if (!contribuyenteId) return next(new AppError(400, 'contribuyenteId requerido')); + const created = await tareasService.seedTareasDefault(req.tenantPool!, contribuyenteId); + res.json({ created }); + } catch (error) { + next(error); + } +} diff --git a/apps/api/src/controllers/tenants.controller.ts b/apps/api/src/controllers/tenants.controller.ts new file mode 100644 index 0000000..cf85aed --- /dev/null +++ b/apps/api/src/controllers/tenants.controller.ts @@ -0,0 +1,145 @@ +import { Request, Response, NextFunction } from 'express'; +import { z } from 'zod'; +import * as tenantsService from '../services/tenants.service.js'; +import { AppError } from '../middlewares/error.middleware.js'; +import { isGlobalAdmin } from '../utils/global-admin.js'; +import { isOwnerSomewhere } from '../utils/memberships.js'; + +async function requireGlobalAdmin(req: Request): Promise { + if (!(await isGlobalAdmin(req.user!.tenantId, req.user!.role))) { + throw new AppError(403, 'Solo el administrador global puede gestionar clientes'); + } +} + +export async function getAllTenants(req: Request, res: Response, next: NextFunction) { + try { + await requireGlobalAdmin(req); + + const tenants = await tenantsService.getAllTenants(); + res.json(tenants); + } catch (error) { + next(error); + } +} + +export async function getTenant(req: Request, res: Response, next: NextFunction) { + try { + await requireGlobalAdmin(req); + + const tenant = await tenantsService.getTenantById(String(req.params.id)); + if (!tenant) { + throw new AppError(404, 'Cliente no encontrado'); + } + + res.json(tenant); + } catch (error) { + next(error); + } +} + +export async function createTenant(req: Request, res: Response, next: NextFunction) { + try { + await requireGlobalAdmin(req); + + const { nombre, rfc, plan, adminEmail, adminNombre, amount, firstPaymentDueAt } = req.body; + + if (!nombre || !rfc || !adminEmail || !adminNombre) { + throw new AppError(400, 'Nombre, RFC, adminEmail y adminNombre son requeridos'); + } + + const result = await tenantsService.createTenant({ + nombre, + rfc, + plan, + adminEmail, + adminNombre, + amount: amount || 0, + firstPaymentDueAt: firstPaymentDueAt || null, + }); + + res.status(201).json(result); + } catch (error) { + next(error); + } +} + +export async function updateTenant(req: Request, res: Response, next: NextFunction) { + try { + await requireGlobalAdmin(req); + + const id = String(req.params.id); + const { nombre, rfc, plan, active } = req.body; + + const tenant = await tenantsService.updateTenant(id, { + nombre, + rfc, + plan, + active, + }); + + res.json(tenant); + } catch (error) { + next(error); + } +} + +export async function deleteTenant(req: Request, res: Response, next: NextFunction) { + try { + await requireGlobalAdmin(req); + + await tenantsService.deleteTenant(String(req.params.id)); + res.status(204).send(); + } catch (error) { + next(error); + } +} + +// ============================================================================ +// Self-serve (multi-tenant memberships) +// ============================================================================ + +/** + * Lista detallada de empresas del caller con estado de suscripción. Usado por + * `/mis-empresas`. A diferencia de `/auth/me`, incluye datos de subscription + * (status, currentPeriodEnd, pendingPlan, etc.). + */ +export async function getMyTenants(req: Request, res: Response, next: NextFunction) { + try { + const data = await tenantsService.getMyTenantsDetailed(req.user!.userId); + res.json(data); + } catch (error) { next(error); } +} + +const addTenantSchema = z.object({ + nombre: z.string().min(2, 'Nombre de empresa requerido'), + rfc: z.string().min(12).max(13, 'RFC inválido'), + plan: z.enum(['mi_empresa', 'mi_empresa_plus', 'business_control', 'business_cloud']).optional(), +}); + +/** + * Agrega una empresa (tenant nuevo) bajo el user autenticado. El caller se + * vuelve owner automáticamente vía TenantMembership. + */ +export async function addMyTenant(req: Request, res: Response, next: NextFunction) { + try { + const data = addTenantSchema.parse(req.body); + // Gate: solo users que son owner en al menos un tenant pueden agregar + // un RFC adicional. Un contador invitado a una empresa ajena no puede. + if (!(await isOwnerSomewhere(req.user!.userId))) { + throw new AppError(403, 'Solo los dueños pueden registrar empresas adicionales.'); + } + const result = await tenantsService.addTenantToOwner({ + userId: req.user!.userId, + nombre: data.nombre, + rfc: data.rfc, + plan: data.plan, + }); + res.status(201).json({ tenant: result.tenant }); + } catch (error) { + if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message)); + if (error instanceof Error && error.message.includes('RFC')) { + return next(new AppError(400, error.message)); + } + next(error); + } +} diff --git a/apps/api/src/controllers/usuarios.controller.ts b/apps/api/src/controllers/usuarios.controller.ts new file mode 100644 index 0000000..5bd9eff --- /dev/null +++ b/apps/api/src/controllers/usuarios.controller.ts @@ -0,0 +1,273 @@ +import { Request, Response, NextFunction } from 'express'; +import { z } from 'zod'; +import * as usuariosService from '../services/usuarios.service.js'; +import { AppError } from '../middlewares/error.middleware.js'; +import { isGlobalAdmin as checkGlobalAdmin } from '../utils/global-admin.js'; + +const inviteSchema = z.object({ + email: z.string().email('email inválido'), + nombre: z.string().min(2).max(100), + // Legacy Horux360 roles + Despacho-specific roles + role: z.enum(['contador', 'visor', 'auxiliar', 'supervisor', 'cliente']), + supervisorUserId: z.string().uuid().optional(), // Required when role=auxiliar +}); + +const updateSchema = z.object({ + nombre: z.string().min(2).max(100).optional(), + // Legacy Horux360 roles + Despacho-specific roles + role: z.enum(['contador', 'visor', 'auxiliar', 'supervisor', 'cliente']).optional(), + active: z.boolean().optional(), +}); + +const updateGlobalSchema = z.object({ + nombre: z.string().min(2).max(100).optional(), + role: z.enum(['owner', 'cfo', 'contador', 'visor', 'auxiliar', 'supervisor', 'cliente']).optional(), + active: z.boolean().optional(), + tenantId: z.string().uuid().optional(), +}); + +async function isGlobalAdmin(req: Request): Promise { + return checkGlobalAdmin(req.user!.tenantId, req.user!.role); +} + +export async function getUsuarios(req: Request, res: Response, next: NextFunction) { + try { + const usuarios = await usuariosService.getUsuarios(req.user!.tenantId); + res.json(usuarios); + } catch (error) { + next(error); + } +} + +/** + * Obtiene todos los usuarios de todas las empresas (solo admin global) + */ +export async function getAllUsuarios(req: Request, res: Response, next: NextFunction) { + try { + if (!(await isGlobalAdmin(req))) { + throw new AppError(403, 'Solo el administrador global puede ver todos los usuarios'); + } + const usuarios = await usuariosService.getAllUsuarios(); + res.json(usuarios); + } catch (error) { + next(error); + } +} + +export async function inviteUsuario(req: Request, res: Response, next: NextFunction) { + try { + if (req.user!.role !== 'owner') { + throw new AppError(403, 'Solo los dueños pueden invitar usuarios'); + } + const data = inviteSchema.parse(req.body); + + // Validate: auxiliar requires a supervisor + if (data.role === 'auxiliar' && !data.supervisorUserId) { + throw new AppError(400, 'Debes asignar un supervisor al auxiliar'); + } + + const usuario = await usuariosService.inviteUsuario(req.user!.tenantId, data); + + // Store auxiliar→supervisor relationship in tenant DB + if (data.role === 'auxiliar' && data.supervisorUserId && req.tenantPool) { + await req.tenantPool.query( + `INSERT INTO auxiliar_supervisores (auxiliar_user_id, supervisor_user_id) + VALUES ($1, $2) ON CONFLICT (auxiliar_user_id) DO UPDATE SET supervisor_user_id = $2`, + [usuario.id, data.supervisorUserId], + ); + } + + res.status(201).json(usuario); + } catch (error) { + if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message)); + next(error); + } +} + +export async function updateUsuario(req: Request, res: Response, next: NextFunction) { + try { + if (req.user!.role !== 'owner') { + throw new AppError(403, 'Solo los dueños pueden modificar usuarios'); + } + const userId = req.params.id as string; + const data = updateSchema.parse(req.body); + const usuario = await usuariosService.updateUsuario(req.user!.tenantId, userId, data); + res.json(usuario); + } catch (error) { + if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message)); + next(error); + } +} + +/** + * Lee el supervisor actualmente asignado a un auxiliar. Resuelve desde 3 + * fuentes (en orden de prioridad): + * 1. `auxiliar_supervisores` (override explícito del owner desde /usuarios). + * 2. Cartera donde el user es `auxiliar_user_id` y la misma tiene supervisor. + * 3. Subcartera donde el user es `auxiliar_user_id`; el supervisor viene + * del cartera padre. + * + * Devuelve `null` si no aparece en ninguna. + */ +export async function getSupervisor(req: Request, res: Response, next: NextFunction) { + try { + if (!req.tenantPool) throw new AppError(500, 'Tenant pool no disponible'); + const userId = String(req.params.id); + const { rows } = await req.tenantPool.query<{ supervisor_user_id: string }>( + `SELECT supervisor_user_id FROM ( + SELECT supervisor_user_id, 1 AS prio FROM auxiliar_supervisores + WHERE auxiliar_user_id = $1 + UNION ALL + SELECT supervisor_user_id, 2 AS prio FROM carteras + WHERE auxiliar_user_id = $1 AND supervisor_user_id IS NOT NULL + UNION ALL + SELECT p.supervisor_user_id, 3 AS prio + FROM carteras sub + JOIN carteras p ON p.id = sub.parent_id + WHERE sub.auxiliar_user_id = $1 AND p.supervisor_user_id IS NOT NULL + ) t + WHERE supervisor_user_id IS NOT NULL + ORDER BY prio + LIMIT 1`, + [userId], + ); + res.json({ supervisorUserId: rows[0]?.supervisor_user_id ?? null }); + } catch (error) { + next(error); + } +} + +const supervisorSchema = z.object({ + supervisorUserId: z.string().uuid().nullable(), +}); + +/** + * Asigna o elimina el supervisor de un auxiliar (BD tenant). + * Solo owner/cfo. Pasar `null` borra la asignación. + */ +export async function updateSupervisor(req: Request, res: Response, next: NextFunction) { + try { + if (req.user!.role !== 'owner' && req.user!.role !== 'cfo') { + throw new AppError(403, 'Solo el owner puede asignar supervisores'); + } + if (!req.tenantPool) throw new AppError(500, 'Tenant pool no disponible'); + const userId = String(req.params.id); + const { supervisorUserId } = supervisorSchema.parse(req.body); + + if (supervisorUserId === null) { + await req.tenantPool.query( + `DELETE FROM auxiliar_supervisores WHERE auxiliar_user_id = $1`, + [userId], + ); + } else { + await req.tenantPool.query( + `INSERT INTO auxiliar_supervisores (auxiliar_user_id, supervisor_user_id) + VALUES ($1, $2) + ON CONFLICT (auxiliar_user_id) DO UPDATE SET supervisor_user_id = $2`, + [userId, supervisorUserId], + ); + } + res.json({ supervisorUserId }); + } catch (error) { + if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message)); + next(error); + } +} + +export async function deleteUsuario(req: Request, res: Response, next: NextFunction) { + try { + if (req.user!.role !== 'owner') { + throw new AppError(403, 'Solo los dueños pueden eliminar usuarios'); + } + const userId = req.params.id as string; + if (userId === req.user!.userId) { + throw new AppError(400, 'No puedes eliminar tu propia cuenta'); + } + await usuariosService.deleteUsuario(req.user!.tenantId, userId); + res.status(204).send(); + } catch (error) { + next(error); + } +} + +/** + * Actualiza un usuario globalmente (puede cambiar de empresa) + */ +export async function updateUsuarioGlobal(req: Request, res: Response, next: NextFunction) { + try { + if (!(await isGlobalAdmin(req))) { + throw new AppError(403, 'Solo el administrador global puede modificar usuarios globalmente'); + } + const userId = req.params.id as string; + const data = updateGlobalSchema.parse(req.body); + if (userId === req.user!.userId && data.tenantId) { + throw new AppError(400, 'No puedes cambiar tu propia empresa'); + } + const usuario = await usuariosService.updateUsuarioGlobal(userId, data); + res.json(usuario); + } catch (error) { + if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message)); + next(error); + } +} + +/** + * Elimina un usuario globalmente + */ +export async function deleteUsuarioGlobal(req: Request, res: Response, next: NextFunction) { + try { + if (!(await isGlobalAdmin(req))) { + throw new AppError(403, 'Solo el administrador global puede eliminar usuarios globalmente'); + } + + const userId = req.params.id as string; + if (userId === req.user!.userId) { + throw new AppError(400, 'No puedes eliminar tu propia cuenta'); + } + await usuariosService.deleteUsuarioGlobal(userId); + res.status(204).send(); + } catch (error) { + next(error); + } +} + +/** + * Get cliente accesos (which contribuyentes a client user can access) + */ +export async function getClienteAccesos(req: Request, res: Response, next: NextFunction) { + try { + if (req.user!.role !== 'owner') throw new AppError(403, 'No autorizado'); + const userId = req.params.id as string; + const { rows } = await req.tenantPool!.query( + 'SELECT entidad_id AS "entidadId" FROM cliente_accesos WHERE user_id = $1', + [userId], + ); + res.json({ data: rows.map(r => r.entidadId) }); + } catch (error) { next(error); } +} + +/** + * Set cliente accesos (replace all accesos for a client user) + */ +export async function setClienteAccesos(req: Request, res: Response, next: NextFunction) { + try { + if (req.user!.role !== 'owner') throw new AppError(403, 'No autorizado'); + const userId = req.params.id as string; + const { entidadIds } = z.object({ + entidadIds: z.array(z.string().uuid()), + }).parse(req.body); + + // Replace all accesos + await req.tenantPool!.query('DELETE FROM cliente_accesos WHERE user_id = $1', [userId]); + for (const entidadId of entidadIds) { + await req.tenantPool!.query( + 'INSERT INTO cliente_accesos (user_id, entidad_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', + [userId, entidadId], + ); + } + res.json({ data: entidadIds }); + } catch (error) { + if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message)); + next(error); + } +} diff --git a/apps/api/src/controllers/webhook.controller.ts b/apps/api/src/controllers/webhook.controller.ts new file mode 100644 index 0000000..99187cc --- /dev/null +++ b/apps/api/src/controllers/webhook.controller.ts @@ -0,0 +1,244 @@ +import type { Request, Response, NextFunction } from 'express'; +import * as mpService from '../services/payment/mercadopago.service.js'; +import * as subscriptionService from '../services/payment/subscription.service.js'; +import * as invoicingService from '../services/payment/invoicing.service.js'; +import * as facturapiService from '../services/facturapi.service.js'; +import { handleAddonPayment } from '../services/payment/addon.service.js'; +import { prisma } from '../config/database.js'; +import { isDespachoPaidPlan } from '@horux/shared'; +import { despachoPlanTieneDualidadDb } from '../services/plan-catalogo.service.js'; +import { emailService } from '../services/email/email.service.js'; +import { getTenantOwnerEmail } from '../utils/memberships.js'; + +export async function handleMercadoPagoWebhook(req: Request, res: Response, next: NextFunction) { + try { + const { type, data } = req.body; + const xSignature = req.headers['x-signature'] as string; + const xRequestId = req.headers['x-request-id'] as string; + + // Verify webhook signature (mandatory) + if (!xSignature || !xRequestId || !data?.id) { + console.warn('[WEBHOOK] Missing signature headers'); + return res.status(401).json({ message: 'Missing signature headers' }); + } + + const isValid = mpService.verifyWebhookSignature(xSignature, xRequestId, String(data.id)); + if (!isValid) { + console.warn('[WEBHOOK] Invalid MercadoPago signature'); + return res.status(401).json({ message: 'Invalid signature' }); + } + + if (type === 'payment') { + await handlePaymentNotification(String(data.id)); + } else if (type === 'subscription_preapproval') { + await handlePreapprovalNotification(String(data.id)); + } + + // Always respond 200 to acknowledge receipt + res.status(200).json({ received: true }); + } catch (error) { + console.error('[WEBHOOK] Error processing MercadoPago webhook:', error); + // Still respond 200 to prevent retries for processing errors + res.status(200).json({ received: true, error: 'processing_error' }); + } +} + +async function handlePaymentNotification(paymentId: string) { + const payment = await mpService.getPaymentDetails(paymentId); + + if (!payment.externalReference) { + console.warn('[WEBHOOK] Payment without external_reference:', paymentId); + return; + } + + // Detecta compras de paquete de timbres. external_reference = `timbres-pack:{paymentId}` + if (payment.externalReference.startsWith('timbres-pack:')) { + const localPaymentId = payment.externalReference.split(':')[1]; + if (!localPaymentId) { + console.warn('[WEBHOOK] external_reference timbres-pack malformado:', payment.externalReference); + return; + } + + // Capturar estado previo para detectar transición (idempotencia: MP puede + // mandar el mismo webhook múltiples veces — solo notificamos en cambio real). + const before = await prisma.payment.findUnique({ + where: { id: localPaymentId }, + select: { status: true, tenantId: true, amount: true }, + }); + const previousStatus = before?.status ?? null; + + await prisma.payment.update({ + where: { id: localPaymentId }, + data: { + status: payment.status || 'unknown', + mpPaymentId: paymentId, + paidAt: payment.status === 'approved' ? new Date() : null, + }, + }); + + if (payment.status === 'approved') { + try { + await facturapiService.activarPaqueteTrasPago(localPaymentId); + } catch (error: any) { + console.error('[WEBHOOK] Error activando paquete de timbres:', error.message); + throw error; // que MP reintente + } + // Auto-emisión de factura (fail-soft) + await invoicingService.emitInvoiceIfApplicable(localPaymentId); + } else if ( + (payment.status === 'rejected' || payment.status === 'cancelled') && + previousStatus !== payment.status && + before + ) { + // Compra de paquete de timbres falló — el owner pagó y MP rechazó. Aviso fail-soft. + const tenant = await prisma.tenant.findUnique({ where: { id: before.tenantId }, select: { nombre: true } }); + const ownerEmail = await getTenantOwnerEmail(before.tenantId); + if (tenant && ownerEmail) { + emailService.sendPaymentFailed(ownerEmail, { + nombre: tenant.nombre, + amount: Number(before.amount), + plan: 'Paquete de timbres', + }).catch(err => console.error('[EMAIL] timbres-pack failed notification:', err)); + } + } + + if (typeof process.send === 'function') { + const pay = await prisma.payment.findUnique({ where: { id: localPaymentId }, select: { tenantId: true } }); + if (pay) process.send({ type: 'invalidate-tenant-cache', tenantId: pay.tenantId }); + } + return; + } + + // Detecta pagos de prorateo (upgrade). external_reference = `proration:${tenantId}:${subscriptionId}` + if (payment.externalReference.startsWith('proration:')) { + const parts = payment.externalReference.split(':'); + const tenantId = parts[1]; + const subscriptionId = parts[2]; + if (!tenantId || !subscriptionId) { + console.warn('[WEBHOOK] external_reference de proration malformado:', payment.externalReference); + return; + } + + const paymentRecord = await subscriptionService.recordPayment({ + tenantId, + subscriptionId, + mpPaymentId: paymentId, + amount: payment.transactionAmount || 0, + status: payment.status || 'unknown', + paymentMethod: `proration-${payment.paymentMethodId || 'unknown'}`, + }); + + if (payment.status === 'approved') { + try { + await subscriptionService.applyApprovedUpgrade(subscriptionId); + } catch (error: any) { + // Re-lanza para que MP reintente el webhook + console.error('[WEBHOOK] Error aplicando upgrade:', error.message); + throw error; + } + // Auto-emisión de factura (fail-soft, no bloquea ni tira) + await invoicingService.emitInvoiceIfApplicable(paymentRecord.id); + } + + if (typeof process.send === 'function') { + process.send({ type: 'invalidate-tenant-cache', tenantId }); + } + return; + } + + // Detecta pagos de addon. external_reference = `addon:{subscriptionAddonId}` + if (payment.externalReference.startsWith('addon:')) { + const addonId = payment.externalReference.replace('addon:', ''); + if (!addonId) { + console.warn('[WEBHOOK] external_reference addon malformado:', payment.externalReference); + return; + } + await handleAddonPayment(addonId, String(paymentId), payment.status || 'unknown'); + // Continue to normal flow only if we have a subscription to record against. + // Addon payments are fully handled by handleAddonPayment; no further action needed. + return; + } + + // Flujo normal: pago recurrente del preapproval + const tenantId = payment.externalReference; + const subscription = await prisma.subscription.findFirst({ + where: { tenantId }, + orderBy: { createdAt: 'desc' }, + }); + + if (!subscription) { + console.warn('[WEBHOOK] No subscription found for tenant:', tenantId); + return; + } + + const paymentRecord = await subscriptionService.recordPayment({ + tenantId, + subscriptionId: subscription.id, + mpPaymentId: paymentId, + amount: payment.transactionAmount || 0, + status: payment.status || 'unknown', + paymentMethod: payment.paymentMethodId || 'unknown', + }); + + if (payment.status === 'approved') { + // Transición pending → authorized es el momento del *primer* pago aprobado. + // En planes despacho con dualidad de precio (firstYear > renewal), bajamos + // el monto recurrente del preapproval para que las renovaciones cobren el + // precio de renewal. Se detecta comparando el monto cobrado contra lo que + // `getPlanPrice(phase='firstYear')` devolvería para este plan. + const esPrimerPago = subscription.status === 'pending'; + await prisma.subscription.update({ + where: { id: subscription.id }, + data: { status: 'authorized' }, + }); + subscriptionService.invalidateSubscriptionCache(tenantId); + + if ( + esPrimerPago && + subscription.mpPreapprovalId && + isDespachoPaidPlan(subscription.plan) && + await despachoPlanTieneDualidadDb(subscription.plan) + ) { + try { + const renewalAmount = await subscriptionService.getPlanPrice( + subscription.plan as any, + subscription.frequency as any, + 'renewal', + ); + await mpService.updatePreapprovalAmount(subscription.mpPreapprovalId, renewalAmount); + await prisma.subscription.update({ + where: { id: subscription.id }, + data: { amount: renewalAmount }, + }); + subscriptionService.invalidateSubscriptionCache(tenantId); + console.log(`[WEBHOOK] Preapproval ${subscription.mpPreapprovalId} bajado a $${renewalAmount} (renewal) tras primer pago`); + } catch (err: any) { + // No fallar el webhook — el cobro ya pasó. Logear para intervención manual. + console.error(`[WEBHOOK] Error bajando preapproval a renewal:`, err?.message || err); + } + } + + // Auto-emisión de factura (fail-soft, no bloquea ni tira) + await invoicingService.emitInvoiceIfApplicable(paymentRecord.id); + } + + if (typeof process.send === 'function') { + process.send({ type: 'invalidate-tenant-cache', tenantId }); + } +} + +async function handlePreapprovalNotification(preapprovalId: string) { + const preapproval = await mpService.getPreapproval(preapprovalId); + + if (preapproval.status) { + await subscriptionService.updateSubscriptionStatus(preapprovalId, preapproval.status); + } + + // Broadcast cache invalidation + const subscription = await prisma.subscription.findFirst({ + where: { mpPreapprovalId: preapprovalId }, + }); + if (subscription && typeof process.send === 'function') { + process.send({ type: 'invalidate-tenant-cache', tenantId: subscription.tenantId }); + } +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts new file mode 100644 index 0000000..b4e136d --- /dev/null +++ b/apps/api/src/index.ts @@ -0,0 +1,57 @@ +import { app } from './app.js'; +import { env } from './config/env.js'; +import { tenantDb } from './config/database.js'; +import { invalidateTenantCache } from './middlewares/plan-limits.middleware.js'; +import { startSatSyncJob } from './jobs/sat-sync.job.js'; +import { startWeeklyUpdateJob } from './jobs/weekly-update.job.js'; +import { startMetricasInvalidationsJob } from './jobs/metricas-invalidations.job.js'; +import { startNotificationsJob } from './jobs/notifications.job.js'; + +const PORT = parseInt(env.PORT, 10); + +const server = app.listen(PORT, '0.0.0.0', () => { + console.log(`API Server running on http://0.0.0.0:${PORT}`); + console.log(`Environment: ${env.NODE_ENV}`); + + // Iniciar jobs programados. + // En dev, los crons se omiten por default para no consumir recursos ni + // disparar efectos (SAT queries, emails). Con ENABLE_CRONS_IN_DEV=1 se + // arrancan los crons seguros (SAT sync/retry, métricas); el weekly-update + // sigue siendo prod-only porque envía emails a owners reales. + const cronsEnabled = env.NODE_ENV === 'production' || process.env.ENABLE_CRONS_IN_DEV === '1'; + const sendRealEmails = env.NODE_ENV === 'production'; + if (cronsEnabled) { + startSatSyncJob(); + startMetricasInvalidationsJob(); + if (sendRealEmails) { + startWeeklyUpdateJob(); + startNotificationsJob(); + } else { + console.log('[Cron] weekly-update + notifications omitidos en dev (evita emails reales)'); + } + console.log(`[Cron] SAT + metricas activos (NODE_ENV=${env.NODE_ENV}, ENABLE_CRONS_IN_DEV=${process.env.ENABLE_CRONS_IN_DEV ?? 'unset'})`); + } else { + console.log('[Cron] Jobs omitidos en dev (usar ENABLE_CRONS_IN_DEV=1 para activar)'); + } +}); + +// Graceful shutdown — close all tenant DB pools before exiting +const gracefulShutdown = async (signal: string) => { + console.log(`${signal} received. Shutting down gracefully...`); + server.close(() => { + console.log('HTTP server closed'); + }); + await tenantDb.shutdown(); + process.exit(0); +}; + +process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); +process.on('SIGINT', () => gracefulShutdown('SIGINT')); + +// PM2 cluster: cross-worker cache invalidation +process.on('message', (msg: any) => { + if (msg?.type === 'invalidate-tenant-cache' && msg.tenantId) { + tenantDb.invalidatePool(msg.tenantId); + invalidateTenantCache(msg.tenantId); + } +}); diff --git a/apps/api/src/jobs/metricas-invalidations.job.ts b/apps/api/src/jobs/metricas-invalidations.job.ts new file mode 100644 index 0000000..e6cbd74 --- /dev/null +++ b/apps/api/src/jobs/metricas-invalidations.job.ts @@ -0,0 +1,53 @@ +import cron from 'node-cron'; +import { processAllTenantsInvalidations } from '../services/metricas-compute.service.js'; + +// Corre cada 15 minutos en dev/test para iteración rápida; en prod puedes +// espaciarlo a 30-60 min si el volumen de cambios es bajo. +const METRICAS_CRON_SCHEDULE = '*/15 * * * *'; + +let scheduledTask: ReturnType | null = null; +let running = false; // evita solapamiento si una corrida tarda más del intervalo + +async function runProcessInvalidations(): Promise { + if (running) { + console.log('[MetricasJob] Corrida previa aún en curso, skip'); + return; + } + running = true; + const start = Date.now(); + try { + const r = await processAllTenantsInvalidations(); + const elapsed = ((Date.now() - start) / 1000).toFixed(1); + if (r.totalProcesadas > 0 || r.totalErrores > 0) { + console.log( + `[MetricasJob] ${r.tenantsRevisados} tenants, ${r.totalProcesadas} invalidaciones procesadas, ` + + `${r.totalFilasEscritas} filas escritas, ${r.totalErrores} errores, ${elapsed}s`, + ); + } + } catch (err: any) { + console.error('[MetricasJob] Error fatal:', err?.message || err); + } finally { + running = false; + } +} + +export function startMetricasInvalidationsJob(): void { + if (scheduledTask) { + console.log('[MetricasJob] Job ya iniciado'); + return; + } + scheduledTask = cron.schedule(METRICAS_CRON_SCHEDULE, runProcessInvalidations, { + timezone: 'America/Mexico_City', + }); + console.log(`[MetricasJob] Cron iniciado (${METRICAS_CRON_SCHEDULE})`); +} + +export function stopMetricasInvalidationsJob(): void { + if (scheduledTask) { + scheduledTask.stop(); + scheduledTask = null; + } +} + +// Exportado para disparos manuales (ej. desde un endpoint admin) +export { runProcessInvalidations }; diff --git a/apps/api/src/jobs/notifications.job.ts b/apps/api/src/jobs/notifications.job.ts new file mode 100644 index 0000000..e444c0a --- /dev/null +++ b/apps/api/src/jobs/notifications.job.ts @@ -0,0 +1,104 @@ +/** + * Cron diario 8:30 AM (America/Mexico_City) que envía emails de: + * - Alertas fiscales nuevas (Option B — una sola vez por alerta). + * - Recordatorios próximos a vencer en ventanas 3d / 1d / 0d. + * + * Por-tenant try/catch: un fallo en un tenant no bloquea al resto. + */ +import cron from 'node-cron'; +import { prisma, tenantDb } from '../config/database.js'; +import { processNewAlertas, processProximosRecordatorios } from '../services/notifications.service.js'; + +const SCHEDULE = '30 8 * * *'; // 08:30 AM diario + +let task: ReturnType | null = null; + +/** Ejecuta ambos procesos para UN tenant. Exportado para disparo manual. */ +export async function runNotificationsForTenant(tenantId: string): Promise<{ + alertasNuevas: number; + recordatoriosEnviados: number; +}> { + const tenant = await prisma.tenant.findUnique({ + where: { id: tenantId }, + select: { id: true, nombre: true, rfc: true, active: true, databaseName: true }, + }); + if (!tenant || !tenant.active) { + return { alertasNuevas: 0, recordatoriosEnviados: 0 }; + } + + const pool = await tenantDb.getPool(tenantId, tenant.databaseName); + const ctx = { rfc: tenant.rfc, nombre: tenant.nombre }; + + const [alertasResult, recordResult] = await Promise.all([ + processNewAlertas(pool, tenantId, ctx).catch(err => { + console.error(`[Notifications] Alertas (${tenant.rfc}) fallo:`, err.message || err); + return { contribuyentes: 0, nuevasTotal: 0 }; + }), + processProximosRecordatorios(pool, tenantId, ctx).catch(err => { + console.error(`[Notifications] Recordatorios (${tenant.rfc}) fallo:`, err.message || err); + return { enviados: 0 }; + }), + ]); + + if (alertasResult.nuevasTotal > 0 || recordResult.enviados > 0) { + console.log(`[Notifications] ${tenant.rfc}: ${alertasResult.nuevasTotal} alertas nuevas, ${recordResult.enviados} recordatorios`); + } + + return { + alertasNuevas: alertasResult.nuevasTotal, + recordatoriosEnviados: recordResult.enviados, + }; +} + +/** Itera todos los tenants activos. */ +export async function runNotifications(): Promise<{ + tenants: number; + alertasNuevas: number; + recordatoriosEnviados: number; +}> { + const tenants = await prisma.tenant.findMany({ + where: { active: true }, + select: { id: true, rfc: true }, + }); + + let alertasNuevas = 0; + let recordatoriosEnviados = 0; + for (const t of tenants) { + try { + const r = await runNotificationsForTenant(t.id); + alertasNuevas += r.alertasNuevas; + recordatoriosEnviados += r.recordatoriosEnviados; + } catch (err: any) { + console.error(`[Notifications] Tenant ${t.rfc} fallo completo:`, err.message || err); + } + } + return { tenants: tenants.length, alertasNuevas, recordatoriosEnviados }; +} + +export function startNotificationsJob(): void { + if (task) { + console.warn('[Notifications Cron] Ya iniciado'); + return; + } + task = cron.schedule(SCHEDULE, async () => { + try { + const result = await runNotifications(); + console.log( + `[Notifications Cron] ${result.tenants} tenants — ` + + `${result.alertasNuevas} alertas nuevas, ${result.recordatoriosEnviados} recordatorios`, + ); + } catch (err: any) { + console.error('[Notifications Cron] Error general:', err.message || err); + } + }, { + timezone: 'America/Mexico_City', + }); + console.log(`[Notifications Cron] Programado: ${SCHEDULE} (08:30 AM diario America/Mexico_City)`); +} + +export function stopNotificationsJob(): void { + if (task) { + task.stop(); + task = null; + } +} diff --git a/apps/api/src/jobs/sat-sync.job.ts b/apps/api/src/jobs/sat-sync.job.ts new file mode 100644 index 0000000..441ec2b --- /dev/null +++ b/apps/api/src/jobs/sat-sync.job.ts @@ -0,0 +1,501 @@ +import cron from 'node-cron'; +import { prisma } from '../config/database.js'; +import { startSync, getSyncStatus, retryTimedOutJobs } from '../services/sat/sat.service.js'; +import { sweepStaleSatJobs } from '../services/sat/sweep-stale-jobs.service.js'; +import { hasFielConfigured } from '../services/fiel.service.js'; +import { consultarOpinion, limpiarOpinionesAntiguas } from '../services/opinion-cumplimiento.service.js'; +import { applyPendingChanges, expireTrials, sendExpiryReminders } from '../services/payment/subscription.service.js'; +import { resetExpiredMonthlyTimbres } from '../services/facturapi.service.js'; +import { purgeDeclaracionesAntiguas } from '../services/declaraciones.service.js'; +import { consultarConstancia, purgeConstanciasAntiguas } from '../services/constancia.service.js'; +import { tenantDb } from '../config/database.js'; + +const SYNC_CRON_SCHEDULE = '0 3 * * *'; // 3:00 AM todos los días +const CONCURRENT_SYNCS = 3; // Máximo de sincronizaciones simultáneas +const OPINION_CRON_SCHEDULE = '0 4 * * 0'; // Sundays 4:00 AM +const CSF_CRON_SCHEDULE = '0 4 1 * *'; // Día 1 de cada mes 04:00 AM (CSF mensual) +const INCREMENTAL_CRON_SCHEDULE = '0 11,15,19 * * *'; // 11:00, 15:00 y 19:00; fuera de ese rango el daily (03:00) cubre +const SUBSCRIPTION_LIFECYCLE_CRON = '30 2 * * *'; // 2:30 AM diario — aplica pending changes + expira trials +const EXPIRY_REMINDERS_CRON = '0 9 * * *'; // 9:00 AM diario — avisos pre-vencimiento (7d/3d/1d/0d) + +let isRunning = false; +let isIncrementalRunning = false; + +/** + * Obtiene los tenants que tienen FIEL configurada y activa + */ +async function getTenantsWithFiel(): Promise { + const tenants = await prisma.tenant.findMany({ + where: { active: true }, + select: { id: true }, + }); + + const tenantsWithFiel: string[] = []; + + for (const tenant of tenants) { + const hasFiel = await hasFielConfigured(tenant.id); + if (hasFiel) { + tenantsWithFiel.push(tenant.id); + } + } + + return tenantsWithFiel; +} + +/** + * Verifica si un tenant necesita sincronización inicial + */ +async function needsInitialSync(tenantId: string): Promise { + const completedSync = await prisma.satSyncJob.findFirst({ + where: { + tenantId, + type: 'initial', + status: 'completed', + }, + }); + + return !completedSync; +} + +/** + * Ejecuta sincronización para un tenant + */ +async function syncTenant(tenantId: string): Promise { + try { + // Verificar si hay sync activo + const status = await getSyncStatus(tenantId); + if (status.hasActiveSync) { + console.log(`[SAT Cron] Tenant ${tenantId} ya tiene sync activo, omitiendo`); + return; + } + + // Determinar tipo de sync + const needsInitial = await needsInitialSync(tenantId); + const syncType = needsInitial ? 'initial' : 'daily'; + + console.log(`[SAT Cron] Iniciando sync ${syncType} para tenant ${tenantId}`); + const jobId = await startSync(tenantId, syncType); + console.log(`[SAT Cron] Job ${jobId} iniciado para tenant ${tenantId}`); + } catch (error: any) { + console.error(`[SAT Cron] Error sincronizando tenant ${tenantId}:`, error.message); + } +} + +/** + * Ejecuta el job de sincronización para todos los tenants + */ +async function runSyncJob(): Promise { + if (isRunning) { + console.log('[SAT Cron] Job ya en ejecución, omitiendo'); + return; + } + + isRunning = true; + console.log('[SAT Cron] Iniciando job de sincronización diaria'); + + try { + const tenantIds = await getTenantsWithFiel(); + console.log(`[SAT Cron] ${tenantIds.length} tenants con FIEL configurada`); + + if (tenantIds.length === 0) { + console.log('[SAT Cron] No hay tenants para sincronizar'); + return; + } + + // Procesar en lotes para no saturar + for (let i = 0; i < tenantIds.length; i += CONCURRENT_SYNCS) { + const batch = tenantIds.slice(i, i + CONCURRENT_SYNCS); + await Promise.all(batch.map(syncTenant)); + + // Pequeña pausa entre lotes + if (i + CONCURRENT_SYNCS < tenantIds.length) { + await new Promise(resolve => setTimeout(resolve, 5000)); + } + } + + console.log('[SAT Cron] Job de sincronización completado'); + } catch (error: any) { + console.error('[SAT Cron] Error en job:', error.message); + } finally { + isRunning = false; + } +} + +/** + * Obtiene los tenants activos cuyo plan habilita SAT incremental (3 syncs/día + * adicionales al daily). El flag vive en `despacho_plan_prices.permite_sat_incremental`, + * editable por admin global desde `/configuracion/precios-suscripcion`. + * Default backfill: mi_empresa_plus, business_control, business_cloud. + */ +async function getTenantsConSatIncremental(): Promise { + const planesIncrementales = await prisma.despachoPlanPrice.findMany({ + where: { permiteSatIncremental: true }, + select: { plan: true }, + }); + const planNames = planesIncrementales.map(p => p.plan); + if (planNames.length === 0) return []; + + const tenants = await prisma.tenant.findMany({ + where: { active: true, plan: { in: planNames as any } }, + select: { id: true }, + }); + + const result: string[] = []; + for (const tenant of tenants) { + if (await hasFielConfigured(tenant.id)) { + result.push(tenant.id); + } + } + return result; +} + +/** + * Dispara una sincronización incremental (ventana de 6 horas) para un tenant. + * Si el tenant ya tiene un sync activo, omite para no solapar solicitudes al SAT. + * Si el tenant nunca ha hecho `initial`, omite: el incremental no debe actuar + * como primera descarga — la inicial requiere correrse aparte. + */ +async function incrementalSyncTenant(tenantId: string): Promise { + try { + const status = await getSyncStatus(tenantId); + if (status.hasActiveSync) { + console.log(`[SAT Cron Inc] Tenant ${tenantId} con sync activo, omitiendo`); + return; + } + + const completedInitial = await prisma.satSyncJob.findFirst({ + where: { tenantId, type: 'initial', status: 'completed' }, + }); + if (!completedInitial) { + console.log(`[SAT Cron Inc] Tenant ${tenantId} sin sync inicial completado, omitiendo incremental`); + return; + } + + console.log(`[SAT Cron Inc] Iniciando incremental para tenant ${tenantId}`); + const jobId = await startSync(tenantId, 'incremental'); + console.log(`[SAT Cron Inc] Job ${jobId} iniciado`); + } catch (error: any) { + console.error(`[SAT Cron Inc] Error para tenant ${tenantId}:`, error.message); + } +} + +/** + * Ejecuta el job incremental para todos los tenants cuyo plan habilita SAT + * incremental (Mi Empresa +, Business Control, Enterprise por default; + * configurable desde admin via despacho_plan_prices.permite_sat_incremental). + */ +async function runIncrementalSyncJob(): Promise { + if (isIncrementalRunning) { + console.log('[SAT Cron Inc] Job ya en ejecución, omitiendo'); + return; + } + + isIncrementalRunning = true; + console.log('[SAT Cron Inc] Iniciando ciclo incremental'); + + try { + const tenantIds = await getTenantsConSatIncremental(); + console.log(`[SAT Cron Inc] ${tenantIds.length} tenants con incremental habilitado y FIEL`); + + if (tenantIds.length === 0) return; + + for (let i = 0; i < tenantIds.length; i += CONCURRENT_SYNCS) { + const batch = tenantIds.slice(i, i + CONCURRENT_SYNCS); + await Promise.all(batch.map(incrementalSyncTenant)); + + if (i + CONCURRENT_SYNCS < tenantIds.length) { + await new Promise(resolve => setTimeout(resolve, 5000)); + } + } + + console.log('[SAT Cron Inc] Ciclo incremental completado'); + } catch (error: any) { + console.error('[SAT Cron Inc] Error en ciclo:', error.message); + } finally { + isIncrementalRunning = false; + } +} + +async function runOpinionJob(): Promise { + console.log('[Opinion Cron] Iniciando descarga semanal de Opinión de Cumplimiento'); + + const tenants = await prisma.tenant.findMany({ + where: { active: true }, + select: { id: true, rfc: true, databaseName: true }, + }); + + let success = 0; + let failed = 0; + let skipped = 0; + + for (const tenant of tenants) { + const hasFiel = await hasFielConfigured(tenant.id); + if (!hasFiel) { + skipped++; + continue; + } + + try { + console.log(`[Opinion Cron] Consultando opinión para ${tenant.rfc}...`); + await consultarOpinion(tenant.id); + success++; + + // Cleanup old records + const pool = await tenantDb.getPool(tenant.id, tenant.databaseName); + const deleted = await limpiarOpinionesAntiguas(pool); + if (deleted > 0) { + console.log(`[Opinion Cron] ${tenant.rfc}: ${deleted} opiniones antiguas eliminadas`); + } + } catch (error: any) { + console.error(`[Opinion Cron] Error para ${tenant.rfc}:`, error.message); + failed++; + } + } + + console.log(`[Opinion Cron] Completado — éxito: ${success}, fallidos: ${failed}, sin FIEL: ${skipped}`); +} + +async function runCsfJob(): Promise { + console.log('[CSF Cron] Iniciando descarga mensual de Constancia de Situación Fiscal'); + + const tenants = await prisma.tenant.findMany({ + where: { active: true }, + select: { id: true, rfc: true }, + }); + + let success = 0; + let failed = 0; + let skipped = 0; + + for (const tenant of tenants) { + const hasFiel = await hasFielConfigured(tenant.id); + if (!hasFiel) { skipped++; continue; } + try { + console.log(`[CSF Cron] Consultando CSF para ${tenant.rfc}...`); + await consultarConstancia(tenant.id); + success++; + } catch (error: any) { + console.error(`[CSF Cron] Error para ${tenant.rfc}:`, error.message); + failed++; + } + } + console.log(`[CSF Cron] Completado — éxito: ${success}, fallidos: ${failed}, sin FIEL: ${skipped}`); +} + +let scheduledTask: ReturnType | null = null; +let retryTask: ReturnType | null = null; +let opinionTask: ReturnType | null = null; +let csfTask: ReturnType | null = null; +let incrementalTask: ReturnType | null = null; +let subscriptionTask: ReturnType | null = null; +let expiryRemindersTask: ReturnType | null = null; +let watchdogTask: ReturnType | null = null; + +const RETRY_CRON_SCHEDULE = '0 * * * *'; // Cada hora +const WATCHDOG_CRON_SCHEDULE = '0 */2 * * *'; // Cada 2 horas — marca stale jobs como failed + +/** + * Inicia el job programado + */ +export function startSatSyncJob(): void { + if (scheduledTask) { + console.log('[SAT Cron] Job ya está programado'); + return; + } + + // Validar expresión cron + if (!cron.validate(SYNC_CRON_SCHEDULE)) { + console.error('[SAT Cron] Expresión cron inválida:', SYNC_CRON_SCHEDULE); + return; + } + + scheduledTask = cron.schedule(SYNC_CRON_SCHEDULE, runSyncJob, { + timezone: 'America/Mexico_City', + }); + + // Cron de reintentos: cada hora revisa si hay jobs pendientes de retry + retryTask = cron.schedule(RETRY_CRON_SCHEDULE, async () => { + try { + await retryTimedOutJobs(); + } catch (error: any) { + console.error('[SAT Retry Cron] Error:', error.message); + } + }, { + timezone: 'America/Mexico_City', + }); + + // Cron watchdog: cada 2h marca como `failed` los jobs que quedaron stale + // (pending con nextRetryAt > 12h atrás, running con startedAt > 4h atrás). + // Thresholds sobreescribibles vía env (STALE_PENDING_HOURS / STALE_RUNNING_HOURS) + // — defaults razonables pensando en que un sync inicial típico termina + // en <2h y el retryCron corre cada hora. + watchdogTask = cron.schedule(WATCHDOG_CRON_SCHEDULE, async () => { + try { + const pendingHours = Number(process.env.STALE_PENDING_HOURS || 12); + const runningHours = Number(process.env.STALE_RUNNING_HOURS || 4); + const result = await sweepStaleSatJobs({ apply: true, pendingHours, runningHours }); + if (result.pendingMarked + result.runningMarked > 0) { + console.log(`[SAT Watchdog] Marcados failed: pending=${result.pendingMarked} running=${result.runningMarked}`); + } + } catch (error: any) { + console.error('[SAT Watchdog] Error:', error.message); + } + }, { + timezone: 'America/Mexico_City', + }); + + opinionTask = cron.schedule(OPINION_CRON_SCHEDULE, async () => { + try { + await runOpinionJob(); + } catch (error: any) { + console.error('[Opinion Cron] Error:', error.message); + } + }, { + timezone: 'America/Mexico_City', + }); + + csfTask = cron.schedule(CSF_CRON_SCHEDULE, async () => { + try { + await runCsfJob(); + } catch (error: any) { + console.error('[CSF Cron] Error:', error.message); + } + }, { + timezone: 'America/Mexico_City', + }); + + incrementalTask = cron.schedule(INCREMENTAL_CRON_SCHEDULE, async () => { + try { + await runIncrementalSyncJob(); + } catch (error: any) { + console.error('[SAT Cron Inc] Error:', error.message); + } + }, { + timezone: 'America/Mexico_City', + }); + + subscriptionTask = cron.schedule(SUBSCRIPTION_LIFECYCLE_CRON, async () => { + try { + const pending = await applyPendingChanges(); + const trials = await expireTrials(); + // Reset mensual de TimbreSuscripcion: para cada tenant cuyo periodoFin + // ya pasó, resetea usados=0 y avanza la ventana +1 mes/año. Los paquetes + // adicionales NO se tocan; su expiraEn = adquiridoEn + 1 año fijo. + const timbres = await resetExpiredMonthlyTimbres(); + + // Cleanup retención 5 años (CFF Art. 30): borra declaraciones provisionales + // viejas. Iteramos cada tenant activo. Por-tenant try/catch para que un + // tenant que falla no bloquee al resto. + let declsBorradas = 0; + let csfsBorradas = 0; + const tenants = await prisma.tenant.findMany({ + where: { active: true }, + select: { id: true, databaseName: true, rfc: true }, + }); + for (const t of tenants) { + try { + const pool = await tenantDb.getPool(t.id, t.databaseName); + const declResult = await purgeDeclaracionesAntiguas(pool); + declsBorradas += declResult.deleted; + const csfResult = await purgeConstanciasAntiguas(pool); + csfsBorradas += csfResult.deleted; + } catch (err: any) { + console.error(`[Cleanup] Tenant ${t.rfc} fallo en purge:`, err.message || err); + } + } + + console.log(`[Subscription Cron] pending aplicados: ${pending.applied} (${pending.errors} errores), trials expirados: ${trials.expired}, timbres reseteados: ${timbres.reset}, declaraciones >5 años borradas: ${declsBorradas}, CSFs >5 años borradas: ${csfsBorradas}`); + } catch (error: any) { + console.error('[Subscription Cron] Error:', error.message); + } + }, { + timezone: 'America/Mexico_City', + }); + + // Cron 9:00 AM diario — emails pre-vencimiento (7d/3d/1d) y aviso 0d post-vencimiento. + // Idempotente vía `Subscription.lastReminderDay`. + expiryRemindersTask = cron.schedule(EXPIRY_REMINDERS_CRON, async () => { + try { + const result = await sendExpiryReminders(); + if (result.sent > 0 || result.errors > 0) { + console.log(`[Expiry Reminders] enviados: ${result.sent}, reset por renovación: ${result.resetOnly}, skipped: ${result.skipped}, errores: ${result.errors}`); + } + } catch (error: any) { + console.error('[Expiry Reminders Cron] Error:', error.message); + } + }, { + timezone: 'America/Mexico_City', + }); + + console.log(`[SAT Cron] Job programado para: ${SYNC_CRON_SCHEDULE} (America/Mexico_City)`); + console.log(`[SAT Cron] Retry programado cada hora`); + console.log(`[Opinion Cron] Programado para: ${OPINION_CRON_SCHEDULE} (America/Mexico_City)`); + console.log(`[CSF Cron] Programado para: ${CSF_CRON_SCHEDULE} (America/Mexico_City)`); + console.log(`[SAT Cron Inc] Incremental Enterprise programado para: ${INCREMENTAL_CRON_SCHEDULE} (America/Mexico_City)`); + console.log(`[Subscription Cron] Lifecycle programado para: ${SUBSCRIPTION_LIFECYCLE_CRON} (America/Mexico_City)`); + console.log(`[SAT Watchdog] Programado para: ${WATCHDOG_CRON_SCHEDULE} (America/Mexico_City)`); +} + +/** + * Detiene el job programado + */ +export function stopSatSyncJob(): void { + if (scheduledTask) { + scheduledTask.stop(); + scheduledTask = null; + } + if (retryTask) { + retryTask.stop(); + retryTask = null; + } + if (opinionTask) { + opinionTask.stop(); + opinionTask = null; + } + if (csfTask) { + csfTask.stop(); + csfTask = null; + } + if (incrementalTask) { + incrementalTask.stop(); + incrementalTask = null; + } + if (subscriptionTask) { + subscriptionTask.stop(); + subscriptionTask = null; + } + if (expiryRemindersTask) { + expiryRemindersTask.stop(); + expiryRemindersTask = null; + } + if (watchdogTask) { + watchdogTask.stop(); + watchdogTask = null; + } + console.log('[SAT Cron] Jobs detenidos'); +} + +/** + * Ejecuta manualmente el ciclo incremental Enterprise (para testing). + */ +export async function runIncrementalSyncJobManually(): Promise { + await runIncrementalSyncJob(); +} + +/** + * Ejecuta el job manualmente (para testing o ejecución forzada) + */ +export async function runSatSyncJobManually(): Promise { + await runSyncJob(); +} + +/** + * Obtiene información del próximo job programado + */ +export function getJobInfo(): { scheduled: boolean; expression: string; timezone: string } { + return { + scheduled: scheduledTask !== null, + expression: SYNC_CRON_SCHEDULE, + timezone: 'America/Mexico_City', + }; +} diff --git a/apps/api/src/jobs/weekly-update.job.ts b/apps/api/src/jobs/weekly-update.job.ts new file mode 100644 index 0000000..6365d1c --- /dev/null +++ b/apps/api/src/jobs/weekly-update.job.ts @@ -0,0 +1,154 @@ +/** + * Cron Lunes 8:00 AM (America/Mexico_City) que envía a cada owner activo de + * cada tenant un correo "Actualización semanal" con KPIs del mes en curso + + * alertas automáticas + breakdown mensual de discrepancias de régimen. + * + * Manejo de errores: por-tenant try/catch — si falla uno, los demás siguen. + * Si SMTP no está configurado el email service logea a consola (dev), así + * que el job es seguro de correr en cualquier ambiente. + */ +import cron from 'node-cron'; +import { prisma } from '../config/database.js'; +import { tenantDb } from '../config/database.js'; +import { getKpis } from '../services/dashboard.service.js'; +import { generarAlertasAutomaticas, getDiscrepanciasPorMes } from '../services/alertas-auto.service.js'; +import { emailService } from '../services/email/email.service.js'; + +const SCHEDULE = '0 8 * * 1'; // Lunes 8:00 AM + +let task: ReturnType | null = null; + +function currentMonthRange(now = new Date()): { fechaInicio: string; fechaFin: string; periodoLabel: string } { + const año = now.getFullYear(); + const mes = now.getMonth(); + const inicio = new Date(año, mes, 1); + const fin = new Date(año, mes + 1, 0); + const NOMBRES = ['Enero','Febrero','Marzo','Abril','Mayo','Junio','Julio','Agosto','Septiembre','Octubre','Noviembre','Diciembre']; + return { + fechaInicio: inicio.toISOString().split('T')[0], + fechaFin: fin.toISOString().split('T')[0], + periodoLabel: `${NOMBRES[mes]} ${año}`, + }; +} + +/** + * Genera y envía el correo para UN tenant. Exportado para que pueda llamarse + * manualmente desde un endpoint admin (p.ej. "Enviar reporte ahora"). + */ +export async function sendWeeklyUpdateForTenant(tenantId: string): Promise<{ sent: number }> { + const tenant = await prisma.tenant.findUnique({ + where: { id: tenantId }, + select: { id: true, nombre: true, rfc: true, active: true, databaseName: true }, + }); + if (!tenant || !tenant.active) { + console.log(`[Weekly] Tenant ${tenantId} no encontrado o inactivo, skip`); + return { sent: 0 }; + } + + // Recipientes: owners activos del tenant + const owners = await prisma.tenantMembership.findMany({ + where: { tenantId, isOwner: true, active: true }, + include: { user: { select: { email: true, nombre: true, active: true } } }, + }); + const recipients = owners.filter(o => o.user.active); + if (recipients.length === 0) { + console.log(`[Weekly] Tenant ${tenant.rfc} sin owners activos, skip`); + return { sent: 0 }; + } + + // Pool del tenant para queries de CFDI + const pool = await tenantDb.getPool(tenantId, tenant.databaseName); + + const { fechaInicio, fechaFin, periodoLabel } = currentMonthRange(); + + // Ejecuta los 3 colectores en paralelo + const [kpis, alertas, discrepanciasPorMes] = await Promise.all([ + getKpis(pool, fechaInicio, fechaFin, tenant.id, false), + generarAlertasAutomaticas(pool, tenant.id), + getDiscrepanciasPorMes(pool, tenant.id, 6), + ]); + + const fechaGeneracion = new Date().toLocaleString('es-MX', { + dateStyle: 'long', + timeStyle: 'short', + timeZone: 'America/Mexico_City', + }); + + let sent = 0; + for (const r of recipients) { + try { + await emailService.sendWeeklyUpdate(r.user.email, { + nombre: r.user.nombre, + empresa: tenant.nombre, + periodoLabel, + kpis: { + ingresos: kpis.ingresos, + egresos: kpis.egresos, + utilidad: kpis.utilidad, + margen: kpis.margen, + ivaBalance: kpis.ivaBalance, + ivaAFavorAcumulado: kpis.ivaAFavorAcumulado, + cfdisEmitidos: kpis.cfdisEmitidos, + cfdisRecibidos: kpis.cfdisRecibidos, + }, + alertas: alertas.map(a => ({ titulo: a.titulo, mensaje: a.mensaje, prioridad: a.prioridad })), + discrepanciasPorMes: discrepanciasPorMes.map(d => ({ label: d.label, count: d.count })), + fechaGeneracion, + }); + sent++; + } catch (err: any) { + console.error(`[Weekly] Error enviando a ${r.user.email} (tenant ${tenant.rfc}):`, err.message || err); + } + } + + console.log(`[Weekly] Tenant ${tenant.rfc}: ${sent}/${recipients.length} correos enviados`); + return { sent }; +} + +/** + * Itera todos los tenants activos y dispara `sendWeeklyUpdateForTenant`. + * Por-tenant try/catch para que un fallo no bloquee al resto. + */ +export async function runWeeklyUpdate(): Promise<{ tenants: number; emails: number }> { + const tenants = await prisma.tenant.findMany({ + where: { active: true }, + select: { id: true, rfc: true }, + }); + + let totalEmails = 0; + for (const t of tenants) { + try { + const { sent } = await sendWeeklyUpdateForTenant(t.id); + totalEmails += sent; + } catch (err: any) { + console.error(`[Weekly] Tenant ${t.rfc} fallo completo:`, err.message || err); + } + } + + return { tenants: tenants.length, emails: totalEmails }; +} + +export function startWeeklyUpdateJob(): void { + if (task) { + console.warn('[Weekly Cron] Ya iniciado'); + return; + } + task = cron.schedule(SCHEDULE, async () => { + try { + const result = await runWeeklyUpdate(); + console.log(`[Weekly Cron] Reporte enviado: ${result.emails} correos a ${result.tenants} tenants`); + } catch (err: any) { + console.error('[Weekly Cron] Error general:', err.message || err); + } + }, { + timezone: 'America/Mexico_City', + }); + console.log(`[Weekly Cron] Programado: ${SCHEDULE} (Lunes 8:00 AM America/Mexico_City)`); +} + +export function stopWeeklyUpdateJob(): void { + if (task) { + task.stop(); + task = null; + } +} diff --git a/apps/api/src/middlewares/auth.middleware.ts b/apps/api/src/middlewares/auth.middleware.ts new file mode 100644 index 0000000..79c7348 --- /dev/null +++ b/apps/api/src/middlewares/auth.middleware.ts @@ -0,0 +1,112 @@ +import type { Request, Response, NextFunction } from 'express'; +import { verifyToken } from '../auth/tokens.js'; +import { AppError } from './error.middleware.js'; +import { prisma } from '../config/database.js'; +import type { JWTPayload, Role } from '@horux/shared'; + +declare global { + namespace Express { + interface Request { + user?: JWTPayload; + } + } +} + +/** + * Cache de `tokenVersion` por userId con TTL 30s. Evita hit a BD en cada + * request autenticada. Al incrementar `tokenVersion` (password change, + * logout-all), se llama `invalidateTokenVersionCache(userId)` que borra la + * entrada y broadcast entre workers PM2. + */ +const tokenVersionCache = new Map(); +const TOKEN_VERSION_TTL_MS = 30 * 1000; + +export function invalidateTokenVersionCache(userId: string) { + tokenVersionCache.delete(userId); + if (typeof process.send === 'function') { + process.send({ type: 'invalidate-token-version', userId }); + } +} + +// Escucha broadcasts entre workers PM2 cluster +if (typeof process.on === 'function') { + process.on('message', (msg: any) => { + if (msg && msg.type === 'invalidate-token-version' && typeof msg.userId === 'string') { + tokenVersionCache.delete(msg.userId); + } + }); +} + +async function getCurrentTokenVersion(userId: string): Promise { + const cached = tokenVersionCache.get(userId); + if (cached && cached.expires > Date.now()) return cached.version; + + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { tokenVersion: true, active: true }, + }); + if (!user || !user.active) return null; // User borrado o desactivado → rechaza + + tokenVersionCache.set(userId, { + version: user.tokenVersion, + expires: Date.now() + TOKEN_VERSION_TTL_MS, + }); + return user.tokenVersion; +} + +export async function authenticate(req: Request, res: Response, next: NextFunction) { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return next(new AppError(401, 'Token no proporcionado')); + } + + const token = authHeader.split(' ')[1]; + + let payload: JWTPayload; + try { + payload = verifyToken(token); + } catch (error) { + return next(new AppError(401, 'Token inválido o expirado')); + } + + // Check tokenVersion contra BD. JWT con versión menor a la actual del user + // quedan rechazados (forzando re-login tras password change / logout-all). + const currentVersion = await getCurrentTokenVersion(payload.userId); + if (currentVersion === null) { + return next(new AppError(401, 'Sesión inválida. Vuelve a iniciar sesión.')); + } + const jwtVersion = payload.tokenVersion ?? 0; + if (jwtVersion !== currentVersion) { + return next(new AppError(401, 'Sesión expirada. Por seguridad, inicia sesión de nuevo.')); + } + + req.user = payload; + next(); +} + +// Roles superset de plataforma: bypassean cualquier `authorize(...)` porque +// son staff transversal de Horux 360. Coincide con `SUPERSET_ROLES` en +// utils/platform-admin.ts (mantener sincronizado). +const PLATFORM_SUPERSET = new Set(['platform_admin', 'platform_ti']); + +export function authorize(...roles: Role[]) { + return (req: Request, res: Response, next: NextFunction) => { + if (!req.user) { + return next(new AppError(401, 'No autenticado')); + } + + // Platform admin/TI bypass: son superrol y deben poder hitear cualquier + // endpoint protegido por rol de tenant. (Documentado en CLAUDE.md → + // "platform_admin y platform_ti son supersets — implican todos los demás".) + const platformRoles = req.user.platformRoles || []; + const hasSuperset = platformRoles.some(r => PLATFORM_SUPERSET.has(r)); + if (hasSuperset) return next(); + + if (roles.length > 0 && !roles.includes(req.user.role)) { + return next(new AppError(403, 'No autorizado')); + } + + next(); + }; +} diff --git a/apps/api/src/middlewares/error.middleware.ts b/apps/api/src/middlewares/error.middleware.ts new file mode 100644 index 0000000..9ec2382 --- /dev/null +++ b/apps/api/src/middlewares/error.middleware.ts @@ -0,0 +1,33 @@ +import type { Request, Response, NextFunction } from 'express'; + +export class AppError extends Error { + constructor( + public statusCode: number, + public message: string, + public isOperational = true + ) { + super(message); + Object.setPrototypeOf(this, AppError.prototype); + } +} + +export function errorMiddleware( + err: Error, + req: Request, + res: Response, + next: NextFunction +) { + if (err instanceof AppError) { + return res.status(err.statusCode).json({ + status: 'error', + message: err.message, + }); + } + + console.error('Unhandled error:', err); + + return res.status(500).json({ + status: 'error', + message: 'Internal server error', + }); +} diff --git a/apps/api/src/middlewares/feature-gate.middleware.ts b/apps/api/src/middlewares/feature-gate.middleware.ts new file mode 100644 index 0000000..487f29c --- /dev/null +++ b/apps/api/src/middlewares/feature-gate.middleware.ts @@ -0,0 +1,41 @@ +import type { Request, Response, NextFunction } from 'express'; +import { hasDespachoFeature, type DespachoPlan } from '@horux/shared'; +import { prisma } from '../config/database.js'; + +const planCache = new Map(); + +/** + * Middleware factory that gates routes based on tenant plan features. + * Usage: requireFeature('reportes') — blocks access if tenant's plan lacks the feature. + * + * Tras eliminar Horux 360 legacy, todos los planes son del catálogo despacho. + * Un plan desconocido (no en DESPACHO_PLANS) hace que `hasDespachoFeature` + * retorne false → 403, lo cual es el comportamiento defensivo correcto. + */ +export function requireFeature(feature: string) { + return async (req: Request, res: Response, next: NextFunction) => { + if (!req.user) return res.status(401).json({ message: 'No autenticado' }); + + let plan: string; + const cached = planCache.get(req.user.tenantId); + if (cached && cached.expires > Date.now()) { + plan = cached.plan; + } else { + const tenant = await prisma.tenant.findUnique({ + where: { id: req.user.tenantId }, + select: { plan: true }, + }); + if (!tenant) return res.status(404).json({ message: 'Tenant no encontrado' }); + plan = tenant.plan; + planCache.set(req.user.tenantId, { plan, expires: Date.now() + 5 * 60 * 1000 }); + } + + if (!hasDespachoFeature(plan as DespachoPlan, feature)) { + return res.status(403).json({ + message: 'Tu plan no incluye esta función. Contacta soporte para upgrade.', + }); + } + + next(); + }; +} diff --git a/apps/api/src/middlewares/plan-limits.middleware.ts b/apps/api/src/middlewares/plan-limits.middleware.ts new file mode 100644 index 0000000..efe5703 --- /dev/null +++ b/apps/api/src/middlewares/plan-limits.middleware.ts @@ -0,0 +1,81 @@ +import type { Request, Response, NextFunction } from 'express'; +import { prisma } from '../config/database.js'; +import { isGlobalAdmin } from '../utils/global-admin.js'; +import { getSubscriptionState } from '@horux/shared'; + +// Simple in-memory cache with TTL +const cache = new Map(); + +async function getCached(key: string, ttlMs: number, fetcher: () => Promise): Promise { + const entry = cache.get(key); + if (entry && entry.expires > Date.now()) return entry.data; + const data = await fetcher(); + cache.set(key, { data, expires: Date.now() + ttlMs }); + return data; +} + +export function invalidateTenantCache(tenantId: string) { + for (const key of cache.keys()) { + if (key.includes(tenantId)) cache.delete(key); + } +} + +/** + * Verifica que el tenant tenga una suscripción que permita escritura. + * GETs siempre pasan (modo lectura preserva acceso histórico para auditorías y export). + * + * La autoridad de "puede escribir" está en `getSubscriptionState().needsRenewal` + * (helper compartido entre frontend y backend) — combina `status` + `currentPeriodEnd`, + * por eso un trial cuyo período ya pasó pero el cron de las 02:30 AM aún no lo ha + * marcado `trial_expired` queda bloqueado igual. + */ +export async function checkPlanLimits(req: Request, res: Response, next: NextFunction) { + if (!req.user) return next(); + + // Allow GET requests siempre — modo lectura habilitado incluso sin suscripción + // activa (preserva acceso a dashboard/CFDIs históricos para auditorías). + if (req.method === 'GET') return next(); + + // Global admin impersonation bypasses subscription check + if (req.headers['x-view-tenant'] && await isGlobalAdmin(req.user.tenantId, req.user.role)) { + return next(); + } + + const subscription = await getCached( + `sub:${req.user.tenantId}`, + 5 * 60 * 1000, + () => prisma.subscription.findFirst({ + where: { tenantId: req.user!.tenantId }, + orderBy: { createdAt: 'desc' }, + }) + ); + + const state = getSubscriptionState(subscription); + + if (state.needsRenewal) { + return res.status(403).json({ + code: 'SUBSCRIPTION_INACTIVE', + message: state.isTrialExpired + ? 'Tu prueba gratuita terminó. Renueva tu plan para continuar.' + : state.isCancelledExpired + ? 'Tu suscripción venció. Renueva tu plan para continuar.' + : 'No tienes una suscripción activa. Contrata un plan para continuar.', + redirectTo: '/configuracion/planes-despacho', + subscriptionStatus: state.status, + }); + } + + next(); +} + +/** + * No-op tras eliminar el catálogo Horux 360 legacy. En el modelo despacho el + * límite de CFDIs es por contribuyente (`DESPACHO_PLANS[plan].maxCfdisPorContribuyente`, + * 1M-3M), no por tenant — tan alto que no tiene sentido enforced en cada + * insert. Si en el futuro se quiere gating real por contribuyente, debe + * leerse del catálogo despacho con conocimiento del contribuyente_id que + * está creando el CFDI (no disponible en este middleware tenant-level). + */ +export async function checkCfdiLimit(_req: Request, _res: Response, next: NextFunction) { + return next(); +} diff --git a/apps/api/src/middlewares/rate-limit.middleware.ts b/apps/api/src/middlewares/rate-limit.middleware.ts new file mode 100644 index 0000000..1a47616 --- /dev/null +++ b/apps/api/src/middlewares/rate-limit.middleware.ts @@ -0,0 +1,92 @@ +import rateLimit, { ipKeyGenerator, type Options } from 'express-rate-limit'; +import type { Request } from 'express'; +import { hasPlatformRole } from '../utils/platform-admin.js'; + +/** + * Rate limiting por endpoint con 4 tiers según sensibilidad / costo computacional. + * + * Todas las keys se generan por `userId` (no IP) — usuarios legítimos detrás de NAT + * compartido (ej: oficina con 20 contadores) no se bloquean entre sí. + * + * Admin global (tenant dueño de la plataforma o platform_admin/platform_ti) está exento: + * necesita hacer operaciones masivas ocasionalmente (backfill, corrección manual, etc). + */ + +const keyByUser = (req: Request): string => { + // User autenticado → rate-limit por userId. Anónimo → por IP normalizada con + // ipKeyGenerator (maneja IPv6 correctamente). Sin esto, express-rate-limit + // emite warning de potential bypass con IPv6 mal truncado. + if (req.user?.userId) return req.user.userId; + return ipKeyGenerator(req.ip || 'anonymous'); +}; + +const skipForGlobalAdmin = async (req: Request): Promise => { + if (!req.user?.userId) return false; + try { + // hasPlatformRole(..., 'platform_admin') retorna true para superset (admin o TI). + // Otros platform roles (support/sales/finance) sí respetan rate limits. + return await hasPlatformRole(req.user.userId, 'platform_admin'); + } catch { + return false; + } +}; + +const baseConfig: Partial = { + keyGenerator: keyByUser, + standardHeaders: true, + legacyHeaders: false, + skip: skipForGlobalAdmin, +}; + +/** + * Tier más estricto — 2 requests por día. + * Para operaciones que disparan syncs largos con el SAT o cómputo muy pesado + * (Playwright headless contra portal SAT). + */ +export const veryStrictLimit = rateLimit({ + ...baseConfig, + windowMs: 24 * 60 * 60 * 1000, + max: 2, + message: { + message: 'Has alcanzado el límite de esta operación (2 por día). Intenta mañana o contacta soporte si es urgente.', + }, +}); + +/** + * Tier estricto — 10 requests por hora. + * Operaciones costosas o con side-effects en terceros (Facturapi, MP). + */ +export const strictLimit = rateLimit({ + ...baseConfig, + windowMs: 60 * 60 * 1000, + max: 10, + message: { + message: 'Demasiadas solicitudes en esta operación. Intenta de nuevo en una hora.', + }, +}); + +/** + * Tier normal — 100 requests por 15 min. + * APIs de negocio (dashboard, cfdi list, reportes, cálculos fiscales). + */ +export const normalLimit = rateLimit({ + ...baseConfig, + windowMs: 15 * 60 * 1000, + max: 100, + message: { + message: 'Demasiadas solicitudes. Espera unos minutos e intenta de nuevo.', + }, +}); + +/** + * Tier relajado — 500 requests por 15 min. + * Endpoints de lectura barata (catálogos SAT, listas fijas). + */ +export const relaxedLimit = rateLimit({ + ...baseConfig, + windowMs: 15 * 60 * 1000, + max: 500, + message: { + message: 'Demasiadas solicitudes. Espera unos minutos e intenta de nuevo.', + }, +}); diff --git a/apps/api/src/middlewares/tenant.middleware.ts b/apps/api/src/middlewares/tenant.middleware.ts new file mode 100644 index 0000000..8ed5c81 --- /dev/null +++ b/apps/api/src/middlewares/tenant.middleware.ts @@ -0,0 +1,144 @@ +import type { Request, Response, NextFunction } from 'express'; +import type { Pool } from 'pg'; +import { prisma, tenantDb } from '../config/database.js'; +import { isGlobalAdmin } from '../utils/global-admin.js'; +import { decryptAesGcm, deriveAesKey } from '@horux/core'; +import { env } from '../config/env.js'; + +declare global { + namespace Express { + interface Request { + tenantPool?: Pool; + viewingTenantId?: string; + } + } +} + +// Cache: tenantId -> { databaseName, expires } +// Only used for MANAGED tenants. BYO tenants always query Prisma so connection info stays fresh. +const tenantDbCache = new Map(); +const CACHE_TTL = 5 * 60 * 1000; // 5 minutes + +type ConnectionOverride = { host: string; port: number; user: string; password: string }; + +/** + * Decrypt the BYO connection string stored in the central DB. + * Returns a connection override object, or null if decryption fails. + */ +async function resolveBYOConnection( + dbConnectionEnc: string, + dbConnectionIv: string, + tenantId: string, +): Promise { + try { + const encKey = env.CONNECTOR_ENCRYPTION_KEY + ? deriveAesKey(env.CONNECTOR_ENCRYPTION_KEY) + : deriveAesKey(env.FIEL_ENCRYPTION_KEY); + + const encData = Buffer.from(dbConnectionEnc, 'base64'); + const iv = Buffer.from(dbConnectionIv, 'base64'); + // AES-GCM: last 16 bytes of the ciphertext blob are the auth tag + const authTag = encData.subarray(encData.length - 16); + const ciphertext = encData.subarray(0, encData.length - 16); + + const decrypted = decryptAesGcm(ciphertext, iv, authTag, encKey); + const config = JSON.parse(decrypted.toString('utf-8')); + + return { + host: config.host, + port: config.port ?? 5432, + user: config.user, + password: config.password, + }; + } catch (err) { + console.error(`[TenantMiddleware] BYO decrypt failed for tenant ${tenantId}:`, err); + return null; + } +} + +export function invalidateTenantDbCache(tenantId: string) { + tenantDbCache.delete(tenantId); +} + +export async function tenantMiddleware(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + return res.status(401).json({ message: 'No autenticado' }); + } + + let tenantId = req.user.tenantId; + + // Admin impersonation via X-View-Tenant header (global admin only) + const viewTenantHeader = req.headers['x-view-tenant'] as string; + if (viewTenantHeader) { + const globalAdmin = await isGlobalAdmin(req.user.tenantId, req.user.role); + if (!globalAdmin) { + return res.status(403).json({ message: 'No autorizado para ver otros tenants' }); + } + + const viewedTenant = await prisma.tenant.findFirst({ + where: { + OR: [ + { id: viewTenantHeader }, + { rfc: viewTenantHeader }, + ], + }, + select: { id: true, databaseName: true, active: true, dbMode: true, dbConnectionEnc: true, dbConnectionIv: true }, + }); + + if (!viewedTenant) { + return res.status(404).json({ message: 'Tenant no encontrado' }); + } + + if (!viewedTenant.active) { + return res.status(403).json({ message: 'Tenant inactivo' }); + } + + tenantId = viewedTenant.id; + req.viewingTenantId = viewedTenant.id; + + let impersonateOverride: ConnectionOverride | undefined; + if (viewedTenant.dbMode === 'BYO' && viewedTenant.dbConnectionEnc && viewedTenant.dbConnectionIv) { + const override = await resolveBYOConnection(viewedTenant.dbConnectionEnc, viewedTenant.dbConnectionIv, tenantId); + if (!override) { + return res.status(503).json({ message: 'Base de datos del despacho no disponible. Verifica tu connector.' }); + } + impersonateOverride = override; + } + + req.tenantPool = await tenantDb.getPool(tenantId, viewedTenant.databaseName, impersonateOverride); + return next(); + } + + // Normal flow: query tenant details including BYO fields. + // BYO tenants bypass the databaseName cache so connection info stays fresh. + // The 5-min idle eviction in TenantConnectionManager still protects against excessive pool creation. + const tenant = await prisma.tenant.findUnique({ + where: { id: tenantId }, + select: { databaseName: true, dbMode: true, dbConnectionEnc: true, dbConnectionIv: true }, + }); + + if (!tenant?.databaseName) { + return res.status(404).json({ message: 'Tenant no encontrado' }); + } + + let connectionOverride: ConnectionOverride | undefined; + + if (tenant.dbMode === 'BYO' && tenant.dbConnectionEnc && tenant.dbConnectionIv) { + const override = await resolveBYOConnection(tenant.dbConnectionEnc, tenant.dbConnectionIv, tenantId); + if (!override) { + return res.status(503).json({ message: 'Base de datos del despacho no disponible. Verifica tu connector.' }); + } + connectionOverride = override; + } else { + // MANAGED: keep the databaseName cache warm to avoid repeated Prisma queries + tenantDbCache.set(tenantId, { databaseName: tenant.databaseName, expires: Date.now() + CACHE_TTL }); + } + + req.tenantPool = await tenantDb.getPool(tenantId, tenant.databaseName, connectionOverride); + next(); + } catch (error) { + console.error('[TenantMiddleware] Error:', error); + return res.status(500).json({ message: 'Error al resolver tenant' }); + } +} diff --git a/apps/api/src/migrations/tenant/001_initial_schema.sql b/apps/api/src/migrations/tenant/001_initial_schema.sql new file mode 100644 index 0000000..76cde40 --- /dev/null +++ b/apps/api/src/migrations/tenant/001_initial_schema.sql @@ -0,0 +1,244 @@ +-- Migration: 001_initial_schema +-- Description: Full initial DDL for tenant databases (horux_) +-- Created: 2026-04-13 +-- Tables: rfcs, bancos, cfdis, cfdi_conceptos, conciliaciones, alertas, recordatorios + +-- Extensions +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +-- Tables + +CREATE TABLE IF NOT EXISTS rfcs ( + id SERIAL PRIMARY KEY, + rfc VARCHAR(14) UNIQUE NOT NULL, + razon_social VARCHAR(255), + regimen_fiscal VARCHAR(3), + codigo_postal VARCHAR(5) +); + +CREATE TABLE IF NOT EXISTS bancos ( + id SERIAL PRIMARY KEY, + banco VARCHAR(100) NOT NULL, + terminacion_cuenta VARCHAR(4) NOT NULL, + creado_en TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS cfdis ( + id SERIAL PRIMARY KEY, + year VARCHAR(4), + month VARCHAR(2), + type VARCHAR(10), + uuid VARCHAR(36) UNIQUE, + serie VARCHAR(50), + folio VARCHAR(50), + status VARCHAR(20), + fecha_emision TIMESTAMP, + rfc_emisor_id INTEGER REFERENCES rfcs(id), + rfc_emisor VARCHAR(13), + nombre_emisor VARCHAR(255), + rfc_receptor_id INTEGER REFERENCES rfcs(id), + rfc_receptor VARCHAR(13), + nombre_receptor VARCHAR(255), + subtotal NUMERIC(18,4), + subtotal_mxn NUMERIC(18,4), + descuento NUMERIC(18,4), + descuento_mxn NUMERIC(18,4), + total NUMERIC(18,4), + total_mxn NUMERIC(18,4), + saldo_insoluto TEXT, + moneda VARCHAR(3), + tipo_cambio NUMERIC(18,6), + tipo_comprobante VARCHAR(1), + metodo_pago VARCHAR(3), + forma_pago VARCHAR(2), + uso_cfdi VARCHAR(5), + pac VARCHAR(13), + fecha_cert_sat TIMESTAMP, + fecha_cancelacion TIMESTAMP, + uuid_relacionado TEXT, + isr_retencion NUMERIC(18,4), + isr_retencion_mxn NUMERIC(18,4), + iva_traslado NUMERIC(18,4), + iva_traslado_mxn NUMERIC(18,4), + iva_retencion NUMERIC(18,4), + iva_retencion_mxn NUMERIC(18,4), + ieps_traslado NUMERIC(18,4), + ieps_traslado_mxn NUMERIC(18,4), + ieps_retencion NUMERIC(18,4), + ieps_retencion_mxn NUMERIC(18,4), + impuestos_locales_trasladado NUMERIC(18,4), + impuestos_locales_trasladado_mxn NUMERIC(18,4), + impuestos_locales_retenidos NUMERIC(18,4), + impuestos_locales_retenidos_mxn NUMERIC(18,4), + monto_pago NUMERIC(18,4), + monto_pago_mxn NUMERIC(18,4), + fecha_pago_p TIMESTAMP, + num_parcialidad TEXT, + isr_retencion_pago NUMERIC(18,4), + isr_retencion_pago_mxn NUMERIC(18,4), + iva_traslado_pago NUMERIC(18,4), + iva_traslado_pago_mxn NUMERIC(18,4), + iva_retencion_pago NUMERIC(18,4), + iva_retencion_pago_mxn NUMERIC(18,4), + ieps_traslado_pago NUMERIC(18,4), + ieps_traslado_pago_mxn NUMERIC(18,4), + ieps_retencion_pago NUMERIC(18,4), + ieps_retencion_pago_mxn NUMERIC(18,4), + saldo_pendiente NUMERIC(18,4), + saldo_pendiente_mxn NUMERIC(18,4), + fecha_liquidacion TIMESTAMP, + fecha_pago DATE, + fecha_inicial_pago DATE, + fecha_final_pago DATE, + num_dias_pagados NUMERIC(10,2), + num_seguro_social VARCHAR(50), + puesto VARCHAR(255), + salario_base_cot_apor NUMERIC(18,4), + salario_base_cot_apor_mxn NUMERIC(18,4), + salario_diario_integrado NUMERIC(18,4), + salario_diario_integrado_mxn NUMERIC(18,4), + total_percepciones NUMERIC(18,4), + total_percepciones_mxn NUMERIC(18,4), + total_deducciones NUMERIC(18,4), + total_deducciones_mxn NUMERIC(18,4), + imp_retenidos_nomina NUMERIC(18,4), + imp_retenidos_nomina_mxn NUMERIC(18,4), + otras_deducciones_nomina NUMERIC(18,4), + otras_deducciones_nomina_mxn NUMERIC(18,4), + subsidio_causado NUMERIC(18,4), + subsidio_causado_mxn NUMERIC(18,4), + conciliado VARCHAR(50), + id_conciliacion INTEGER, + xml_url TEXT, + pdf_url TEXT, + xml_original TEXT, + last_sat_sync TIMESTAMP, + sat_sync_job_id UUID, + source VARCHAR(20) DEFAULT 'manual', + facturapi_id VARCHAR(50), + regimen_fiscal_emisor VARCHAR(3), + regimen_fiscal_receptor VARCHAR(3), + creado_en TIMESTAMP DEFAULT NOW(), + actualizado_en TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS cfdi_conceptos ( + id SERIAL PRIMARY KEY, + cfdi_id INTEGER REFERENCES cfdis(id) ON DELETE CASCADE, + clave_prod_serv VARCHAR(10), + no_identificacion VARCHAR(100), + descripcion TEXT, + cantidad NUMERIC(18,4), + clave_unidad VARCHAR(10), + unidad VARCHAR(100), + valor_unitario NUMERIC(18,4), + valor_unitario_mxn NUMERIC(18,4), + importe NUMERIC(18,4), + importe_mxn NUMERIC(18,4), + descuento NUMERIC(18,4), + descuento_mxn NUMERIC(18,4), + isr_retencion NUMERIC(18,4), + isr_retencion_mxn NUMERIC(18,4), + iva_traslado NUMERIC(18,4), + iva_traslado_mxn NUMERIC(18,4), + iva_retencion NUMERIC(18,4), + iva_retencion_mxn NUMERIC(18,4), + ieps_traslado NUMERIC(18,4), + ieps_traslado_mxn NUMERIC(18,4), + ieps_retencion NUMERIC(18,4), + ieps_retencion_mxn NUMERIC(18,4), + impuestos_locales_trasladado NUMERIC(18,4), + impuestos_locales_trasladado_mxn NUMERIC(18,4), + impuestos_locales_retenidos NUMERIC(18,4), + impuestos_locales_retenidos_mxn NUMERIC(18,4), + total_percepciones NUMERIC(18,4), + total_percepciones_mxn NUMERIC(18,4), + total_deducciones NUMERIC(18,4), + total_deducciones_mxn NUMERIC(18,4), + imp_retenidos_nomina NUMERIC(18,4), + imp_retenidos_nomina_mxn NUMERIC(18,4), + otras_deducciones_nomina NUMERIC(18,4), + otras_deducciones_nomina_mxn NUMERIC(18,4), + subsidio_causado NUMERIC(18,4), + subsidio_causado_mxn NUMERIC(18,4), + creado_en TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS conciliaciones ( + id SERIAL PRIMARY KEY, + anio VARCHAR(4) NOT NULL, + mes VARCHAR(2) NOT NULL, + id_cfdi INTEGER NOT NULL UNIQUE REFERENCES cfdis(id), + fecha_de_pago DATE NOT NULL, + id_banco INTEGER NOT NULL REFERENCES bancos(id), + creado_en TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS alertas ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tipo VARCHAR(50) NOT NULL, + titulo VARCHAR(200) NOT NULL, + mensaje TEXT, + prioridad VARCHAR(20) DEFAULT 'media', + fecha_vencimiento TIMESTAMP, + leida BOOLEAN DEFAULT FALSE, + resuelta BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS recordatorios ( + id SERIAL PRIMARY KEY, + titulo VARCHAR(200) NOT NULL, + descripcion TEXT, + fecha_limite DATE NOT NULL, + notas TEXT, + completado BOOLEAN DEFAULT FALSE, + privado BOOLEAN DEFAULT FALSE, + creado_por UUID NOT NULL, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- ============================================= +-- Columns that may be missing on older tenants +-- (CREATE TABLE IF NOT EXISTS won't add these if the table already existed) +-- ============================================= + +ALTER TABLE cfdis ADD COLUMN IF NOT EXISTS id_conciliacion INTEGER; +ALTER TABLE cfdis ADD COLUMN IF NOT EXISTS conciliado VARCHAR(50); +ALTER TABLE cfdis ADD COLUMN IF NOT EXISTS source VARCHAR(20) DEFAULT 'manual'; +ALTER TABLE cfdis ADD COLUMN IF NOT EXISTS facturapi_id VARCHAR(50); +ALTER TABLE cfdis ADD COLUMN IF NOT EXISTS regimen_fiscal_emisor VARCHAR(3); +ALTER TABLE cfdis ADD COLUMN IF NOT EXISTS regimen_fiscal_receptor VARCHAR(3); +ALTER TABLE cfdis ADD COLUMN IF NOT EXISTS last_sat_sync TIMESTAMP; +ALTER TABLE cfdis ADD COLUMN IF NOT EXISTS sat_sync_job_id UUID; + +-- Indexes + +CREATE INDEX IF NOT EXISTS idx_cfdis_fecha_emision ON cfdis(fecha_emision DESC); +CREATE INDEX IF NOT EXISTS idx_cfdis_type ON cfdis(type); +CREATE INDEX IF NOT EXISTS idx_cfdis_rfc_emisor ON cfdis(rfc_emisor); +CREATE INDEX IF NOT EXISTS idx_cfdis_rfc_receptor ON cfdis(rfc_receptor); +CREATE INDEX IF NOT EXISTS idx_cfdis_status ON cfdis(status); +CREATE INDEX IF NOT EXISTS idx_cfdis_year_month ON cfdis(year, month); +CREATE INDEX IF NOT EXISTS idx_cfdis_nombre_emisor_trgm ON cfdis USING gin(nombre_emisor gin_trgm_ops); +CREATE INDEX IF NOT EXISTS idx_cfdis_nombre_receptor_trgm ON cfdis USING gin(nombre_receptor gin_trgm_ops); + +CREATE INDEX IF NOT EXISTS idx_cfdis_rfc_emisor_id ON cfdis(rfc_emisor_id); +CREATE INDEX IF NOT EXISTS idx_cfdis_rfc_receptor_id ON cfdis(rfc_receptor_id); + +CREATE INDEX IF NOT EXISTS idx_cfdi_conceptos_cfdi_id ON cfdi_conceptos(cfdi_id); +CREATE INDEX IF NOT EXISTS idx_cfdi_conceptos_clave ON cfdi_conceptos(clave_prod_serv); + +CREATE INDEX IF NOT EXISTS idx_conciliaciones_anio_mes ON conciliaciones(anio, mes); +CREATE INDEX IF NOT EXISTS idx_conciliaciones_id_cfdi ON conciliaciones(id_cfdi); +CREATE INDEX IF NOT EXISTS idx_cfdis_id_conciliacion ON cfdis(id_conciliacion); + +-- Deferred FK: cfdis.id_conciliacion -> conciliaciones(id) +-- (cfdis is created before conciliaciones, so this constraint is added after both tables exist) + +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'cfdis_id_conciliacion_fkey') THEN + ALTER TABLE cfdis ADD CONSTRAINT cfdis_id_conciliacion_fkey FOREIGN KEY (id_conciliacion) REFERENCES conciliaciones(id); + END IF; +END $$; diff --git a/apps/api/src/migrations/tenant/002_create_opiniones_cumplimiento.sql b/apps/api/src/migrations/tenant/002_create_opiniones_cumplimiento.sql new file mode 100644 index 0000000..bf86509 --- /dev/null +++ b/apps/api/src/migrations/tenant/002_create_opiniones_cumplimiento.sql @@ -0,0 +1,16 @@ +-- 002_create_opiniones_cumplimiento +-- Table for storing SAT Opinión de Cumplimiento PDFs and metadata + +CREATE TABLE IF NOT EXISTS opiniones_cumplimiento ( + id SERIAL PRIMARY KEY, + rfc VARCHAR(14) NOT NULL, + razon_social VARCHAR(255), + estatus VARCHAR(50) NOT NULL, + folio VARCHAR(50), + cadena_original TEXT, + fecha_consulta TIMESTAMP NOT NULL, + pdf BYTEA NOT NULL, + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_opiniones_fecha ON opiniones_cumplimiento(fecha_consulta DESC); diff --git a/apps/api/src/migrations/tenant/003_create_declaraciones_provisionales.sql b/apps/api/src/migrations/tenant/003_create_declaraciones_provisionales.sql new file mode 100644 index 0000000..17b2701 --- /dev/null +++ b/apps/api/src/migrations/tenant/003_create_declaraciones_provisionales.sql @@ -0,0 +1,36 @@ +-- Declaraciones provisionales del tenant: PDFs subidos por el contador con +-- el comprobante de la declaración + opcionalmente el comprobante de pago. +-- Al subir una declaración o un comprobante, el sistema marca como resueltas +-- las alertas correspondientes (decl-XXX o pago-XXX) en la tabla `alertas`. +-- +-- Reglas: +-- - 1 declaración tipo='normal' por (año, mes) — UNIQUE parcial +-- - N declaraciones tipo='complementaria' por (año, mes) — sin restricción +-- - `impuestos` es un array de strings: ['IVA', 'ISR', 'IEPS', etc.] que +-- cubre la declaración. Permite saber qué alertas resolver. + +CREATE TABLE IF NOT EXISTS declaraciones_provisionales ( + id SERIAL PRIMARY KEY, + año INT NOT NULL, + mes INT NOT NULL CHECK (mes BETWEEN 1 AND 12), + tipo VARCHAR(15) NOT NULL CHECK (tipo IN ('normal', 'complementaria')), + impuestos TEXT[] NOT NULL, -- ['IVA', 'ISR', 'IEPS', ...] + pdf_declaracion BYTEA NOT NULL, + pdf_filename VARCHAR(255), + link_pago TEXT, + pdf_pago BYTEA, + pdf_pago_filename VARCHAR(255), + pagado_at TIMESTAMP, + creado_por VARCHAR(255), -- email del user que la subió + notas TEXT, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_declaraciones_periodo ON declaraciones_provisionales(año DESC, mes DESC); + +-- Solo 1 declaración tipo='normal' por (año, mes). Las complementarias no +-- tienen restricción de cantidad. +CREATE UNIQUE INDEX IF NOT EXISTS uniq_declaracion_normal_mes + ON declaraciones_provisionales(año, mes) + WHERE tipo = 'normal'; diff --git a/apps/api/src/migrations/tenant/004_declaraciones_liga_pago_pdf.sql b/apps/api/src/migrations/tenant/004_declaraciones_liga_pago_pdf.sql new file mode 100644 index 0000000..7eecbc7 --- /dev/null +++ b/apps/api/src/migrations/tenant/004_declaraciones_liga_pago_pdf.sql @@ -0,0 +1,11 @@ +-- La "liga de pago" de la declaración es un PDF (no un URL). Reemplazamos +-- la columna TEXT por un par BYTEA+filename, consistente con pdf_declaracion +-- y pdf_pago. Si la migración 003 aún no se desplegó en algún ambiente, +-- este ALTER aplica igual (DROP IF EXISTS + ADD COLUMN IF NOT EXISTS). + +ALTER TABLE declaraciones_provisionales + DROP COLUMN IF EXISTS link_pago; + +ALTER TABLE declaraciones_provisionales + ADD COLUMN IF NOT EXISTS pdf_liga_pago BYTEA, + ADD COLUMN IF NOT EXISTS pdf_liga_pago_filename VARCHAR(255); diff --git a/apps/api/src/migrations/tenant/005_create_constancias_situacion_fiscal.sql b/apps/api/src/migrations/tenant/005_create_constancias_situacion_fiscal.sql new file mode 100644 index 0000000..5ac1d01 --- /dev/null +++ b/apps/api/src/migrations/tenant/005_create_constancias_situacion_fiscal.sql @@ -0,0 +1,24 @@ +-- Constancia de Situación Fiscal: PDF descargado del portal SAT con Playwright +-- + FIEL. Se descarga automáticamente el 1° de cada mes y al primer upload de +-- FIEL del tenant. Retención 5 años (similar a declaraciones_provisionales). +-- +-- `datos` es un JSONB con el shape `ConstanciaSituacionFiscal` del prototipo +-- (domicilio, régimenes activos, actividades, obligaciones, sellos). Se +-- guarda completo para poder re-hidratar la UI sin re-parsear el PDF, y +-- comparar entre consultas (detectar cambios de domicilio/régimen). + +CREATE TABLE IF NOT EXISTS constancias_situacion_fiscal ( + id SERIAL PRIMARY KEY, + rfc VARCHAR(13) NOT NULL, + id_cif VARCHAR(20), + razon_social TEXT, + estatus_padron VARCHAR(30), + fecha_emision TEXT, -- "GUADALAJARA, JALISCO A 14 DE ABRIL DE 2026" (formato libre del SAT) + datos JSONB NOT NULL, -- shape ConstanciaSituacionFiscal completo + pdf BYTEA NOT NULL, + fecha_consulta TIMESTAMP DEFAULT NOW(), + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_csf_fecha_consulta + ON constancias_situacion_fiscal(fecha_consulta DESC); diff --git a/apps/api/src/migrations/tenant/006_tenant_migrations_tracking.sql b/apps/api/src/migrations/tenant/006_tenant_migrations_tracking.sql new file mode 100644 index 0000000..71003f1 --- /dev/null +++ b/apps/api/src/migrations/tenant/006_tenant_migrations_tracking.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS tenant_migrations ( + scope varchar(50) NOT NULL, + version int NOT NULL, + name varchar(255), + applied_at timestamptz DEFAULT now(), + PRIMARY KEY (scope, version) +); + +INSERT INTO tenant_migrations (scope, version, name) +VALUES + ('legacy', 1, '001_initial_schema'), + ('legacy', 2, '002_create_opiniones_cumplimiento'), + ('legacy', 3, '003_create_declaraciones_provisionales'), + ('legacy', 4, '004_declaraciones_liga_pago_pdf'), + ('legacy', 5, '005_create_constancias_situacion_fiscal'), + ('legacy', 6, '006_tenant_migrations_tracking') +ON CONFLICT (scope, version) DO NOTHING; diff --git a/apps/api/src/migrations/tenant/007_entidades_gestionadas.sql b/apps/api/src/migrations/tenant/007_entidades_gestionadas.sql new file mode 100644 index 0000000..ee1e685 --- /dev/null +++ b/apps/api/src/migrations/tenant/007_entidades_gestionadas.sql @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS entidades_gestionadas ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tipo varchar(20) NOT NULL, + nombre text NOT NULL, + identificador text, + supervisor_user_id uuid, + active boolean DEFAULT true, + created_at timestamptz DEFAULT now(), + updated_at timestamptz DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS ix_entidades_supervisor ON entidades_gestionadas(supervisor_user_id); +CREATE INDEX IF NOT EXISTS ix_entidades_tipo ON entidades_gestionadas(tipo, active); +CREATE INDEX IF NOT EXISTS ix_entidades_identificador ON entidades_gestionadas(identificador); + +INSERT INTO tenant_migrations (scope, version, name) +VALUES ('core', 7, '007_entidades_gestionadas') +ON CONFLICT (scope, version) DO NOTHING; diff --git a/apps/api/src/migrations/tenant/008_carteras.sql b/apps/api/src/migrations/tenant/008_carteras.sql new file mode 100644 index 0000000..ef73b15 --- /dev/null +++ b/apps/api/src/migrations/tenant/008_carteras.sql @@ -0,0 +1,27 @@ +CREATE TABLE IF NOT EXISTS carteras ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + supervisor_user_id uuid NOT NULL, + nombre text NOT NULL, + descripcion text, + created_at timestamptz DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS ix_carteras_supervisor ON carteras(supervisor_user_id); + +CREATE TABLE IF NOT EXISTS cartera_entidades ( + cartera_id uuid NOT NULL REFERENCES carteras(id) ON DELETE CASCADE, + entidad_id uuid NOT NULL REFERENCES entidades_gestionadas(id) ON DELETE CASCADE, + added_at timestamptz DEFAULT now(), + PRIMARY KEY (cartera_id, entidad_id) +); + +CREATE TABLE IF NOT EXISTS cartera_auxiliares ( + cartera_id uuid NOT NULL REFERENCES carteras(id) ON DELETE CASCADE, + auxiliar_user_id uuid NOT NULL, + added_at timestamptz DEFAULT now(), + PRIMARY KEY (cartera_id, auxiliar_user_id) +); + +INSERT INTO tenant_migrations (scope, version, name) +VALUES ('core', 8, '008_carteras') +ON CONFLICT (scope, version) DO NOTHING; diff --git a/apps/api/src/migrations/tenant/009_cliente_accesos.sql b/apps/api/src/migrations/tenant/009_cliente_accesos.sql new file mode 100644 index 0000000..d28672a --- /dev/null +++ b/apps/api/src/migrations/tenant/009_cliente_accesos.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS cliente_accesos ( + user_id uuid NOT NULL, + entidad_id uuid NOT NULL REFERENCES entidades_gestionadas(id) ON DELETE CASCADE, + granted_at timestamptz DEFAULT now(), + PRIMARY KEY (user_id, entidad_id) +); + +INSERT INTO tenant_migrations (scope, version, name) +VALUES ('core', 9, '009_cliente_accesos') +ON CONFLICT (scope, version) DO NOTHING; diff --git a/apps/api/src/migrations/tenant/010_contribuyentes.sql b/apps/api/src/migrations/tenant/010_contribuyentes.sql new file mode 100644 index 0000000..3c9fd02 --- /dev/null +++ b/apps/api/src/migrations/tenant/010_contribuyentes.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS contribuyentes ( + entidad_id uuid PRIMARY KEY REFERENCES entidades_gestionadas(id) ON DELETE CASCADE, + rfc varchar(13) NOT NULL UNIQUE, + regimen_fiscal varchar(3), + codigo_postal varchar(5), + domicilio jsonb +); + +CREATE INDEX IF NOT EXISTS ix_contribuyentes_rfc ON contribuyentes(rfc); + +INSERT INTO tenant_migrations (scope, version, name) +VALUES ('vertical-contable', 10, '010_contribuyentes') +ON CONFLICT (scope, version) DO NOTHING; diff --git a/apps/api/src/migrations/tenant/011_fiel_per_contribuyente.sql b/apps/api/src/migrations/tenant/011_fiel_per_contribuyente.sql new file mode 100644 index 0000000..35e0269 --- /dev/null +++ b/apps/api/src/migrations/tenant/011_fiel_per_contribuyente.sql @@ -0,0 +1,23 @@ +CREATE TABLE IF NOT EXISTS fiel_contribuyente ( + contribuyente_id uuid PRIMARY KEY REFERENCES contribuyentes(entidad_id) ON DELETE CASCADE, + rfc varchar(13) NOT NULL, + cer_data bytea NOT NULL, + key_data bytea NOT NULL, + key_password_enc 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 timestamptz NOT NULL, + valid_until timestamptz NOT NULL, + is_active boolean DEFAULT true, + uploaded_at timestamptz DEFAULT now(), + updated_at timestamptz DEFAULT now() +); + +INSERT INTO tenant_migrations (scope, version, name) +VALUES ('vertical-contable', 11, '011_fiel_per_contribuyente') +ON CONFLICT (scope, version) DO NOTHING; diff --git a/apps/api/src/migrations/tenant/012_facturapi_per_contribuyente.sql b/apps/api/src/migrations/tenant/012_facturapi_per_contribuyente.sql new file mode 100644 index 0000000..e310dc2 --- /dev/null +++ b/apps/api/src/migrations/tenant/012_facturapi_per_contribuyente.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS facturapi_orgs ( + contribuyente_id uuid PRIMARY KEY REFERENCES contribuyentes(entidad_id) ON DELETE CASCADE, + facturapi_org_id text NOT NULL UNIQUE, + csd_uploaded boolean DEFAULT false, + active boolean DEFAULT true, + created_at timestamptz DEFAULT now() +); + +INSERT INTO tenant_migrations (scope, version, name) +VALUES ('vertical-contable', 12, '012_facturapi_per_contribuyente') +ON CONFLICT (scope, version) DO NOTHING; diff --git a/apps/api/src/migrations/tenant/013_cfdi_contribuyente_id.sql b/apps/api/src/migrations/tenant/013_cfdi_contribuyente_id.sql new file mode 100644 index 0000000..b6bab8b --- /dev/null +++ b/apps/api/src/migrations/tenant/013_cfdi_contribuyente_id.sql @@ -0,0 +1,7 @@ +ALTER TABLE cfdis ADD COLUMN IF NOT EXISTS contribuyente_id uuid REFERENCES contribuyentes(entidad_id); + +CREATE INDEX IF NOT EXISTS ix_cfdi_contribuyente ON cfdis(contribuyente_id) WHERE contribuyente_id IS NOT NULL; + +INSERT INTO tenant_migrations (scope, version, name) +VALUES ('vertical-contable', 13, '013_cfdi_contribuyente_id') +ON CONFLICT (scope, version) DO NOTHING; diff --git a/apps/api/src/migrations/tenant/014_metricas_mensuales.sql b/apps/api/src/migrations/tenant/014_metricas_mensuales.sql new file mode 100644 index 0000000..cb372d0 --- /dev/null +++ b/apps/api/src/migrations/tenant/014_metricas_mensuales.sql @@ -0,0 +1,52 @@ +CREATE TABLE IF NOT EXISTS metricas_mensuales ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + contribuyente_id uuid NOT NULL REFERENCES contribuyentes(entidad_id) ON DELETE CASCADE, + anio smallint NOT NULL, + mes smallint NOT NULL, + regimen_fiscal varchar(3), + formula_version smallint DEFAULT 1, + iva_trasladado_16 numeric(18,2) DEFAULT 0, + iva_trasladado_8 numeric(18,2) DEFAULT 0, + iva_trasladado_0 numeric(18,2) DEFAULT 0, + iva_trasladado_exento numeric(18,2) DEFAULT 0, + iva_trasladado_total numeric(18,2) DEFAULT 0, + iva_acreditable numeric(18,2) DEFAULT 0, + iva_retenido_cobrado numeric(18,2) DEFAULT 0, + iva_retenido_pagado numeric(18,2) DEFAULT 0, + iva_resultado numeric(18,2) DEFAULT 0, + iva_a_favor_mes numeric(18,2) DEFAULT 0, + isr_ingresos_brutos numeric(18,2) DEFAULT 0, + isr_deducciones_autoriz numeric(18,2) DEFAULT 0, + isr_base numeric(18,2) DEFAULT 0, + isr_causado numeric(18,2) DEFAULT 0, + isr_retenido numeric(18,2) DEFAULT 0, + isr_a_pagar numeric(18,2) DEFAULT 0, + ieps_trasladado numeric(18,2) DEFAULT 0, + ieps_acreditable numeric(18,2) DEFAULT 0, + cfdis_emitidos_count int DEFAULT 0, + cfdis_recibidos_count int DEFAULT 0, + cfdis_cancelados_count int DEFAULT 0, + ingresos_devengados numeric(18,2) DEFAULT 0, + ingresos_cobrados numeric(18,2) DEFAULT 0, + egresos_devengados numeric(18,2) DEFAULT 0, + egresos_pagados numeric(18,2) DEFAULT 0, + utilidad_devengada numeric(18,2) DEFAULT 0, + utilidad_realizada numeric(18,2) DEFAULT 0, + flujo_entradas numeric(18,2) DEFAULT 0, + flujo_salidas numeric(18,2) DEFAULT 0, + flujo_neto numeric(18,2) DEFAULT 0, + cxc_saldo_final numeric(18,2) DEFAULT 0, + cxp_saldo_final numeric(18,2) DEFAULT 0, + cxc_cfdis_count int DEFAULT 0, + cxp_cfdis_count int DEFAULT 0, + cerrado boolean DEFAULT false, + computed_at timestamptz DEFAULT now(), + source_max_cfdi_at timestamptz, + UNIQUE (contribuyente_id, anio, mes, regimen_fiscal) +); +CREATE INDEX IF NOT EXISTS ix_metricas_contrib_anio ON metricas_mensuales(contribuyente_id, anio DESC, mes DESC); +CREATE INDEX IF NOT EXISTS ix_metricas_cerrado ON metricas_mensuales(cerrado, computed_at); + +INSERT INTO tenant_migrations (scope, version, name) +VALUES ('vertical-contable', 14, '014_metricas_mensuales') +ON CONFLICT (scope, version) DO NOTHING; diff --git a/apps/api/src/migrations/tenant/015_metricas_acumuladas_anuales.sql b/apps/api/src/migrations/tenant/015_metricas_acumuladas_anuales.sql new file mode 100644 index 0000000..f7be484 --- /dev/null +++ b/apps/api/src/migrations/tenant/015_metricas_acumuladas_anuales.sql @@ -0,0 +1,24 @@ +CREATE TABLE IF NOT EXISTS metricas_acumuladas_anuales ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + contribuyente_id uuid NOT NULL REFERENCES contribuyentes(entidad_id) ON DELETE CASCADE, + anio smallint NOT NULL, + regimen_fiscal varchar(3), + formula_version smallint DEFAULT 1, + iva_a_favor_arrastrado numeric(18,2) DEFAULT 0, + iva_a_favor_generado numeric(18,2) DEFAULT 0, + iva_a_favor_aplicado numeric(18,2) DEFAULT 0, + iva_a_favor_saldo numeric(18,2) DEFAULT 0, + ingresos_anuales numeric(18,2) DEFAULT 0, + deducciones_anuales numeric(18,2) DEFAULT 0, + utilidad_anual numeric(18,2) DEFAULT 0, + isr_causado_anual numeric(18,2) DEFAULT 0, + isr_retenido_anual numeric(18,2) DEFAULT 0, + isr_a_pagar_anual numeric(18,2) DEFAULT 0, + cerrado boolean DEFAULT false, + computed_at timestamptz DEFAULT now(), + UNIQUE (contribuyente_id, anio, regimen_fiscal) +); + +INSERT INTO tenant_migrations (scope, version, name) +VALUES ('vertical-contable', 15, '015_metricas_acumuladas_anuales') +ON CONFLICT (scope, version) DO NOTHING; diff --git a/apps/api/src/migrations/tenant/016_metricas_por_contraparte_anuales.sql b/apps/api/src/migrations/tenant/016_metricas_por_contraparte_anuales.sql new file mode 100644 index 0000000..6d6ff14 --- /dev/null +++ b/apps/api/src/migrations/tenant/016_metricas_por_contraparte_anuales.sql @@ -0,0 +1,19 @@ +CREATE TABLE IF NOT EXISTS metricas_por_contraparte_anuales ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + contribuyente_id uuid NOT NULL REFERENCES contribuyentes(entidad_id) ON DELETE CASCADE, + anio smallint NOT NULL, + rfc_contraparte varchar(13) NOT NULL, + nombre_contraparte text, + tipo char(1), + subtotal numeric(18,2), + total numeric(18,2), + cfdis_count int, + concentracion_pct numeric(5,2), + computed_at timestamptz DEFAULT now(), + UNIQUE (contribuyente_id, anio, rfc_contraparte, tipo) +); +CREATE INDEX IF NOT EXISTS ix_metricas_contraparte_top ON metricas_por_contraparte_anuales(contribuyente_id, anio, tipo, total DESC); + +INSERT INTO tenant_migrations (scope, version, name) +VALUES ('vertical-contable', 16, '016_metricas_por_contraparte_anuales') +ON CONFLICT (scope, version) DO NOTHING; diff --git a/apps/api/src/migrations/tenant/017_metricas_invalidaciones.sql b/apps/api/src/migrations/tenant/017_metricas_invalidaciones.sql new file mode 100644 index 0000000..ed9d79c --- /dev/null +++ b/apps/api/src/migrations/tenant/017_metricas_invalidaciones.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS metricas_invalidaciones ( + contribuyente_id uuid NOT NULL, + anio smallint NOT NULL, + mes smallint NOT NULL, + reason text, + marcado_at timestamptz DEFAULT now(), + PRIMARY KEY (contribuyente_id, anio, mes) +); + +INSERT INTO tenant_migrations (scope, version, name) +VALUES ('vertical-contable', 17, '017_metricas_invalidaciones') +ON CONFLICT (scope, version) DO NOTHING; diff --git a/apps/api/src/migrations/tenant/018_obligaciones_contribuyente.sql b/apps/api/src/migrations/tenant/018_obligaciones_contribuyente.sql new file mode 100644 index 0000000..4d77847 --- /dev/null +++ b/apps/api/src/migrations/tenant/018_obligaciones_contribuyente.sql @@ -0,0 +1,20 @@ +CREATE TABLE IF NOT EXISTS obligaciones_contribuyente ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + contribuyente_id uuid NOT NULL REFERENCES contribuyentes(entidad_id) ON DELETE CASCADE, + catalogo_id text, + nombre text NOT NULL, + fundamento text, + frecuencia text, + fecha_limite text, + categoria text, + activa boolean DEFAULT true, + es_recomendada boolean DEFAULT false, + es_custom boolean DEFAULT false, + created_at timestamptz DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS ix_obligaciones_contrib ON obligaciones_contribuyente(contribuyente_id, activa); + +INSERT INTO tenant_migrations (scope, version, name) +VALUES ('vertical-contable', 18, '018_obligaciones_contribuyente') +ON CONFLICT (scope, version) DO NOTHING; diff --git a/apps/api/src/migrations/tenant/019_obligaciones_completada.sql b/apps/api/src/migrations/tenant/019_obligaciones_completada.sql new file mode 100644 index 0000000..059ce1d --- /dev/null +++ b/apps/api/src/migrations/tenant/019_obligaciones_completada.sql @@ -0,0 +1,8 @@ +ALTER TABLE obligaciones_contribuyente ADD COLUMN IF NOT EXISTS completada boolean DEFAULT false; +ALTER TABLE obligaciones_contribuyente ADD COLUMN IF NOT EXISTS completada_at timestamptz; +ALTER TABLE obligaciones_contribuyente ADD COLUMN IF NOT EXISTS completada_por uuid; +ALTER TABLE obligaciones_contribuyente ADD COLUMN IF NOT EXISTS periodo_completado varchar(7); -- "2026-04" (year-month) + +INSERT INTO tenant_migrations (scope, version, name) +VALUES ('vertical-contable', 19, '019_obligaciones_completada') +ON CONFLICT (scope, version) DO NOTHING; diff --git a/apps/api/src/migrations/tenant/020_obligacion_periodos.sql b/apps/api/src/migrations/tenant/020_obligacion_periodos.sql new file mode 100644 index 0000000..7982cbb --- /dev/null +++ b/apps/api/src/migrations/tenant/020_obligacion_periodos.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS obligacion_periodos ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + obligacion_id uuid NOT NULL REFERENCES obligaciones_contribuyente(id) ON DELETE CASCADE, + periodo varchar(7) NOT NULL, + completada boolean DEFAULT false, + completada_at timestamptz, + completada_por uuid, + notas text, + created_at timestamptz DEFAULT now(), + UNIQUE (obligacion_id, periodo) +); + +CREATE INDEX IF NOT EXISTS ix_obligacion_periodos_periodo ON obligacion_periodos(periodo); + +INSERT INTO tenant_migrations (scope, version, name) +VALUES ('vertical-contable', 20, '020_obligacion_periodos') +ON CONFLICT (scope, version) DO NOTHING; diff --git a/apps/api/src/migrations/tenant/021_declaraciones_periodicidad_monto.sql b/apps/api/src/migrations/tenant/021_declaraciones_periodicidad_monto.sql new file mode 100644 index 0000000..b9a623e --- /dev/null +++ b/apps/api/src/migrations/tenant/021_declaraciones_periodicidad_monto.sql @@ -0,0 +1,15 @@ +-- Add periodicidad (period type) and monto_pago (payment amount) to declaraciones. +-- periodicidad replaces the assumption that all declarations are monthly. +-- monto_pago = 0 means the declaration results in $0 to pay (auto-mark as paid). + +ALTER TABLE declaraciones_provisionales + ADD COLUMN IF NOT EXISTS periodicidad VARCHAR(15) NOT NULL DEFAULT 'mensual' + CHECK (periodicidad IN ('mensual', 'bimestral', 'trimestral', 'semestral', 'anual')); + +ALTER TABLE declaraciones_provisionales + ADD COLUMN IF NOT EXISTS monto_pago NUMERIC(15,2); + +-- For existing rows that already have a payment proof, backfill pagado_at if null +UPDATE declaraciones_provisionales +SET pagado_at = updated_at +WHERE pdf_pago IS NOT NULL AND pagado_at IS NULL; diff --git a/apps/api/src/migrations/tenant/022_carteras_subcarteras.sql b/apps/api/src/migrations/tenant/022_carteras_subcarteras.sql new file mode 100644 index 0000000..037b8cf --- /dev/null +++ b/apps/api/src/migrations/tenant/022_carteras_subcarteras.sql @@ -0,0 +1,23 @@ +-- Subcarteras: a cartera can be a child of another cartera. +-- Top-level carteras belong to a supervisor (or owner). +-- Subcarteras belong to an auxiliar within a parent cartera. + +ALTER TABLE carteras + ADD COLUMN IF NOT EXISTS parent_id uuid REFERENCES carteras(id) ON DELETE CASCADE; + +ALTER TABLE carteras + ADD COLUMN IF NOT EXISTS auxiliar_user_id uuid; + +-- Allow supervisor_user_id to be NULL for subcarteras (inherited from parent) +ALTER TABLE carteras + ALTER COLUMN supervisor_user_id DROP NOT NULL; + +CREATE INDEX IF NOT EXISTS ix_carteras_parent ON carteras(parent_id); +CREATE INDEX IF NOT EXISTS ix_carteras_auxiliar ON carteras(auxiliar_user_id); + +-- Track which supervisor an auxiliar reports to (1:1 per auxiliar) +CREATE TABLE IF NOT EXISTS auxiliar_supervisores ( + auxiliar_user_id uuid NOT NULL PRIMARY KEY, + supervisor_user_id uuid NOT NULL, + created_at timestamptz DEFAULT now() +); diff --git a/apps/api/src/migrations/tenant/023_bancos_contribuyente.sql b/apps/api/src/migrations/tenant/023_bancos_contribuyente.sql new file mode 100644 index 0000000..fc89b23 --- /dev/null +++ b/apps/api/src/migrations/tenant/023_bancos_contribuyente.sql @@ -0,0 +1,4 @@ +-- Bancos belong to individual contribuyentes, not the whole tenant. +-- Used for conciliación per-contribuyente. +ALTER TABLE bancos ADD COLUMN IF NOT EXISTS contribuyente_id uuid; +CREATE INDEX IF NOT EXISTS ix_bancos_contribuyente ON bancos(contribuyente_id); diff --git a/apps/api/src/migrations/tenant/024_cfdi_descartados.sql b/apps/api/src/migrations/tenant/024_cfdi_descartados.sql new file mode 100644 index 0000000..4f839da --- /dev/null +++ b/apps/api/src/migrations/tenant/024_cfdi_descartados.sql @@ -0,0 +1,12 @@ +-- CFDIs descartados de alertas (ej: discrepancias de régimen que el usuario revisó y decidió ignorar). +-- El descarte se aplica por tipo de alerta y cfdi_id. +CREATE TABLE IF NOT EXISTS cfdi_descartados ( + id serial PRIMARY KEY, + cfdi_id integer NOT NULL, + tipo_alerta text NOT NULL, -- e.g. 'discrepancia-regimen' + descartado_por text, -- email or userId + created_at timestamptz DEFAULT now(), + UNIQUE (cfdi_id, tipo_alerta) +); + +CREATE INDEX IF NOT EXISTS ix_cfdi_descartados_tipo ON cfdi_descartados(tipo_alerta); diff --git a/apps/api/src/migrations/tenant/025_contribuyentes_regimen_fiscal_text.sql b/apps/api/src/migrations/tenant/025_contribuyentes_regimen_fiscal_text.sql new file mode 100644 index 0000000..a0c79b6 --- /dev/null +++ b/apps/api/src/migrations/tenant/025_contribuyentes_regimen_fiscal_text.sql @@ -0,0 +1,18 @@ +-- Amplía contribuyentes.regimen_fiscal a TEXT para soportar CSV de múltiples +-- regímenes (ej. "626,605"). La migración 010 original lo declaró varchar(3) +-- asumiendo un solo régimen por contribuyente, pero el código sincroniza CSV +-- desde la CSF (sincronizarDatosFiscales en constancia.service.ts). +-- +-- Síntoma antes del fix: el sync falla con "el valor es demasiado largo para +-- el tipo character varying(3)" cuando un contribuyente tiene ≥2 regímenes +-- activos en su CSF, y los campos regimen_fiscal/codigo_postal/domicilio +-- quedan NULL. +-- +-- Idempotente: si ya es text (patito tenía parche manual), el ALTER es no-op +-- en términos de filas — Postgres lo resuelve como metadata change. + +ALTER TABLE contribuyentes ALTER COLUMN regimen_fiscal TYPE text; + +INSERT INTO tenant_migrations (scope, version, name) +VALUES ('vertical-contable', 25, '025_contribuyentes_regimen_fiscal_text') +ON CONFLICT (scope, version) DO NOTHING; diff --git a/apps/api/src/migrations/tenant/026_normalize_cfdi_uuid_case.sql b/apps/api/src/migrations/tenant/026_normalize_cfdi_uuid_case.sql new file mode 100644 index 0000000..31a8800 --- /dev/null +++ b/apps/api/src/migrations/tenant/026_normalize_cfdi_uuid_case.sql @@ -0,0 +1,47 @@ +-- Normaliza el case de cfdis.uuid y elimina duplicados generados por el +-- mismatch case-sensitive entre los dos paths de sync SAT: +-- - source='sat' (XML parser): insertaba UUIDs como venían del XML (lowercase) +-- - source='sat-metadata' (CSV parser): insertaba UUIDs del CSV (UPPERCASE) +-- La constraint UNIQUE(uuid) de Postgres es case-sensitive → ambos convivían +-- como filas distintas, duplicando el CFDI en todas las métricas. +-- +-- Estrategia (idempotente — en tenants sin duplicados es no-op): +-- 1. Propagar status=Cancelado de metadata→sat si corresponde (guard de +-- data loss; verificado que en Patito no aplica a ninguna fila, pero la +-- cláusula queda como protección para futuros despachos). +-- 2. Borrar las filas 'sat-metadata' que tengan par 'sat' con el mismo UUID +-- (case-insensitive). Las filas sat-metadata sin par se conservan (son +-- legítimas: CFDIs cancelados sin XML). +-- 3. Normalizar todos los UUIDs restantes a lowercase. +-- El código de saveCfdis/saveMetadata también fue actualizado para (a) matchear +-- case-insensitive, (b) insertar siempre en lowercase. + +-- 1. Propagar Cancelado antes de borrar +UPDATE cfdis sat +SET status = 'Cancelado', + fecha_cancelacion = COALESCE(sat.fecha_cancelacion, meta.fecha_cancelacion), + actualizado_en = NOW() +FROM cfdis meta +WHERE LOWER(sat.uuid) = LOWER(meta.uuid) + AND sat.id != meta.id + AND sat.source = 'sat' + AND meta.source = 'sat-metadata' + AND meta.status = 'Cancelado' + AND sat.status != 'Cancelado'; + +-- 2. Borrar duplicados sat-metadata +DELETE FROM cfdis meta +WHERE meta.source = 'sat-metadata' + AND EXISTS ( + SELECT 1 FROM cfdis sat + WHERE LOWER(meta.uuid) = LOWER(sat.uuid) + AND sat.id != meta.id + AND sat.source = 'sat' + ); + +-- 3. Normalizar a lowercase +UPDATE cfdis SET uuid = LOWER(uuid) WHERE uuid IS NOT NULL AND uuid != LOWER(uuid); + +INSERT INTO tenant_migrations (scope, version, name) +VALUES ('vertical-contable', 26, '026_normalize_cfdi_uuid_case') +ON CONFLICT (scope, version) DO NOTHING; diff --git a/apps/api/src/migrations/tenant/027_cfdi_uuid_unique_case_insensitive.sql b/apps/api/src/migrations/tenant/027_cfdi_uuid_unique_case_insensitive.sql new file mode 100644 index 0000000..0e3b33f --- /dev/null +++ b/apps/api/src/migrations/tenant/027_cfdi_uuid_unique_case_insensitive.sql @@ -0,0 +1,22 @@ +-- Reemplaza el UNIQUE (uuid) case-sensitive por un índice funcional +-- UNIQUE (LOWER(uuid)), como defensa en profundidad contra duplicados por +-- mismatch de case. El código fuente ya normaliza a lowercase en el insert +-- (saveCfdis y saveMetadata en sat.service.ts), pero este constraint previene +-- que cualquier insert manual o vía futuro path pueda reintroducir el bug. +-- +-- Prerequisito: migración 026 ya normalizó todos los UUIDs existentes a +-- lowercase y eliminó duplicados case-insensitive. Si no se aplicó antes, el +-- CREATE UNIQUE INDEX fallará con "could not create unique index" y habrá que +-- correr 026 primero. +-- +-- Idempotente: si el índice nuevo ya existe, IF NOT EXISTS lo salta. + +ALTER TABLE cfdis DROP CONSTRAINT IF EXISTS cfdis_uuid_key; + +CREATE UNIQUE INDEX IF NOT EXISTS cfdis_uuid_lower_key + ON cfdis (LOWER(uuid)) + WHERE uuid IS NOT NULL; + +INSERT INTO tenant_migrations (scope, version, name) +VALUES ('vertical-contable', 27, '027_cfdi_uuid_unique_case_insensitive') +ON CONFLICT (scope, version) DO NOTHING; diff --git a/apps/api/src/migrations/tenant/028_documentos_extras.sql b/apps/api/src/migrations/tenant/028_documentos_extras.sql new file mode 100644 index 0000000..c93eb6d --- /dev/null +++ b/apps/api/src/migrations/tenant/028_documentos_extras.sql @@ -0,0 +1,26 @@ +-- Pestaña "Extras" en /documentos: PDFs libres (acuses SAT, contratos, poderes, +-- estados de cuenta, comprobantes) organizados por contribuyente con categoría +-- de texto libre. +CREATE TABLE IF NOT EXISTS documentos_extras ( + id serial PRIMARY KEY, + contribuyente_id uuid REFERENCES contribuyentes(entidad_id) ON DELETE CASCADE, + nombre varchar(255) NOT NULL, + descripcion text, + categoria varchar(100), + pdf bytea NOT NULL, + pdf_filename varchar(255) NOT NULL, + subido_por varchar(255), + created_at timestamptz DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS ix_documentos_extras_contrib + ON documentos_extras(contribuyente_id, created_at DESC) + WHERE contribuyente_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS ix_documentos_extras_categoria + ON documentos_extras(categoria) + WHERE categoria IS NOT NULL; + +INSERT INTO tenant_migrations (scope, version, name) +VALUES ('vertical-contable', 28, '028_documentos_extras') +ON CONFLICT (scope, version) DO NOTHING; diff --git a/apps/api/src/migrations/tenant/030_obligacion_periodos_declaracion_id.sql b/apps/api/src/migrations/tenant/030_obligacion_periodos_declaracion_id.sql new file mode 100644 index 0000000..54dfe64 --- /dev/null +++ b/apps/api/src/migrations/tenant/030_obligacion_periodos_declaracion_id.sql @@ -0,0 +1,17 @@ +-- #6 Trazabilidad declaración↔obligación: agrega FK a declaraciones_provisionales +-- en obligacion_periodos. ON DELETE SET NULL porque si la declaración se borra +-- el periodo puede seguir completado (el usuario puede haberlo cerrado sin +-- re-subir, o la completitud viene de otra fuente — "marcar manualmente" +-- via UI, etc.). La UI puede mostrar "via Declaración #123" cuando hay FK. + +ALTER TABLE obligacion_periodos + ADD COLUMN IF NOT EXISTS declaracion_id integer + REFERENCES declaraciones_provisionales(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS ix_obligacion_periodos_declaracion + ON obligacion_periodos(declaracion_id) + WHERE declaracion_id IS NOT NULL; + +INSERT INTO tenant_migrations (scope, version, name) +VALUES ('vertical-contable', 30, '030_obligacion_periodos_declaracion_id') +ON CONFLICT (scope, version) DO NOTHING; diff --git a/apps/api/src/migrations/tenant/031_declaraciones_contribuyente_id.sql b/apps/api/src/migrations/tenant/031_declaraciones_contribuyente_id.sql new file mode 100644 index 0000000..de251ca --- /dev/null +++ b/apps/api/src/migrations/tenant/031_declaraciones_contribuyente_id.sql @@ -0,0 +1,27 @@ +-- Fix: las declaraciones provisionales no distinguían contribuyente. En un +-- despacho con N RFCs, la declaración IVA de Alexa aparecía también cuando +-- se seleccionaba a Carlos. Agregamos FK nullable para linkear, y las +-- existentes quedan con NULL (interpretadas como "tenant-wide / legacy"). +-- `ON DELETE SET NULL` para que borrar un contribuyente no tire declaraciones. + +ALTER TABLE declaraciones_provisionales + ADD COLUMN IF NOT EXISTS contribuyente_id uuid + REFERENCES contribuyentes(entidad_id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS ix_declaraciones_contribuyente + ON declaraciones_provisionales(contribuyente_id, año DESC, mes DESC) + WHERE contribuyente_id IS NOT NULL; + +-- Reemplaza el UNIQUE (año, mes) WHERE tipo='normal' por uno que incluye +-- contribuyente: cada RFC debe poder tener su propia declaración normal +-- para el mismo mes. Postgres trata NULL != NULL en índices, así que +-- declaraciones legacy sin contribuyente siguen pudiendo coexistir entre +-- sí — no se auto-de-duplican, pero tampoco bloquean las nuevas. +DROP INDEX IF EXISTS uniq_declaracion_normal_mes; +CREATE UNIQUE INDEX IF NOT EXISTS uniq_declaracion_normal_mes_contrib + ON declaraciones_provisionales(año, mes, contribuyente_id) + WHERE tipo = 'normal'; + +INSERT INTO tenant_migrations (scope, version, name) +VALUES ('vertical-contable', 31, '031_declaraciones_contribuyente_id') +ON CONFLICT (scope, version) DO NOTHING; diff --git a/apps/api/src/migrations/tenant/032_cfdis_relaciones.sql b/apps/api/src/migrations/tenant/032_cfdis_relaciones.sql new file mode 100644 index 0000000..7b269dc --- /dev/null +++ b/apps/api/src/migrations/tenant/032_cfdis_relaciones.sql @@ -0,0 +1,22 @@ +-- Agrega soporte para CfdiRelacionados del propio comprobante (CFDI 4.0). +-- El campo existente `uuid_relacionado` se sigue usando para DoctoRelacionado +-- del complemento de Pagos (tipo P). Estas dos columnas nuevas son para los +-- CfdiRelacionados a nivel raíz del comprobante (típico en tipo E — notas +-- de crédito relacionadas a facturas I, P, o a anticipos aplicados). +-- +-- `cfdi_tipo_relacion` — clave SAT de 2 chars (01 NC, 02 Sustitución, +-- 03 Devolución, 04 Sustitución CFDIs previos, 05 Traslados mercancía, +-- 06 Factura por traslado previo, 07 Aplicación de anticipo). +-- `cfdis_relacionados` — UUIDs pipe-separated del/los CfdiRelacionado. + +ALTER TABLE cfdis + ADD COLUMN IF NOT EXISTS cfdi_tipo_relacion VARCHAR(2), + ADD COLUMN IF NOT EXISTS cfdis_relacionados TEXT; + +CREATE INDEX IF NOT EXISTS ix_cfdis_tipo_relacion + ON cfdis(cfdi_tipo_relacion) + WHERE cfdi_tipo_relacion IS NOT NULL; + +INSERT INTO tenant_migrations (scope, version, name) +VALUES ('vertical-contable', 32, '032_cfdis_relaciones') +ON CONFLICT (scope, version) DO NOTHING; diff --git a/apps/api/src/migrations/tenant/033_facturapi_orgs_lco_rejection.sql b/apps/api/src/migrations/tenant/033_facturapi_orgs_lco_rejection.sql new file mode 100644 index 0000000..0033bc1 --- /dev/null +++ b/apps/api/src/migrations/tenant/033_facturapi_orgs_lco_rejection.sql @@ -0,0 +1,11 @@ +-- Marca el timestamp del último rechazo SAT que sugiere que el CSD +-- aún no está propagado en la Lista de Contribuyentes Obligados (LCO). +-- La propagación tarda 24-72h; el frontend muestra un banner mientras +-- esta marca esté dentro de las últimas 24h. + +ALTER TABLE facturapi_orgs + ADD COLUMN IF NOT EXISTS last_lco_rejection_at timestamptz; + +INSERT INTO tenant_migrations (scope, version, name) +VALUES ('vertical-contable', 33, '033_facturapi_orgs_lco_rejection') +ON CONFLICT (scope, version) DO NOTHING; diff --git a/apps/api/src/migrations/tenant/034_contribuyentes_email_preferences.sql b/apps/api/src/migrations/tenant/034_contribuyentes_email_preferences.sql new file mode 100644 index 0000000..2ab8052 --- /dev/null +++ b/apps/api/src/migrations/tenant/034_contribuyentes_email_preferences.sql @@ -0,0 +1,12 @@ +-- Preferencias de notificación por correo, por contribuyente. +-- Default: objeto vacío → el código interpreta "todo activado" como +-- comportamiento previo. Cuando el user desactiva un tipo, se guarda +-- `{ "": false }`. Tipos soportados (informativos): +-- weekly_update, fiel_notification, documento_subido, subscription_expiring + +ALTER TABLE contribuyentes + ADD COLUMN IF NOT EXISTS email_preferences jsonb DEFAULT '{}'::jsonb; + +INSERT INTO tenant_migrations (scope, version, name) +VALUES ('vertical-contable', 34, '034_contribuyentes_email_preferences') +ON CONFLICT (scope, version) DO NOTHING; diff --git a/apps/api/src/migrations/tenant/035_tareas.sql b/apps/api/src/migrations/tenant/035_tareas.sql new file mode 100644 index 0000000..c10ef05 --- /dev/null +++ b/apps/api/src/migrations/tenant/035_tareas.sql @@ -0,0 +1,46 @@ +-- Tareas operativas del despacho por contribuyente. Recurrentes (semanal a +-- anual). Materialización lazy en tarea_periodos cuando el frontend lee. +-- Solo del presente en adelante. + +CREATE TABLE IF NOT EXISTS tareas_catalogo ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + contribuyente_id uuid NOT NULL REFERENCES contribuyentes(entidad_id) ON DELETE CASCADE, + nombre text NOT NULL, + descripcion text, + recurrencia varchar(15) NOT NULL CHECK (recurrencia IN + ('semanal','quincenal','mensual','bimestral','trimestral','semestral','anual')), + -- Para semanal/quincenal: día de la semana (1=lunes, 7=domingo) + dia_semana int CHECK (dia_semana BETWEEN 1 AND 7), + -- Para mensual y mayores: día del mes (1-31). Si > último día del mes, + -- se materializa al último día (ej. 31 en febrero → 28/29). + dia_mes int CHECK (dia_mes BETWEEN 1 AND 31), + solo_supervisor_completa boolean DEFAULT false, + es_default boolean DEFAULT false, + active boolean DEFAULT true, + orden int DEFAULT 0, + created_at timestamptz DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS ix_tareas_catalogo_contrib ON tareas_catalogo(contribuyente_id, active); + +CREATE TABLE IF NOT EXISTS tarea_periodos ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tarea_id uuid NOT NULL REFERENCES tareas_catalogo(id) ON DELETE CASCADE, + -- '2025-W12' semanal/quincenal, '2025-01' mensual, '2025-B1' bimestral, + -- '2025-Q1' trimestral, '2025-S1' semestral, '2025' anual. + periodo varchar(10) NOT NULL, + fecha_limite date NOT NULL, + completada boolean DEFAULT false, + completada_at timestamptz, + completada_por uuid, + notas text, + created_at timestamptz DEFAULT now(), + UNIQUE (tarea_id, periodo) +); + +CREATE INDEX IF NOT EXISTS ix_tarea_periodos_fecha ON tarea_periodos(fecha_limite); +CREATE INDEX IF NOT EXISTS ix_tarea_periodos_completada ON tarea_periodos(completada); + +INSERT INTO tenant_migrations (scope, version, name) +VALUES ('vertical-contable', 35, '035_tareas') +ON CONFLICT (scope, version) DO NOTHING; diff --git a/apps/api/src/migrations/tenant/036_papeleria_trabajo.sql b/apps/api/src/migrations/tenant/036_papeleria_trabajo.sql new file mode 100644 index 0000000..25fd635 --- /dev/null +++ b/apps/api/src/migrations/tenant/036_papeleria_trabajo.sql @@ -0,0 +1,38 @@ +-- Papelería de trabajo: archivos del despacho por contribuyente, organizados +-- por mes/año y con flujo opcional de aprobación. Formatos permitidos +-- (validados en backend): pdf, word (doc/docx), excel (xls/xlsx). Máx 5 MB +-- por archivo (validado en backend). NO accesible para usuarios rol cliente. + +CREATE TABLE IF NOT EXISTS papeleria_trabajo ( + id serial PRIMARY KEY, + contribuyente_id uuid NOT NULL REFERENCES contribuyentes(entidad_id) ON DELETE CASCADE, + nombre varchar(255) NOT NULL, -- "Reporte de cuentas Q1" + descripcion text, + archivo bytea NOT NULL, + archivo_filename varchar(255) NOT NULL, -- "reporte.pdf" + archivo_mime varchar(100) NOT NULL, + archivo_size int NOT NULL, + -- periodo (mes + año) + anio int NOT NULL CHECK (anio BETWEEN 2000 AND 2100), + mes int NOT NULL CHECK (mes BETWEEN 1 AND 12), + -- flujo de aprobación + requiere_aprobacion boolean NOT NULL DEFAULT false, + estado varchar(20) CHECK (estado IS NULL OR estado IN ('pendiente','aprobado','rechazado')), + aprobado_por uuid, + aprobado_at timestamptz, + comentario_rechazo text, + subido_por uuid NOT NULL, + created_at timestamptz DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS ix_papeleria_contrib + ON papeleria_trabajo(contribuyente_id, created_at DESC); +CREATE INDEX IF NOT EXISTS ix_papeleria_periodo + ON papeleria_trabajo(contribuyente_id, anio, mes); +CREATE INDEX IF NOT EXISTS ix_papeleria_estado + ON papeleria_trabajo(estado) + WHERE estado IS NOT NULL; + +INSERT INTO tenant_migrations (scope, version, name) +VALUES ('vertical-contable', 36, '036_papeleria_trabajo') +ON CONFLICT (scope, version) DO NOTHING; diff --git a/apps/api/src/migrations/tenant/037_activos_fijos_baja.sql b/apps/api/src/migrations/tenant/037_activos_fijos_baja.sql new file mode 100644 index 0000000..3912dc5 --- /dev/null +++ b/apps/api/src/migrations/tenant/037_activos_fijos_baja.sql @@ -0,0 +1,22 @@ +-- Tracking de activos fijos dados de baja (vendidos / desechados / otro). +-- Solo aplica a CFDIs tipo I con uso_cfdi I01-I08 cuyo receptor es el +-- contribuyente. Una fila por CFDI dado de baja; revertir = DELETE. +-- La pestaña "Activos Fijos" en /impuestos consulta esta tabla para +-- detener el cómputo de deducción mensual a partir de `fecha_baja`. + +CREATE TABLE IF NOT EXISTS activos_fijos_baja ( + id serial PRIMARY KEY, + cfdi_id int NOT NULL REFERENCES cfdis(id) ON DELETE CASCADE, + fecha_baja date NOT NULL, + motivo varchar(20) NOT NULL CHECK (motivo IN ('venta','desecho','otro')), + comentario text, + dado_de_baja_por uuid NOT NULL, + created_at timestamptz DEFAULT now(), + UNIQUE (cfdi_id) +); + +CREATE INDEX IF NOT EXISTS ix_activos_fijos_baja_cfdi ON activos_fijos_baja(cfdi_id); + +INSERT INTO tenant_migrations (scope, version, name) +VALUES ('vertical-contable', 37, '037_activos_fijos_baja') +ON CONFLICT (scope, version) DO NOTHING; diff --git a/apps/api/src/migrations/tenant/038_activos_fijos_usos_excluidos.sql b/apps/api/src/migrations/tenant/038_activos_fijos_usos_excluidos.sql new file mode 100644 index 0000000..4805353 --- /dev/null +++ b/apps/api/src/migrations/tenant/038_activos_fijos_usos_excluidos.sql @@ -0,0 +1,11 @@ +-- Permite al contador descartar conceptos de uso CFDI (ej. I06, I07) que +-- en su contribuyente no representan adquisiciones de activos fijos sino +-- gastos regulares (ej. servicio telefónico mensual). Default: lista vacía +-- (todos los usos I01-I08 se consideran activos fijos como hasta ahora). + +ALTER TABLE contribuyentes + ADD COLUMN IF NOT EXISTS activos_fijos_usos_excluidos jsonb DEFAULT '[]'::jsonb; + +INSERT INTO tenant_migrations (scope, version, name) +VALUES ('vertical-contable', 38, '038_activos_fijos_usos_excluidos') +ON CONFLICT (scope, version) DO NOTHING; diff --git a/apps/api/src/migrations/tenant/039_alertas_notificadas.sql b/apps/api/src/migrations/tenant/039_alertas_notificadas.sql new file mode 100644 index 0000000..3746623 --- /dev/null +++ b/apps/api/src/migrations/tenant/039_alertas_notificadas.sql @@ -0,0 +1,22 @@ +-- Tracking de alertas automáticas que ya fueron notificadas por email. +-- Mecanismo idempotente: una sola email por (alerta_id, contribuyente_id). +-- Si la alerta deja de devolverse por `generarAlertasAutomaticas`, se marca +-- `resuelta_at`. Si vuelve a aparecer (NULL en resuelta_at), no se re-notifica +-- — política conservadora de Option B (una sola notificación por evento). + +CREATE TABLE IF NOT EXISTS alertas_notificadas ( + id BIGSERIAL PRIMARY KEY, + alerta_id TEXT NOT NULL, + contribuyente_id UUID, + notified_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + resuelta_at TIMESTAMPTZ +); + +-- UNIQUE compuesto con COALESCE para que NULL en contribuyente_id no rompa +-- la dedup (alertas tenant-level vs contribuyente-específicas comparten tabla). +CREATE UNIQUE INDEX IF NOT EXISTS uniq_alertas_notif + ON alertas_notificadas (alerta_id, COALESCE(contribuyente_id::text, '')); + +-- Índice para queries del cron que filtra por contribuyente. +CREATE INDEX IF NOT EXISTS idx_alertas_notif_contribuyente + ON alertas_notificadas (contribuyente_id) WHERE contribuyente_id IS NOT NULL; diff --git a/apps/api/src/migrations/tenant/040_recordatorios_email_notif.sql b/apps/api/src/migrations/tenant/040_recordatorios_email_notif.sql new file mode 100644 index 0000000..1a777b0 --- /dev/null +++ b/apps/api/src/migrations/tenant/040_recordatorios_email_notif.sql @@ -0,0 +1,12 @@ +-- Tracking de notificaciones email enviadas por recordatorio en cada +-- ventana de proximidad (3 días, 1 día, mismo día). Cada columna se llena +-- una sola vez cuando el cron envía el email correspondiente. +-- +-- Si el usuario edita `fecha_limite` después de haber enviado un email, +-- las columnas previas siguen marcadas — el cron no re-notificará para +-- ventanas ya enviadas. Decisión MVP: simple y predecible. + +ALTER TABLE recordatorios + ADD COLUMN IF NOT EXISTS email_3d_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS email_1d_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS email_0d_at TIMESTAMPTZ; diff --git a/apps/api/src/migrations/tenant/041_facturapi_orgs_api_key_enc.sql b/apps/api/src/migrations/tenant/041_facturapi_orgs_api_key_enc.sql new file mode 100644 index 0000000..803e99e --- /dev/null +++ b/apps/api/src/migrations/tenant/041_facturapi_orgs_api_key_enc.sql @@ -0,0 +1,13 @@ +-- Live Secret Key por organización Facturapi (modo Live multi-RFC). +-- Cada organización Facturapi (1:1 con contribuyente del despacho) tiene su +-- propia sk_live_xxx generada vía PUT /v2/organizations/{id}/apikeys/live. +-- La key se cifra con AES-256-GCM (misma derivación que FIEL_ENCRYPTION_KEY) +-- y se guarda con IV + auth tag por componente, igual que las credenciales FIEL. +ALTER TABLE facturapi_orgs + ADD COLUMN IF NOT EXISTS api_key_enc bytea, + ADD COLUMN IF NOT EXISTS api_key_iv bytea, + ADD COLUMN IF NOT EXISTS api_key_tag bytea; + +INSERT INTO tenant_migrations (scope, version, name) +VALUES ('vertical-contable', 41, '041_facturapi_orgs_api_key_enc') +ON CONFLICT (scope, version) DO NOTHING; diff --git a/apps/api/src/migrations/tenant/042_metricas_ncs.sql b/apps/api/src/migrations/tenant/042_metricas_ncs.sql new file mode 100644 index 0000000..92e7206 --- /dev/null +++ b/apps/api/src/migrations/tenant/042_metricas_ncs.sql @@ -0,0 +1,15 @@ +-- NCs Emitidas y NCs Recibidas — surface metrics nuevas en /impuestos > ISR. +-- Persistidas en metricas_mensuales para acceso vía cache (mismo patrón que +-- ingresos_cobrados/egresos_pagados) y disponibles para queries directas / +-- reportes BI sin tener que recomputar desde raw CFDIs. +-- +-- Defecto 0 — los registros existentes se exponen con valor 0 hasta que el +-- cron de invalidaciones los recompute. El cache total se invalida al deploy +-- vía scripts/refresh-metricas-cache.ts. +ALTER TABLE metricas_mensuales + ADD COLUMN IF NOT EXISTS ncs_emitidas numeric(18,2) DEFAULT 0, + ADD COLUMN IF NOT EXISTS ncs_recibidas numeric(18,2) DEFAULT 0; + +INSERT INTO tenant_migrations (scope, version, name) +VALUES ('vertical-contable', 42, '042_metricas_ncs') +ON CONFLICT (scope, version) DO NOTHING; diff --git a/apps/api/src/migrations/tenant/043_metricas_no_deducibles.sql b/apps/api/src/migrations/tenant/043_metricas_no_deducibles.sql new file mode 100644 index 0000000..5ebfdd9 --- /dev/null +++ b/apps/api/src/migrations/tenant/043_metricas_no_deducibles.sql @@ -0,0 +1,13 @@ +-- Gastos no deducibles por Art. 27 fracción III LISR — facturas recibidas +-- pagadas en efectivo (forma_pago='01') con monto > $2,000. Persistido en +-- metricas_mensuales para acceso vía cache + queries directas / reportes BI. +-- +-- Defecto 0 — los registros existentes se exponen con 0 hasta que el cron +-- de invalidaciones los recompute. El cache total se invalida al deploy +-- vía scripts/refresh-metricas-cache.ts. +ALTER TABLE metricas_mensuales + ADD COLUMN IF NOT EXISTS gastos_no_deducibles_efectivo numeric(18,2) DEFAULT 0; + +INSERT INTO tenant_migrations (scope, version, name) +VALUES ('vertical-contable', 43, '043_metricas_no_deducibles') +ON CONFLICT (scope, version) DO NOTHING; diff --git a/apps/api/src/routes/admin-addons.routes.ts b/apps/api/src/routes/admin-addons.routes.ts new file mode 100644 index 0000000..d87857f --- /dev/null +++ b/apps/api/src/routes/admin-addons.routes.ts @@ -0,0 +1,11 @@ +import { Router, type IRouter } from 'express'; +import { authenticate } from '../middlewares/auth.middleware.js'; +import * as ctrl from '../controllers/admin-addons.controller.js'; + +const router: IRouter = Router(); +router.use(authenticate); + +router.get('/catalogo', ctrl.listCatalogo); +router.put('/catalogo/:id', ctrl.updateCatalogoItem); + +export default router; diff --git a/apps/api/src/routes/admin-clientes.routes.ts b/apps/api/src/routes/admin-clientes.routes.ts new file mode 100644 index 0000000..7ace09d --- /dev/null +++ b/apps/api/src/routes/admin-clientes.routes.ts @@ -0,0 +1,11 @@ +import { Router, type IRouter } from 'express'; +import { authenticate } from '../middlewares/auth.middleware.js'; +import * as ctrl from '../controllers/admin-clientes.controller.js'; + +const router: IRouter = Router(); +router.use(authenticate); + +router.get('/stats', ctrl.getStats); +router.get('/:tenantId/usuarios', ctrl.listUsuarios); + +export default router; diff --git a/apps/api/src/routes/admin-dashboard.routes.ts b/apps/api/src/routes/admin-dashboard.routes.ts new file mode 100644 index 0000000..594fa2c --- /dev/null +++ b/apps/api/src/routes/admin-dashboard.routes.ts @@ -0,0 +1,12 @@ +import { Router, type IRouter } from 'express'; +import { authenticate } from '../middlewares/auth.middleware.js'; +import * as ctrl from '../controllers/admin-dashboard.controller.js'; + +const router: IRouter = Router(); +router.use(authenticate); + +router.get('/metrics', ctrl.getMetrics); +router.get('/despachos', ctrl.listDespachos); +router.get('/activity', ctrl.getActivity); + +export default router; diff --git a/apps/api/src/routes/admin-impersonate.routes.ts b/apps/api/src/routes/admin-impersonate.routes.ts new file mode 100644 index 0000000..4580c36 --- /dev/null +++ b/apps/api/src/routes/admin-impersonate.routes.ts @@ -0,0 +1,11 @@ +import { Router, type IRouter } from 'express'; +import { authenticate } from '../middlewares/auth.middleware.js'; +import * as ctrl from '../controllers/admin-impersonate.controller.js'; + +const router: IRouter = Router(); +router.use(authenticate); + +router.post('/', ctrl.startImpersonation); +router.post('/stop', ctrl.stopImpersonation); + +export default router; diff --git a/apps/api/src/routes/alertas.routes.ts b/apps/api/src/routes/alertas.routes.ts new file mode 100644 index 0000000..fb7cc7a --- /dev/null +++ b/apps/api/src/routes/alertas.routes.ts @@ -0,0 +1,34 @@ +import { Router, type IRouter } from 'express'; +import { authenticate } from '../middlewares/auth.middleware.js'; +import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; +import * as alertasController from '../controllers/alertas.controller.js'; + +const router: IRouter = Router(); + +router.use(authenticate); +router.use(tenantMiddleware); + +router.get('/', alertasController.getAlertas); +router.get('/automaticas', alertasController.getAlertasAutomaticas); +router.get('/manuales', alertasController.getManualesPendientes); +router.patch('/manuales/:id/resolver', alertasController.resolverAlertaManual); +router.get('/drilldown/lista-negra-clientes', alertasController.getListaNegraClientes); +router.get('/drilldown/lista-negra-proveedores', alertasController.getListaNegraProveedores); +router.get('/drilldown/concentracion-clientes', alertasController.getConcentracionClientes); +router.get('/drilldown/concentracion-proveedores', alertasController.getConcentracionProveedores); +router.get('/drilldown/discrepancia-regimen', alertasController.getDiscrepanciaRegimen); +router.get('/drilldown/cancelaciones', alertasController.getCancelados); +router.get('/drilldown/cancelaciones-periodo-anterior', alertasController.getCancelacionesPeriodoAnterior); +router.get('/drilldown/efectivo', alertasController.getEfectivo); +router.get('/drilldown/tipo-relacion-sospechosa', alertasController.getTipoRelacionSospechosa); +router.post('/descartar', alertasController.descartarCfdis); +router.delete('/descartar', alertasController.restaurarDescartados); +router.get('/descartados', alertasController.getDescartados); +router.get('/stats', alertasController.getStats); +router.post('/mark-all-read', alertasController.markAllAsRead); +router.get('/:id', alertasController.getAlerta); +router.post('/', alertasController.createAlerta); +router.patch('/:id', alertasController.updateAlerta); +router.delete('/:id', alertasController.deleteAlerta); + +export { router as alertasRoutes }; diff --git a/apps/api/src/routes/audit-log.routes.ts b/apps/api/src/routes/audit-log.routes.ts new file mode 100644 index 0000000..a5996be --- /dev/null +++ b/apps/api/src/routes/audit-log.routes.ts @@ -0,0 +1,13 @@ +import { Router, type IRouter } from 'express'; +import { authenticate, authorize } from '../middlewares/auth.middleware.js'; +import * as auditLogController from '../controllers/audit-log.controller.js'; + +const router: IRouter = Router(); + +router.use(authenticate); +router.use(authorize('owner', 'cfo')); + +// Solo admin global (verificado dentro del controller) +router.get('/', auditLogController.listAuditLog); + +export { router as auditLogRoutes }; diff --git a/apps/api/src/routes/auth.routes.ts b/apps/api/src/routes/auth.routes.ts new file mode 100644 index 0000000..6aac029 --- /dev/null +++ b/apps/api/src/routes/auth.routes.ts @@ -0,0 +1,69 @@ +import { Router, type IRouter } from 'express'; +import rateLimit from 'express-rate-limit'; +import * as authController from '../controllers/auth.controller.js'; +import { authenticate } from '../middlewares/auth.middleware.js'; +import { strictLimit } from '../middlewares/rate-limit.middleware.js'; + +const router: IRouter = Router(); + +// Rate limiting: 10 login attempts per 15 minutes per IP +const loginLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 10, + message: { message: 'Demasiados intentos de login. Intenta de nuevo en 15 minutos.' }, + standardHeaders: true, + legacyHeaders: false, +}); + +// Rate limiting: 3 registrations per hour per IP +const registerLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, + max: 3, + message: { message: 'Demasiados registros. Intenta de nuevo en 1 hora.' }, + standardHeaders: true, + legacyHeaders: false, +}); + +// Rate limiting: 20 refresh attempts per 15 minutes per IP +const refreshLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 20, + message: { message: 'Demasiadas solicitudes. Intenta de nuevo más tarde.' }, + standardHeaders: true, + legacyHeaders: false, +}); + +// Rate limiting: 3 password reset requests per hour per IP — evita spam + enumeration +const passwordResetRequestLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, + max: 3, + message: { message: 'Demasiadas solicitudes de recuperación. Intenta de nuevo en 1 hora.' }, + standardHeaders: true, + legacyHeaders: false, +}); + +// Rate limiting: 10 confirm attempts per hour per IP — prevenir brute-force del token +const passwordResetConfirmLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, + max: 10, + message: { message: 'Demasiados intentos. Intenta de nuevo más tarde.' }, + standardHeaders: true, + legacyHeaders: false, +}); + +router.post('/register', registerLimiter, authController.register); +router.post('/login', loginLimiter, authController.login); +router.post('/refresh', refreshLimiter, authController.refresh); +router.post('/logout', authenticate, authController.logout); +router.get('/me', authenticate, authController.me); +router.post('/password-reset/request', passwordResetRequestLimiter, authController.requestPasswordReset); +router.post('/password-reset/confirm', passwordResetConfirmLimiter, authController.confirmPasswordReset); +// 10/hora — prevenir brute-force del currentPassword +router.post('/password-change', authenticate, strictLimit, authController.changePassword); +router.post('/logout-all', authenticate, authController.logoutAll); +router.post('/switch-tenant', authenticate, authController.switchTenant); +// Auto-dismiss del onboarding (lo llama el frontend cuando el user completa +// los pasos requeridos). Idempotente — múltiples llamadas no rompen. +router.post('/onboarding/dismiss', authenticate, authController.dismissOnboarding); + +export { router as authRoutes }; diff --git a/apps/api/src/routes/bancos.routes.ts b/apps/api/src/routes/bancos.routes.ts new file mode 100644 index 0000000..1b751ac --- /dev/null +++ b/apps/api/src/routes/bancos.routes.ts @@ -0,0 +1,16 @@ +import { Router, type IRouter } from 'express'; +import { authenticate } from '../middlewares/auth.middleware.js'; +import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; +import * as bancosController from '../controllers/bancos.controller.js'; + +const router: IRouter = Router(); + +router.use(authenticate); +router.use(tenantMiddleware); + +router.get('/', bancosController.getBancos); +router.post('/', bancosController.createBanco); +router.put('/:id', bancosController.updateBanco); +router.delete('/:id', bancosController.deleteBanco); + +export { router as bancosRoutes }; diff --git a/apps/api/src/routes/calendario.routes.ts b/apps/api/src/routes/calendario.routes.ts new file mode 100644 index 0000000..7b69c83 --- /dev/null +++ b/apps/api/src/routes/calendario.routes.ts @@ -0,0 +1,23 @@ +import { Router, type IRouter } from 'express'; +import { authenticate } from '../middlewares/auth.middleware.js'; +import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; +import * as calendarioController from '../controllers/calendario.controller.js'; + +const router: IRouter = Router(); + +router.use(authenticate); +router.use(tenantMiddleware); + +// GET /api/calendario/generados — eventos fiscales + recordatorios custom +router.get('/generados', calendarioController.getEventosGenerados); + +// POST /api/calendario — crear recordatorio custom +router.post('/', calendarioController.createRecordatorio); + +// PATCH /api/calendario/:id — editar recordatorio custom +router.patch('/:id', calendarioController.updateRecordatorio); + +// DELETE /api/calendario/:id — eliminar recordatorio custom +router.delete('/:id', calendarioController.deleteRecordatorio); + +export { router as calendarioRoutes }; diff --git a/apps/api/src/routes/cartera.routes.ts b/apps/api/src/routes/cartera.routes.ts new file mode 100644 index 0000000..308415f --- /dev/null +++ b/apps/api/src/routes/cartera.routes.ts @@ -0,0 +1,32 @@ +import { Router, type IRouter } from 'express'; +import { authenticate, authorize } from '../middlewares/auth.middleware.js'; +import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; +import * as ctrl from '../controllers/cartera.controller.js'; + +const router: IRouter = Router(); + +router.use(authenticate); +router.use(tenantMiddleware); + +// Static routes first +router.get('/supervisores', authorize('owner'), ctrl.getSupervisores); + +// Read: owner + supervisor + auxiliar +router.get('/', authorize('owner', 'supervisor', 'auxiliar'), ctrl.list); +router.get('/:id', authorize('owner', 'supervisor', 'auxiliar'), ctrl.getById); +router.get('/:id/subcarteras', authorize('owner', 'supervisor', 'auxiliar'), ctrl.listSubcarteras); +router.get('/:id/entidades', authorize('owner', 'supervisor', 'auxiliar'), ctrl.getEntidades); +router.get('/:id/auxiliares', authorize('owner', 'supervisor', 'auxiliar'), ctrl.getAuxiliares); +router.get('/:supervisorId/auxiliares-disponibles', authorize('owner', 'supervisor'), ctrl.getAuxiliaresDelSupervisor); + +// Write: owner + supervisor (with permission checks in controller) +router.post('/', authorize('owner', 'supervisor'), ctrl.create); +router.put('/:id', authorize('owner', 'supervisor'), ctrl.update); +router.delete('/:id', authorize('owner', 'supervisor'), ctrl.remove); +router.post('/:id/subcarteras', authorize('owner', 'supervisor'), ctrl.createSubcartera); +router.post('/:id/entidades', authorize('owner', 'supervisor'), ctrl.addEntidad); +router.delete('/:id/entidades/:entidadId', authorize('owner', 'supervisor'), ctrl.removeEntidad); +router.post('/:id/auxiliares', authorize('owner', 'supervisor'), ctrl.addAuxiliar); +router.delete('/:id/auxiliares/:auxiliarUserId', authorize('owner', 'supervisor'), ctrl.removeAuxiliar); + +export default router; diff --git a/apps/api/src/routes/catalogos.routes.ts b/apps/api/src/routes/catalogos.routes.ts new file mode 100644 index 0000000..9b63eda --- /dev/null +++ b/apps/api/src/routes/catalogos.routes.ts @@ -0,0 +1,21 @@ +import { Router, type IRouter } from 'express'; +import { authenticate } from '../middlewares/auth.middleware.js'; +import { relaxedLimit } from '../middlewares/rate-limit.middleware.js'; +import * as catalogosController from '../controllers/catalogos.controller.js'; + +const router: IRouter = Router(); + +router.use(authenticate); +router.use(relaxedLimit); + +router.get('/forma-pago', catalogosController.getFormasPago); +router.get('/metodo-pago', catalogosController.getMetodosPago); +router.get('/uso-cfdi', catalogosController.getUsosCfdi); +router.get('/moneda', catalogosController.getMonedas); +router.get('/clave-unidad', catalogosController.getClavesUnidad); +router.get('/clave-prod-serv', catalogosController.searchClaveProdServ); +router.get('/objeto-imp', catalogosController.getObjetosImp); +router.get('/tipo-relacion', catalogosController.getTiposRelacion); +router.get('/exportacion', catalogosController.getExportaciones); + +export { router as catalogosRoutes }; diff --git a/apps/api/src/routes/cfdi.routes.ts b/apps/api/src/routes/cfdi.routes.ts new file mode 100644 index 0000000..75ccd62 --- /dev/null +++ b/apps/api/src/routes/cfdi.routes.ts @@ -0,0 +1,31 @@ +import { Router, type IRouter } from 'express'; +import express from 'express'; +import { authenticate } from '../middlewares/auth.middleware.js'; +import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; +import { checkPlanLimits, checkCfdiLimit } from '../middlewares/plan-limits.middleware.js'; +import { strictLimit } from '../middlewares/rate-limit.middleware.js'; +import * as cfdiController from '../controllers/cfdi.controller.js'; + +const router: IRouter = Router(); + +router.use(authenticate); +router.use(tenantMiddleware); +router.use(checkPlanLimits); + +router.get('/', cfdiController.getCfdis); +router.get('/resumen', cfdiController.getResumen); +router.get('/emisores', cfdiController.getEmisores); +router.get('/receptores', cfdiController.getReceptores); +router.get('/drill-down', cfdiController.drillDown); +// Listado de conceptos cross-CFDI (pestaña Conceptos en /cfdi). +// Debe registrarse antes que /:id para que Express no lo trate como id. +router.get('/conceptos', cfdiController.listConceptos); +router.get('/:id', cfdiController.getCfdiById); +router.get('/:id/conceptos', cfdiController.getConceptos); +router.get('/:id/xml', cfdiController.getXml); +router.post('/', checkCfdiLimit, cfdiController.createCfdi); +// Bulk upload: 10/hora — procesa hasta 50MB, pesado en parseo + inserts +router.post('/bulk', strictLimit, express.json({ limit: '50mb' }), checkCfdiLimit, cfdiController.createManyCfdis); +router.delete('/:id', cfdiController.deleteCfdi); + +export { router as cfdiRoutes }; diff --git a/apps/api/src/routes/conciliacion.routes.ts b/apps/api/src/routes/conciliacion.routes.ts new file mode 100644 index 0000000..1ab1d4c --- /dev/null +++ b/apps/api/src/routes/conciliacion.routes.ts @@ -0,0 +1,17 @@ +import { Router, type IRouter } from 'express'; +import { authenticate } from '../middlewares/auth.middleware.js'; +import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; +import { requireFeature } from '../middlewares/feature-gate.middleware.js'; +import * as conciliacionController from '../controllers/conciliacion.controller.js'; + +const router: IRouter = Router(); + +router.use(authenticate); +router.use(tenantMiddleware); +router.use(requireFeature('conciliacion')); + +router.get('/', conciliacionController.getCfdis); +router.post('/', conciliacionController.conciliar); +router.delete('/:id', conciliacionController.desconciliar); + +export { router as conciliacionRoutes }; diff --git a/apps/api/src/routes/connector.routes.ts b/apps/api/src/routes/connector.routes.ts new file mode 100644 index 0000000..ce041ce --- /dev/null +++ b/apps/api/src/routes/connector.routes.ts @@ -0,0 +1,15 @@ +import { Router, type IRouter } from 'express'; +import { authenticate, authorize } from '../middlewares/auth.middleware.js'; +import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; +import * as ctrl from '../controllers/connector.controller.js'; + +const router: IRouter = Router(); + +// Public endpoint — called by connector Docker container (no user JWT, uses HORUX_TOKEN) +router.post('/heartbeat', ctrl.heartbeat); + +// Authenticated endpoints — for tenant owners managing their connector +router.get('/status', authenticate, tenantMiddleware, ctrl.status); +router.post('/provision', authenticate, tenantMiddleware, authorize('owner', 'cfo'), ctrl.provision); + +export default router; diff --git a/apps/api/src/routes/contribuyente.routes.ts b/apps/api/src/routes/contribuyente.routes.ts new file mode 100644 index 0000000..63d012a --- /dev/null +++ b/apps/api/src/routes/contribuyente.routes.ts @@ -0,0 +1,47 @@ +import { Router, type IRouter } from 'express'; +import { authenticate, authorize } from '../middlewares/auth.middleware.js'; +import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; +import * as ctrl from '../controllers/contribuyente.controller.js'; +import * as configCtrl from '../controllers/contribuyente-config.controller.js'; +import * as obligacionesCtrl from '../controllers/obligaciones.controller.js'; + +const router: IRouter = Router(); + +router.use(authenticate); +router.use(tenantMiddleware); + +// === Static routes FIRST (before /:id to avoid route conflict) === +router.get('/', ctrl.list); +router.post('/', authorize('owner', 'cfo'), ctrl.create); +router.post('/backfill', authorize('owner'), ctrl.backfill); +router.get('/catalogo-obligaciones', obligacionesCtrl.getCatalogo); + +// === Dynamic routes with :id === +router.get('/:id', ctrl.getById); +router.put('/:id', authorize('owner', 'cfo'), ctrl.update); +router.delete('/:id', authorize('owner'), ctrl.deactivate); +router.post('/:id/cliente-acceso', authorize('owner', 'supervisor'), ctrl.addClienteAcceso); + +// FIEL per contribuyente +router.post('/:id/fiel', authorize('owner', 'cfo'), configCtrl.uploadFiel); +router.get('/:id/fiel/status', configCtrl.fielStatus); +router.delete('/:id/fiel', authorize('owner', 'cfo'), configCtrl.deleteFiel); + +// Facturapi per contribuyente +router.post('/:id/facturapi/org', authorize('owner', 'cfo'), configCtrl.createOrg); +router.get('/:id/facturapi/status', configCtrl.orgStatus); +router.post('/:id/facturapi/csd', authorize('owner', 'cfo'), configCtrl.uploadCsd); + +// Obligaciones fiscales per contribuyente +router.get('/:id/obligaciones/periodo', obligacionesCtrl.getObligacionesPorPeriodo); +router.get('/:id/obligaciones', obligacionesCtrl.getObligaciones); +router.post('/:id/obligaciones/init', authorize('owner', 'cfo'), obligacionesCtrl.initRecomendaciones); +router.post('/:id/obligaciones', authorize('owner', 'cfo'), obligacionesCtrl.addObligacion); +router.delete('/:id/obligaciones/:obligacionId', authorize('owner', 'cfo'), obligacionesCtrl.removeObligacion); +router.post('/:id/obligaciones/:obligacionId/restore', authorize('owner', 'cfo'), obligacionesCtrl.restoreObligacion); +router.post('/:id/obligaciones/:obligacionId/complete', authorize('owner', 'cfo', 'contador', 'auxiliar'), obligacionesCtrl.completeObligacion); +router.post('/:id/obligaciones/:obligacionId/uncomplete', authorize('owner', 'cfo', 'contador', 'auxiliar'), obligacionesCtrl.uncompleteObligacion); +router.post('/:id/obligaciones/:obligacionId/complete-periodo', authorize('owner', 'cfo', 'contador', 'auxiliar'), obligacionesCtrl.completePeriodo); +router.post('/:id/obligaciones/:obligacionId/uncomplete-periodo', authorize('owner', 'cfo', 'contador', 'auxiliar'), obligacionesCtrl.uncompletePeriodo); + +export default router; diff --git a/apps/api/src/routes/dashboard.routes.ts b/apps/api/src/routes/dashboard.routes.ts new file mode 100644 index 0000000..0e89041 --- /dev/null +++ b/apps/api/src/routes/dashboard.routes.ts @@ -0,0 +1,20 @@ +import { Router, type IRouter } from 'express'; +import { authenticate } from '../middlewares/auth.middleware.js'; +import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; +import { checkPlanLimits } from '../middlewares/plan-limits.middleware.js'; +import { normalLimit } from '../middlewares/rate-limit.middleware.js'; +import * as dashboardController from '../controllers/dashboard.controller.js'; + +const router: IRouter = Router(); + +router.use(authenticate); +router.use(normalLimit); +router.use(tenantMiddleware); +router.use(checkPlanLimits); + +router.get('/kpis', dashboardController.getKpis); +router.get('/ingresos-egresos', dashboardController.getIngresosEgresos); +router.get('/regimenes-periodo', dashboardController.getRegimenesDelPeriodo); +router.get('/alertas', dashboardController.getAlertas); + +export { router as dashboardRoutes }; diff --git a/apps/api/src/routes/despacho-audit.routes.ts b/apps/api/src/routes/despacho-audit.routes.ts new file mode 100644 index 0000000..b61923e --- /dev/null +++ b/apps/api/src/routes/despacho-audit.routes.ts @@ -0,0 +1,12 @@ +import { Router, type IRouter } from 'express'; +import { authenticate } from '../middlewares/auth.middleware.js'; +import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; +import * as ctrl from '../controllers/despacho-audit.controller.js'; + +const router: IRouter = Router(); +router.use(authenticate); +router.use(tenantMiddleware); + +router.get('/', ctrl.getDespachoAuditLog); + +export default router; diff --git a/apps/api/src/routes/despacho-stats.routes.ts b/apps/api/src/routes/despacho-stats.routes.ts new file mode 100644 index 0000000..d9bc0bd --- /dev/null +++ b/apps/api/src/routes/despacho-stats.routes.ts @@ -0,0 +1,15 @@ +import { Router, type IRouter } from 'express'; +import { authenticate } from '../middlewares/auth.middleware.js'; +import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; +import * as ctrl from '../controllers/despacho-stats.controller.js'; + +const router: IRouter = Router(); + +router.use(authenticate); +router.use(tenantMiddleware); + +router.get('/contribuyentes-stats', ctrl.getContribuyentesStats); +router.get('/mis-asignados', ctrl.getMisAsignados); +router.get('/equipo-stats', ctrl.getEquipoStats); + +export { router as despachoStatsRoutes }; diff --git a/apps/api/src/routes/despacho.routes.ts b/apps/api/src/routes/despacho.routes.ts new file mode 100644 index 0000000..92338d6 --- /dev/null +++ b/apps/api/src/routes/despacho.routes.ts @@ -0,0 +1,17 @@ +import { Router, type IRouter } from 'express'; +import rateLimit from 'express-rate-limit'; +import { signup, getMyPlan } from '../controllers/despacho.controller.js'; +import { authenticate } from '../middlewares/auth.middleware.js'; + +const router: IRouter = Router(); + +const signupLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, + max: 5, + message: { message: 'Demasiados intentos de registro. Intenta en una hora.' }, +}); + +router.post('/signup', signupLimiter, signup); +router.get('/me/plan', authenticate, getMyPlan); + +export default router; diff --git a/apps/api/src/routes/documentos.routes.ts b/apps/api/src/routes/documentos.routes.ts new file mode 100644 index 0000000..d264bcc --- /dev/null +++ b/apps/api/src/routes/documentos.routes.ts @@ -0,0 +1,38 @@ +import { Router, type IRouter } from 'express'; +import { authenticate, authorize } from '../middlewares/auth.middleware.js'; +import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; +import { requireFeature } from '../middlewares/feature-gate.middleware.js'; +import { veryStrictLimit } from '../middlewares/rate-limit.middleware.js'; +import * as documentosController from '../controllers/documentos.controller.js'; + +const router: IRouter = Router(); + +router.use(authenticate); +router.use(tenantMiddleware); +router.use(requireFeature('documentos')); + +router.get('/opiniones', documentosController.listarOpiniones); +router.get('/opiniones/:id/pdf', documentosController.descargarPdf); +// 2/día — Playwright headless contra portal SAT es caro +router.post('/opiniones/consultar', veryStrictLimit, authorize('owner', 'cfo'), documentosController.consultarManual); + +// Constancia de Situación Fiscal +router.get('/constancias', documentosController.listarConstancias); +router.get('/constancias/:id/pdf', documentosController.descargarConstanciaPdf); +router.post('/constancias/consultar', veryStrictLimit, authorize('owner', 'cfo'), documentosController.consultarConstanciaManual); + +// Declaraciones provisionales +router.get('/declaraciones', documentosController.listarDeclaraciones); +router.post('/declaraciones', documentosController.crearDeclaracion); +router.post('/declaraciones/:id/comprobante-pago', documentosController.subirComprobantePago); +router.get('/declaraciones/:id/pdf/:variant', documentosController.descargarDeclaracionPdf); +router.delete('/declaraciones/:id', documentosController.eliminarDeclaracion); + +// Extras (PDFs libres) +router.get('/extras', documentosController.listarExtras); +router.get('/extras/categorias', documentosController.listarCategoriasExtras); +router.post('/extras', documentosController.crearExtra); +router.get('/extras/:id/pdf', documentosController.descargarExtraPdf); +router.delete('/extras/:id', documentosController.eliminarExtra); + +export { router as documentosRoutes }; diff --git a/apps/api/src/routes/export.routes.ts b/apps/api/src/routes/export.routes.ts new file mode 100644 index 0000000..ed5eba8 --- /dev/null +++ b/apps/api/src/routes/export.routes.ts @@ -0,0 +1,14 @@ +import { Router, type IRouter } from 'express'; +import { authenticate } from '../middlewares/auth.middleware.js'; +import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; +import * as exportController from '../controllers/export.controller.js'; + +const router: IRouter = Router(); + +router.use(authenticate); +router.use(tenantMiddleware); + +router.get('/cfdis', exportController.exportCfdis); +router.get('/reporte', exportController.exportReporte); + +export { router as exportRoutes }; diff --git a/apps/api/src/routes/facturacion.routes.ts b/apps/api/src/routes/facturacion.routes.ts new file mode 100644 index 0000000..e5c2b6f --- /dev/null +++ b/apps/api/src/routes/facturacion.routes.ts @@ -0,0 +1,64 @@ +import { Router, type IRouter } from 'express'; +import { authenticate, authorize } from '../middlewares/auth.middleware.js'; +import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; +import { strictLimit } from '../middlewares/rate-limit.middleware.js'; +import * as facturacionController from '../controllers/facturacion.controller.js'; + +const router: IRouter = Router(); + +router.use(authenticate); +router.use(tenantMiddleware); + +// Organización Facturapi +router.get('/org/status', facturacionController.getOrgStatus); +router.post('/org', authorize('owner', 'cfo'), facturacionController.createOrg); + +// CSD +router.post('/csd', authorize('owner', 'cfo'), facturacionController.uploadCsd); + +// Estado LCO — banner "CSD en proceso de validación" si hubo rechazo del SAT en últimas 24h +router.get('/lco-status', facturacionController.getLcoStatus); + +// Emisión y cancelación (admin + contador). 10/hora — cada emisión quema timbre + side-effect en Facturapi/SAT +router.post('/emitir', strictLimit, facturacionController.emitir); +router.post('/cancelar/:uuid', strictLimit, facturacionController.cancelar); + +// Descargas +router.get('/pdf/:id', facturacionController.downloadPdf); +router.get('/xml/:id', facturacionController.downloadXml); + +// Timbres +router.get('/timbres', facturacionController.getTimbres); +router.get('/timbres/paquetes-catalogo', facturacionController.getPaquetesCatalogo); +// 10/h — cada compra crea MP Preference (side-effect en tercero) +router.post('/timbres/paquetes/comprar', strictLimit, facturacionController.comprarPaquete); +// Admin global: catálogo completo + editar precios +router.get('/timbres/paquetes-catalogo/admin', facturacionController.getPaquetesCatalogoAdmin); +router.put('/timbres/paquetes-catalogo/:id', facturacionController.updatePaqueteCatalogo); + +// Personalización (logo, color) +router.get('/customization', facturacionController.getCustomization); +router.post('/logo', facturacionController.uploadLogo); +router.put('/color', facturacionController.updateColor); + +// Datos fiscales del tenant +router.get('/datos-fiscales', facturacionController.getDatosFiscales); +router.put('/datos-fiscales', facturacionController.updateDatosFiscales); + +// Preferencias de auto-facturación de pagos de suscripción (Fase 2) +router.get('/preferencias-facturacion', facturacionController.getPreferenciasFacturacion); +router.put('/preferencias-facturacion', facturacionController.updatePreferenciasFacturacion); + +// Búsqueda de RFCs para autocompletar +router.get('/rfcs/search', facturacionController.searchRfcs); + +// Búsqueda de conceptos previos +router.get('/conceptos/search', facturacionController.searchConceptos); + +// CFDIs PPD pendientes de pago por RFC +router.get('/cfdis-ppd', facturacionController.getCfdisPpdPendientes); + +// CFDIs emitidos por el contribuyente al receptor (para sección "CFDIs relacionados") +router.get('/cfdis-relacionables', facturacionController.getCfdisRelacionables); + +export { router as facturacionRoutes }; diff --git a/apps/api/src/routes/fiel.routes.ts b/apps/api/src/routes/fiel.routes.ts new file mode 100644 index 0000000..177b5a3 --- /dev/null +++ b/apps/api/src/routes/fiel.routes.ts @@ -0,0 +1,20 @@ +import { Router, type IRouter } from 'express'; +import * as fielController from '../controllers/fiel.controller.js'; +import { authenticate } from '../middlewares/auth.middleware.js'; +import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; + +const router: IRouter = Router(); + +router.use(authenticate); +router.use(tenantMiddleware); + +// POST /api/fiel/upload - Subir credenciales FIEL +router.post('/upload', fielController.upload); + +// GET /api/fiel/status - Obtener estado de la FIEL +router.get('/status', fielController.status); + +// DELETE /api/fiel - Eliminar credenciales FIEL +router.delete('/', fielController.remove); + +export default router; diff --git a/apps/api/src/routes/impuestos.routes.ts b/apps/api/src/routes/impuestos.routes.ts new file mode 100644 index 0000000..7784cd6 --- /dev/null +++ b/apps/api/src/routes/impuestos.routes.ts @@ -0,0 +1,30 @@ +import { Router, type IRouter } from 'express'; +import { authenticate } from '../middlewares/auth.middleware.js'; +import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; +import { normalLimit } from '../middlewares/rate-limit.middleware.js'; +import * as impuestosController from '../controllers/impuestos.controller.js'; +import * as activosFijosController from '../controllers/activos-fijos.controller.js'; + +const router: IRouter = Router(); + +router.use(authenticate); +router.use(normalLimit); +router.use(tenantMiddleware); + +router.get('/iva/mensual', impuestosController.getIvaMensual); +router.get('/iva/resumen', impuestosController.getResumenIva); +router.get('/isr/mensual', impuestosController.getIsrMensual); +router.get('/isr/resumen', impuestosController.getResumenIsr); +router.get('/isr/resumen-desglosado', impuestosController.getResumenIsrDesglosado); +router.get('/isr/coeficiente', impuestosController.getCoeficiente); +router.put('/isr/coeficiente', impuestosController.setCoeficiente); + +// Activos fijos: vista informativa de deducción mensual proporcional. +// NO afecta gastos ni ISR — el SAT y el dashboard tratan estos CFDIs +// como gasto del periodo (igual que antes). +router.get('/activos-fijos', activosFijosController.list); +router.put('/activos-fijos/usos-excluidos', activosFijosController.setUsosExcluidos); +router.post('/activos-fijos/:cfdiId/baja', activosFijosController.darDeBaja); +router.delete('/activos-fijos/:cfdiId/baja', activosFijosController.revertirBaja); + +export { router as impuestosRoutes }; diff --git a/apps/api/src/routes/metricas.routes.ts b/apps/api/src/routes/metricas.routes.ts new file mode 100644 index 0000000..ae90a5c --- /dev/null +++ b/apps/api/src/routes/metricas.routes.ts @@ -0,0 +1,12 @@ +import { Router, type IRouter } from 'express'; +import { authenticate } from '../middlewares/auth.middleware.js'; +import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; +import * as ctrl from '../controllers/metricas.controller.js'; + +const router: IRouter = Router(); +router.use(authenticate); +router.use(tenantMiddleware); + +router.get('/mensuales', ctrl.getMensuales); + +export default router; diff --git a/apps/api/src/routes/notification-preferences.routes.ts b/apps/api/src/routes/notification-preferences.routes.ts new file mode 100644 index 0000000..aae9297 --- /dev/null +++ b/apps/api/src/routes/notification-preferences.routes.ts @@ -0,0 +1,14 @@ +import { Router, type IRouter } from 'express'; +import { authenticate } from '../middlewares/auth.middleware.js'; +import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; +import * as ctrl from '../controllers/notification-preferences.controller.js'; + +const router: IRouter = Router(); + +router.use(authenticate); +router.use(tenantMiddleware); + +router.get('/', ctrl.listPreferences); +router.put('/', ctrl.updatePreferences); + +export { router as notificationPreferencesRoutes }; diff --git a/apps/api/src/routes/papeleria.routes.ts b/apps/api/src/routes/papeleria.routes.ts new file mode 100644 index 0000000..91c3e03 --- /dev/null +++ b/apps/api/src/routes/papeleria.routes.ts @@ -0,0 +1,18 @@ +import { Router, type IRouter } from 'express'; +import { authenticate } from '../middlewares/auth.middleware.js'; +import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; +import * as ctrl from '../controllers/papeleria.controller.js'; + +const router: IRouter = Router(); + +router.use(authenticate); +router.use(tenantMiddleware); + +router.get('/', ctrl.list); +router.post('/', ctrl.upload); +router.get('/:id/download', ctrl.download); +router.post('/:id/aprobar', ctrl.aprobar); +router.post('/:id/rechazar', ctrl.rechazar); +router.delete('/:id', ctrl.eliminar); + +export { router as papeleriaRoutes }; diff --git a/apps/api/src/routes/plan-catalogo.routes.ts b/apps/api/src/routes/plan-catalogo.routes.ts new file mode 100644 index 0000000..a175ffd --- /dev/null +++ b/apps/api/src/routes/plan-catalogo.routes.ts @@ -0,0 +1,17 @@ +import { Router, type IRouter } from 'express'; +import * as ctrl from '../controllers/plan-catalogo.controller.js'; +import { authenticate } from '../middlewares/auth.middleware.js'; + +const router: IRouter = Router(); + +// Public endpoints — no auth needed (pricing page) +router.get('/', ctrl.getPlans); +router.get('/addons', ctrl.getAddons); + +// Catálogo despacho — admin only (require auth) +router.get('/despacho', authenticate, ctrl.listDespachoCatalogo); +router.patch('/despacho/:plan', authenticate, ctrl.updateDespachoCatalogo); + +router.get('/:codename', ctrl.getPlan); + +export default router; diff --git a/apps/api/src/routes/platform-staff.routes.ts b/apps/api/src/routes/platform-staff.routes.ts new file mode 100644 index 0000000..a3e6611 --- /dev/null +++ b/apps/api/src/routes/platform-staff.routes.ts @@ -0,0 +1,16 @@ +import { Router, type IRouter } from 'express'; +import { authenticate, authorize } from '../middlewares/auth.middleware.js'; +import * as platformStaffController from '../controllers/platform-staff.controller.js'; + +const router: IRouter = Router(); + +router.use(authenticate); +router.use(authorize('owner', 'cfo')); + +// Solo platform_admin (verificado dentro del controller) +router.get('/', platformStaffController.listStaff); +router.get('/search', platformStaffController.searchUsers); +router.post('/grant', platformStaffController.grantRole); +router.post('/revoke', platformStaffController.revokeRole); + +export { router as platformStaffRoutes }; diff --git a/apps/api/src/routes/regimen.routes.ts b/apps/api/src/routes/regimen.routes.ts new file mode 100644 index 0000000..dc5de36 --- /dev/null +++ b/apps/api/src/routes/regimen.routes.ts @@ -0,0 +1,28 @@ +import { Router, type IRouter } from 'express'; +import { authenticate } from '../middlewares/auth.middleware.js'; +import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; +import { relaxedLimit } from '../middlewares/rate-limit.middleware.js'; +import * as regimenController from '../controllers/regimen.controller.js'; + +const router: IRouter = Router(); + +router.use(authenticate); +router.use(relaxedLimit); +router.use(tenantMiddleware); + +// GET /api/regimenes — catálogo completo +router.get('/', regimenController.getAllRegimenes); + +// GET /api/regimenes/activos — regímenes activos del tenant +router.get('/activos', regimenController.getActivos); + +// PUT /api/regimenes/activos — configurar regímenes activos +router.put('/activos', regimenController.setActivos); + +// GET /api/regimenes/ignorados — regímenes ignorados del tenant actual +router.get('/ignorados', regimenController.getIgnorados); + +// PUT /api/regimenes/ignorados — configurar regímenes ignorados +router.put('/ignorados', regimenController.setIgnorados); + +export { router as regimenRoutes }; diff --git a/apps/api/src/routes/reportes.routes.ts b/apps/api/src/routes/reportes.routes.ts new file mode 100644 index 0000000..4ae8ad6 --- /dev/null +++ b/apps/api/src/routes/reportes.routes.ts @@ -0,0 +1,24 @@ +import { Router, type IRouter } from 'express'; +import { authenticate } from '../middlewares/auth.middleware.js'; +import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; +import { checkPlanLimits } from '../middlewares/plan-limits.middleware.js'; +import { requireFeature } from '../middlewares/feature-gate.middleware.js'; +import { normalLimit } from '../middlewares/rate-limit.middleware.js'; +import * as reportesController from '../controllers/reportes.controller.js'; + +const router: IRouter = Router(); + +router.use(authenticate); +router.use(normalLimit); +router.use(tenantMiddleware); +router.use(checkPlanLimits); +router.use(requireFeature('reportes')); + +router.get('/estado-resultados', reportesController.getEstadoResultados); +router.get('/flujo-efectivo', reportesController.getFlujoEfectivo); +router.get('/comparativo', reportesController.getComparativo); +router.get('/concentrado-rfc', reportesController.getConcentradoRfc); +router.get('/cuentas-x-pagar', reportesController.getCuentasXPagar); +router.get('/cuentas-x-cobrar', reportesController.getCuentasXCobrar); + +export { router as reportesRoutes }; diff --git a/apps/api/src/routes/sat.routes.ts b/apps/api/src/routes/sat.routes.ts new file mode 100644 index 0000000..0dcf7ce --- /dev/null +++ b/apps/api/src/routes/sat.routes.ts @@ -0,0 +1,31 @@ +import { Router, type IRouter } from 'express'; +import * as satController from '../controllers/sat.controller.js'; +import { authenticate, authorize } from '../middlewares/auth.middleware.js'; +import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; +import { veryStrictLimit } from '../middlewares/rate-limit.middleware.js'; + +const router: IRouter = Router(); + +router.use(authenticate); +router.use(tenantMiddleware); + +// POST /api/sat/sync - Iniciar sincronización manual (2/día por user, admin global exento) +router.post('/sync', veryStrictLimit, satController.start); + +// GET /api/sat/sync/status - Estado actual de sincronización +router.get('/sync/status', satController.status); + +// GET /api/sat/sync/history - Historial de sincronizaciones +router.get('/sync/history', satController.history); + +// GET /api/sat/sync/:id - Detalle de un job +router.get('/sync/:id', satController.jobDetail); + +// POST /api/sat/sync/:id/retry - Reintentar job fallido +router.post('/sync/:id/retry', satController.retry); + +// Admin-only cron endpoints (global admin verified in controller) +router.get('/cron', authorize('owner', 'cfo'), satController.cronInfo); +router.post('/cron/run', authorize('owner', 'cfo'), satController.runCron); + +export default router; diff --git a/apps/api/src/routes/subscription.routes.ts b/apps/api/src/routes/subscription.routes.ts new file mode 100644 index 0000000..6329aac --- /dev/null +++ b/apps/api/src/routes/subscription.routes.ts @@ -0,0 +1,37 @@ +import { Router, type IRouter } from 'express'; +import { authenticate, authorize } from '../middlewares/auth.middleware.js'; +import { strictLimit } from '../middlewares/rate-limit.middleware.js'; +import * as subscriptionController from '../controllers/subscription.controller.js'; + +const router: IRouter = Router(); + +// All endpoints require authentication + admin role +router.use(authenticate); +router.use(authorize('owner', 'cfo')); + +// (Endpoints /plans y /plans/:id eliminados con el modelo PlanPrice legacy. +// Para precios despacho usa /api/planes/despacho.) + +// Self-serve: el usuario actúa sobre el tenant de su JWT +router.post('/me/trial', subscriptionController.startMyTrial); +// 10/hora — crea preapprovals/preferences en MercadoPago (side-effect en tercero) +router.post('/me/subscribe', strictLimit, subscriptionController.subscribeMe); +router.post('/me/change', strictLimit, subscriptionController.changeMyPlan); +router.post('/me/upgrade', strictLimit, subscriptionController.upgradeMe); +router.post('/me/upgrade/cancel', subscriptionController.cancelMyPendingUpgrade); +router.post('/me/cancel', subscriptionController.cancelMySubscription); +router.post('/me/reactivate', subscriptionController.reactivateMe); +router.get('/me/addons', subscriptionController.getMyAddons); +router.post('/me/addons', strictLimit, subscriptionController.addMyAddon); +router.delete('/me/addons/:addonId', subscriptionController.cancelMyAddon); + +// Listar todas las suscripciones (global admin) +router.get('/', subscriptionController.getAllSubscriptions); + +// Admin subscription management (global admin verified in controller) +router.get('/:tenantId', subscriptionController.getSubscription); +router.post('/:tenantId/generate-link', subscriptionController.generatePaymentLink); +router.post('/:tenantId/mark-paid', subscriptionController.markAsPaid); +router.get('/:tenantId/payments', subscriptionController.getPayments); + +export { router as subscriptionRoutes }; diff --git a/apps/api/src/routes/tareas.routes.ts b/apps/api/src/routes/tareas.routes.ts new file mode 100644 index 0000000..271b429 --- /dev/null +++ b/apps/api/src/routes/tareas.routes.ts @@ -0,0 +1,20 @@ +import { Router, type IRouter } from 'express'; +import { authenticate } from '../middlewares/auth.middleware.js'; +import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; +import * as ctrl from '../controllers/tareas.controller.js'; + +const router: IRouter = Router(); + +router.use(authenticate); +router.use(tenantMiddleware); + +router.get('/', ctrl.listTareas); +router.post('/', ctrl.createTarea); +router.post('/seed', ctrl.seedDefaults); +router.patch('/:id', ctrl.updateTarea); +router.delete('/:id', ctrl.deleteTarea); + +router.post('/periodo/:id/completar', ctrl.completarPeriodo); +router.delete('/periodo/:id/completar', ctrl.descompletarPeriodo); + +export { router as tareasRoutes }; diff --git a/apps/api/src/routes/tenants.routes.ts b/apps/api/src/routes/tenants.routes.ts new file mode 100644 index 0000000..17764b9 --- /dev/null +++ b/apps/api/src/routes/tenants.routes.ts @@ -0,0 +1,20 @@ +import { Router, type IRouter } from 'express'; +import { authenticate } from '../middlewares/auth.middleware.js'; +import * as tenantsController from '../controllers/tenants.controller.js'; + +const router: IRouter = Router(); + +router.use(authenticate); + +// Self-serve multi-tenant (el caller actúa sobre sus propias memberships) +router.get('/mine', tenantsController.getMyTenants); +router.post('/mine', tenantsController.addMyTenant); + +// Admin global +router.get('/', tenantsController.getAllTenants); +router.get('/:id', tenantsController.getTenant); +router.post('/', tenantsController.createTenant); +router.put('/:id', tenantsController.updateTenant); +router.delete('/:id', tenantsController.deleteTenant); + +export { router as tenantsRoutes }; diff --git a/apps/api/src/routes/usuarios.routes.ts b/apps/api/src/routes/usuarios.routes.ts new file mode 100644 index 0000000..0dbda62 --- /dev/null +++ b/apps/api/src/routes/usuarios.routes.ts @@ -0,0 +1,29 @@ +import { Router, type IRouter } from 'express'; +import { authenticate } from '../middlewares/auth.middleware.js'; +import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; +import * as usuariosController from '../controllers/usuarios.controller.js'; + +const router: IRouter = Router(); + +router.use(authenticate); + +// Rutas por tenant +router.get('/', usuariosController.getUsuarios); +router.post('/invite', usuariosController.inviteUsuario); +router.patch('/:id', usuariosController.updateUsuario); +router.delete('/:id', usuariosController.deleteUsuario); + +// Cliente accesos (requires tenantPool) +router.get('/:id/accesos', tenantMiddleware, usuariosController.getClienteAccesos); +router.post('/:id/accesos', tenantMiddleware, usuariosController.setClienteAccesos); + +// Supervisor de auxiliar (requires tenantPool) +router.get('/:id/supervisor', tenantMiddleware, usuariosController.getSupervisor); +router.put('/:id/supervisor', tenantMiddleware, usuariosController.updateSupervisor); + +// Rutas globales (solo admin global) +router.get('/global/all', usuariosController.getAllUsuarios); +router.patch('/global/:id', usuariosController.updateUsuarioGlobal); +router.delete('/global/:id', usuariosController.deleteUsuarioGlobal); + +export { router as usuariosRoutes }; diff --git a/apps/api/src/routes/webhook.routes.ts b/apps/api/src/routes/webhook.routes.ts new file mode 100644 index 0000000..7096d02 --- /dev/null +++ b/apps/api/src/routes/webhook.routes.ts @@ -0,0 +1,9 @@ +import { Router, type IRouter } from 'express'; +import { handleMercadoPagoWebhook } from '../controllers/webhook.controller.js'; + +const router: IRouter = Router(); + +// Public endpoint — no auth middleware +router.post('/mercadopago', handleMercadoPagoWebhook); + +export { router as webhookRoutes }; diff --git a/apps/api/src/services/_shared/cfdi-filters.ts b/apps/api/src/services/_shared/cfdi-filters.ts new file mode 100644 index 0000000..98eaf8a --- /dev/null +++ b/apps/api/src/services/_shared/cfdi-filters.ts @@ -0,0 +1,148 @@ +/** + * Helpers para construir fragmentos AND adicionales en WHERE clauses según + * los toggles "Considerar activos" y "Considerar NCs" de la UI de impuestos. + * + * - considerarActivos === false → excluir facturas relacionadas a activos: + * 1) I directo con uso_cfdi I01-I08. + * 2) P pagando una I-activo (vía uuid_relacionado). + * 3) E que referencia una I-activo o una P-de-activo (vía cfdis_relacionados, + * cualquier tipoRel — cubre NCs tipoRel=01, devoluciones tipoRel=03, etc.). + * 4) Anticipo (I PUE/PPD) que es referenciado por una I/07 PPD con uso_cfdi + * de activo (la I/07 "aplica" el anticipo al activo, así que el anticipo + * también es para un activo). + * - considerarNCs === false → excluir TODAS las facturas tipo E (cualquier + * tipo_relacion). Además, los callers que aplican la compensación I/07 PPD + * ↔ E (ingresos Grupo 1 y deducciones) deben saltarla cuando este flag es + * false (la compensación lee valores de E para compensar; sin E, no aplica). + * + * Cuando ambos son true (default backend = "include todo"), retorna string + * vacío. Esto preserva el comportamiento histórico para callers que no pasan + * los flags (ej. dashboard, reportes). + * + * Las versiones `Alias` se usan en subqueries con alias de tabla + * (ej. `cfdis e` en SUM_E_REFERENCING_*). El filtro de NCs aplica directo; + * el de activos aplica también pero algunos predicados son no-op funcional + * en subqueries que filtran por tipo_comprobante específico (Postgres los + * optimiza away). + */ + +const ACTIVOS_USOS = "('I01','I02','I03','I04','I05','I06','I07','I08')"; + +/** + * Predicado SQL que detecta si el row actual (sin alias de tabla, asume + * `FROM cfdis`) referencia un activo directamente (I), indirectamente vía + * pago (P→I), o transitivamente vía relación (E→I, E→P→I). + * + * IMPORTANTE — qualifying outer refs: dentro de los subqueries `cfdis i_act` + * y `cfdis r_act`, la tabla interna también tiene columnas `uuid_relacionado` + * y `cfdis_relacionados`. Una referencia no-qualificada las resolvería a las + * columnas internas (NO al row outer), volviendo el predicado a no-op. + * Por eso usamos `cfdis.uuid_relacionado` y `cfdis.cfdis_relacionados` + * explícitamente — fuerza la resolución al outer. + */ +function activosExclusionNoAlias(): string { + return ` + AND NOT (tipo_comprobante = 'I' AND uso_cfdi IN ${ACTIVOS_USOS}) + 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_USOS} + )) + 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_USOS}) + 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_USOS} + )) + ) + )) + AND NOT (tipo_comprobante = 'I' AND EXISTS ( + -- Anticipo: CFDI tipo I (puede no tener uso_cfdi de activo) que es + -- referenciado por una I/07 PPD con uso_cfdi de activo. La I/07 PPD + -- "aplica" el anticipo a la compra del activo, así que el anticipo + -- también debe filtrarse cuando se desactiva "Considerar activos". + 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), '|')) + )) + `.trim(); +} + +/** + * Misma lógica que activosExclusionNoAlias pero referenciando columnas con + * el alias de tabla externo (ej. 'e' en `FROM cfdis e`). + */ +function activosExclusionAlias(alias: string): string { + return ` + AND NOT (${alias}.tipo_comprobante = 'I' AND ${alias}.uso_cfdi IN ${ACTIVOS_USOS}) + AND NOT (${alias}.tipo_comprobante = 'P' AND EXISTS ( + SELECT 1 FROM cfdis i_act + WHERE LOWER(i_act.uuid) = LOWER(${alias}.uuid_relacionado) + AND i_act.tipo_comprobante = 'I' + AND i_act.uso_cfdi IN ${ACTIVOS_USOS} + )) + AND NOT (${alias}.tipo_comprobante = 'E' AND ${alias}.cfdis_relacionados IS NOT NULL AND EXISTS ( + SELECT 1 FROM cfdis r_act + WHERE LOWER(r_act.uuid) = ANY(string_to_array(LOWER(${alias}.cfdis_relacionados), '|')) + AND ( + (r_act.tipo_comprobante = 'I' AND r_act.uso_cfdi IN ${ACTIVOS_USOS}) + 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_USOS} + )) + ) + )) + AND NOT (${alias}.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(${alias}.uuid) = ANY(string_to_array(LOWER(i07_act.cfdis_relacionados), '|')) + )) + `.trim(); +} + +export function buildExtraFilters( + considerarActivos: boolean, + considerarNCs: boolean, +): string { + const parts: string[] = []; + if (!considerarActivos) { + parts.push(activosExclusionNoAlias()); + } + if (!considerarNCs) { + parts.push(`AND NOT (tipo_comprobante = 'E')`); + } + return parts.length > 0 ? ' ' + parts.join(' ') : ''; +} + +export function buildExtraFiltersAlias( + alias: string, + considerarActivos: boolean, + considerarNCs: boolean, +): string { + const parts: string[] = []; + if (!considerarActivos) { + parts.push(activosExclusionAlias(alias)); + } + if (!considerarNCs) { + parts.push(`AND NOT (${alias}.tipo_comprobante = 'E')`); + } + return parts.length > 0 ? ' ' + parts.join(' ') : ''; +} diff --git a/apps/api/src/services/activos-fijos.service.ts b/apps/api/src/services/activos-fijos.service.ts new file mode 100644 index 0000000..65f6e82 --- /dev/null +++ b/apps/api/src/services/activos-fijos.service.ts @@ -0,0 +1,265 @@ +import type { Pool } from 'pg'; +import { resolveContribuyenteContext } from '../utils/contribuyente-context.js'; + +/** + * Activos fijos: CFDIs tipo I con uso_cfdi I01-I08 recibidos por el + * contribuyente bajo régimen fiscal aplicable. Vista INFORMATIVA — no + * modifica gastos ni ISR (el sistema sigue tratándolos como gasto del + * periodo, igual que el SAT). Esta vista permite que el contador haga + * su seguimiento de deducción mensual proporcional y decida si la + * aplica o no en su declaración. + * + * % anual de deducción según LISR Art. 34. Mensual = anual / 12. + */ +export const PORCENTAJES_ANUALES: Record = { + I01: { concepto: 'Construcciones', pct: 0.05 }, + I02: { concepto: 'Mobiliario y equipo de oficina', pct: 0.10 }, + I03: { concepto: 'Equipo de transporte', pct: 0.25 }, + I04: { concepto: 'Equipo de cómputo y accesorios', pct: 0.30 }, + I05: { concepto: 'Dados, troqueles, moldes, matrices', pct: 0.35 }, + I06: { concepto: 'Comunicaciones telefónicas', pct: 0.10 }, + I07: { concepto: 'Comunicaciones satelitales', pct: 0.08 }, + I08: { concepto: 'Otra maquinaria y equipo', pct: 0.10 }, +}; + +const USOS_CFDI = Object.keys(PORCENTAJES_ANUALES); +const REGIMENES_APLICABLES = ['601', '606', '611', '612', '625', '626']; + +export type EstadoActivo = 'activo' | 'agotado' | 'baja_venta' | 'baja_desecho' | 'baja_otro'; + +export interface ActivoFijoItem { + cfdiId: number; + uuid: string; + fechaEmision: string; + rfcEmisor: string; + nombreEmisor: string; + usoCfdi: string; + concepto: string; + porcentajeAnual: number; + porcentajeMensual: number; + total: number; + iva: number; + moi: number; + acumuladoHastaMesAnterior: number; + acreditableEsteMes: number; + saldoPendiente: number; + estado: EstadoActivo; + baja: { fechaBaja: string; motivo: string; comentario: string | null } | null; +} + +export interface ActivosFijosTotales { + cantidad: number; + totalMoi: number; + totalAcumuladoPrevio: number; + totalEsteMes: number; + totalSaldoPendiente: number; + cantidadActivos: number; + cantidadAgotados: number; + cantidadDeBaja: number; +} + +function clamp(v: number, lo: number, hi: number): number { + return Math.max(lo, Math.min(hi, v)); +} + +function diffMeses(start: Date, end: Date): number { + return (end.getFullYear() - start.getFullYear()) * 12 + (end.getMonth() - start.getMonth()) + 1; +} + +export async function listActivosFijos( + pool: Pool, + tenantId: string, + año: number, + mes: number, + contribuyenteId?: string | null, + filtroEstado?: 'todos' | 'activos' | 'baja' | 'agotados', +): Promise<{ items: ActivoFijoItem[]; totales: ActivosFijosTotales; usosExcluidos: string[] }> { + const ctx = await resolveContribuyenteContext(pool, tenantId, contribuyenteId); + const esReceptor = ctx.esReceptor; + const esPM = ctx.rfcLength === 12; + + // Lee usos excluidos del contribuyente (lista de claves a saltarse, ej. + // I06/I07 cuando son gastos regulares y no activos fijos reales). + let usosExcluidos: string[] = []; + if (contribuyenteId) { + const { rows } = await pool.query<{ activos_fijos_usos_excluidos: string[] | null }>( + `SELECT activos_fijos_usos_excluidos FROM contribuyentes WHERE entidad_id = $1`, + [contribuyenteId.replace(/[^a-f0-9-]/gi, '')], + ); + usosExcluidos = (rows[0]?.activos_fijos_usos_excluidos ?? []).filter(u => USOS_CFDI.includes(u)); + } + const usosAplicables = USOS_CFDI.filter(u => !usosExcluidos.includes(u)); + + // Filtro de régimen: 626 solo aplica si el contribuyente es PM. + const regsAplicables = esPM ? REGIMENES_APLICABLES : REGIMENES_APLICABLES.filter(r => r !== '626'); + + const usosArray = `ARRAY[${usosAplicables.map(u => `'${u}'`).join(',')}]`; + const regsArray = `ARRAY[${regsAplicables.map(r => `'${r}'`).join(',')}]`; + + const { rows } = await pool.query( + `SELECT c.id AS cfdi_id, c.uuid, c.fecha_emision, c.rfc_emisor, c.nombre_emisor, + c.uso_cfdi, c.total_mxn, c.iva_traslado_mxn, c.ieps_traslado_mxn, + c.impuestos_locales_trasladado_mxn, c.regimen_fiscal_receptor, + b.fecha_baja, b.motivo, b.comentario + FROM cfdis c + LEFT JOIN activos_fijos_baja b ON b.cfdi_id = c.id + WHERE ${esReceptor} + AND c.tipo_comprobante = 'I' + AND c.uso_cfdi = ANY(${usosArray}) + AND c.regimen_fiscal_receptor = ANY(${regsArray}) + AND c.status NOT IN ('Cancelado','0') + ORDER BY c.fecha_emision DESC`, + ); + + const items: ActivoFijoItem[] = []; + const periodoFin = new Date(año, mes - 1, 1); // primer día del mes filtrado + + for (const r of rows) { + const fechaEmision = new Date(r.fecha_emision); + const moi = Number(r.total_mxn ?? 0) + - Number(r.iva_traslado_mxn ?? 0) + - Number(r.ieps_traslado_mxn ?? 0) + - Number(r.impuestos_locales_trasladado_mxn ?? 0); + const meta = PORCENTAJES_ANUALES[r.uso_cfdi]; + if (!meta) continue; + const pctAnual = meta.pct; + const pctMensual = pctAnual / 12; + + // Fecha de baja (si existe) limita los meses aplicables + const fechaBaja = r.fecha_baja ? new Date(r.fecha_baja) : null; + + // Mes ancla del periodo filtrado + const mesEjAnchor = new Date(año, mes - 1, 1); + const mesAdqAnchor = new Date(fechaEmision.getFullYear(), fechaEmision.getMonth(), 1); + + // Meses transcurridos hasta el mes filtrado (incluido) + let mesesHasta = diffMeses(mesAdqAnchor, mesEjAnchor); + let mesesHastaPrev = mesesHasta - 1; + + // Recortar si hay baja: máximo el mes de la baja inclusive + if (fechaBaja) { + const mesBaja = new Date(fechaBaja.getFullYear(), fechaBaja.getMonth(), 1); + const mesesHastaBaja = diffMeses(mesAdqAnchor, mesBaja); + mesesHasta = Math.min(mesesHasta, mesesHastaBaja); + mesesHastaPrev = Math.min(mesesHastaPrev, mesesHastaBaja); + } + + mesesHasta = Math.max(0, mesesHasta); + mesesHastaPrev = Math.max(0, mesesHastaPrev); + + const acumHasta = clamp(moi * pctMensual * mesesHasta, 0, moi); + const acumPrev = clamp(moi * pctMensual * mesesHastaPrev, 0, moi); + const acreditable = Math.max(0, acumHasta - acumPrev); + const saldo = Math.max(0, moi - acumHasta); + + let estado: EstadoActivo = 'activo'; + if (fechaBaja) { + estado = `baja_${r.motivo}` as EstadoActivo; + } else if (saldo === 0) { + estado = 'agotado'; + } + + if (filtroEstado === 'activos' && estado !== 'activo') continue; + if (filtroEstado === 'agotados' && estado !== 'agotado') continue; + if (filtroEstado === 'baja' && !estado.startsWith('baja_')) continue; + + items.push({ + cfdiId: r.cfdi_id, + uuid: r.uuid, + fechaEmision: fechaEmision.toISOString().slice(0, 10), + rfcEmisor: r.rfc_emisor, + nombreEmisor: r.nombre_emisor ?? '', + usoCfdi: r.uso_cfdi, + concepto: meta.concepto, + porcentajeAnual: pctAnual, + porcentajeMensual: pctMensual, + total: Number(r.total_mxn ?? 0), + iva: Number(r.iva_traslado_mxn ?? 0), + moi, + acumuladoHastaMesAnterior: Math.round(acumPrev * 100) / 100, + acreditableEsteMes: Math.round(acreditable * 100) / 100, + saldoPendiente: Math.round(saldo * 100) / 100, + estado, + baja: fechaBaja + ? { fechaBaja: fechaBaja.toISOString().slice(0, 10), motivo: r.motivo, comentario: r.comentario } + : null, + }); + } + + const totales: ActivosFijosTotales = { + cantidad: items.length, + totalMoi: 0, + totalAcumuladoPrevio: 0, + totalEsteMes: 0, + totalSaldoPendiente: 0, + cantidadActivos: 0, + cantidadAgotados: 0, + cantidadDeBaja: 0, + }; + for (const i of items) { + totales.totalMoi += i.moi; + totales.totalAcumuladoPrevio += i.acumuladoHastaMesAnterior; + totales.totalEsteMes += i.acreditableEsteMes; + totales.totalSaldoPendiente += i.saldoPendiente; + if (i.estado === 'activo') totales.cantidadActivos++; + else if (i.estado === 'agotado') totales.cantidadAgotados++; + else totales.cantidadDeBaja++; + } + totales.totalMoi = Math.round(totales.totalMoi * 100) / 100; + totales.totalAcumuladoPrevio = Math.round(totales.totalAcumuladoPrevio * 100) / 100; + totales.totalEsteMes = Math.round(totales.totalEsteMes * 100) / 100; + totales.totalSaldoPendiente = Math.round(totales.totalSaldoPendiente * 100) / 100; + + return { items, totales, usosExcluidos }; +} + +/** Lee los usos CFDI excluidos para un contribuyente. */ +export async function getUsosExcluidos(pool: Pool, contribuyenteId: string): Promise { + const { rows } = await pool.query<{ activos_fijos_usos_excluidos: string[] | null }>( + `SELECT activos_fijos_usos_excluidos FROM contribuyentes WHERE entidad_id = $1`, + [contribuyenteId.replace(/[^a-f0-9-]/gi, '')], + ); + return (rows[0]?.activos_fijos_usos_excluidos ?? []).filter(u => USOS_CFDI.includes(u)); +} + +/** Guarda la lista de usos excluidos (filtra a I01-I08 y deduplica). */ +export async function setUsosExcluidos( + pool: Pool, + contribuyenteId: string, + usos: string[], +): Promise { + const valid = [...new Set(usos.filter(u => USOS_CFDI.includes(u)))]; + await pool.query( + `UPDATE contribuyentes SET activos_fijos_usos_excluidos = $2::jsonb WHERE entidad_id = $1`, + [contribuyenteId.replace(/[^a-f0-9-]/gi, ''), JSON.stringify(valid)], + ); + return valid; +} + +export async function darDeBaja( + pool: Pool, + cfdiId: number, + fechaBaja: string, + motivo: 'venta' | 'desecho' | 'otro', + userId: string, + comentario: string | null, +): Promise { + await pool.query( + `INSERT INTO activos_fijos_baja (cfdi_id, fecha_baja, motivo, comentario, dado_de_baja_por) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (cfdi_id) DO UPDATE + SET fecha_baja = EXCLUDED.fecha_baja, + motivo = EXCLUDED.motivo, + comentario = EXCLUDED.comentario, + dado_de_baja_por = EXCLUDED.dado_de_baja_por`, + [cfdiId, fechaBaja, motivo, comentario, userId], + ); +} + +export async function revertirBaja(pool: Pool, cfdiId: number): Promise { + const { rowCount } = await pool.query( + `DELETE FROM activos_fijos_baja WHERE cfdi_id = $1`, + [cfdiId], + ); + return (rowCount ?? 0) > 0; +} diff --git a/apps/api/src/services/admin-clientes.service.ts b/apps/api/src/services/admin-clientes.service.ts new file mode 100644 index 0000000..2d5b20b --- /dev/null +++ b/apps/api/src/services/admin-clientes.service.ts @@ -0,0 +1,147 @@ +/** + * Métricas para la página de Gestión de Clientes (admin global). + * + * Distinto de `admin-dashboard.service.ts` que provee KPIs generales fijos + * a 30 días. Aquí el rango es parametrizado y se enfoca en suscripciones + * activas por plan, ingresos del periodo, clientes que no renovaron, y + * usuarios por cliente. + */ +import { prisma } from '../config/database.js'; + +export interface ClientesStatsRange { + from: Date; + to: Date; +} + +export interface ClientesStats { + /** Suscripciones activas (status=authorized) agrupadas por plan. */ + suscripcionesPorPlan: Array<{ plan: string; count: number }>; + /** Ingresos del periodo (payments approved con createdAt en rango). */ + ingresos: { + total: number; + paymentsCount: number; + }; + /** Clientes cuya suscripción venció en el rango y NO se renovó. */ + noRenovaciones: Array<{ + tenantId: string; + tenantNombre: string; + rfc: string; + plan: string; + currentPeriodEnd: string; + statusActual: string; + }>; + /** Cuenta de usuarios activos por tenant. */ + usuariosPorCliente: Array<{ + tenantId: string; + tenantNombre: string; + rfc: string; + activeUsers: number; + owners: number; + }>; +} + +export async function getClientesStats(range: ClientesStatsRange): Promise { + // 1) Suscripciones activas agrupadas por plan + const subsByPlan = await prisma.subscription.groupBy({ + by: ['plan'], + where: { status: 'authorized' }, + _count: { _all: true }, + }); + const suscripcionesPorPlan = subsByPlan.map(s => ({ + plan: String(s.plan), + count: s._count._all, + })); + + // 2) Ingresos del periodo + const payments = await prisma.payment.aggregate({ + where: { + status: 'approved', + createdAt: { gte: range.from, lte: range.to }, + }, + _sum: { amount: true }, + _count: true, + }); + const ingresos = { + total: Number(payments._sum.amount || 0), + paymentsCount: payments._count, + }; + + // 3) Clientes que NO renovaron: subs cuyo currentPeriodEnd cae en el rango + // y que están en status terminal (cancelled, trial_expired, paused) o sin + // payment posterior aprobado. Nota: un sub `authorized` con periodEnd + // pasado es un "se renovó automáticamente" — para detectar no-renovaciones + // miramos status efectivo + ausencia de payment en los siguientes 7 días. + const subsExpiradas = await prisma.subscription.findMany({ + where: { + currentPeriodEnd: { gte: range.from, lte: range.to }, + status: { in: ['cancelled', 'trial_expired', 'paused'] }, + }, + select: { + tenantId: true, + plan: true, + status: true, + currentPeriodEnd: true, + tenant: { select: { id: true, nombre: true, rfc: true } }, + }, + }); + const noRenovaciones = subsExpiradas.map(s => ({ + tenantId: s.tenantId, + tenantNombre: s.tenant?.nombre ?? '', + rfc: s.tenant?.rfc ?? '', + plan: String(s.plan), + currentPeriodEnd: s.currentPeriodEnd?.toISOString() ?? '', + statusActual: s.status, + })); + + // 4) Usuarios por cliente (memberships activos por tenant) + const memberships = await prisma.tenantMembership.findMany({ + where: { active: true }, + select: { tenantId: true, isOwner: true, tenant: { select: { nombre: true, rfc: true } } }, + }); + const byTenant = new Map(); + for (const m of memberships) { + const ent = byTenant.get(m.tenantId) ?? { + nombre: m.tenant?.nombre ?? '', + rfc: m.tenant?.rfc ?? '', + total: 0, + owners: 0, + }; + ent.total += 1; + if (m.isOwner) ent.owners += 1; + byTenant.set(m.tenantId, ent); + } + const usuariosPorCliente = Array.from(byTenant.entries()).map(([tenantId, v]) => ({ + tenantId, + tenantNombre: v.nombre, + rfc: v.rfc, + activeUsers: v.total, + owners: v.owners, + })).sort((a, b) => b.activeUsers - a.activeUsers); + + return { suscripcionesPorPlan, ingresos, noRenovaciones, usuariosPorCliente }; +} + +/** Lista usuarios activos de un tenant (con email + rol). Para el drill-down de la UI. */ +export async function getTenantUsuarios(tenantId: string) { + const memberships = await prisma.tenantMembership.findMany({ + where: { tenantId, active: true }, + select: { + isOwner: true, + joinedAt: true, + user: { select: { id: true, email: true, nombre: true, active: true, lastLogin: true } }, + rol: { select: { nombre: true } }, + }, + orderBy: { joinedAt: 'asc' }, + }); + return memberships + .filter(m => m.user.active) + .map(m => ({ + userId: m.user.id, + email: m.user.email, + nombre: m.user.nombre, + rol: m.rol?.nombre ?? 'sin_rol', + isOwner: m.isOwner, + joinedAt: m.joinedAt.toISOString(), + lastLogin: m.user.lastLogin?.toISOString() ?? null, + })); +} diff --git a/apps/api/src/services/admin-dashboard.service.ts b/apps/api/src/services/admin-dashboard.service.ts new file mode 100644 index 0000000..665b23d --- /dev/null +++ b/apps/api/src/services/admin-dashboard.service.ts @@ -0,0 +1,89 @@ +import { prisma } from '../config/database.js'; + +export async function getDashboardMetrics() { + const now = new Date(); + const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + + const [ + totalTenants, + activeTenants, + trialTenants, + cancelledSubs, + recentSignups, + connectorStatuses, + payments, + ] = await Promise.all([ + prisma.tenant.count(), + prisma.tenant.count({ where: { active: true } }), + prisma.tenant.count({ where: { trialEndsAt: { gt: now } } }), + prisma.subscription.count({ where: { status: 'cancelled' } }), + prisma.tenant.count({ where: { createdAt: { gte: thirtyDaysAgo } } }), + prisma.tenant.findMany({ + where: { dbMode: 'BYO', connectorTunnelHostname: { not: null } }, + select: { id: true, nombre: true, rfc: true, connectorLastSeen: true, connectorVersion: true }, + }), + prisma.payment.aggregate({ + where: { status: 'approved', createdAt: { gte: thirtyDaysAgo } }, + _sum: { amount: true }, + _count: true, + }), + ]); + + const connectors = connectorStatuses.map(t => { + let status: 'connected' | 'degraded' | 'disconnected' = 'disconnected'; + if (t.connectorLastSeen) { + const diff = now.getTime() - t.connectorLastSeen.getTime(); + if (diff < 60_000) status = 'connected'; + else if (diff < 300_000) status = 'degraded'; + } + return { id: t.id, nombre: t.nombre, rfc: t.rfc, status, lastSeen: t.connectorLastSeen?.toISOString(), version: t.connectorVersion }; + }); + + const connectorsDown = connectors.filter(c => c.status === 'disconnected').length; + + return { + tenants: { total: totalTenants, active: activeTenants, trial: trialTenants, cancelled: cancelledSubs }, + signupsLast30Days: recentSignups, + revenue: { last30Days: Number(payments._sum.amount || 0), paymentsCount: payments._count }, + connectors: { total: connectors.length, down: connectorsDown, list: connectors }, + }; +} + +export async function listAllDespachos(filters?: { vertical?: string; status?: string; search?: string }) { + const where: any = {}; + if (filters?.vertical) where.verticalProfile = filters.vertical; + if (filters?.status === 'active') where.active = true; + if (filters?.status === 'inactive') where.active = false; + if (filters?.search) { + where.OR = [ + { nombre: { contains: filters.search, mode: 'insensitive' } }, + { rfc: { contains: filters.search, mode: 'insensitive' } }, + ]; + } + + const tenants = await prisma.tenant.findMany({ + where, + select: { + id: true, nombre: true, rfc: true, plan: true, active: true, verticalProfile: true, + dbMode: true, connectorLastSeen: true, createdAt: true, trialEndsAt: true, + }, + orderBy: { createdAt: 'desc' }, + take: 100, + }); + + return tenants.map(t => ({ + ...t, + connectorLastSeen: t.connectorLastSeen?.toISOString(), + createdAt: t.createdAt.toISOString(), + trialEndsAt: t.trialEndsAt?.toISOString(), + })); +} + +export async function getRecentActivity(limit = 20) { + const logs = await prisma.auditLog.findMany({ + where: { action: { in: ['user.register', 'user.login', 'subscription.created', 'subscription.cancelled', 'subscription.upgraded', 'price.updated'] } }, + orderBy: { createdAt: 'desc' }, + take: limit, + }); + return logs; +} diff --git a/apps/api/src/services/alertas-auto.service.ts b/apps/api/src/services/alertas-auto.service.ts new file mode 100644 index 0000000..7d2ac26 --- /dev/null +++ b/apps/api/src/services/alertas-auto.service.ts @@ -0,0 +1,683 @@ +import type { Pool } from 'pg'; +import { prisma } from '../config/database.js'; +import { getRegimenesActivosClavesEfectivos } from './regimen.service.js'; +import { contarTareasProximasVencer } from './tareas.service.js'; + +const VIGENTE = `status NOT IN ('Cancelado', '0')`; + +/** Sanitize a contribuyente UUID for safe inline SQL injection */ +function sanitizeUuid(id: string): string { + return id.replace(/[^a-f0-9-]/gi, ''); +} + +export interface AlertaAuto { + id: string; + tipo: string; + titulo: string; + mensaje: string; + prioridad: 'alta' | 'media' | 'baja'; + detalle?: string; // ruta para drill-down + valor?: number; +} + +/** + * CFDI con discrepancia: facturas recibidas donde regimen_fiscal_receptor + * no coincide con los regímenes activos del tenant. + */ +async function alertaDiscrepanciaRegimen( + pool: Pool, + tenantId: string, + contribuyenteId?: string | null, +): Promise { + const activos = await getRegimenesActivosClavesEfectivos(tenantId, pool, contribuyenteId); + if (activos.length === 0) return null; + + const cf = contribuyenteId ? `AND contribuyente_id = '${sanitizeUuid(contribuyenteId)}'` : ''; + + const { rows: [r] } = await pool.query(` + SELECT COUNT(*)::int as total + FROM cfdis + WHERE type = 'RECIBIDO' AND status = 'Vigente' + AND fecha_cancelacion IS NULL + AND regimen_fiscal_receptor IS NOT NULL + AND regimen_fiscal_receptor != ALL($1) + AND id NOT IN (SELECT cfdi_id FROM cfdi_descartados WHERE tipo_alerta = 'discrepancia-regimen') + ${cf} + `, [activos]); + + const total = r?.total || 0; + if (total === 0) return null; + + return { + id: 'discrepancia-regimen', + tipo: 'discrepancia', + titulo: 'CFDI con Discrepancia de Regimen', + mensaje: `${total} factura(s) recibida(s) con regimen fiscal del receptor que no coincide con los regimenes activos.`, + prioridad: 'alta', + detalle: '/alertas/discrepancia-regimen', + valor: total, + }; +} + +/** + * Calcula el Índice de Herfindahl-Hirschman (IHH). + * IHH = Σ (cuota_de_mercado_i)^2 × 10000 + */ +async function calcularIHH( + pool: Pool, + type: 'EMITIDO' | 'RECIBIDO', + contribuyenteId?: string | null, +): Promise { + const rfcField = type === 'EMITIDO' ? 'rfc_receptor' : 'rfc_emisor'; + const cf = contribuyenteId ? `AND contribuyente_id = '${sanitizeUuid(contribuyenteId)}'` : ''; + + const { rows } = await pool.query(` + SELECT ${rfcField} as rfc, SUM(total_mxn) as total + FROM cfdis + WHERE type = $1 AND tipo_comprobante = 'I' AND ${VIGENTE} + AND total_mxn > 0 + ${cf} + GROUP BY ${rfcField} + `, [type]); + + if (rows.length === 0) return 0; + + const totalGeneral = rows.reduce((s: number, r: any) => s + Number(r.total), 0); + if (totalGeneral === 0) return 0; + + let ihh = 0; + for (const row of rows) { + const cuota = Number(row.total) / totalGeneral; + ihh += cuota * cuota; + } + + return Math.round(ihh * 10000); +} + +/** + * Concentración de clientes: IHH >= 2500 en facturas emitidas + */ +async function alertaConcentracionClientes(pool: Pool, contribuyenteId?: string | null): Promise { + const ihh = await calcularIHH(pool, 'EMITIDO', contribuyenteId); + if (ihh < 2500) return null; + + return { + id: 'concentracion-clientes', + tipo: 'concentracion', + titulo: 'Concentracion de Clientes', + mensaje: `El indice HHI de clientes es ${ihh.toLocaleString()} (>=2500 indica alta concentracion). Dependencia excesiva en pocos clientes.`, + prioridad: ihh >= 5000 ? 'alta' : 'media', + detalle: '/alertas/concentracion-clientes', + valor: ihh, + }; +} + +/** + * Concentración de proveedores: IHH >= 2500 en facturas recibidas + */ +async function alertaConcentracionProveedores(pool: Pool, contribuyenteId?: string | null): Promise { + const ihh = await calcularIHH(pool, 'RECIBIDO', contribuyenteId); + if (ihh < 2500) return null; + + return { + id: 'concentracion-proveedores', + tipo: 'concentracion', + titulo: 'Concentracion de Proveedores', + mensaje: `El indice HHI de proveedores es ${ihh.toLocaleString()} (>=2500 indica alta concentracion). Dependencia excesiva en pocos proveedores.`, + prioridad: ihh >= 5000 ? 'alta' : 'media', + detalle: '/alertas/concentracion-proveedores', + valor: ihh, + }; +} + +/** + * Riesgo cambiario: >10% de facturas en moneda != MXN + */ +async function alertaRiesgoCambiario(pool: Pool, contribuyenteId?: string | null): Promise { + const cf = contribuyenteId ? `AND contribuyente_id = '${sanitizeUuid(contribuyenteId)}'` : ''; + const { rows: [r] } = await pool.query(` + SELECT + COUNT(*)::int as total, + COUNT(CASE WHEN moneda IS NOT NULL AND moneda != 'MXN' THEN 1 END)::int as no_mxn + FROM cfdis + WHERE ${VIGENTE} AND tipo_comprobante = 'I' + ${cf} + `); + + const total = r?.total || 0; + const noMxn = r?.no_mxn || 0; + if (total === 0 || noMxn === 0) return null; + + const porcentaje = Math.round((noMxn / total) * 10000) / 100; + if (porcentaje <= 10) return null; + + return { + id: 'riesgo-cambiario', + tipo: 'riesgo', + titulo: 'Riesgo Cambiario', + mensaje: `${porcentaje}% de las facturas (${noMxn} de ${total}) estan en moneda extranjera. Exposicion a fluctuaciones del tipo de cambio.`, + prioridad: porcentaje > 30 ? 'alta' : 'media', + valor: porcentaje, + }; +} + +/** + * Riesgo de cancelaciones: >10% de facturas canceladas en últimos 5 años + */ +async function alertaRiesgoCancelaciones(pool: Pool, contribuyenteId?: string | null): Promise { + const hace5 = new Date(); + hace5.setFullYear(hace5.getFullYear() - 5); + const fechaDesde = hace5.toISOString().split('T')[0]; + + const cf = contribuyenteId ? `AND contribuyente_id = '${sanitizeUuid(contribuyenteId)}'` : ''; + + const { rows: [r] } = await pool.query(` + SELECT + COUNT(*)::int as total, + COUNT(CASE WHEN status IN ('Cancelado', '0') THEN 1 END)::int as cancelados + FROM cfdis + WHERE fecha_emision >= $1::date + ${cf} + `, [fechaDesde]); + + const total = r?.total || 0; + const cancelados = r?.cancelados || 0; + if (total === 0 || cancelados === 0) return null; + + const porcentaje = Math.round((cancelados / total) * 10000) / 100; + if (porcentaje <= 10) return null; + + return { + id: 'riesgo-cancelaciones', + tipo: 'riesgo', + titulo: 'Riesgo de Cancelaciones', + mensaje: `${porcentaje}% de las facturas (${cancelados} de ${total}) en los ultimos 5 años han sido canceladas.`, + prioridad: porcentaje > 25 ? 'alta' : 'media', + detalle: '/alertas/cancelaciones', + valor: porcentaje, + }; +} + +/** + * Gastos recibidos pagados en efectivo > $2,000 — Art. 27 fracción III LISR. + * No son deducibles. Surface el total + conteo para que el contador entienda + * el impacto y, si aplica, gestione cambio de forma de pago con el proveedor. + */ +async function alertaRiesgoTransaccional(pool: Pool, contribuyenteId?: string | null): Promise { + const cf = contribuyenteId ? `AND contribuyente_id = '${sanitizeUuid(contribuyenteId)}'` : ''; + const { rows: [r] } = await pool.query(` + SELECT + COUNT(*)::int AS facturas, + COALESCE(SUM(COALESCE(total_mxn, 0)), 0)::numeric(14,2) AS monto + FROM cfdis + WHERE ${VIGENTE} + AND type = 'RECIBIDO' + AND tipo_comprobante = 'I' + AND metodo_pago = 'PUE' + AND forma_pago = '01' + AND COALESCE(total_mxn, 0) > 2000 + ${cf} + `); + + const facturas = r?.facturas || 0; + const monto = Number(r?.monto || 0); + if (facturas === 0) return null; + + return { + id: 'gastos-no-deducibles-efectivo', + tipo: 'riesgo', + titulo: 'Gastos no deducibles (efectivo > $2,000)', + mensaje: `${facturas} factura${facturas === 1 ? '' : 's'} recibida${facturas === 1 ? '' : 's'} por $${monto.toLocaleString('es-MX')} MXN se pagaron en efectivo (>$2,000). Art. 27 fracción III LISR las hace NO deducibles para ISR.`, + prioridad: monto > 50000 ? 'alta' : 'media', + detalle: '/impuestos', + valor: monto, + }; +} + +/** + * Estatus lista negra: si el RFC del tenant/contribuyente aparece en la lista negra + */ +async function alertaListaNegraPropia( + pool: Pool, + tenantId: string, + contribuyenteId?: string | null, +): Promise { + let rfc: string | undefined; + + if (contribuyenteId) { + const safeId = sanitizeUuid(contribuyenteId); + const { rows } = await pool.query( + 'SELECT rfc FROM contribuyentes WHERE entidad_id = $1', + [safeId], + ); + rfc = rows[0]?.rfc; + } else { + const tenant = await prisma.tenant.findUnique({ + where: { id: tenantId }, + select: { rfc: true }, + }); + rfc = tenant?.rfc; + } + + if (!rfc) return null; + + const registro = await prisma.listaNegra.findUnique({ + where: { rfc }, + }); + if (!registro) return null; + + return { + id: 'lista-negra-propia', + tipo: 'lista-negra', + titulo: 'RFC en Lista Negra del SAT', + mensaje: `Tu RFC (${rfc}) aparece en la lista del Art. 69-B del CFF con situacion: ${registro.situacion}.`, + prioridad: 'alta', + }; +} + +/** + * Factura emitida a cliente en lista negra + */ +async function alertaClienteListaNegra(pool: Pool, contribuyenteId?: string | null): Promise { + // Fallback: consultar directo si dblink no funciona + const listaRfcs = await prisma.listaNegra.findMany({ + where: { situacion: { in: ['Definitivo', 'Presunto'] } }, + select: { rfc: true }, + }); + const rfcSet = new Set(listaRfcs.map(l => l.rfc)); + + const cf = contribuyenteId ? `AND contribuyente_id = '${sanitizeUuid(contribuyenteId)}'` : ''; + + const { rows } = await pool.query(` + SELECT DISTINCT rfc_receptor as rfc + FROM cfdis + WHERE type = 'EMITIDO' AND ${VIGENTE} AND tipo_comprobante = 'I' + ${cf} + `); + + const clientesEnLista = rows.filter((r: any) => rfcSet.has(r.rfc)); + if (clientesEnLista.length === 0) return null; + + return { + id: 'lista-negra-clientes', + tipo: 'lista-negra', + titulo: 'Facturas Emitidas a Clientes en Lista Negra', + mensaje: `${clientesEnLista.length} cliente(s) a los que has facturado aparecen en la lista negra del SAT (Art. 69-B).`, + prioridad: 'alta', + detalle: '/alertas/lista-negra-clientes', + valor: clientesEnLista.length, + }; +} + +/** + * Factura recibida de proveedor en lista negra + */ +async function alertaProveedorListaNegra(pool: Pool, contribuyenteId?: string | null): Promise { + const listaRfcs = await prisma.listaNegra.findMany({ + where: { situacion: { in: ['Definitivo', 'Presunto'] } }, + select: { rfc: true }, + }); + const rfcSet = new Set(listaRfcs.map(l => l.rfc)); + + const cf = contribuyenteId ? `AND contribuyente_id = '${sanitizeUuid(contribuyenteId)}'` : ''; + + const { rows } = await pool.query(` + SELECT DISTINCT rfc_emisor as rfc + FROM cfdis + WHERE type = 'RECIBIDO' AND ${VIGENTE} AND tipo_comprobante = 'I' + ${cf} + `); + + const proveedoresEnLista = rows.filter((r: any) => rfcSet.has(r.rfc)); + if (proveedoresEnLista.length === 0) return null; + + return { + id: 'lista-negra-proveedores', + tipo: 'lista-negra', + titulo: 'Facturas Recibidas de Proveedores en Lista Negra', + mensaje: `${proveedoresEnLista.length} proveedor(es) de los que has recibido facturas aparecen en la lista negra del SAT (Art. 69-B).`, + prioridad: 'alta', + detalle: '/alertas/lista-negra-proveedores', + valor: proveedoresEnLista.length, + }; +} + +/** + * Facturas de periodos anteriores canceladas este mes. + * Detecta CFDIs cuya fecha_cancelacion cae en el mes actual pero + * cuya fecha_emision es de un mes anterior. + */ +async function alertaCancelacionPeriodoAnterior(pool: Pool, contribuyenteId?: string | null): Promise { + const ahora = new Date(); + const inicioMes = `${ahora.getFullYear()}-${String(ahora.getMonth() + 1).padStart(2, '0')}-01`; + + const cf = contribuyenteId ? `AND contribuyente_id = '${sanitizeUuid(contribuyenteId)}'` : ''; + + const { rows: [r] } = await pool.query(` + SELECT COUNT(*)::int as total, + COALESCE(SUM(COALESCE(total_mxn, 0)), 0) as monto + FROM cfdis + WHERE status IN ('Cancelado', '0') + AND fecha_cancelacion >= $1::date + AND fecha_emision < $1::date + ${cf} + `, [inicioMes]); + + const total = r?.total || 0; + if (total === 0) return null; + + const monto = Number(r.monto); + const montoFmt = monto.toLocaleString('es-MX', { style: 'currency', currency: 'MXN' }); + + return { + id: 'cancelacion-periodo-anterior', + tipo: 'cancelacion-retroactiva', + titulo: 'Facturas de Periodos Anteriores Canceladas', + mensaje: `${total} factura(s) emitida(s) en meses anteriores fueron canceladas este mes por un total de ${montoFmt}. Esto puede afectar declaraciones ya presentadas.`, + prioridad: 'alta', + detalle: '/alertas/cancelaciones-periodo-anterior', + valor: total, + }; +} + +/** + * CFDIs tipo E (Egreso / nota de crédito) con cfdi_tipo_relacion != '07' + * cuya(s) referencia(s) en cfdis_relacionados también aparecen en otro CFDI + * con cfdi_tipo_relacion = '07'. Señal de que el emisor debió usar 07 + * (aplicación de anticipo) pero puso 01/02/03/04: inflá gastos e IVA + * acreditable contra un anticipo ya consumido. + * + * La detección usa overlap de arrays (`&&`) sobre cfdis_relacionados + * pipe-separados — si X y Y comparten al menos un UUID referenciado y Y + * es 07, X es sospechoso. + */ +const SOSPECHOSA_TIPO_RELACION_WHERE = ` + c.tipo_comprobante = 'E' + AND c.status NOT IN ('Cancelado', '0') + AND c.cfdi_tipo_relacion IS NOT NULL + AND c.cfdi_tipo_relacion <> '07' + AND c.cfdis_relacionados IS NOT NULL + AND c.cfdis_relacionados <> '' + AND EXISTS ( + SELECT 1 FROM cfdis y + WHERE y.id <> c.id + AND y.cfdi_tipo_relacion = '07' + AND y.status NOT IN ('Cancelado', '0') + AND y.cfdis_relacionados IS NOT NULL + AND y.cfdis_relacionados <> '' + AND string_to_array(LOWER(y.cfdis_relacionados), '|') + && string_to_array(LOWER(c.cfdis_relacionados), '|') + ) + AND c.id NOT IN ( + SELECT cfdi_id FROM cfdi_descartados WHERE tipo_alerta = 'tipo-relacion-sospechosa' + ) +`; + +async function alertaTipoRelacionSospechosa( + pool: Pool, + contribuyenteId?: string | null, +): Promise { + const cf = contribuyenteId ? `AND c.contribuyente_id = '${sanitizeUuid(contribuyenteId)}'` : ''; + const { rows: [r] } = await pool.query(` + SELECT COUNT(*)::int AS total + FROM cfdis c + WHERE ${SOSPECHOSA_TIPO_RELACION_WHERE} + ${cf} + `); + + const total = r?.total || 0; + if (total === 0) return null; + + return { + id: 'tipo-relacion-sospechosa', + tipo: 'cfdi-inconsistente', + titulo: 'Nota de Crédito con Tipo de Relación sospechoso', + mensaje: `${total} CFDI(s) tipo E con TipoRelacion distinto de 07 referencian un CFDI tratado como anticipo por otra factura. Revisa si deberían haberse emitido como 07 (aplicación de anticipo).`, + prioridad: 'alta', + detalle: '/alertas/tipo-relacion-sospechosa', + valor: total, + }; +} + +/** Exportado para reutilizar en el controller de drill-down. */ +export const SOSPECHOSA_TIPO_RELACION_WHERE_EXPORT = SOSPECHOSA_TIPO_RELACION_WHERE; + +/** + * Tareas operativas próximas a vencer (≤3 días). Solo aplica cuando hay un + * contribuyente seleccionado — sin contribuyente, no se puede contar + * porque las tareas son siempre per-contribuyente. + */ +async function alertaTareasProximasVencer( + pool: Pool, + contribuyenteId?: string | null, +): Promise { + if (!contribuyenteId) return null; + const { total } = await contarTareasProximasVencer(pool, contribuyenteId); + if (total === 0) return null; + return { + id: 'tareas-proximas-vencer', + tipo: 'tareas', + titulo: 'Tareas próximas a vencer', + mensaje: `${total} tarea(s) operativa(s) vencen en los próximos 3 días.`, + prioridad: 'media', + detalle: '/configuracion/obligaciones', + valor: total, + }; +} + +/** + * RESICO PF cerca de salir del régimen por exceso de ingresos anuales. + * + * Art. 113-E LISR — el contribuyente PF en RESICO debe salir del régimen si + * sus ingresos del ejercicio exceden $3,500,000. **Importante:** el SAT + * considera ingresos acumulados de TODOS los regímenes del contribuyente, no + * solo los del 626. Por eso este query no filtra por `regimen_fiscal_emisor`. + * + * Umbrales: + * - $2,500,000 → alerta media (margen ~$1M) + * - $3,000,000 → alerta alta (margen ~$500k al límite) + * - $3,500,000 → alerta alta crítica ("ya superaste") + * + * Aplica solo cuando: + * 1. Hay un contribuyente seleccionado (per-tenant no tiene sentido — la + * alerta es por entidad fiscal individual) + * 2. RFC de 13 caracteres (Persona Física — RESICO PM no tiene este límite) + * 3. Régimen 626 está en su lista de regímenes activos + * + * Cálculo de ingresos: agregado de CFDIs emitidos vigentes del año en curso: + * + I PUE (cobradas al emitir) + * + P (complementos de pago — cobros de PPD anteriores) + * - E PUE (notas de crédito netan) + * + * Sin desglose por régimen ni filtro de conciliación. Se usa `total_mxn` como + * proxy de ingreso (incluye IVA — sobreestima ~16%, conservador para alerta). + */ +async function alertaResicoPfLimiteIngresos( + pool: Pool, + contribuyenteId?: string | null, +): Promise { + if (!contribuyenteId) return null; + + const safeId = sanitizeUuid(contribuyenteId); + + // Verificar elegibilidad: PF (RFC 13) + régimen 626 activo + const { rows: contribRows } = await pool.query( + `SELECT rfc, regimen_fiscal FROM contribuyentes WHERE entidad_id = $1`, + [safeId], + ); + const contrib = contribRows[0]; + if (!contrib) return null; + + const rfc: string = contrib.rfc || ''; + if (rfc.length !== 13) return null; // PM no aplica + + const regimenesCsv: string = contrib.regimen_fiscal || ''; + const regimenes = regimenesCsv.split(',').map((s: string) => s.trim()).filter(Boolean); + if (!regimenes.includes('626')) return null; + + // Suma ingresos del año en curso, agregado de TODOS los regímenes + const año = new Date().getFullYear(); + const { rows: [r] } = await pool.query(` + SELECT COALESCE(SUM( + CASE + WHEN tipo_comprobante = 'I' AND metodo_pago = 'PUE' THEN COALESCE(total_mxn, 0) + WHEN tipo_comprobante = 'P' THEN COALESCE(monto_pago_mxn, 0) + WHEN tipo_comprobante = 'E' AND metodo_pago = 'PUE' THEN -COALESCE(total_mxn, 0) + ELSE 0 + END + ), 0)::numeric AS ingresos + FROM cfdis + WHERE type = 'EMITIDO' + AND status NOT IN ('Cancelado', '0') + AND EXTRACT(YEAR FROM fecha_emision) = $1 + AND contribuyente_id = $2 + `, [año, safeId]); + + const ingresos = Number(r?.ingresos || 0); + const UMBRAL_AVISO = 2_500_000; + const UMBRAL_ALTO = 3_000_000; + const LIMITE_LEGAL = 3_500_000; // Art. 113-E LISR + + if (ingresos < UMBRAL_AVISO) return null; + + const ingresosFmt = ingresos.toLocaleString('es-MX', { + style: 'currency', currency: 'MXN', maximumFractionDigits: 0, + }); + + let prioridad: 'alta' | 'media' = 'media'; + let titulo = `RESICO PF cerca del límite anual`; + let mensaje = `Ingresos acumulados ${año} (todos los regímenes): ${ingresosFmt}. Límite RESICO PF: $3,500,000 (Art. 113-E LISR). Se considera ingresos de TODOS los regímenes, no solo del 626.`; + + if (ingresos >= LIMITE_LEGAL) { + prioridad = 'alta'; + titulo = `RESICO PF: límite anual EXCEDIDO`; + mensaje = `Ingresos acumulados ${año} (todos los regímenes): ${ingresosFmt}. Excede el límite de $3,500,000 del Art. 113-E LISR. El contribuyente debe salir de RESICO PF y tributar bajo régimen general (PF Empresarial).`; + } else if (ingresos >= UMBRAL_ALTO) { + prioridad = 'alta'; + titulo = `RESICO PF: cerca del límite ($3M+)`; + } + + return { + id: 'resico-pf-limite-ingresos', + tipo: 'limite-regimen', + titulo, + mensaje, + prioridad, + valor: ingresos, + }; +} + +/** + * Alerta si la última Opinión de Cumplimiento no es Positiva. + */ +async function alertaOpinionCumplimiento(pool: Pool, contribuyenteId?: string | null): Promise { + let rfcFilter = ''; + if (contribuyenteId) { + const safeId = sanitizeUuid(contribuyenteId); + const { rows: rfcRows } = await pool.query( + 'SELECT rfc FROM contribuyentes WHERE entidad_id = $1', + [safeId], + ); + const rfc: string | undefined = rfcRows[0]?.rfc; + if (rfc) rfcFilter = `WHERE rfc = '${rfc.replace(/'/g, "''")}'`; + } + + const { rows } = await pool.query(` + SELECT estatus, fecha_consulta + FROM opiniones_cumplimiento + ${rfcFilter} + ORDER BY fecha_consulta DESC + LIMIT 1 + `); + + if (rows.length === 0) return null; + + const { estatus, fecha_consulta } = rows[0]; + if (estatus === 'Positiva') return null; + + const fecha = new Date(fecha_consulta).toLocaleDateString('es-MX'); + + return { + id: 'opinion-cumplimiento-negativa', + tipo: 'opinion-cumplimiento', + titulo: `Opinión de Cumplimiento: ${estatus}`, + mensaje: `Tu Opinión de Cumplimiento ante el SAT es ${estatus}. Última consulta: ${fecha}. Revisa tus obligaciones fiscales.`, + prioridad: 'alta', + valor: 1, + }; +} + +/** + * Genera todas las alertas automáticas para un tenant. + */ +export async function generarAlertasAutomaticas( + pool: Pool, + tenantId: string, + contribuyenteId?: string | null, +): Promise { + const alertas = await Promise.all([ + alertaListaNegraPropia(pool, tenantId, contribuyenteId), + alertaClienteListaNegra(pool, contribuyenteId), + alertaProveedorListaNegra(pool, contribuyenteId), + alertaDiscrepanciaRegimen(pool, tenantId, contribuyenteId), + alertaConcentracionClientes(pool, contribuyenteId), + alertaConcentracionProveedores(pool, contribuyenteId), + alertaRiesgoCambiario(pool, contribuyenteId), + alertaRiesgoCancelaciones(pool, contribuyenteId), + alertaRiesgoTransaccional(pool, contribuyenteId), + alertaCancelacionPeriodoAnterior(pool, contribuyenteId), + alertaOpinionCumplimiento(pool, contribuyenteId), + alertaTipoRelacionSospechosa(pool, contribuyenteId), + alertaTareasProximasVencer(pool, contribuyenteId), + alertaResicoPfLimiteIngresos(pool, contribuyenteId), + ]); + + return alertas.filter((a): a is AlertaAuto => a !== null); +} + +/** + * Breakdown mensual de discrepancias de régimen de los últimos N meses. + * Cuenta facturas RECIBIDAS donde regimen_fiscal_receptor no coincide con + * los regímenes activos del tenant. Útil para el correo semanal — el cliente + * ve cuántas facturas con error le emitieron mes por mes. + */ +export async function getDiscrepanciasPorMes( + pool: Pool, + tenantId: string, + monthsBack = 6, + contribuyenteId?: string | null, +): Promise> { + const activos = await getRegimenesActivosClavesEfectivos(tenantId, pool, contribuyenteId); + if (activos.length === 0) return []; + + const desde = new Date(); + desde.setMonth(desde.getMonth() - (monthsBack - 1)); + desde.setDate(1); + const desdeStr = desde.toISOString().split('T')[0]; + + const cf = contribuyenteId ? `AND contribuyente_id = '${sanitizeUuid(contribuyenteId)}'` : ''; + + const { rows } = await pool.query(` + SELECT + EXTRACT(YEAR FROM fecha_emision)::int as año, + EXTRACT(MONTH FROM fecha_emision)::int as mes, + COUNT(*)::int as count + FROM cfdis + WHERE type = 'RECIBIDO' AND ${VIGENTE} + AND regimen_fiscal_receptor IS NOT NULL + AND regimen_fiscal_receptor != ALL($1) + AND fecha_emision >= $2::date + ${cf} + GROUP BY año, mes + ORDER BY año DESC, mes DESC + `, [activos, desdeStr]); + + const NOMBRES_MES = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre']; + + return rows.map((r: any) => ({ + año: r.año, + mes: r.mes, + count: r.count, + label: `${NOMBRES_MES[r.mes - 1]} ${r.año}`, + })); +} diff --git a/apps/api/src/services/alertas-manuales.service.ts b/apps/api/src/services/alertas-manuales.service.ts new file mode 100644 index 0000000..0353a5b --- /dev/null +++ b/apps/api/src/services/alertas-manuales.service.ts @@ -0,0 +1,299 @@ +import type { Pool } from 'pg'; +import { prisma } from '../config/database.js'; +import { generarEventosFiscales } from './calendario-fiscal.service.js'; +import { isDespachoTenant } from '@horux/shared'; + +interface AlertaManualGenerada { + tipo: string; + titulo: string; + mensaje: string; + prioridad: 'alta' | 'media'; + fechaVencimiento: string; +} + +// Mapeo de eventos del calendario a tipos de alerta (legacy Horux360) +const EVENTO_A_ALERTA: Record = { + 'Declaración mensual ISR': { prefijo: 'decl-isr', prioridad: 'alta' }, + 'Declaración mensual IVA': { prefijo: 'decl-iva', prioridad: 'alta' }, + 'Declaración mensual IEPS': { prefijo: 'decl-ieps', prioridad: 'media' }, + 'Pago provisional ISR': { prefijo: 'pago-isr', prioridad: 'alta' }, + 'Pago provisional IVA': { prefijo: 'pago-iva', prioridad: 'alta' }, + 'Pago provisional IEPS': { prefijo: 'pago-ieps', prioridad: 'media' }, + 'Declaración de sueldos y salarios': { prefijo: 'decl-sueldos', prioridad: 'media' }, + 'DIOT': { prefijo: 'diot', prioridad: 'media' }, + 'Contabilidad electrónica': { prefijo: 'contabilidad', prioridad: 'media' }, + 'Declaración anual PM': { prefijo: 'decl-anual-pm', prioridad: 'alta' }, + 'Declaración anual PF': { prefijo: 'decl-anual-pf', prioridad: 'alta' }, + 'Informativa Sueldos y Salarios': { prefijo: 'inf-sueldos', prioridad: 'media' }, +}; + +/** + * For despachos: generate alerts from the contribuyente's actual obligations + * (obligaciones_contribuyente) instead of the static fiscal calendar. + * Only generates alerts for obligations that the contribuyente actually has. + */ +async function sincronizarDesdeObligacionesContribuyente( + pool: Pool, + contribuyenteId: string, +): Promise<{ creadas: number; existentes: number }> { + const hoy = new Date(); + const currentPeriodo = hoy.toISOString().substring(0, 7); // "2026-04" + + // Get active obligations for this contribuyente + const { rows: obligaciones } = await pool.query(` + SELECT id, nombre, frecuencia, fecha_limite AS "fechaLimite", created_at AS "createdAt" + FROM obligaciones_contribuyente + WHERE contribuyente_id = $1 AND activa = true + `, [contribuyenteId]); + + // Get existing completions + const { rows: completions } = await pool.query(` + SELECT op.obligacion_id, op.periodo + FROM obligacion_periodos op + JOIN obligaciones_contribuyente oc ON oc.id = op.obligacion_id + WHERE oc.contribuyente_id = $1 AND op.completada = true + `, [contribuyenteId]); + const completionSet = new Set(completions.map(c => `${c.obligacion_id}:${c.periodo}`)); + + let creadas = 0; + let existentes = 0; + + for (const ob of obligaciones) { + const obStartPeriodo = ob.createdAt + ? new Date(ob.createdAt).toISOString().substring(0, 7) + : '2000-01'; + + // Check current and previous month + for (let offset = 0; offset <= 1; offset++) { + const d = new Date(hoy.getFullYear(), hoy.getMonth() - offset, 1); + const periodo = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; + + if (periodo < obStartPeriodo) continue; + if (!appliesToPeriod(ob.frecuencia, periodo)) continue; + if (completionSet.has(`${ob.id}:${periodo}`)) continue; + + // Generate alert type unique per obligation+period + const tipoUnico = `ob-${ob.id}-${periodo}`; + + const { rows: existing } = await pool.query( + `SELECT id FROM alertas WHERE tipo = $1`, + [tipoUnico], + ); + + if (existing.length > 0) { + existentes++; + continue; + } + + // Determine deadline (day 17 of next month for mensual) + const [y, m] = periodo.split('-').map(Number); + const nextMonth = m === 12 ? 1 : m + 1; + const nextYear = m === 12 ? y + 1 : y; + const fechaVencimiento = `${nextYear}-${String(nextMonth).padStart(2, '0')}-17`; + + const deadlineDate = new Date(fechaVencimiento + 'T23:59:59'); + const isPastDue = deadlineDate < hoy; + const prioridad = isPastDue ? 'alta' : 'media'; + const statusLabel = isPastDue ? 'Vencida' : 'Pendiente'; + + await pool.query(` + INSERT INTO alertas (tipo, titulo, mensaje, prioridad, fecha_vencimiento) + VALUES ($1, $2, $3, $4, $5) + `, [ + tipoUnico, + `${ob.nombre} - ${statusLabel}`, + `${ob.fechaLimite || 'Sin fecha límite especificada'}. Periodo: ${periodo}`, + prioridad, + fechaVencimiento, + ]); + creadas++; + } + } + + return { creadas, existentes }; +} + +function appliesToPeriod(frecuencia: string | null, periodo: string): boolean { + const [, month] = periodo.split('-').map(Number); + switch (frecuencia) { + case 'mensual': return true; + case 'bimestral': return month % 2 === 1; + case 'trimestral': return [1, 4, 7, 10].includes(month); + case 'anual': return month === 3 || month === 4; + case 'eventual': return false; + default: return true; + } +} + +/** + * Genera alertas manuales para eventos fiscales vencidos que no han sido resueltos. + * Para despachos: usa obligaciones per-contribuyente. + * Para Horux360: usa el calendario fiscal estático (legacy). + */ +export async function sincronizarAlertasManuales( + pool: Pool, + tenantId: string, + contribuyenteId?: string | null, +): Promise<{ creadas: number; existentes: number }> { + // Check if this is a despacho tenant + const tenant = await prisma.tenant.findUnique({ + where: { id: tenantId }, + select: { rfc: true, createdAt: true }, + }); + if (!tenant) return { creadas: 0, existentes: 0 }; + + // Despacho: use per-contribuyente obligations + if (isDespachoTenant(tenant.rfc)) { + if (contribuyenteId) { + return sincronizarDesdeObligacionesContribuyente(pool, contribuyenteId); + } + // "Todos los RFCs": don't generate new alerts — individual contribuyente alerts already exist + return { creadas: 0, existentes: 0 }; + } + + // Legacy Horux360: use static fiscal calendar + const hoy = new Date(); + const añoActual = hoy.getFullYear(); + const fechaCreacion = tenant.createdAt || hoy; + + const eventosActual = await generarEventosFiscales(tenantId, añoActual); + const eventosAnterior = await generarEventosFiscales(tenantId, añoActual - 1); + const todosEventos = [...eventosAnterior, ...eventosActual]; + + const vencidos = todosEventos.filter(e => { + const fecha = new Date(e.fechaLimite + 'T23:59:59'); + return fecha < hoy && fecha >= fechaCreacion; + }); + + let creadas = 0; + let existentes = 0; + + for (const evento of vencidos) { + const config = EVENTO_A_ALERTA[evento.titulo]; + if (!config) continue; + + const tipoUnico = `${config.prefijo}-${evento.fechaLimite}`; + + const { rows: existing } = await pool.query( + `SELECT id, resuelta FROM alertas WHERE tipo = $1`, + [tipoUnico] + ); + + if (existing.length > 0) { + existentes++; + continue; + } + + const esPago = evento.titulo.startsWith('Pago'); + const accion = esPago ? 'Pago pendiente' : 'No presentada'; + + await pool.query(` + INSERT INTO alertas (tipo, titulo, mensaje, prioridad, fecha_vencimiento) + VALUES ($1, $2, $3, $4, $5) + `, [ + tipoUnico, + `${evento.titulo} - ${accion}`, + `${evento.descripcion}. Fecha limite: ${new Date(evento.fechaLimite + 'T00:00:00').toLocaleDateString('es-MX', { day: 'numeric', month: 'long', year: 'numeric' })}`, + config.prioridad, + evento.fechaLimite, + ]); + + creadas++; + } + + return { creadas, existentes }; +} + +/** + * Obtiene alertas manuales pendientes (no resueltas). + * Filters by contribuyente or by user's accessible contribuyentes (for clientes). + */ +export async function getAlertasManualesPendientes( + pool: Pool, + contribuyenteId?: string | null, + userId?: string | null, + role?: string | null, +): Promise { + let contribuyenteFilter = ''; + const params: unknown[] = []; + + if (contribuyenteId) { + // Specific contribuyente selected + params.push(contribuyenteId); + contribuyenteFilter = `AND ( + tipo LIKE 'ob-%' AND SUBSTRING(tipo FROM 4 FOR 36) IN ( + SELECT id::text FROM obligaciones_contribuyente WHERE contribuyente_id = $${params.length} + ) + )`; + } else if (role === 'cliente' && userId) { + // Client with "Todos los RFCs" — only their accessible contribuyentes + params.push(userId); + contribuyenteFilter = `AND ( + tipo LIKE 'ob-%' AND SUBSTRING(tipo FROM 4 FOR 36) IN ( + SELECT id::text FROM obligaciones_contribuyente WHERE contribuyente_id IN ( + SELECT entidad_id FROM cliente_accesos WHERE user_id = $${params.length} + ) + ) + )`; + } else if (role === 'auxiliar' && userId) { + // Auxiliar: only their subcarteras' contribuyentes + params.push(userId); + contribuyenteFilter = `AND ( + tipo LIKE 'ob-%' AND SUBSTRING(tipo FROM 4 FOR 36) IN ( + SELECT id::text FROM obligaciones_contribuyente WHERE contribuyente_id IN ( + SELECT ce.entidad_id FROM cartera_entidades ce + JOIN carteras c ON c.id = ce.cartera_id + WHERE c.auxiliar_user_id = $${params.length} + UNION + SELECT ce.entidad_id FROM cartera_entidades ce + JOIN cartera_auxiliares ca ON ca.cartera_id = ce.cartera_id + WHERE ca.auxiliar_user_id = $${params.length} + ) + ) + )`; + } else if (role === 'supervisor' && userId) { + // Supervisor: only their carteras' contribuyentes + params.push(userId); + contribuyenteFilter = `AND ( + tipo LIKE 'ob-%' AND SUBSTRING(tipo FROM 4 FOR 36) IN ( + SELECT id::text FROM obligaciones_contribuyente WHERE contribuyente_id IN ( + SELECT ce.entidad_id FROM cartera_entidades ce + JOIN carteras c ON c.id = ce.cartera_id AND c.parent_id IS NULL + WHERE c.supervisor_user_id = $${params.length} + ) + ) + )`; + } + + // Exclude alerts for inactive obligations + const inactiveFilter = `AND NOT ( + tipo LIKE 'ob-%' AND SUBSTRING(tipo FROM 4 FOR 36) IN ( + SELECT id::text FROM obligaciones_contribuyente WHERE activa = false + ) + )`; + + const { rows } = await pool.query(` + SELECT id, tipo, titulo, mensaje, prioridad, + fecha_vencimiento as "fechaVencimiento", + leida, resuelta, created_at as "createdAt" + FROM alertas + WHERE resuelta = false + ${inactiveFilter} + ${contribuyenteFilter} + ORDER BY + CASE prioridad WHEN 'alta' THEN 1 WHEN 'media' THEN 2 ELSE 3 END, + fecha_vencimiento ASC + `, params); + + return rows; +} + +/** + * Marca una alerta como resuelta (presentada/pagada). + */ +export async function resolverAlerta(pool: Pool, id: string): Promise { + await pool.query( + `UPDATE alertas SET resuelta = true, leida = true WHERE id = $1`, + [id] + ); +} diff --git a/apps/api/src/services/alertas.service.ts b/apps/api/src/services/alertas.service.ts new file mode 100644 index 0000000..ff30f42 --- /dev/null +++ b/apps/api/src/services/alertas.service.ts @@ -0,0 +1,108 @@ +import type { Pool } from 'pg'; +import type { AlertaFull, AlertaCreate, AlertaUpdate, AlertasStats } from '@horux/shared'; + +export async function getAlertas( + pool: Pool, + filters: { leida?: boolean; resuelta?: boolean; prioridad?: string } +): Promise { + let whereClause = 'WHERE 1=1'; + const params: any[] = []; + let paramIndex = 1; + + if (filters.leida !== undefined) { + whereClause += ` AND leida = $${paramIndex++}`; + params.push(filters.leida); + } + if (filters.resuelta !== undefined) { + whereClause += ` AND resuelta = $${paramIndex++}`; + params.push(filters.resuelta); + } + if (filters.prioridad) { + whereClause += ` AND prioridad = $${paramIndex++}`; + params.push(filters.prioridad); + } + + const { rows } = await pool.query(` + SELECT id, tipo, titulo, mensaje, prioridad, + fecha_vencimiento as "fechaVencimiento", + leida, resuelta, created_at as "createdAt" + FROM alertas + ${whereClause} + ORDER BY + CASE prioridad WHEN 'alta' THEN 1 WHEN 'media' THEN 2 ELSE 3 END, + created_at DESC + `, params); + + return rows; +} + +export async function getAlertaById(pool: Pool, id: number): Promise { + const { rows } = await pool.query(` + SELECT id, tipo, titulo, mensaje, prioridad, + fecha_vencimiento as "fechaVencimiento", + leida, resuelta, created_at as "createdAt" + FROM alertas + WHERE id = $1 + `, [id]); + return rows[0] || null; +} + +export async function createAlerta(pool: Pool, data: AlertaCreate): Promise { + const { rows } = await pool.query(` + INSERT INTO alertas (tipo, titulo, mensaje, prioridad, fecha_vencimiento) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, tipo, titulo, mensaje, prioridad, + fecha_vencimiento as "fechaVencimiento", + leida, resuelta, created_at as "createdAt" + `, [data.tipo, data.titulo, data.mensaje, data.prioridad, data.fechaVencimiento || null]); + return rows[0]; +} + +export async function updateAlerta(pool: Pool, id: number, data: AlertaUpdate): Promise { + const sets: string[] = []; + const params: any[] = []; + let paramIndex = 1; + + if (data.leida !== undefined) { + sets.push(`leida = $${paramIndex++}`); + params.push(data.leida); + } + if (data.resuelta !== undefined) { + sets.push(`resuelta = $${paramIndex++}`); + params.push(data.resuelta); + } + + params.push(id); + + const { rows } = await pool.query(` + UPDATE alertas + SET ${sets.join(', ')} + WHERE id = $${paramIndex} + RETURNING id, tipo, titulo, mensaje, prioridad, + fecha_vencimiento as "fechaVencimiento", + leida, resuelta, created_at as "createdAt" + `, params); + + return rows[0]; +} + +export async function deleteAlerta(pool: Pool, id: number): Promise { + await pool.query(`DELETE FROM alertas WHERE id = $1`, [id]); +} + +export async function getStats(pool: Pool): Promise { + const { rows: [stats] } = await pool.query(` + SELECT + COUNT(*)::int as total, + COUNT(CASE WHEN leida = false THEN 1 END)::int as "noLeidas", + COUNT(CASE WHEN prioridad = 'alta' AND resuelta = false THEN 1 END)::int as alta, + COUNT(CASE WHEN prioridad = 'media' AND resuelta = false THEN 1 END)::int as media, + COUNT(CASE WHEN prioridad = 'baja' AND resuelta = false THEN 1 END)::int as baja + FROM alertas + `); + return stats; +} + +export async function markAllAsRead(pool: Pool): Promise { + await pool.query(`UPDATE alertas SET leida = true WHERE leida = false`); +} diff --git a/apps/api/src/services/auth.service.ts b/apps/api/src/services/auth.service.ts new file mode 100644 index 0000000..b69dbc2 --- /dev/null +++ b/apps/api/src/services/auth.service.ts @@ -0,0 +1,653 @@ +import { prisma, tenantDb } from '../config/database.js'; +import { hashPassword, verifyPassword } from '../auth/passwords.js'; +import { generateAccessToken, generateRefreshToken, verifyToken } from '../auth/tokens.js'; +import { AppError } from '../middlewares/error.middleware.js'; +import { auditLog } from '../utils/audit.js'; +import { getPlatformRoles } from '../utils/platform-admin.js'; +import { getUserTenants, verifyMembership } from '../utils/memberships.js'; +import { emailService } from './email/email.service.js'; +import { env } from '../config/env.js'; +import { invalidateTokenVersionCache } from '../middlewares/auth.middleware.js'; +import type { LoginRequest, RegisterRequest, LoginResponse, Role } from '@horux/shared'; +import { randomBytes } from 'crypto'; + +export async function register(data: RegisterRequest): Promise { + const existingUser = await prisma.user.findUnique({ + where: { email: data.usuario.email.toLowerCase() }, + }); + + if (existingUser) { + throw new AppError(400, 'El email ya está registrado'); + } + + const existingTenant = await prisma.tenant.findUnique({ + where: { rfc: data.empresa.rfc }, + }); + + if (existingTenant) { + throw new AppError(400, 'El RFC ya está registrado'); + } + + // Provision a dedicated database for this tenant + const databaseName = await tenantDb.provisionDatabase(data.empresa.rfc); + + const tenant = await prisma.tenant.create({ + data: { + nombre: data.empresa.nombre, + rfc: data.empresa.rfc.toUpperCase(), + plan: 'trial', + databaseName, + }, + }); + + const passwordHash = await hashPassword(data.usuario.password); + const adminRol = await prisma.rol.findUnique({ where: { nombre: 'owner' } }); + if (!adminRol) throw new AppError(500, 'Rol admin no encontrado en catálogo'); + + const user = await prisma.user.create({ + data: { + email: data.usuario.email.toLowerCase(), + passwordHash, + nombre: data.usuario.nombre, + lastTenantId: tenant.id, + }, + }); + + // Crea membership owner del caller en el tenant recién creado (fase 4 multi-tenant) + await prisma.tenantMembership.create({ + data: { + userId: user.id, + tenantId: tenant.id, + rolId: adminRol.id, + isOwner: true, + active: true, + }, + }); + + const ownerRole: Role = 'owner'; + const tokenPayload = { + userId: user.id, + email: user.email, + role: ownerRole, + tenantId: tenant.id, + }; + + const accessToken = generateAccessToken(tokenPayload); + const refreshToken = generateRefreshToken(tokenPayload); + + await prisma.refreshToken.create({ + data: { + userId: user.id, + token: refreshToken, + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + }, + }); + + return { + accessToken, + refreshToken, + user: { + id: user.id, + email: user.email, + nombre: user.nombre, + role: ownerRole, + tenantId: tenant.id, + tenantName: tenant.nombre, + tenantRfc: tenant.rfc, + plan: tenant.plan, + }, + }; +} + +export async function login(data: LoginRequest): Promise { + const user = await prisma.user.findUnique({ + where: { email: data.email.toLowerCase() }, + }); + + if (!user) { + throw new AppError(401, 'Credenciales inválidas'); + } + + if (!user.active) { + throw new AppError(401, 'Usuario desactivado'); + } + + const isValidPassword = await verifyPassword(data.password, user.passwordHash); + + if (!isValidPassword) { + throw new AppError(401, 'Credenciales inválidas'); + } + + // Resuelve el tenant activo desde memberships. Prefiere `lastTenantId` si + // existe Y el user tiene membership activa ahí; sino cae al primer membership + // por joinedAt ASC. + const allMemberships = await prisma.tenantMembership.findMany({ + where: { userId: user.id, active: true, tenant: { active: true } }, + include: { tenant: true, rol: true }, + orderBy: { joinedAt: 'asc' }, + }); + + let activeTenant; + let activeRole: Role; + + if (allMemberships.length === 0) { + // Edge case: user sin membership activa. Si tiene platformRoles (admin + // global), permite login con cualquier tenant activo como nominal — su + // trabajo real es vía impersonación desde /clientes. Sin esto, no podría + // ni entrar a la plataforma para crear el primer cliente. + const earlyPlatformRoles = await getPlatformRoles(user.id); + if (earlyPlatformRoles.length === 0) { + throw new AppError(401, 'No tienes acceso a ninguna empresa activa'); + } + const fallbackTenant = await prisma.tenant.findFirst({ + where: { active: true }, + orderBy: { createdAt: 'asc' }, + }); + if (!fallbackTenant) { + throw new AppError(503, 'No hay tenants activos en el sistema. Ejecuta `pnpm db:seed` para bootstrap.'); + } + activeTenant = fallbackTenant; + activeRole = 'visor' as Role; // mínimo — la autorización real viene de platformRoles + } else { + const preferred = user.lastTenantId + ? allMemberships.find(m => m.tenantId === user.lastTenantId) + : null; + const activeMembership = preferred ?? allMemberships[0]; + activeTenant = activeMembership.tenant; + activeRole = activeMembership.rol.nombre as Role; + } + + // `loginCount` se incrementa SOLO en login (NO en refresh) — es la métrica + // que dispara el auto-dismiss del onboarding tras N sesiones. + const updatedUser = await prisma.user.update({ + where: { id: user.id }, + data: { + lastLogin: new Date(), + lastTenantId: activeTenant.id, + loginCount: { increment: 1 }, + }, + select: { loginCount: true, onboardingDismissedAt: true }, + }); + + auditLog({ + userId: user.id, + tenantId: activeTenant.id, + action: 'user.login', + metadata: { email: user.email, tenantRfc: activeTenant.rfc }, + }); + + const [platformRoles, tenants] = await Promise.all([ + getPlatformRoles(user.id), + getUserTenants(user.id), + ]); + + const tokenPayload = { + userId: user.id, + email: user.email, + role: activeRole, + tenantId: activeTenant.id, + platformRoles, + tokenVersion: user.tokenVersion, + }; + + const accessToken = generateAccessToken(tokenPayload); + const refreshToken = generateRefreshToken(tokenPayload); + + await prisma.refreshToken.create({ + data: { + userId: user.id, + token: refreshToken, + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + }, + }); + + return { + accessToken, + refreshToken, + user: { + id: user.id, + email: user.email, + nombre: user.nombre, + role: activeRole, + tenantId: activeTenant.id, + tenantName: activeTenant.nombre, + tenantRfc: activeTenant.rfc, + plan: activeTenant.plan, + platformRoles, + tenants, + loginCount: updatedUser.loginCount, + onboardingDismissedAt: updatedUser.onboardingDismissedAt, + }, + }; +} + +export async function refreshTokens(token: string): Promise<{ accessToken: string; refreshToken: string }> { + // Use a transaction to prevent race conditions + return await prisma.$transaction(async (tx) => { + const storedToken = await tx.refreshToken.findUnique({ + where: { token }, + }); + + if (!storedToken) { + throw new AppError(401, 'Token inválido'); + } + + if (storedToken.expiresAt < new Date()) { + await tx.refreshToken.deleteMany({ where: { id: storedToken.id } }); + throw new AppError(401, 'Token expirado'); + } + + const payload = verifyToken(token); + + const user = await tx.user.findUnique({ + where: { id: payload.userId }, + }); + + if (!user || !user.active) { + throw new AppError(401, 'Usuario no encontrado o desactivado'); + } + + // Re-valida que el user sigue teniendo membership activa en el tenant del + // JWT. Si lo removieron de ahí, cae al primer membership disponible. + const currentMembership = await tx.tenantMembership.findFirst({ + where: { userId: user.id, tenantId: payload.tenantId, active: true, tenant: { active: true } }, + include: { tenant: true, rol: true }, + }); + let activeMembership = currentMembership; + if (!activeMembership) { + activeMembership = await tx.tenantMembership.findFirst({ + where: { userId: user.id, active: true, tenant: { active: true } }, + include: { tenant: true, rol: true }, + orderBy: { joinedAt: 'asc' }, + }); + } + if (!activeMembership) { + throw new AppError(401, 'No tienes acceso a ninguna empresa activa'); + } + + // Use deleteMany to avoid error if already deleted (race condition) + await tx.refreshToken.deleteMany({ where: { id: storedToken.id } }); + + const platformRoles = await getPlatformRoles(user.id); + + const newTokenPayload = { + userId: user.id, + email: user.email, + role: activeMembership.rol.nombre as Role, + tenantId: activeMembership.tenantId, + platformRoles, + tokenVersion: user.tokenVersion, + }; + + const accessToken = generateAccessToken(newTokenPayload); + const refreshToken = generateRefreshToken(newTokenPayload); + + await tx.refreshToken.create({ + data: { + userId: user.id, + token: refreshToken, + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + }, + }); + + return { accessToken, refreshToken }; + }); +} + +export async function logout(token: string): Promise { + // Busca el refreshToken antes de borrarlo para capturar el userId en auditoría + const rt = await prisma.refreshToken.findFirst({ + where: { token }, + select: { userId: true }, + }); + + await prisma.refreshToken.deleteMany({ + where: { token }, + }); + + if (rt) { + const tenantId = (await prisma.user.findUnique({ + where: { id: rt.userId }, + select: { lastTenantId: true }, + }))?.lastTenantId ?? undefined; + + auditLog({ + userId: rt.userId, + tenantId, + action: 'user.logout', + }); + } +} + +// ============================================================================ +// Password reset +// ============================================================================ + +const PASSWORD_RESET_EXPIRY_MS = 60 * 60 * 1000; // 1 hora + +/** + * Solicita recuperación de contraseña. No revela si el email existe (anti-enumeration). + * + * Si el email es válido y user activo: + * - Invalida cualquier token previo no usado del mismo user + * - Genera token criptográficamente seguro (32 bytes hex) + * - Envía email con link de reset (expira en 1h) + * + * Rate limit: aplicar en la capa de rutas (3/hora por IP). + */ +export async function requestPasswordReset(email: string): Promise { + const normalizedEmail = email.trim().toLowerCase(); + const user = await prisma.user.findUnique({ + where: { email: normalizedEmail }, + select: { id: true, email: true, nombre: true, active: true, lastTenantId: true }, + }); + + // Respuesta idéntica para email existente/no-existente (anti-enumeration) + if (!user || !user.active) { + console.log(`[PasswordReset] Request para email inexistente o inactivo: ${normalizedEmail}`); + return; + } + + // Invalida tokens previos no usados (marca como usados) + await prisma.passwordResetToken.updateMany({ + where: { userId: user.id, usedAt: null }, + data: { usedAt: new Date() }, + }); + + const token = randomBytes(32).toString('hex'); + const expiresAt = new Date(Date.now() + PASSWORD_RESET_EXPIRY_MS); + + await prisma.passwordResetToken.create({ + data: { userId: user.id, token, expiresAt }, + }); + + const resetUrl = `${env.FRONTEND_URL}/reset-password?token=${token}`; + emailService.sendPasswordReset(user.email, { nombre: user.nombre, resetUrl }) + .catch(err => console.error('[EMAIL] Password reset notification failed:', err)); + + auditLog({ + userId: user.id, + tenantId: user.lastTenantId ?? undefined, + action: 'user.password_reset_requested', + metadata: { email: user.email }, + }); + + console.log(`[PasswordReset] Token emitido para ${normalizedEmail}, expira ${expiresAt.toISOString()}`); +} + +/** + * Confirma recuperación de contraseña con token válido + nueva contraseña. + * + * Validaciones: + * - Password mínimo 8 caracteres + * - Token existe, no usado, no expirado + * + * Al éxito: + * - Actualiza password hash + * - Marca token como usado (single-use) + * - Borra todos los refresh tokens del user (invalida sesiones activas → re-login forzado) + */ +export async function confirmPasswordReset(token: string, newPassword: string): Promise { + if (!newPassword || newPassword.length < 8) { + throw new AppError(400, 'La contraseña debe tener al menos 8 caracteres'); + } + + const record = await prisma.passwordResetToken.findUnique({ + where: { token }, + select: { id: true, userId: true, usedAt: true, expiresAt: true }, + }); + + if (!record) throw new AppError(400, 'Token inválido'); + if (record.usedAt) throw new AppError(400, 'Este enlace ya fue usado. Solicita uno nuevo.'); + if (record.expiresAt < new Date()) throw new AppError(400, 'El enlace expiró. Solicita uno nuevo.'); + + const passwordHash = await hashPassword(newPassword); + + await prisma.$transaction([ + // Actualiza hash + incrementa tokenVersion (invalida access tokens vivos) + prisma.user.update({ + where: { id: record.userId }, + data: { passwordHash, tokenVersion: { increment: 1 } }, + }), + prisma.passwordResetToken.update({ + where: { id: record.id }, + data: { usedAt: new Date() }, + }), + // Invalida todos los refresh tokens activos del user + prisma.refreshToken.deleteMany({ + where: { userId: record.userId }, + }), + ]); + + // Propaga el incremento al cache del middleware (en todos los PM2 workers) + invalidateTokenVersionCache(record.userId); + + const user = await prisma.user.findUnique({ + where: { id: record.userId }, + select: { lastTenantId: true, email: true }, + }); + + auditLog({ + userId: record.userId, + tenantId: user?.lastTenantId ?? undefined, + action: 'user.password_reset_completed', + metadata: { email: user?.email }, + }); + + console.log(`[PasswordReset] Completado para user ${record.userId}`); +} + +// ============================================================================ +// Change password (authenticated) + Logout all +// ============================================================================ + +/** + * Cambia la contraseña de un user autenticado. Requiere password actual para + * prevenir cambios por alguien con acceso temporal a la sesión (ej: laptop + * compartida dejada abierta). Incrementa tokenVersion — invalida TODAS las + * sesiones activas del user (forza re-login en otros dispositivos). + */ +export async function changePassword(params: { + userId: string; + currentPassword: string; + newPassword: string; +}): Promise { + if (params.newPassword.length < 8) { + throw new AppError(400, 'La contraseña debe tener al menos 8 caracteres'); + } + if (params.currentPassword === params.newPassword) { + throw new AppError(400, 'La nueva contraseña debe ser distinta a la actual'); + } + + const user = await prisma.user.findUnique({ + where: { id: params.userId }, + select: { id: true, passwordHash: true, email: true, lastTenantId: true, active: true }, + }); + if (!user || !user.active) throw new AppError(401, 'Usuario no encontrado'); + + const validCurrent = await verifyPassword(params.currentPassword, user.passwordHash); + if (!validCurrent) throw new AppError(401, 'Contraseña actual incorrecta'); + + const newHash = await hashPassword(params.newPassword); + + await prisma.$transaction([ + prisma.user.update({ + where: { id: user.id }, + data: { passwordHash: newHash, tokenVersion: { increment: 1 } }, + }), + prisma.refreshToken.deleteMany({ where: { userId: user.id } }), + ]); + + invalidateTokenVersionCache(user.id); + + auditLog({ + userId: user.id, + tenantId: user.lastTenantId ?? undefined, + action: 'user.password_changed', + metadata: { email: user.email }, + }); + + console.log(`[ChangePassword] Completado para user ${user.id}`); +} + +/** + * Cierra todas las sesiones activas del user. Usado por el botón + * "Cerrar todas las sesiones" en /configuracion/seguridad. Incrementa + * tokenVersion + borra refresh tokens. El user se queda sin acceso y debe + * re-loguearse (incluyendo la sesión actual, por diseño — es lo que el + * usuario pidió explícitamente). + */ +export async function logoutAllSessions(userId: string): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { lastTenantId: true, email: true }, + }); + if (!user) throw new AppError(404, 'Usuario no encontrado'); + + await prisma.$transaction([ + prisma.user.update({ + where: { id: userId }, + data: { tokenVersion: { increment: 1 } }, + }), + prisma.refreshToken.deleteMany({ where: { userId } }), + ]); + + invalidateTokenVersionCache(userId); + + auditLog({ + userId, + tenantId: user.lastTenantId ?? undefined, + action: 'user.sessions_invalidated', + metadata: { email: user.email, reason: 'logout_all' }, + }); + + console.log(`[LogoutAll] Sesiones invalidadas para user ${userId}`); +} + +// ============================================================================ +// Onboarding dismiss +// ============================================================================ + +/** + * Marca el onboarding como dismissed. Idempotente — si ya estaba seteado, no + * sobrescribe el timestamp original (preserva la fecha del primer dismiss). + */ +export async function dismissOnboarding(userId: string): Promise<{ onboardingDismissedAt: Date }> { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { onboardingDismissedAt: true }, + }); + if (!user) throw new AppError(404, 'Usuario no encontrado'); + + if (user.onboardingDismissedAt) { + return { onboardingDismissedAt: user.onboardingDismissedAt }; + } + + const updated = await prisma.user.update({ + where: { id: userId }, + data: { onboardingDismissedAt: new Date() }, + select: { onboardingDismissedAt: true }, + }); + return { onboardingDismissedAt: updated.onboardingDismissedAt! }; +} + +// ============================================================================ +// Switch tenant (multi-membership) +// ============================================================================ + +/** + * Cambia el tenant activo del user. Valida que tenga membership activa en el + * tenant destino, luego emite un nuevo par de tokens apuntando a ese tenantId + * (con el rol que tiene en ese tenant específico). El refresh token actual se + * invalida — el user opera con el par nuevo desde este momento. + * + * Casos de uso: + * - Owner con varias empresas cambia entre ellas + * - Contador que atiende múltiples clientes cambia de empresa activa + * + * Un user con 1 sola membership no debería llamarlo (no cambia nada), pero si + * lo hace funciona igual: le da tokens nuevos apuntando al mismo tenant. + */ +export async function switchTenant(params: { + userId: string; + currentRefreshToken: string; + targetTenantId: string; +}): Promise { + const membership = await verifyMembership(params.userId, params.targetTenantId); + if (!membership) { + throw new AppError(403, 'No tienes acceso a esa empresa'); + } + + const user = await prisma.user.findUnique({ + where: { id: params.userId }, + }); + if (!user || !user.active) throw new AppError(401, 'Usuario no encontrado'); + + const targetTenant = await prisma.tenant.findUnique({ + where: { id: params.targetTenantId }, + }); + if (!targetTenant || !targetTenant.active) { + throw new AppError(404, 'Empresa no encontrada o desactivada'); + } + + // Persiste el target como "último tenant activo" — al re-loguear caerá aquí + // sin tener que volver a hacer switch. + const previousTenantId = user.lastTenantId; + await prisma.user.update({ + where: { id: user.id }, + data: { lastTenantId: targetTenant.id }, + }); + + // Invalida el refresh token actual (puede no existir si el caller pasó el + // access token por error — deleteMany es idempotente). + await prisma.refreshToken.deleteMany({ where: { token: params.currentRefreshToken } }); + + const [platformRoles, tenants] = await Promise.all([ + getPlatformRoles(user.id), + getUserTenants(user.id), + ]); + + const tokenPayload = { + userId: user.id, + email: user.email, + role: membership.rolNombre, + tenantId: targetTenant.id, + platformRoles, + tokenVersion: user.tokenVersion, + }; + + const accessToken = generateAccessToken(tokenPayload); + const refreshToken = generateRefreshToken(tokenPayload); + + await prisma.refreshToken.create({ + data: { + userId: user.id, + token: refreshToken, + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + }, + }); + + auditLog({ + userId: user.id, + tenantId: targetTenant.id, + action: 'user.tenant_switched', + metadata: { from: previousTenantId ?? null, to: targetTenant.id, targetRfc: targetTenant.rfc }, + }); + + return { + accessToken, + refreshToken, + user: { + id: user.id, + email: user.email, + nombre: user.nombre, + role: membership.rolNombre, + tenantId: targetTenant.id, + tenantName: targetTenant.nombre, + tenantRfc: targetTenant.rfc, + plan: targetTenant.plan, + platformRoles, + tenants, + }, + }; +} diff --git a/apps/api/src/services/bancos.service.ts b/apps/api/src/services/bancos.service.ts new file mode 100644 index 0000000..0a60b2c --- /dev/null +++ b/apps/api/src/services/bancos.service.ts @@ -0,0 +1,63 @@ +import type { Pool } from 'pg'; + +export interface Banco { + id: number; + banco: string; + terminacionCuenta: string; + creadoEn: string; +} + +export async function getBancos(pool: Pool, contribuyenteId?: string | null): Promise { + const conditions = []; + const params: unknown[] = []; + if (contribuyenteId) { + params.push(contribuyenteId); + conditions.push(`contribuyente_id = $${params.length}`); + } + const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + const { rows } = await pool.query(` + SELECT id, banco, terminacion_cuenta as "terminacionCuenta", + creado_en as "creadoEn" + FROM bancos ${where} ORDER BY banco + `, params); + return rows; +} + +export async function createBanco(pool: Pool, data: { banco: string; terminacionCuenta: string; contribuyenteId?: string }): Promise { + const { rows } = await pool.query(` + INSERT INTO bancos (banco, terminacion_cuenta, contribuyente_id) + VALUES ($1, $2, $3) + RETURNING id, banco, terminacion_cuenta as "terminacionCuenta", creado_en as "creadoEn" + `, [data.banco, data.terminacionCuenta, data.contribuyenteId || null]); + return rows[0]; +} + +export async function updateBanco(pool: Pool, id: number, data: { banco?: string; terminacionCuenta?: string }): Promise { + const fields: string[] = []; + const params: any[] = []; + let idx = 1; + + if (data.banco) { fields.push(`banco = $${idx++}`); params.push(data.banco); } + if (data.terminacionCuenta) { fields.push(`terminacion_cuenta = $${idx++}`); params.push(data.terminacionCuenta); } + + if (fields.length === 0) throw new Error('Nada que actualizar'); + + params.push(id); + const { rows } = await pool.query(` + UPDATE bancos SET ${fields.join(', ')} WHERE id = $${idx} + RETURNING id, banco, terminacion_cuenta as "terminacionCuenta", creado_en as "creadoEn" + `, params); + + if (rows.length === 0) throw new Error('Banco no encontrado'); + return rows[0]; +} + +export async function deleteBanco(pool: Pool, id: number): Promise { + const { rows } = await pool.query( + `SELECT COUNT(*)::int as count FROM conciliaciones WHERE id_banco = $1`, [id] + ); + if (rows[0].count > 0) { + throw new Error('No se puede eliminar un banco con conciliaciones asociadas'); + } + await pool.query(`DELETE FROM bancos WHERE id = $1`, [id]); +} diff --git a/apps/api/src/services/calendario-fiscal.service.ts b/apps/api/src/services/calendario-fiscal.service.ts new file mode 100644 index 0000000..326eb2c --- /dev/null +++ b/apps/api/src/services/calendario-fiscal.service.ts @@ -0,0 +1,271 @@ +import { prisma } from '../config/database.js'; +import { getRegimenesActivosClaves } from './regimen.service.js'; +import { getObligaciones } from './obligaciones.service.js'; +import type { Pool } from 'pg'; + +interface EventoGenerado { + titulo: string; + tipo: string; + fechaLimite: string; + recurrencia: string; + completado: boolean; + descripcion: string; +} + +/** + * Obtener días inhábiles del año como Set de strings 'YYYY-MM-DD' + */ +async function getDiasInhabiles(año: number): Promise> { + const rows = await prisma.diaInhabil.findMany({ + where: { + fecha: { + gte: new Date(`${año}-01-01`), + lte: new Date(`${año}-12-31`), + }, + }, + }); + return new Set(rows.map(r => r.fecha.toISOString().split('T')[0])); +} + +/** + * Si la fecha cae en día inhábil (sábado, domingo, festivo), recorrer al siguiente día hábil + */ +function siguienteDiaHabil(fecha: Date, inhabiles: Set): Date { + const d = new Date(fecha); + while (true) { + const dow = d.getDay(); + const str = d.toISOString().split('T')[0]; + if (dow !== 0 && dow !== 6 && !inhabiles.has(str)) { + return d; + } + d.setDate(d.getDate() + 1); + } +} + +/** + * Agregar N días hábiles a una fecha + */ +function agregarDiasHabiles(fecha: Date, dias: number, inhabiles: Set): Date { + const d = new Date(fecha); + let added = 0; + while (added < dias) { + d.setDate(d.getDate() + 1); + const dow = d.getDay(); + const str = d.toISOString().split('T')[0]; + if (dow !== 0 && dow !== 6 && !inhabiles.has(str)) { + added++; + } + } + return d; +} + +/** + * Calcula días adicionales por RFC según Resolución Miscelánea Fiscal + * Sexto dígito numérico del RFC: + * 1-2: +1 día, 3-4: +2, 5-6: +3, 7-8: +4, 9-0: +5 + */ +function diasExtensionRfc(rfc: string): number { + // Extraer sexto dígito numérico + const numeros = rfc.replace(/[^0-9]/g, ''); + if (numeros.length < 6) return 0; + const sexto = parseInt(numeros[5]); + + if (sexto === 1 || sexto === 2) return 1; + if (sexto === 3 || sexto === 4) return 2; + if (sexto === 5 || sexto === 6) return 3; + if (sexto === 7 || sexto === 8) return 4; + return 5; // 9 o 0 +} + +/** + * Genera los eventos fiscales para un tenant en un año dado, + * basándose en el catálogo central y las reglas del SAT. + */ +export async function generarEventosFiscales( + tenantId: string, + año: number, +): Promise { + const tenant = await prisma.tenant.findUnique({ + where: { id: tenantId }, + select: { rfc: true }, + }); + if (!tenant) return []; + + const rfc = tenant.rfc; + const inhabiles = await getDiasInhabiles(año); + + // Regímenes activos del tenant + const activos = await getRegimenesActivosClaves(tenantId); + const activosSet = new Set(activos); + + const catalogo = await prisma.eventoFiscalCatalogo.findMany({ + where: { activo: true }, + }); + + const eventos: EventoGenerado[] = []; + const hoy = new Date(); + + for (const cat of catalogo) { + // Filtrar por régimen: si el evento es para regímenes específicos, + // verificar que el tenant tenga al menos uno de ellos activo + if (cat.regimenes !== 'todos' && activos.length > 0) { + const regimenesEvento = cat.regimenes.split(',').map(r => r.trim()); + const aplica = regimenesEvento.some(r => activosSet.has(r)); + if (!aplica) continue; + } + + if (cat.recurrencia === 'mensual') { + for (let mes = 1; mes <= 12; mes++) { + // Mes relativo: 1 = mes posterior al que se declara + const mesObligacion = mes; // mes que se declara + const mesVencimiento = mes + cat.mesRelativo; + const añoVencimiento = mesVencimiento > 12 ? año + 1 : año; + const mesReal = mesVencimiento > 12 ? mesVencimiento - 12 : mesVencimiento; + + // Fecha base + const lastDay = new Date(añoVencimiento, mesReal, 0).getDate(); + const dia = Math.min(cat.diaBase, lastDay); + let fechaLimite = new Date(añoVencimiento, mesReal - 1, dia); + + // Ajustar a día hábil + fechaLimite = siguienteDiaHabil(fechaLimite, inhabiles); + + // Extensión por RFC + if (cat.usaExtensionRfc) { + const diasExtra = diasExtensionRfc(rfc); + fechaLimite = agregarDiasHabiles(fechaLimite, diasExtra, inhabiles); + } + + const completado = fechaLimite < hoy; + + const meses = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic']; + + eventos.push({ + titulo: cat.titulo, + tipo: cat.tipo, + fechaLimite: fechaLimite.toISOString().split('T')[0], + recurrencia: cat.recurrencia, + completado, + descripcion: `${cat.titulo} — ${meses[mesObligacion - 1]} ${año}`, + }); + } + } else if (cat.recurrencia === 'anual' && cat.mesFijo) { + const lastDay = new Date(año, cat.mesFijo, 0).getDate(); + const dia = Math.min(cat.diaBase, lastDay); + let fechaLimite = new Date(año, cat.mesFijo - 1, dia); + + fechaLimite = siguienteDiaHabil(fechaLimite, inhabiles); + + eventos.push({ + titulo: cat.titulo, + tipo: cat.tipo, + fechaLimite: fechaLimite.toISOString().split('T')[0], + recurrencia: cat.recurrencia, + completado: fechaLimite < hoy, + descripcion: `${cat.titulo} — Ejercicio ${año - 1}`, + }); + } + } + + // Ordenar por fecha + eventos.sort((a, b) => a.fechaLimite.localeCompare(b.fechaLimite)); + + return eventos; +} + +/** + * Genera eventos de calendario a partir de las obligaciones reales de un contribuyente. + * Usado en tenants despacho — reemplaza el catálogo estático por las obligaciones + * registradas en obligaciones_contribuyente con su estado de cumplimiento en obligacion_periodos. + */ +export async function generarEventosDesdeObligaciones( + pool: Pool, + contribuyenteId: string | null, + año: number, +): Promise { + if (!contribuyenteId) return []; + + const inhabiles = await getDiasInhabiles(año); + const obligaciones = await getObligaciones(pool, contribuyenteId); + const activas = obligaciones.filter(o => o.activa); + const eventos: EventoGenerado[] = []; + + // Get completion records for this contribuyente + const { rows: completions } = await pool.query(` + SELECT op.obligacion_id, op.periodo, op.completada + FROM obligacion_periodos op + JOIN obligaciones_contribuyente oc ON oc.id = op.obligacion_id + WHERE oc.contribuyente_id = $1 + `, [contribuyenteId]); + + const completionMap = new Map(); + for (const c of completions) { + completionMap.set(`${c.obligacion_id}:${c.periodo}`, c.completada); + } + + const meses = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic']; + + for (const ob of activas) { + const freq = ob.frecuencia || 'mensual'; + + // Determine which months this obligation applies to + const monthsToGenerate: number[] = []; + for (let m = 1; m <= 12; m++) { + if (freq === 'mensual') monthsToGenerate.push(m); + else if (freq === 'bimestral' && m % 2 === 1) monthsToGenerate.push(m); + else if (freq === 'trimestral' && [1, 4, 7, 10].includes(m)) monthsToGenerate.push(m); + else if (freq === 'anual' && (m === 3 || m === 4)) monthsToGenerate.push(m); + // 'eventual' and unknown: skip auto-generation + } + + for (const mes of monthsToGenerate) { + // Parse day from fechaLimite text; default to 17 + let diaBase = 17; + if (ob.fechaLimite) { + const matchDia = ob.fechaLimite.match(/d[íi]a?\s*(\d+)/i); + if (matchDia) diaBase = parseInt(matchDia[1]); + // "Último día" → last day of month + if (ob.fechaLimite.toLowerCase().includes('ltimo d')) diaBase = 0; + } + + // Deadline is usually next month for mensual/bimestral/trimestral obligations + let mesVencimiento = mes + 1; + let añoVencimiento = año; + if (mesVencimiento > 12) { mesVencimiento = 1; añoVencimiento++; } + + // For annual obligations the deadline month IS the month (marzo/abril) + if (freq === 'anual') { + mesVencimiento = mes; + añoVencimiento = año; + } + + const lastDayOfMonth = new Date(añoVencimiento, mesVencimiento, 0).getDate(); + const dia = diaBase === 0 ? lastDayOfMonth : Math.min(diaBase, lastDayOfMonth); + let fechaLimite = new Date(añoVencimiento, mesVencimiento - 1, dia); + fechaLimite = siguienteDiaHabil(fechaLimite, inhabiles); + + const periodo = `${año}-${String(mes).padStart(2, '0')}`; + const isCompleted = completionMap.get(`${ob.id}:${periodo}`) === true; + const isPastDue = !isCompleted && fechaLimite < new Date(); + + // Type encodes the status for calendar coloring + const tipoEvento = isCompleted + ? 'obligacion-completada' + : isPastDue + ? 'obligacion-atrasada' + : 'obligacion-pendiente'; + + eventos.push({ + titulo: ob.nombre, + tipo: tipoEvento, + fechaLimite: fechaLimite.toISOString().split('T')[0], + recurrencia: freq, + completado: isCompleted, + descripcion: `${ob.nombre} — ${meses[mes - 1]} ${año}`, + }); + } + } + + eventos.sort((a, b) => a.fechaLimite.localeCompare(b.fechaLimite)); + return eventos; +} diff --git a/apps/api/src/services/cartera.service.ts b/apps/api/src/services/cartera.service.ts new file mode 100644 index 0000000..6d3c299 --- /dev/null +++ b/apps/api/src/services/cartera.service.ts @@ -0,0 +1,160 @@ +import type { Pool } from 'pg'; +import { prisma } from '../config/database.js'; + +export interface CarteraRow { + id: string; + supervisorUserId: string | null; + auxiliarUserId: string | null; + parentId: string | null; + nombre: string; + descripcion: string | null; + createdAt: string; + entidadesCount?: number; + subcarterasCount?: number; +} + +const BASE_SELECT = ` + SELECT c.id, c.supervisor_user_id AS "supervisorUserId", + c.auxiliar_user_id AS "auxiliarUserId", c.parent_id AS "parentId", + c.nombre, c.descripcion, c.created_at AS "createdAt", + (SELECT count(*) FROM cartera_entidades ce WHERE ce.cartera_id = c.id)::int AS "entidadesCount", + (SELECT count(*) FROM carteras sc WHERE sc.parent_id = c.id)::int AS "subcarterasCount" + FROM carteras c +`; + +/** + * List top-level carteras (parent_id IS NULL). + * If supervisorUserId is provided, filter by that supervisor. + */ +export async function listCarteras(pool: Pool, supervisorUserId?: string): Promise { + const conditions = ['c.parent_id IS NULL']; + const params: unknown[] = []; + if (supervisorUserId) { + params.push(supervisorUserId); + conditions.push(`c.supervisor_user_id = $${params.length}`); + } + const { rows } = await pool.query( + `${BASE_SELECT} WHERE ${conditions.join(' AND ')} ORDER BY c.created_at DESC`, + params, + ); + return rows; +} + +/** + * List subcarteras of a parent cartera. + */ +export async function listSubcarteras(pool: Pool, parentId: string): Promise { + const { rows } = await pool.query( + `${BASE_SELECT} WHERE c.parent_id = $1 ORDER BY c.nombre`, + [parentId], + ); + return rows; +} + +export async function getCarteraById(pool: Pool, id: string): Promise { + const { rows } = await pool.query(`${BASE_SELECT} WHERE c.id = $1`, [id]); + return rows[0] ?? null; +} + +export async function createCartera(pool: Pool, data: { + supervisorUserId: string; + nombre: string; + descripcion?: string; +}): Promise { + const { rows: [row] } = await pool.query(` + INSERT INTO carteras (supervisor_user_id, nombre, descripcion) + VALUES ($1, $2, $3) RETURNING id + `, [data.supervisorUserId, data.nombre, data.descripcion ?? null]); + return (await getCarteraById(pool, row.id))!; +} + +/** + * Create a subcartera within a parent cartera, assigned to an auxiliar. + */ +export async function createSubcartera(pool: Pool, data: { + parentId: string; + auxiliarUserId: string; + nombre: string; + descripcion?: string; +}): Promise { + const { rows: [row] } = await pool.query(` + INSERT INTO carteras (parent_id, auxiliar_user_id, nombre, descripcion) + VALUES ($1, $2, $3, $4) RETURNING id + `, [data.parentId, data.auxiliarUserId, data.nombre, data.descripcion ?? null]); + return (await getCarteraById(pool, row.id))!; +} + +export async function updateCartera(pool: Pool, id: string, data: { + nombre?: string; + descripcion?: string; + supervisorUserId?: string; +}): Promise { + const existing = await getCarteraById(pool, id); + if (!existing) return null; + const sets: string[] = []; + const vals: unknown[] = []; + let idx = 1; + if (data.nombre !== undefined) { sets.push(`nombre = $${idx}`); vals.push(data.nombre); idx++; } + if (data.descripcion !== undefined) { sets.push(`descripcion = $${idx}`); vals.push(data.descripcion); idx++; } + if (data.supervisorUserId !== undefined) { sets.push(`supervisor_user_id = $${idx}`); vals.push(data.supervisorUserId); idx++; } + if (sets.length === 0) return existing; + vals.push(id); + await pool.query(`UPDATE carteras SET ${sets.join(', ')} WHERE id = $${idx}`, vals); + return (await getCarteraById(pool, id))!; +} + +export async function deleteCartera(pool: Pool, id: string): Promise { + const { rowCount } = await pool.query('DELETE FROM carteras WHERE id = $1', [id]); + return (rowCount ?? 0) > 0; +} + +// Entidades in cartera +export async function addEntidadToCartera(pool: Pool, carteraId: string, entidadId: string): Promise { + await pool.query('INSERT INTO cartera_entidades (cartera_id, entidad_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', [carteraId, entidadId]); +} + +export async function removeEntidadFromCartera(pool: Pool, carteraId: string, entidadId: string): Promise { + await pool.query('DELETE FROM cartera_entidades WHERE cartera_id = $1 AND entidad_id = $2', [carteraId, entidadId]); +} + +export async function getCarteraEntidades(pool: Pool, carteraId: string): Promise { + const { rows } = await pool.query('SELECT entidad_id AS "entidadId" FROM cartera_entidades WHERE cartera_id = $1', [carteraId]); + return rows.map(r => r.entidadId); +} + +// Auxiliares assigned to a supervisor +export async function getAuxiliaresDelSupervisor(pool: Pool, supervisorUserId: string): Promise> { + const { rows } = await pool.query( + 'SELECT auxiliar_user_id AS "auxiliarUserId" FROM auxiliar_supervisores WHERE supervisor_user_id = $1', + [supervisorUserId], + ); + return rows; +} + +// Legacy auxiliares in cartera (backward compat) +export async function addAuxiliarToCartera(pool: Pool, carteraId: string, auxiliarUserId: string): Promise { + await pool.query('INSERT INTO cartera_auxiliares (cartera_id, auxiliar_user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', [carteraId, auxiliarUserId]); +} + +export async function removeAuxiliarFromCartera(pool: Pool, carteraId: string, auxiliarUserId: string): Promise { + await pool.query('DELETE FROM cartera_auxiliares WHERE cartera_id = $1 AND auxiliar_user_id = $2', [carteraId, auxiliarUserId]); +} + +export async function getCarteraAuxiliares(pool: Pool, carteraId: string): Promise { + const { rows } = await pool.query('SELECT auxiliar_user_id AS "auxiliarUserId" FROM cartera_auxiliares WHERE cartera_id = $1', [carteraId]); + return rows.map(r => r.auxiliarUserId); +} + +// Supervisors list (for the invite form dropdown) +export async function getSupervisores(pool: Pool, tenantId: string): Promise> { + // Query tenant_memberships joined with users for supervisor role (rolId=9) + const memberships = await prisma.tenantMembership.findMany({ + where: { tenantId, rolId: 9, active: true }, + include: { user: { select: { id: true, nombre: true, email: true } } }, + }); + return memberships.map(m => ({ + userId: m.user.id, + nombre: m.user.nombre, + email: m.user.email, + })); +} diff --git a/apps/api/src/services/cfdi.service.ts b/apps/api/src/services/cfdi.service.ts new file mode 100644 index 0000000..9d98605 --- /dev/null +++ b/apps/api/src/services/cfdi.service.ts @@ -0,0 +1,769 @@ +import type { Pool } from 'pg'; +import type { Cfdi, CfdiFilters, CfdiListResponse } from '@horux/shared'; +import { markForInvalidation } from './metricas.service.js'; +import { recomputarSaldoPendiente, uuidsAfectadosPorCfdi } from '../utils/saldo.js'; + +// Common SELECT columns mapping DB → camelCase +const CFDI_SELECT = ` + id, year, month, type, uuid, serie, folio, status, + fecha_emision as "fechaEmision", + rfc_emisor_id as "rfcEmisorId", rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor", + rfc_receptor_id as "rfcReceptorId", rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor", + subtotal, subtotal_mxn as "subtotalMxn", + descuento, descuento_mxn as "descuentoMxn", + total, total_mxn as "totalMxn", + saldo_insoluto as "saldoInsoluto", + moneda, tipo_cambio as "tipoCambio", + tipo_comprobante as "tipoComprobante", + metodo_pago as "metodoPago", forma_pago as "formaPago", + uso_cfdi as "usoCfdi", + pac, fecha_cert_sat as "fechaCertSat", + fecha_cancelacion as "fechaCancelacion", + uuid_relacionado as "uuidRelacionado", + isr_retencion as "isrRetencion", isr_retencion_mxn as "isrRetencionMxn", + iva_traslado as "ivaTraslado", iva_traslado_mxn as "ivaTrasladoMxn", + iva_retencion as "ivaRetencion", iva_retencion_mxn as "ivaRetencionMxn", + ieps_traslado as "iepsTraslado", ieps_traslado_mxn as "iepsTrasladoMxn", + ieps_retencion as "iepsRetencion", ieps_retencion_mxn as "iepsRetencionMxn", + impuestos_locales_trasladado as "impuestosLocalesTrasladado", + impuestos_locales_trasladado_mxn as "impuestosLocalesTrasladoMxn", + impuestos_locales_retenidos as "impuestosLocalesRetenidos", + impuestos_locales_retenidos_mxn as "impuestosLocalesRetenidosMxn", + monto_pago as "montoPago", monto_pago_mxn as "montoPagoMxn", + fecha_pago_p as "fechaPagoP", num_parcialidad as "numParcialidad", + isr_retencion_pago as "isrRetencionPago", isr_retencion_pago_mxn as "isrRetencionPagoMxn", + iva_traslado_pago as "ivaTrasladoPago", iva_traslado_pago_mxn as "ivaTrasladoPagoMxn", + iva_retencion_pago as "ivaRetencionPago", iva_retencion_pago_mxn as "ivaRetencionPagoMxn", + ieps_traslado_pago as "iepsTrasladoPago", ieps_traslado_pago_mxn as "iepsTrasladoPagoMxn", + ieps_retencion_pago as "iepsRetencionPago", ieps_retencion_pago_mxn as "iepsRetencionPagoMxn", + saldo_pendiente as "saldoPendiente", saldo_pendiente_mxn as "saldoPendienteMxn", + fecha_liquidacion as "fechaLiquidacion", + fecha_pago as "fechaPago", + fecha_inicial_pago as "fechaInicialPago", + fecha_final_pago as "fechaFinalPago", + num_dias_pagados as "numDiasPagados", + num_seguro_social as "numSeguroSocial", puesto, + salario_base_cot_apor as "salarioBaseCotApor", + salario_base_cot_apor_mxn as "salarioBaseCotAporMxn", + salario_diario_integrado as "salarioDiarioIntegrado", + salario_diario_integrado_mxn as "salarioDiarioIntegradoMxn", + total_percepciones as "totalPercepciones", + total_percepciones_mxn as "totalPercepcionesMxn", + total_deducciones as "totalDeducciones", + total_deducciones_mxn as "totalDeduccionesMxn", + imp_retenidos_nomina as "impRetenidosNomina", + imp_retenidos_nomina_mxn as "impRetenidosNominaMxn", + otras_deducciones_nomina as "otrasDeduccionesNomina", + otras_deducciones_nomina_mxn as "otrasDeduccionesNominaMxn", + subsidio_causado as "subsidioCausado", + subsidio_causado_mxn as "subsidioCausadoMxn", + conciliado, + regimen_fiscal_emisor as "regimenFiscalEmisor", + regimen_fiscal_receptor as "regimenFiscalReceptor", + xml_url as "xmlUrl", pdf_url as "pdfUrl", + xml_original as "xmlOriginal", + cfdi_tipo_relacion as "cfdiTipoRelacion", + cfdis_relacionados as "cfdisRelacionados", + last_sat_sync as "lastSatSync", + sat_sync_job_id as "satSyncJobId", + source, facturapi_id as "facturapiId", + creado_en as "creadoEn", actualizado_en as "actualizadoEn", + contribuyente_id AS "contribuyenteId" +`; + +export async function getCfdis(pool: Pool, filters: CfdiFilters): Promise { + const page = filters.page || 1; + const limit = filters.limit || 20; + const offset = (page - 1) * limit; + + let whereClause = 'WHERE 1=1'; + const params: any[] = []; + let paramIndex = 1; + + // El filtro "tipo" (EMITIDO / RECIBIDO) usa la posición del RFC del + // contribuyente cuando viene contribuyenteId — más confiable que la + // columna `type`, que puede quedar inconsistente cuando dos + // contribuyentes del mismo tenant se facturan entre sí. Se aplica + // abajo cuando ya conocemos el RFC vía la subquery. + if (filters.tipo && !filters.contribuyenteId) { + whereClause += ` AND type = $${paramIndex++}`; + params.push(filters.tipo); + } + + if (filters.tipoComprobante) { + whereClause += ` AND tipo_comprobante = $${paramIndex++}`; + params.push(filters.tipoComprobante); + } + + if (filters.estado) { + whereClause += ` AND status = $${paramIndex++}`; + params.push(filters.estado); + } + + if (filters.fechaInicio) { + whereClause += ` AND fecha_emision >= $${paramIndex++}::date`; + params.push(filters.fechaInicio); + } + + if (filters.fechaFin) { + whereClause += ` AND fecha_emision <= ($${paramIndex++}::date + interval '1 day')`; + params.push(filters.fechaFin); + } + + if (filters.rfc) { + whereClause += ` AND (rfc_emisor ILIKE $${paramIndex} OR rfc_receptor ILIKE $${paramIndex++})`; + params.push(`%${filters.rfc}%`); + } + + if (filters.emisor) { + whereClause += ` AND (rfc_emisor ILIKE $${paramIndex} OR nombre_emisor ILIKE $${paramIndex++})`; + params.push(`%${filters.emisor}%`); + } + + if (filters.receptor) { + whereClause += ` AND (rfc_receptor ILIKE $${paramIndex} OR nombre_receptor ILIKE $${paramIndex++})`; + params.push(`%${filters.receptor}%`); + } + + if (filters.search) { + whereClause += ` AND (uuid ILIKE $${paramIndex} OR nombre_emisor ILIKE $${paramIndex} OR nombre_receptor ILIKE $${paramIndex} OR rfc_emisor ILIKE $${paramIndex} OR rfc_receptor ILIKE $${paramIndex++})`; + params.push(`%${filters.search}%`); + } + + if (filters.contribuyenteId) { + // Lado del contribuyente: si filters.tipo viene, restringe a EMITIDO + // (rfc_emisor = X) o RECIBIDO (rfc_receptor = X). Si no, ambos lados + // (OR contribuyente_id = X para casos donde el RFC no quedó + // correctamente asignado pero el tenant lo poseía). + if (filters.tipo === 'EMITIDO') { + whereClause += ` AND rfc_emisor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex++})`; + params.push(filters.contribuyenteId); + } else if (filters.tipo === 'RECIBIDO') { + whereClause += ` AND rfc_receptor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex++})`; + params.push(filters.contribuyenteId); + } else { + whereClause += ` AND (contribuyente_id = $${paramIndex} OR rfc_emisor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex}) OR rfc_receptor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex++}))`; + params.push(filters.contribuyenteId); + } + } + + params.push(limit, offset); + const { rows: dataWithCount } = await pool.query(` + SELECT ${CFDI_SELECT}, + COUNT(*) OVER() as total_count + FROM cfdis + ${whereClause} + ORDER BY fecha_emision DESC + LIMIT $${paramIndex++} OFFSET $${paramIndex} + `, params); + + const total = Number(dataWithCount[0]?.total_count || 0); + const data = dataWithCount.map(({ total_count, ...cfdi }: any) => cfdi) as Cfdi[]; + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; +} + +/** + * Lista paginada de conceptos (cfdi_conceptos) — reusa los mismos filtros de + * `getCfdis` aplicados contra la tabla `cfdis` joined. Devuelve TODAS las + * columnas non-MXN del concepto + fecha/uuid/RFCs del CFDI padre, para + * alimentar la pestaña "Conceptos" en /cfdi y su export a Excel. + */ +export async function getConceptosList( + pool: Pool, + filters: CfdiFilters & { + uuidLike?: string; + claveProdServ?: string; + descripcionConcepto?: string; + orderBy?: 'fecha' | 'importe'; + orderDir?: 'asc' | 'desc'; + }, +): Promise<{ + data: any[]; + total: number; + page: number; + limit: number; + totalPages: number; +}> { + const page = filters.page || 1; + const limit = filters.limit || 50; + const offset = (page - 1) * limit; + + let whereClause = 'WHERE 1=1'; + const params: any[] = []; + let paramIndex = 1; + + if (filters.tipo && !filters.contribuyenteId) { + whereClause += ` AND c.type = $${paramIndex++}`; + params.push(filters.tipo); + } + if (filters.tipoComprobante) { + whereClause += ` AND c.tipo_comprobante = $${paramIndex++}`; + params.push(filters.tipoComprobante); + } + if (filters.estado) { + whereClause += ` AND c.status = $${paramIndex++}`; + params.push(filters.estado); + } + if (filters.fechaInicio) { + whereClause += ` AND c.fecha_emision >= $${paramIndex++}::date`; + params.push(filters.fechaInicio); + } + if (filters.fechaFin) { + whereClause += ` AND c.fecha_emision <= ($${paramIndex++}::date + interval '1 day')`; + params.push(filters.fechaFin); + } + if (filters.rfc) { + whereClause += ` AND (c.rfc_emisor ILIKE $${paramIndex} OR c.rfc_receptor ILIKE $${paramIndex++})`; + params.push(`%${filters.rfc}%`); + } + if (filters.emisor) { + whereClause += ` AND (c.rfc_emisor ILIKE $${paramIndex} OR c.nombre_emisor ILIKE $${paramIndex++})`; + params.push(`%${filters.emisor}%`); + } + if (filters.receptor) { + whereClause += ` AND (c.rfc_receptor ILIKE $${paramIndex} OR c.nombre_receptor ILIKE $${paramIndex++})`; + params.push(`%${filters.receptor}%`); + } + if (filters.search) { + whereClause += ` AND (c.uuid ILIKE $${paramIndex} OR c.nombre_emisor ILIKE $${paramIndex} OR c.nombre_receptor ILIKE $${paramIndex} OR c.rfc_emisor ILIKE $${paramIndex} OR c.rfc_receptor ILIKE $${paramIndex} OR cc.descripcion ILIKE $${paramIndex++})`; + params.push(`%${filters.search}%`); + } + if (filters.contribuyenteId) { + if (filters.tipo === 'EMITIDO') { + whereClause += ` AND c.rfc_emisor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex++})`; + params.push(filters.contribuyenteId); + } else if (filters.tipo === 'RECIBIDO') { + whereClause += ` AND c.rfc_receptor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex++})`; + params.push(filters.contribuyenteId); + } else { + whereClause += ` AND (c.contribuyente_id = $${paramIndex} OR c.rfc_emisor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex}) OR c.rfc_receptor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex++}))`; + params.push(filters.contribuyenteId); + } + } + + // Filtros específicos de la tabla Conceptos (popovers en headers). + if (filters.uuidLike) { + whereClause += ` AND c.uuid ILIKE $${paramIndex++}`; + params.push(`%${filters.uuidLike}%`); + } + if (filters.claveProdServ) { + whereClause += ` AND cc.clave_prod_serv ILIKE $${paramIndex++}`; + params.push(`%${filters.claveProdServ}%`); + } + if (filters.descripcionConcepto) { + whereClause += ` AND cc.descripcion ILIKE $${paramIndex++}`; + params.push(`%${filters.descripcionConcepto}%`); + } + + // Ordenamiento configurable. Default: fecha DESC, id ASC (estable). + const orderDir = filters.orderDir === 'asc' ? 'ASC' : 'DESC'; + let orderClause = `ORDER BY c.fecha_emision ${orderDir}, cc.id ASC`; + if (filters.orderBy === 'importe') { + orderClause = `ORDER BY cc.importe ${orderDir}, cc.id ASC`; + } + + params.push(limit, offset); + // SELECT * de cfdi_conceptos para devolver todas las columnas non-MXN + // (las MXN también se traen por simplicidad — el frontend las ignora al + // exportar; el filtro "no terminen en _mxn" se aplica en el cliente). + const { rows: dataWithCount } = await pool.query(` + SELECT + c.fecha_emision AS "fechaEmision", + c.uuid AS "uuid", + c.rfc_emisor AS "rfcEmisor", + c.rfc_receptor AS "rfcReceptor", + c.nombre_emisor AS "nombreEmisor", + c.nombre_receptor AS "nombreReceptor", + c.tipo_comprobante AS "tipoComprobante", + c.status AS "status", + cc.*, + COUNT(*) OVER() AS total_count + FROM cfdi_conceptos cc + JOIN cfdis c ON c.id = cc.cfdi_id + ${whereClause} + ${orderClause} + LIMIT $${paramIndex++} OFFSET $${paramIndex} + `, params); + + const total = Number(dataWithCount[0]?.total_count || 0); + const data = dataWithCount.map(({ total_count, ...row }: any) => row); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; +} + +export async function getCfdiById(pool: Pool, id: string): Promise { + const { rows } = await pool.query(` + SELECT ${CFDI_SELECT} + FROM cfdis + WHERE id = $1 + `, [id]); + + return rows[0] || null; +} + +export async function getConceptos(pool: Pool, cfdiId: string): Promise { + const { rows } = await pool.query(` + SELECT + id, cfdi_id as "cfdiId", + clave_prod_serv as "claveProdServ", + no_identificacion as "noIdentificacion", + descripcion, cantidad, + clave_unidad as "claveUnidad", unidad, + valor_unitario as "valorUnitario", + valor_unitario_mxn as "valorUnitarioMxn", + importe, importe_mxn as "importeMxn", + descuento, descuento_mxn as "descuentoMxn", + isr_retencion as "isrRetencion", + isr_retencion_mxn as "isrRetencionMxn", + iva_traslado as "ivaTraslado", + iva_traslado_mxn as "ivaTrasladoMxn", + iva_retencion as "ivaRetencion", + iva_retencion_mxn as "ivaRetencionMxn", + ieps_traslado as "iepsTraslado", + ieps_traslado_mxn as "iepsTrasladoMxn", + ieps_retencion as "iepsRetencion", + ieps_retencion_mxn as "iepsRetencionMxn" + FROM cfdi_conceptos + WHERE cfdi_id = $1 + ORDER BY id + `, [cfdiId]); + return rows; +} + +export async function getXmlById(pool: Pool, id: string): Promise { + const { rows } = await pool.query(` + SELECT xml_original FROM cfdis WHERE id = $1 + `, [id]); + + return rows[0]?.xml_original || null; +} + +export interface CreateCfdiData { + uuid: string; + type: 'EMITIDO' | 'RECIBIDO'; + serie?: string; + folio?: string; + status?: string; + fechaEmision: string; + rfcEmisor: string; + nombreEmisor: string; + rfcReceptor: string; + nombreReceptor: string; + subtotal: number; + subtotalMxn?: number; + descuento?: number; + descuentoMxn?: number; + total: number; + totalMxn?: number; + saldoInsoluto?: string; + moneda?: string; + tipoCambio?: number; + tipoComprobante?: string; + metodoPago?: string; + formaPago?: string; + usoCfdi?: string; + pac?: string; + fechaCertSat?: string; + fechaCancelacion?: string; + uuidRelacionado?: string; + isrRetencion?: number; + isrRetencionMxn?: number; + ivaTraslado?: number; + ivaTrasladoMxn?: number; + ivaRetencion?: number; + ivaRetencionMxn?: number; + iepsTraslado?: number; + iepsTrasladoMxn?: number; + iepsRetencion?: number; + iepsRetencionMxn?: number; + impuestosLocalesTrasladado?: number; + impuestosLocalesTrasladoMxn?: number; + impuestosLocalesRetenidos?: number; + impuestosLocalesRetenidosMxn?: number; + montoPago?: number; + montoPagoMxn?: number; + fechaPagoP?: string; + numParcialidad?: string; + isrRetencionPago?: number; + isrRetencionPagoMxn?: number; + ivaTrasladoPago?: number; + ivaTrasladoPagoMxn?: number; + ivaRetencionPago?: number; + ivaRetencionPagoMxn?: number; + iepsTrasladoPago?: number; + iepsTrasladoPagoMxn?: number; + iepsRetencionPago?: number; + iepsRetencionPagoMxn?: number; + saldoPendiente?: number; + saldoPendienteMxn?: number; + fechaLiquidacion?: string; + fechaPago?: string; + fechaInicialPago?: string; + fechaFinalPago?: string; + numDiasPagados?: number; + numSeguroSocial?: string; + puesto?: string; + salarioBaseCotApor?: number; + salarioBaseCotAporMxn?: number; + salarioDiarioIntegrado?: number; + salarioDiarioIntegradoMxn?: number; + totalPercepciones?: number; + totalPercepcionesMxn?: number; + totalDeducciones?: number; + totalDeduccionesMxn?: number; + impRetenidosNomina?: number; + impRetenidosNominaMxn?: number; + otrasDeduccionesNomina?: number; + otrasDeduccionesNominaMxn?: number; + subsidioCausado?: number; + subsidioCausadoMxn?: number; + conciliado?: string; + regimenFiscalEmisor?: string; + regimenFiscalReceptor?: string; + xmlUrl?: string; + pdfUrl?: string; + xmlOriginal?: string; + cfdiTipoRelacion?: string; + cfdisRelacionados?: string; + source?: string; + contribuyenteId?: string; +} + +function computeMxn(value: number | undefined, tipoCambio: number): number { + return (value || 0) * tipoCambio; +} + +export async function createCfdi(pool: Pool, data: CreateCfdiData): Promise { + if (!data.uuid) throw new Error('UUID es requerido'); + if (!data.fechaEmision) throw new Error('Fecha de emisión es requerida'); + if (!data.rfcEmisor) throw new Error('RFC Emisor es requerido'); + if (!data.rfcReceptor) throw new Error('RFC Receptor es requerido'); + + const dateStr = typeof data.fechaEmision === 'string' && data.fechaEmision.match(/^\d{4}-\d{2}-\d{2}$/) + ? `${data.fechaEmision}T12:00:00` + : data.fechaEmision; + + const fechaEmision = new Date(dateStr); + if (isNaN(fechaEmision.getTime())) { + throw new Error(`Fecha de emisión inválida: ${data.fechaEmision}`); + } + + const year = String(fechaEmision.getFullYear()); + const month = String(fechaEmision.getMonth() + 1).padStart(2, '0'); + const tc = data.tipoCambio || 1; + + const { rows } = await pool.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, saldo_insoluto, moneda, tipo_cambio, + tipo_comprobante, metodo_pago, forma_pago, uso_cfdi, + pac, fecha_cert_sat, fecha_cancelacion, uuid_relacionado, + isr_retencion, isr_retencion_mxn, iva_traslado, iva_traslado_mxn, + iva_retencion, iva_retencion_mxn, ieps_traslado, ieps_traslado_mxn, + ieps_retencion, ieps_retencion_mxn, + impuestos_locales_trasladado, impuestos_locales_trasladado_mxn, + impuestos_locales_retenidos, impuestos_locales_retenidos_mxn, + monto_pago, monto_pago_mxn, fecha_pago_p, num_parcialidad, + isr_retencion_pago, isr_retencion_pago_mxn, + iva_traslado_pago, iva_traslado_pago_mxn, + iva_retencion_pago, iva_retencion_pago_mxn, + ieps_traslado_pago, ieps_traslado_pago_mxn, + ieps_retencion_pago, ieps_retencion_pago_mxn, + saldo_pendiente, saldo_pendiente_mxn, + fecha_liquidacion, fecha_pago, fecha_inicial_pago, fecha_final_pago, + num_dias_pagados, num_seguro_social, puesto, + salario_base_cot_apor, salario_base_cot_apor_mxn, + salario_diario_integrado, salario_diario_integrado_mxn, + total_percepciones, total_percepciones_mxn, + total_deducciones, total_deducciones_mxn, + imp_retenidos_nomina, imp_retenidos_nomina_mxn, + otras_deducciones_nomina, otras_deducciones_nomina_mxn, + subsidio_causado, subsidio_causado_mxn, + conciliado, + regimen_fiscal_emisor, regimen_fiscal_receptor, + xml_url, pdf_url, xml_original, + cfdi_tipo_relacion, cfdis_relacionados, + source, + contribuyente_id + ) 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,$27,$28,$29,$30, + $31,$32,$33,$34,$35,$36,$37,$38,$39,$40, + $41,$42,$43,$44,$45,$46,$47,$48,$49,$50, + $51,$52,$53,$54,$55,$56,$57,$58,$59,$60, + $61,$62,$63,$64,$65,$66,$67,$68,$69,$70, + $71,$72,$73,$74,$75,$76,$77,$78,$79,$80, + $81,$82,$83,$84,$85,$86, + $87,$88, + $89 + ) + RETURNING ${CFDI_SELECT} + `, [ + year, month, + data.type || 'ingreso', + data.uuid, + data.serie || null, + data.folio || null, + data.status || 'vigente', + fechaEmision, + data.rfcEmisor, + data.nombreEmisor || 'Sin nombre', + data.rfcReceptor, + data.nombreReceptor || 'Sin nombre', + data.subtotal || 0, + data.subtotalMxn ?? computeMxn(data.subtotal, tc), + data.descuento || 0, + data.descuentoMxn ?? computeMxn(data.descuento, tc), + data.total || 0, + data.totalMxn ?? computeMxn(data.total, tc), + data.saldoInsoluto || null, + data.moneda || 'MXN', + tc, + data.tipoComprobante || null, + data.metodoPago || null, + data.formaPago || null, + data.usoCfdi || null, + data.pac || null, + data.fechaCertSat || null, + data.fechaCancelacion || null, + data.uuidRelacionado || null, + data.isrRetencion || 0, + data.isrRetencionMxn ?? computeMxn(data.isrRetencion, tc), + data.ivaTraslado || 0, + data.ivaTrasladoMxn ?? computeMxn(data.ivaTraslado, tc), + data.ivaRetencion || 0, + data.ivaRetencionMxn ?? computeMxn(data.ivaRetencion, tc), + data.iepsTraslado || 0, + data.iepsTrasladoMxn ?? computeMxn(data.iepsTraslado, tc), + data.iepsRetencion || 0, + data.iepsRetencionMxn ?? computeMxn(data.iepsRetencion, tc), + data.impuestosLocalesTrasladado || 0, + data.impuestosLocalesTrasladoMxn ?? computeMxn(data.impuestosLocalesTrasladado, tc), + data.impuestosLocalesRetenidos || 0, + data.impuestosLocalesRetenidosMxn ?? computeMxn(data.impuestosLocalesRetenidos, tc), + data.montoPago || 0, + data.montoPagoMxn ?? computeMxn(data.montoPago, tc), + data.fechaPagoP || null, + data.numParcialidad || null, + data.isrRetencionPago || 0, + data.isrRetencionPagoMxn ?? computeMxn(data.isrRetencionPago, tc), + data.ivaTrasladoPago || 0, + data.ivaTrasladoPagoMxn ?? computeMxn(data.ivaTrasladoPago, tc), + data.ivaRetencionPago || 0, + data.ivaRetencionPagoMxn ?? computeMxn(data.ivaRetencionPago, tc), + data.iepsTrasladoPago || 0, + data.iepsTrasladoPagoMxn ?? computeMxn(data.iepsTrasladoPago, tc), + data.iepsRetencionPago || 0, + data.iepsRetencionPagoMxn ?? computeMxn(data.iepsRetencionPago, tc), + data.saldoPendiente || 0, + data.saldoPendienteMxn ?? computeMxn(data.saldoPendiente, tc), + data.fechaLiquidacion || null, + data.fechaPago || null, + data.fechaInicialPago || null, + data.fechaFinalPago || null, + data.numDiasPagados || 0, + data.numSeguroSocial || null, + data.puesto || null, + data.salarioBaseCotApor || 0, + data.salarioBaseCotAporMxn ?? computeMxn(data.salarioBaseCotApor, tc), + data.salarioDiarioIntegrado || 0, + data.salarioDiarioIntegradoMxn ?? computeMxn(data.salarioDiarioIntegrado, tc), + data.totalPercepciones || 0, + data.totalPercepcionesMxn ?? computeMxn(data.totalPercepciones, tc), + data.totalDeducciones || 0, + data.totalDeduccionesMxn ?? computeMxn(data.totalDeducciones, tc), + data.impRetenidosNomina || 0, + data.impRetenidosNominaMxn ?? computeMxn(data.impRetenidosNomina, tc), + data.otrasDeduccionesNomina || 0, + data.otrasDeduccionesNominaMxn ?? computeMxn(data.otrasDeduccionesNomina, tc), + data.subsidioCausado || 0, + data.subsidioCausadoMxn ?? computeMxn(data.subsidioCausado, tc), + data.conciliado || null, + data.regimenFiscalEmisor || null, + data.regimenFiscalReceptor || null, + data.xmlUrl || null, + data.pdfUrl || null, + data.xmlOriginal || null, + data.cfdiTipoRelacion || null, + data.cfdisRelacionados || null, + data.source || 'manual', + data.contribuyenteId ?? null, + ]); + + // Retroactive invalidation hook: mark cached metrics stale for prior-year CFDIs + try { + const cfdiDate = new Date(data.fechaEmision || new Date()); + const cfdiYear = cfdiDate.getFullYear(); + const currentYear = new Date().getFullYear(); + if (cfdiYear < currentYear && data.contribuyenteId) { + await markForInvalidation(pool, data.contribuyenteId, cfdiYear, cfdiDate.getMonth() + 1, 'CFDI_INSERT'); + } + } catch (err) { + console.error('[Metricas] Invalidation hook failed (non-blocking):', err); + } + + // Recompute saldo_pendiente_mxn de los CFDIs afectados por este insert. + // Un I PPD recalcula su propio saldo (considera anticipo si es I/07); un + // P o E no-07 recalcula los I PPD que referencia. + try { + const afectados = uuidsAfectadosPorCfdi({ + uuid: data.uuid!, + tipoComprobante: data.tipoComprobante ?? null, + metodoPago: data.metodoPago ?? null, + cfdiTipoRelacion: data.cfdiTipoRelacion ?? null, + uuidRelacionado: data.uuidRelacionado ?? null, + cfdisRelacionados: data.cfdisRelacionados ?? null, + }); + if (afectados.length > 0) { + await recomputarSaldoPendiente(pool, afectados); + } + } catch (err) { + console.error('[Saldo] Recompute hook failed (non-blocking):', err); + } + + return rows[0]; +} + +export interface BatchInsertResult { + inserted: number; + duplicates: number; + errors: number; + errorMessages: string[]; +} + +export async function createManyCfdis(pool: Pool, cfdis: CreateCfdiData[]): Promise { + const result = await createManyCfdisBatch(pool, cfdis); + return result.inserted; +} + +export async function createManyCfdisBatch(pool: Pool, cfdis: CreateCfdiData[]): Promise { + const result: BatchInsertResult = { + inserted: 0, + duplicates: 0, + errors: 0, + errorMessages: [] + }; + + if (cfdis.length === 0) return result; + + for (const cfdi of cfdis) { + try { + await createCfdi(pool, cfdi); + result.inserted++; + } catch (error: any) { + const errorMsg = error.message || 'Error desconocido'; + if (errorMsg.includes('duplicate') || errorMsg.includes('unique')) { + result.duplicates++; + } else { + result.errors++; + if (result.errorMessages.length < 10) { + result.errorMessages.push(`${cfdi.uuid?.substring(0, 8) || 'N/A'}: ${errorMsg}`); + } + } + } + } + + return result; +} + +export async function deleteCfdi(pool: Pool, id: string): Promise { + // Fetch before deleting so we can fire the invalidation hook + const { rows: pre } = await pool.query( + `SELECT fecha_emision, contribuyente_id FROM cfdis WHERE id = $1`, + [id] + ); + await pool.query(`DELETE FROM cfdis WHERE id = $1`, [id]); + + // Retroactive invalidation hook: mark cached metrics stale for prior-year CFDIs + try { + if (pre[0]) { + const cfdiDate = new Date(pre[0].fecha_emision || new Date()); + const cfdiYear = cfdiDate.getFullYear(); + const currentYear = new Date().getFullYear(); + if (cfdiYear < currentYear && pre[0].contribuyente_id) { + await markForInvalidation(pool, pre[0].contribuyente_id, cfdiYear, cfdiDate.getMonth() + 1, 'CFDI_INSERT'); + } + } + } catch (err) { + console.error('[Metricas] Invalidation hook failed (non-blocking):', err); + } +} + +export async function getEmisores(pool: Pool, search: string, limit: number = 10, contribuyenteId?: string): Promise<{ rfc: string; nombre: string }[]> { + let whereClause = 'WHERE rfc_emisor ILIKE $1 OR nombre_emisor ILIKE $1'; + const params: any[] = [`%${search}%`, limit]; + if (contribuyenteId) { + const safeId = contribuyenteId.replace(/[^a-f0-9-]/gi, ''); + whereClause += ` AND (contribuyente_id = '${safeId}' OR rfc_emisor = (SELECT rfc FROM contribuyentes WHERE entidad_id = '${safeId}') OR rfc_receptor = (SELECT rfc FROM contribuyentes WHERE entidad_id = '${safeId}'))`; + } + const { rows } = await pool.query(` + SELECT DISTINCT rfc_emisor as rfc, nombre_emisor as nombre + FROM cfdis + ${whereClause} + ORDER BY nombre_emisor + LIMIT $2 + `, params); + return rows; +} + +export async function getReceptores(pool: Pool, search: string, limit: number = 10, contribuyenteId?: string): Promise<{ rfc: string; nombre: string }[]> { + let whereClause = 'WHERE rfc_receptor ILIKE $1 OR nombre_receptor ILIKE $1'; + const params: any[] = [`%${search}%`, limit]; + if (contribuyenteId) { + const safeId = contribuyenteId.replace(/[^a-f0-9-]/gi, ''); + whereClause += ` AND (contribuyente_id = '${safeId}' OR rfc_emisor = (SELECT rfc FROM contribuyentes WHERE entidad_id = '${safeId}') OR rfc_receptor = (SELECT rfc FROM contribuyentes WHERE entidad_id = '${safeId}'))`; + } + const { rows } = await pool.query(` + SELECT DISTINCT rfc_receptor as rfc, nombre_receptor as nombre + FROM cfdis + ${whereClause} + ORDER BY nombre_receptor + LIMIT $2 + `, params); + return rows; +} + +export async function getResumenCfdis(pool: Pool, año: number, mes: number, contribuyenteId?: string) { + let whereClause = `WHERE status NOT IN ('Cancelado', '0') AND year = $1 AND month = $2`; + if (contribuyenteId) { + const safeId = contribuyenteId.replace(/[^a-f0-9-]/gi, ''); + whereClause += ` AND (contribuyente_id = '${safeId}' OR rfc_emisor = (SELECT rfc FROM contribuyentes WHERE entidad_id = '${safeId}') OR rfc_receptor = (SELECT rfc FROM contribuyentes WHERE entidad_id = '${safeId}'))`; + } + const { rows } = await pool.query(` + SELECT + COALESCE(SUM(CASE WHEN type = 'EMITIDO' AND tipo_comprobante = 'I' THEN total_mxn ELSE 0 END), 0) as total_ingresos, + COALESCE(SUM(CASE WHEN type = 'RECIBIDO' AND tipo_comprobante = 'I' THEN total_mxn ELSE 0 END), 0) as total_egresos, + COUNT(CASE WHEN type = 'EMITIDO' AND tipo_comprobante = 'I' THEN 1 END) as count_ingresos, + COUNT(CASE WHEN type = 'RECIBIDO' AND tipo_comprobante = 'I' THEN 1 END) as count_egresos, + COALESCE(SUM(CASE WHEN type = 'EMITIDO' THEN iva_traslado_mxn ELSE 0 END), 0) as iva_trasladado, + COALESCE(SUM(CASE WHEN type = 'RECIBIDO' THEN iva_traslado_mxn ELSE 0 END), 0) as iva_acreditable + FROM cfdis + ${whereClause} + `, [String(año), String(mes).padStart(2, '0')]); + + const r = rows[0]; + return { + totalIngresos: Number(r?.total_ingresos || 0), + totalEgresos: Number(r?.total_egresos || 0), + countIngresos: Number(r?.count_ingresos || 0), + countEgresos: Number(r?.count_egresos || 0), + ivaTrasladado: Number(r?.iva_trasladado || 0), + ivaAcreditable: Number(r?.iva_acreditable || 0), + }; +} diff --git a/apps/api/src/services/conciliacion.service.ts b/apps/api/src/services/conciliacion.service.ts new file mode 100644 index 0000000..78360c3 --- /dev/null +++ b/apps/api/src/services/conciliacion.service.ts @@ -0,0 +1,257 @@ +import type { Pool } from 'pg'; + +const VIGENTE = `status NOT IN ('Cancelado', '0')`; + +export interface ConciliacionCfdi { + id: number; + uuid: string; + type: string; + fechaEmision: string; + rfcEmisor: string; + nombreEmisor: string; + rfcReceptor: string; + nombreReceptor: string; + total: number; + totalMxn: number; + metodoPago: string | null; + conciliado: string | null; + idConciliacion: number | null; + conciliacion: { + id: number; + fechaDePago: string; + banco: string; + terminacionCuenta: string; + } | null; +} + +export async function getCfdisConConciliacion( + pool: Pool, + filters: { + tipo: string; + fechaInicio?: string; + fechaFin?: string; + regimen?: string; + estado?: string; + contribuyenteId?: string; + } +): Promise { + const params: any[] = []; + let idx = 1; + + let where = `WHERE c.type = $${idx++} AND c.${VIGENTE}`; + params.push(filters.tipo); + + // Excluir PPD en recibidos + if (filters.tipo === 'RECIBIDO') { + where += ` AND (c.metodo_pago IS NULL OR c.metodo_pago != 'PPD')`; + } + + // Excluir PPD en emitidos para todos los regimenes excepto 605 y 616 + if (filters.tipo === 'EMITIDO') { + where += ` AND NOT (c.metodo_pago = 'PPD' AND (c.regimen_fiscal_emisor IS NULL OR c.regimen_fiscal_emisor NOT IN ('605','616')))`; + } + + if (filters.fechaInicio) { + where += ` AND c.fecha_emision >= $${idx++}::date`; + params.push(filters.fechaInicio); + } + if (filters.fechaFin) { + where += ` AND c.fecha_emision <= ($${idx++}::date + interval '1 day')`; + params.push(filters.fechaFin); + } + if (filters.regimen) { + const regimenCol = filters.tipo === 'EMITIDO' ? 'regimen_fiscal_emisor' : 'regimen_fiscal_receptor'; + where += ` AND c.${regimenCol} = $${idx++}`; + params.push(filters.regimen); + } + if (filters.estado === 'conciliado') { + where += ` AND c.conciliado = 'true'`; + } else if (filters.estado === 'pendiente') { + where += ` AND (c.conciliado IS NULL OR c.conciliado != 'true')`; + } + + if (filters.contribuyenteId) { + const safeId = filters.contribuyenteId.replace(/[^a-f0-9-]/gi, ''); + where += ` AND c.contribuyente_id = '${safeId}'`; + } + + const { rows } = await pool.query(` + SELECT + c.id, c.uuid, c.type, + c.fecha_emision as "fechaEmision", + c.rfc_emisor as "rfcEmisor", c.nombre_emisor as "nombreEmisor", + c.rfc_receptor as "rfcReceptor", c.nombre_receptor as "nombreReceptor", + c.total, c.total_mxn as "totalMxn", + c.tipo_comprobante as "tipoComprobante", + c.monto_pago_mxn as "montoPagoMxn", + c.metodo_pago as "metodoPago", + c.conciliado, + c.id_conciliacion as "idConciliacion", + con.id as "conId", + con.fecha_de_pago as "conFechaDePago", + b.banco as "conBanco", + b.terminacion_cuenta as "conTerminacionCuenta" + FROM cfdis c + LEFT JOIN conciliaciones con ON con.id_cfdi = c.id + LEFT JOIN bancos b ON b.id = con.id_banco + ${where} + ORDER BY c.fecha_emision DESC + `, params); + + return rows.map((r: any) => ({ + id: r.id, + uuid: r.uuid, + type: r.type, + fechaEmision: r.fechaEmision, + rfcEmisor: r.rfcEmisor, + nombreEmisor: r.nombreEmisor, + rfcReceptor: r.rfcReceptor, + nombreReceptor: r.nombreReceptor, + total: Number(r.total), + totalMxn: Number(r.totalMxn), + tipoComprobante: r.tipoComprobante, + montoPagoMxn: Number(r.montoPagoMxn || 0), + // P usa monto_pago_mxn, PPD conciliada no suma (evitar duplicar con su P), resto usa total_mxn + montoMxn: r.tipoComprobante === 'P' + ? Number(r.montoPagoMxn || 0) + : (r.metodoPago === 'PPD' && r.conciliado === 'true') ? 0 : Number(r.totalMxn || 0), + metodoPago: r.metodoPago, + conciliado: r.conciliado, + idConciliacion: r.idConciliacion, + conciliacion: r.conId ? { + id: r.conId, + fechaDePago: r.conFechaDePago, + banco: r.conBanco, + terminacionCuenta: r.conTerminacionCuenta, + } : null, + })); +} + +export async function conciliar( + pool: Pool, + data: { cfdiIds: number[]; fechaDePago: string; idBanco: number }, + tenantCreatedYear: number, +): Promise { + const fechaPago = new Date(data.fechaDePago + 'T12:00:00'); + const anio = String(fechaPago.getFullYear()); + const mes = String(fechaPago.getMonth() + 1).padStart(2, '0'); + + if (fechaPago.getFullYear() < tenantCreatedYear) { + throw new Error(`Solo se puede conciliar del año ${tenantCreatedYear} en adelante`); + } + + const { rows: bancoRows } = await pool.query(`SELECT id FROM bancos WHERE id = $1`, [data.idBanco]); + if (bancoRows.length === 0) throw new Error('Banco no encontrado'); + + const { rows: cfdis } = await pool.query(` + SELECT id, conciliado FROM cfdis + WHERE id = ANY($1) AND ${VIGENTE} + `, [data.cfdiIds]); + + if (cfdis.length !== data.cfdiIds.length) { + throw new Error('Algunos CFDIs no existen o estan cancelados'); + } + + const yaConc = cfdis.filter((c: any) => c.conciliado === 'true'); + if (yaConc.length > 0) { + throw new Error(`${yaConc.length} CFDIs ya estan conciliados`); + } + + let count = 0; + for (const cfdiId of data.cfdiIds) { + const { rows: inserted } = await pool.query(` + INSERT INTO conciliaciones (anio, mes, id_cfdi, fecha_de_pago, id_banco) + VALUES ($1, $2, $3, $4, $5) + RETURNING id + `, [anio, mes, cfdiId, data.fechaDePago, data.idBanco]); + + await pool.query(` + UPDATE cfdis SET conciliado = 'true', id_conciliacion = $1 WHERE id = $2 + `, [inserted[0].id, cfdiId]); + + count++; + + // Auto-conciliar PPD si esta factura tipo P lleva saldo pendiente a 0 + await autoConciliarPpd(pool, cfdiId, anio, mes, data.fechaDePago, data.idBanco); + } + + return count; +} + +/** + * Cuando se concilia una factura tipo P con saldo_pendiente = 0, + * auto-concilia la factura PPD original relacionada con los mismos datos. + */ +async function autoConciliarPpd( + pool: Pool, + cfdiId: number, + anio: string, + mes: string, + fechaDePago: string, + idBanco: number, +): Promise { + // Verificar si es tipo P con saldo pendiente 0 + const { rows: pRows } = await pool.query(` + SELECT tipo_comprobante, uuid_relacionado, saldo_pendiente + FROM cfdis WHERE id = $1 + `, [cfdiId]); + + const pCfdi = pRows[0]; + if (!pCfdi || pCfdi.tipo_comprobante !== 'P') return; + if (!pCfdi.uuid_relacionado) return; + + const saldoPendiente = Number(pCfdi.saldo_pendiente || 0); + if (saldoPendiente !== 0) return; + + // Buscar la factura PPD original por UUID + const { rows: ppdRows } = await pool.query(` + SELECT id, conciliado FROM cfdis + WHERE uuid = $1 AND metodo_pago = 'PPD' AND ${VIGENTE} + `, [pCfdi.uuid_relacionado]); + + if (ppdRows.length === 0) return; + const ppd = ppdRows[0]; + if (ppd.conciliado === 'true') return; // ya conciliada + + // Auto-conciliar la PPD con los mismos datos + const { rows: inserted } = await pool.query(` + INSERT INTO conciliaciones (anio, mes, id_cfdi, fecha_de_pago, id_banco) + VALUES ($1, $2, $3, $4, $5) + RETURNING id + `, [anio, mes, ppd.id, fechaDePago, idBanco]); + + await pool.query(` + UPDATE cfdis SET conciliado = 'true', id_conciliacion = $1 WHERE id = $2 + `, [inserted[0].id, ppd.id]); +} + +export async function desconciliar(pool: Pool, conciliacionId: number): Promise { + // Buscar la conciliacion y el CFDI asociado + const { rows } = await pool.query(` + SELECT con.id_cfdi, c.tipo_comprobante, c.uuid_relacionado + FROM conciliaciones con + JOIN cfdis c ON c.id = con.id_cfdi + WHERE con.id = $1 + `, [conciliacionId]); + if (rows.length === 0) throw new Error('Conciliacion no encontrada'); + + const cfdi = rows[0]; + + // Desconciliar el CFDI principal + await pool.query(`UPDATE cfdis SET conciliado = NULL, id_conciliacion = NULL WHERE id_conciliacion = $1`, [conciliacionId]); + await pool.query(`DELETE FROM conciliaciones WHERE id = $1`, [conciliacionId]); + + // Si es tipo P, también desconciliar la PPD auto-conciliada + if (cfdi.tipo_comprobante === 'P' && cfdi.uuid_relacionado) { + const { rows: ppdRows } = await pool.query(` + SELECT c.id_conciliacion FROM cfdis c + WHERE c.uuid = $1 AND c.conciliado = 'true' AND c.metodo_pago = 'PPD' + `, [cfdi.uuid_relacionado]); + + if (ppdRows.length > 0 && ppdRows[0].id_conciliacion) { + const ppdConcId = ppdRows[0].id_conciliacion; + await pool.query(`UPDATE cfdis SET conciliado = NULL, id_conciliacion = NULL WHERE id_conciliacion = $1`, [ppdConcId]); + await pool.query(`DELETE FROM conciliaciones WHERE id = $1`, [ppdConcId]); + } + } +} diff --git a/apps/api/src/services/connector.service.ts b/apps/api/src/services/connector.service.ts new file mode 100644 index 0000000..d4db807 --- /dev/null +++ b/apps/api/src/services/connector.service.ts @@ -0,0 +1,156 @@ +import { prisma } from '../config/database.js'; +import { encryptAesGcm, decryptAesGcm, deriveAesKey } from '@horux/core'; +import { env } from '../config/env.js'; +import { randomBytes } from 'crypto'; + +function getEncryptionKey(): Buffer { + const secret = env.CONNECTOR_ENCRYPTION_KEY || env.FIEL_ENCRYPTION_KEY; + return deriveAesKey(secret); +} + +export async function provisionConnector(tenantId: string): Promise<{ + tunnelHostname: string; + horuxToken: string; + dockerRunCommand: string; +}> { + const tenant = await prisma.tenant.findUnique({ + where: { id: tenantId }, + select: { nombre: true, rfc: true, dbMode: true }, + }); + if (!tenant) throw new Error('Tenant no encontrado'); + + const slug = tenant.rfc.toLowerCase().replace(/[^a-z0-9]/g, ''); + const tunnelDomain = env.CLOUDFLARE_TUNNEL_DOMAIN || 'tunnel.horux.mx'; + const hostname = `${slug}.${tunnelDomain}`; + + // Generate a secure token for the connector + const horuxToken = randomBytes(32).toString('hex'); + + // Encrypt the token for storage — format: iv(16) + ciphertext + tag(16), base64 + const key = getEncryptionKey(); + const { encrypted, iv, tag } = encryptAesGcm(Buffer.from(horuxToken, 'utf-8'), key); + const tokenEncoded = Buffer.concat([iv, encrypted, tag]).toString('base64'); + + // TODO: Call Cloudflare API to create tunnel when CLOUDFLARE_API_TOKEN is configured + // For now, store the config and let the user manually configure cloudflared + if (env.CLOUDFLARE_API_TOKEN) { + console.log(`[Connector] Would create Cloudflare tunnel for ${hostname} — API integration pending`); + } + + await prisma.tenant.update({ + where: { id: tenantId }, + data: { + dbMode: 'BYO', + connectorTokenEnc: tokenEncoded, + connectorTunnelHostname: hostname, + }, + }); + + const dockerRunCommand = [ + 'docker run -d --name horux-connector', + ` -e HORUX_TOKEN="${horuxToken}"`, + ` -e HORUX_API_URL="${env.CORS_ORIGIN || 'https://horuxfin.com'}"`, + ' -e POSTGRES_HOST="localhost"', + ' -e POSTGRES_PORT="5432"', + ' horux/connector:latest', + ].join(' \\\n'); + + return { tunnelHostname: hostname, horuxToken, dockerRunCommand }; +} + +export async function recordHeartbeat(tenantId: string, data: { + version: string; + uptimeSeconds: number; + postgresPingMs: number; + pgVersion?: string; + lastMigration?: string; + status?: string; + errorMsg?: string; +}): Promise { + await Promise.all([ + prisma.connectorHeartbeat.create({ + data: { + tenantId, + latencyMs: data.postgresPingMs, + version: data.version, + pgVersion: data.pgVersion, + status: data.status || 'OK', + errorMsg: data.errorMsg, + }, + }), + prisma.tenant.update({ + where: { id: tenantId }, + data: { + connectorLastSeen: new Date(), + connectorVersion: data.version, + }, + }), + ]); +} + +export async function verifyConnectorToken(token: string): Promise { + // Find tenant by trying to decrypt stored tokens. + // This is O(N) — for production, use a hashed token lookup table. + const tenants = await prisma.tenant.findMany({ + where: { dbMode: 'BYO', connectorTokenEnc: { not: null } }, + select: { id: true, connectorTokenEnc: true }, + }); + + const key = getEncryptionKey(); + // Stored format: iv(16 bytes) + ciphertext + tag(16 bytes), base64-encoded + const IV_LENGTH = 16; + const TAG_LENGTH = 16; + + for (const t of tenants) { + if (!t.connectorTokenEnc) continue; + try { + const blob = Buffer.from(t.connectorTokenEnc, 'base64'); + const iv = blob.subarray(0, IV_LENGTH); + const tag = blob.subarray(blob.length - TAG_LENGTH); + const ciphertext = blob.subarray(IV_LENGTH, blob.length - TAG_LENGTH); + const decrypted = decryptAesGcm(ciphertext, iv, tag, key); + if (decrypted.toString('utf-8') === token) { + return t.id; + } + } catch { + continue; + } + } + + return null; +} + +export async function getConnectorStatus(tenantId: string): Promise<{ + configured: boolean; + tunnelHostname?: string; + lastSeen?: string; + version?: string; + status: 'connected' | 'degraded' | 'disconnected' | 'not_configured'; +}> { + const tenant = await prisma.tenant.findUnique({ + where: { id: tenantId }, + select: { dbMode: true, connectorTunnelHostname: true, connectorLastSeen: true, connectorVersion: true }, + }); + + if (!tenant || tenant.dbMode !== 'BYO' || !tenant.connectorTunnelHostname) { + return { configured: false, status: 'not_configured' }; + } + + const lastSeen = tenant.connectorLastSeen; + const now = new Date(); + let status: 'connected' | 'degraded' | 'disconnected' = 'disconnected'; + + if (lastSeen) { + const diffMs = now.getTime() - lastSeen.getTime(); + if (diffMs < 60_000) status = 'connected'; + else if (diffMs < 300_000) status = 'degraded'; + } + + return { + configured: true, + tunnelHostname: tenant.connectorTunnelHostname ?? undefined, + lastSeen: lastSeen?.toISOString(), + version: tenant.connectorVersion ?? undefined, + status, + }; +} diff --git a/apps/api/src/services/constancia.service.ts b/apps/api/src/services/constancia.service.ts new file mode 100644 index 0000000..d49fbed --- /dev/null +++ b/apps/api/src/services/constancia.service.ts @@ -0,0 +1,402 @@ +import { chromium } from 'playwright'; +import { writeFileSync, unlinkSync, mkdirSync, rmdirSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { randomUUID } from 'crypto'; +import type { Pool } from 'pg'; +import { prisma, tenantDb } from '../config/database.js'; +import { getDecryptedFiel } from './fiel.service.js'; +import { getDecryptedFielContribuyente } from './contribuyente-fiel.service.js'; +import { loginSatCsf } from './sat/sat-csf-login.js'; +import { extractCsfPdf } from './sat/sat-csf-scraper.js'; +import { parseCsfPdf, type ConstanciaSituacionFiscal, type Domicilio, type RegimenCsf } from './sat/sat-csf-parser.js'; + +const PROCESS_TIMEOUT = 180_000; + +export interface ConstanciaRow { + id: number; + rfc: string; + idCif: string | null; + razonSocial: string | null; + estatusPadron: string | null; + fechaEmision: string | null; + datos: ConstanciaSituacionFiscal; + fechaConsulta: string; + createdAt: string; +} + +function rowToConstancia(r: any): ConstanciaRow { + return { + id: r.id, + rfc: r.rfc, + idCif: r.id_cif, + razonSocial: r.razon_social, + estatusPadron: r.estatus_padron, + fechaEmision: r.fecha_emision, + datos: r.datos, + fechaConsulta: r.fecha_consulta.toISOString(), + createdAt: r.created_at.toISOString(), + }; +} + +/** + * Descarga la CSF del portal SAT, la parsea, guarda en BD del tenant, y + * sincroniza automáticamente domicilio + regímenes activos con lo que reporta + * el SAT. El auto-fill NO es destructivo para datos custom del usuario: + * solo sobreescribe campos si la CSF tiene un valor no-vacío. + */ +export async function consultarConstancia(tenantId: string): Promise { + const fiel = await getDecryptedFiel(tenantId); + if (!fiel) throw new Error('No hay FIEL configurada o está vencida'); + + const tenant = await prisma.tenant.findUnique({ + where: { id: tenantId }, + select: { databaseName: true }, + }); + if (!tenant) throw new Error('Tenant no encontrado'); + + const tempId = randomUUID(); + const tempDir = join(tmpdir(), `horux-csf-${tempId}`); + mkdirSync(tempDir, { recursive: true, mode: 0o700 }); + const cerPath = join(tempDir, 'cert.cer'); + const keyPath = join(tempDir, 'key.key'); + + try { + writeFileSync(cerPath, Buffer.from(fiel.cerContent, 'binary'), { mode: 0o600 }); + writeFileSync(keyPath, Buffer.from(fiel.keyContent, 'binary'), { mode: 0o600 }); + + // Headless por default. El fix de dispatchEvent en sat-csf-login cubre el + // caso donde el click sintético no dispara el handler del SAT. Si algún + // ambiente necesita ver el browser (debug), setear SAT_HEADLESS=false. + const headless = process.env.SAT_HEADLESS !== 'false'; + const browser = await chromium.launch({ headless }); + try { + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('Timeout: proceso de CSF excedió 3 minutos')), PROCESS_TIMEOUT), + ); + + const resultPromise = (async () => { + const session = await loginSatCsf(browser, cerPath, keyPath, fiel.password); + const pdfBuffer = await extractCsfPdf(session); + const csf = await parseCsfPdf(pdfBuffer); + + const pool = await tenantDb.getPool(tenantId, tenant.databaseName); + const { rows } = await pool.query( + `INSERT INTO constancias_situacion_fiscal + (rfc, id_cif, razon_social, estatus_padron, fecha_emision, datos, pdf) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id, rfc, id_cif, razon_social, estatus_padron, fecha_emision, + datos, fecha_consulta, created_at`, + [ + csf.rfc, + csf.idCIF, + csf.razonSocial ?? [csf.nombre, csf.primerApellido, csf.segundoApellido].filter(Boolean).join(' ') ?? null, + csf.estatusPadron, + csf.lugarFechaEmision, + JSON.stringify(csf), + pdfBuffer, + ], + ); + + // Auto-fill domicilio del tenant + regímenes activos desde el CSF. + // Se hace después del INSERT para que si algo falla en la sincronización + // la CSF ya quedó guardada y el usuario puede verla. + await sincronizarDatosFiscales(tenantId, csf).catch(err => { + console.error(`[CSF] Error sincronizando datos fiscales para tenant ${tenantId}:`, err); + }); + + return rowToConstancia(rows[0]); + })(); + + return await Promise.race([resultPromise, timeoutPromise]); + } finally { + await browser.close(); + } + } finally { + try { unlinkSync(cerPath); } catch { /* ok */ } + try { unlinkSync(keyPath); } catch { /* ok */ } + try { rmdirSync(tempDir); } catch { /* ok */ } + } +} + +/** + * Convierte el domicilio del CSF a los campos de `tenants` (calle compuesta + * por tipoVialidad + nombreVialidad). Solo actualiza campos cuando el CSF + * trae un valor — nunca pisa con null. + */ +function domicilioToTenantFields(d: Domicilio): Record { + const calleComponents = [d.tipoVialidad, d.nombreVialidad].filter(Boolean); + const calle = calleComponents.length > 0 ? calleComponents.join(' ') : undefined; + return { + codigoPostal: d.codigoPostal, + calle, + numExterior: d.numeroExterior, + numInterior: d.numeroInterior && d.numeroInterior.toUpperCase() !== 'SIN NUMERO' ? d.numeroInterior : undefined, + colonia: d.colonia, + ciudad: d.localidad, + municipio: d.municipio, + estado: d.entidadFederativa, + }; +} + +/** + * Matchea el nombre del régimen como aparece en la CSF contra el catálogo + * `regimenes` (clave SAT + descripción). La CSF prefija "Régimen " o + * "Régimen de " a veces, y el catálogo no — normalizamos ambos para matchear. + */ +function normalizeRegimenName(s: string): string { + return s + .normalize('NFD').replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/^r[eé]gimen\s+(?:de\s+(?:las?|los)?\s*)?/i, '') + .replace(/\s+/g, ' ') + .trim(); +} + +async function matchRegimenesToCatalogo(regimenesCsf: RegimenCsf[]): Promise { + const activos = regimenesCsf.filter(r => !r.fechaFin); + if (activos.length === 0) return []; + + const catalogo = await prisma.regimen.findMany({ where: { activo: true } }); + const ids: number[] = []; + + for (const rc of activos) { + const nNormalizado = normalizeRegimenName(rc.nombre); + const match = catalogo.find(c => { + const catNorm = normalizeRegimenName(c.descripcion); + return catNorm === nNormalizado || catNorm.includes(nNormalizado) || nNormalizado.includes(catNorm); + }); + if (match) ids.push(match.id); + } + return [...new Set(ids)]; +} + +/** + * Aplica el domicilio + regímenes activos de la CSF al tenant. Idempotente: + * se puede llamar N veces, el resultado final refleja el último CSF. + */ +export async function sincronizarDatosFiscales( + tenantId: string, + csf: ConstanciaSituacionFiscal, +): Promise<{ domicilioActualizado: boolean; regimenesSincronizados: number }> { + // 1. Domicilio + const fields = domicilioToTenantFields(csf.domicilio); + const updates: Record = {}; + for (const [k, v] of Object.entries(fields)) { + if (v && v.trim().length > 0) updates[k] = v.trim(); + } + + if (Object.keys(updates).length > 0) { + await prisma.tenant.update({ where: { id: tenantId }, data: updates }); + } + + // 2. Regímenes activos — sobreescribe la lista completa con lo que diga la CSF + const regimenIds = await matchRegimenesToCatalogo(csf.regimenes); + if (regimenIds.length > 0) { + await prisma.$transaction([ + prisma.tenantRegimenActivo.deleteMany({ where: { tenantId } }), + prisma.tenantRegimenActivo.createMany({ data: regimenIds.map(regimenId => ({ tenantId, regimenId })) }), + ]); + } + + return { + domicilioActualizado: Object.keys(updates).length > 0, + regimenesSincronizados: regimenIds.length, + }; +} + +export async function listConstancias(pool: Pool, limit = 12, rfc?: string): Promise { + const params: unknown[] = [limit]; + let rfcFilter = ''; + if (rfc) { + rfcFilter = 'WHERE rfc = $2'; + params.push(rfc); + } + const { rows } = await pool.query( + `SELECT id, rfc, id_cif, razon_social, estatus_padron, fecha_emision, + datos, fecha_consulta, created_at + FROM constancias_situacion_fiscal + ${rfcFilter} + ORDER BY fecha_consulta DESC + LIMIT $1`, + params, + ); + return rows.map(rowToConstancia); +} + +export async function getConstanciaPdf(pool: Pool, id: number): Promise { + const { rows } = await pool.query(`SELECT pdf FROM constancias_situacion_fiscal WHERE id = $1`, [id]); + return rows.length > 0 ? rows[0].pdf : null; +} + +/** + * Retención 5 años (CFF Art. 30). Se ejecuta en cron diario. + */ +export async function purgeConstanciasAntiguas(pool: Pool): Promise<{ deleted: number }> { + const { rowCount } = await pool.query( + `DELETE FROM constancias_situacion_fiscal WHERE created_at < NOW() - INTERVAL '5 years'`, + ); + return { deleted: rowCount ?? 0 }; +} + +/** + * Descarga la CSF para un contribuyente específico (modo despacho). + * Usa la FIEL almacenada en la BD del tenant en lugar de la BD central. + */ +export async function consultarConstanciaContribuyente( + pool: Pool, + contribuyenteId: string, +): Promise { + const safeId = contribuyenteId.replace(/[^a-f0-9-]/gi, ''); + const fiel = await getDecryptedFielContribuyente(pool, safeId); + if (!fiel) throw new Error('No hay FIEL configurada para este contribuyente o está vencida'); + + const tempId = randomUUID(); + const tempDir = join(tmpdir(), `horux-csf-${tempId}`); + mkdirSync(tempDir, { recursive: true, mode: 0o700 }); + const cerPath = join(tempDir, 'cert.cer'); + const keyPath = join(tempDir, 'key.key'); + + try { + writeFileSync(cerPath, Buffer.from(fiel.cerContent, 'binary'), { mode: 0o600 }); + writeFileSync(keyPath, Buffer.from(fiel.keyContent, 'binary'), { mode: 0o600 }); + + const headless = process.env.SAT_HEADLESS !== 'false'; + const browser = await chromium.launch({ headless }); + try { + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('Timeout: proceso de CSF excedió 3 minutos')), PROCESS_TIMEOUT), + ); + + const resultPromise = (async () => { + const session = await loginSatCsf(browser, cerPath, keyPath, fiel.password); + const pdfBuffer = await extractCsfPdf(session); + const csf = await parseCsfPdf(pdfBuffer); + + const { rows } = await pool.query( + `INSERT INTO constancias_situacion_fiscal + (rfc, id_cif, razon_social, estatus_padron, fecha_emision, datos, pdf) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id, rfc, id_cif, razon_social, estatus_padron, fecha_emision, + datos, fecha_consulta, created_at`, + [ + csf.rfc, + csf.idCIF, + csf.razonSocial ?? [csf.nombre, csf.primerApellido, csf.segundoApellido].filter(Boolean).join(' ') ?? null, + csf.estatusPadron, + csf.lugarFechaEmision, + JSON.stringify(csf), + pdfBuffer, + ], + ); + + // Sync datos fiscales to contribuyente table + try { + const rawDom = csf.domicilio || {}; + + // The PDF parser sometimes captures label prefixes inside values + // when the PDF has a two-column layout. Clean them out. + function cleanDomField(val: string | undefined): string { + if (!val) return ''; + // Remove embedded label prefixes like "Nombre de la Colonia: " + return val + .replace(/^.*(?:Nombre de la Colonia|Nombre del Municipio|Nombre de la Localidad|Nombre de la Entidad|Número Exterior|Número Interior|Tipo de Vialidad|Entre Calle|Y Calle|Código Postal)\s*:\s*/i, '') + .trim(); + } + + // Extract embedded values from fields that swallowed the next column + function extractEmbedded(val: string | undefined, labelPrefix: string): string { + if (!val) return ''; + const re = new RegExp(`${labelPrefix}\\s*:\\s*(.+)`, 'i'); + const m = val.match(re); + return m ? m[1].trim() : ''; + } + + // Check if values have embedded labels and extract the correct fields + const rawNumInterior = rawDom.numeroInterior || ''; + const rawLocalidad = rawDom.localidad || ''; + + const colonia = rawDom.colonia + || extractEmbedded(rawNumInterior, 'Nombre de la Colonia') + || extractEmbedded(rawLocalidad, 'Nombre de la Colonia') + || ''; + const municipio = rawDom.municipio + || extractEmbedded(rawLocalidad, 'Nombre del Municipio o Demarcación Territorial') + || extractEmbedded(rawNumInterior, 'Nombre del Municipio') + || ''; + + // Map CSF field names → UI field names + const domicilioMapped = { + codigoPostal: cleanDomField(rawDom.codigoPostal), + calle: cleanDomField(rawDom.nombreVialidad) || '', + numExterior: cleanDomField(rawDom.numeroExterior), + numInterior: cleanDomField(rawDom.numeroInterior), + colonia: cleanDomField(colonia), + ciudad: cleanDomField(rawDom.localidad) || cleanDomField(rawDom.municipio) || '', + municipio: cleanDomField(municipio), + estado: cleanDomField(rawDom.entidadFederativa), + entreCalle: cleanDomField(rawDom.entreCalle), + yCalle: cleanDomField(rawDom.yCalle), + }; + + // Resolve ALL regímenes (not just the first) + let regimenClaves: string[] = []; + if (csf.regimenes?.length) { + const { prisma: centralPrisma } = await import('../config/database.js'); + const allRegimenes = await centralPrisma.regimen.findMany({ + where: { activo: true }, + select: { clave: true, descripcion: true }, + }); + + // Normalize for accent-insensitive comparison + const norm = (s: string) => s.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().trim(); + + for (const reg of csf.regimenes) { + if (reg.fechaFin) continue; // Skip inactive regimenes + const regNorm = norm(reg.nombre); + // Score-based: prefer the match with the highest overlap + let bestMatch: { clave: string; score: number } | null = null; + for (const r of allRegimenes) { + const catNorm = norm(r.descripcion); + // Exact match or containment + if (regNorm === catNorm || regNorm.includes(catNorm) || catNorm.includes(regNorm)) { + const score = catNorm.length; // Longer match = more specific = better + if (!bestMatch || score > bestMatch.score) { + bestMatch = { clave: r.clave, score }; + } + } + } + if (bestMatch) regimenClaves.push(bestMatch.clave); + } + } + + await pool.query(` + UPDATE contribuyentes SET + regimen_fiscal = COALESCE($2, regimen_fiscal), + codigo_postal = COALESCE($3, codigo_postal), + domicilio = COALESCE($4, domicilio) + WHERE entidad_id = $1 + `, [ + contribuyenteId, + regimenClaves.length > 0 ? regimenClaves.join(',') : null, + domicilioMapped.codigoPostal || null, + JSON.stringify(domicilioMapped), + ]); + console.log(`[CSF] Datos fiscales sincronizados para contribuyente ${contribuyenteId}: regímenes=${regimenClaves.join(',')}, CP=${domicilioMapped.codigoPostal}`); + } catch (syncErr: any) { + console.error(`[CSF] Error sincronizando datos fiscales:`, syncErr.message); + } + + return rowToConstancia(rows[0]); + })(); + + return await Promise.race([resultPromise, timeoutPromise]); + } finally { + await browser.close(); + } + } finally { + try { unlinkSync(cerPath); } catch { /* ok */ } + try { unlinkSync(keyPath); } catch { /* ok */ } + try { rmdirSync(tempDir); } catch { /* ok */ } + } +} diff --git a/apps/api/src/services/contribuyente-facturapi.service.ts b/apps/api/src/services/contribuyente-facturapi.service.ts new file mode 100644 index 0000000..f9dda97 --- /dev/null +++ b/apps/api/src/services/contribuyente-facturapi.service.ts @@ -0,0 +1,493 @@ +import Facturapi from 'facturapi'; +import type { Pool } from 'pg'; +import { Credential } from '@nodecfdi/credentials/node'; +import { env } from '../config/env.js'; +import { encryptString, decryptToString } from './sat/sat-crypto.service.js'; + +function getUserClient(): Facturapi { + if (!env.FACTURAPI_USER_KEY) throw new Error('FACTURAPI_USER_KEY no configurada'); + return new Facturapi(env.FACTURAPI_USER_KEY); +} + +/** + * Genera una Live Secret Key para una organización Facturapi via PUT idempotente. + * Si la org ya tiene live key, devuelve la existente; si no, crea una nueva. + * Endpoint oficial Facturapi: PUT /v2/organizations/{id}/apikeys/live + */ +async function generateLiveKey(orgId: string): Promise { + const userKey = env.FACTURAPI_USER_KEY!; + const res = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}/apikeys/live`, { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${userKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }); + if (!res.ok) { + const errBody = await res.text().catch(() => ''); + throw new Error(`Facturapi PUT /apikeys/live falló (${res.status}): ${errBody}`); + } + const key = (await res.text()).replace(/"/g, '').trim(); + if (!key.startsWith('sk_live_')) { + throw new Error(`Respuesta inesperada de Facturapi (no es sk_live_*): ${key.slice(0, 10)}...`); + } + return key; +} + +/** + * Cifra y persiste la Live Secret Key de una organización. + * AES-256-GCM con la clave derivada de FIEL_ENCRYPTION_KEY. + */ +async function persistEncryptedKey(pool: Pool, orgId: string, plaintextKey: string): Promise { + const { encrypted, iv, tag } = encryptString(plaintextKey); + await pool.query( + `UPDATE facturapi_orgs SET api_key_enc = $1, api_key_iv = $2, api_key_tag = $3 WHERE facturapi_org_id = $4`, + [encrypted, iv, tag, orgId], + ); +} + +/** + * Obtiene la Live Secret Key cacheada (descifra de BD) o la genera vía PUT + * y la persiste si no existe (caso de orgs legacy creadas antes del refactor live). + */ +async function getOrgApiKey(pool: Pool, orgId: string): Promise { + const { rows } = await pool.query<{ api_key_enc: Buffer | null; api_key_iv: Buffer | null; api_key_tag: Buffer | null }>( + `SELECT api_key_enc, api_key_iv, api_key_tag FROM facturapi_orgs WHERE facturapi_org_id = $1 LIMIT 1`, + [orgId], + ); + if (rows.length === 0) throw new Error(`Organización ${orgId} no encontrada en BD tenant`); + + const row = rows[0]; + if (row.api_key_enc && row.api_key_iv && row.api_key_tag) { + return decryptToString(row.api_key_enc, row.api_key_iv, row.api_key_tag); + } + + // Org legacy sin live key cacheada — generar y guardar (idempotente). + const apiKey = await generateLiveKey(orgId); + await persistEncryptedKey(pool, orgId, apiKey); + return apiKey; +} + +export async function createOrgContribuyente( + pool: Pool, + contribuyenteId: string, + nombre: string +): Promise<{ orgId: string; reused?: boolean; recreated?: boolean }> { + const { rows: existing } = await pool.query( + 'SELECT facturapi_org_id FROM facturapi_orgs WHERE contribuyente_id = $1', + [contribuyenteId] + ); + const client = getUserClient(); + + // Caso 1: hay fila local → verificar si la org sigue viva en Facturapi. + // Si existe en ambos lados, idempotente (devolver la existente). + // Si existe solo local pero Facturapi no la tiene (eliminada allá, API key + // cambió, etc.), recrear y actualizar el FK local — desbloquea el flujo + // de CSD que si no se quedaba trabado. + if (existing.length > 0) { + const existingId = existing[0].facturapi_org_id; + try { + await client.organizations.retrieve(existingId); + // Idempotente: si existe en ambos lados, asegurar que la live key está + // cacheada (puede faltar en orgs legacy creadas antes del refactor live). + await ensureLiveKeyCached(pool, existingId); + return { orgId: existingId, reused: true }; + } catch { + const org = await client.organizations.create({ name: nombre }); + await pool.query( + 'UPDATE facturapi_orgs SET facturapi_org_id = $2, csd_uploaded = false, active = true, api_key_enc = NULL, api_key_iv = NULL, api_key_tag = NULL WHERE contribuyente_id = $1', + [contribuyenteId, org.id] + ); + // Eager: generar y cachear live key para que la org quede lista para emitir. + await ensureLiveKeyCached(pool, org.id); + return { orgId: org.id, recreated: true }; + } + } + + // Caso 2: no hay fila local → crear fresh. + const org = await client.organizations.create({ name: nombre }); + await pool.query( + 'INSERT INTO facturapi_orgs (contribuyente_id, facturapi_org_id) VALUES ($1, $2)', + [contribuyenteId, org.id] + ); + // Eager: generar y cachear live key inmediatamente tras crear la org. + await ensureLiveKeyCached(pool, org.id); + return { orgId: org.id }; +} + +/** + * Garantiza que la org tiene su Live Secret Key cifrada en BD. Si ya existe, + * no-op. Si no, hace PUT live y la persiste. Idempotente — el endpoint + * Facturapi PUT /apikeys/live es idempotente, devuelve la misma key si ya + * existe en su lado. + */ +async function ensureLiveKeyCached(pool: Pool, orgId: string): Promise { + const { rows } = await pool.query( + `SELECT 1 FROM facturapi_orgs WHERE facturapi_org_id = $1 AND api_key_enc IS NOT NULL LIMIT 1`, + [orgId], + ); + if (rows.length > 0) return; + const apiKey = await generateLiveKey(orgId); + await persistEncryptedKey(pool, orgId, apiKey); +} + +export async function getOrgStatusContribuyente( + pool: Pool, + contribuyenteId: string +): Promise<{ configured: boolean; orgId?: string; legalName?: string; hasCsd?: boolean }> { + const { rows } = await pool.query( + 'SELECT facturapi_org_id, csd_uploaded FROM facturapi_orgs WHERE contribuyente_id = $1 AND active = true', + [contribuyenteId] + ); + if (rows.length === 0) return { configured: false }; + + try { + const client = getUserClient(); + const org = await client.organizations.retrieve(rows[0].facturapi_org_id); + return { + configured: true, + orgId: org.id, + legalName: org.legal?.name || undefined, + hasCsd: !!org.certificate?.has_certificate, + }; + } catch { + return { configured: false }; + } +} + +export async function uploadCsdContribuyente( + pool: Pool, + contribuyenteId: string, + cerFile: string, + keyFile: string, + password: string +): Promise<{ success: boolean; message: string }> { + const { rows } = await pool.query<{ facturapi_org_id: string; rfc: string }>( + `SELECT fo.facturapi_org_id, c.rfc + FROM facturapi_orgs fo + JOIN contribuyentes c ON c.entidad_id = fo.contribuyente_id + WHERE fo.contribuyente_id = $1 AND fo.active = true`, + [contribuyenteId] + ); + if (rows.length === 0) throw new Error('Primero debe crearse la organización Facturapi del contribuyente'); + + const { facturapi_org_id, rfc: contribuyenteRfc } = rows[0]; + + // Validación preventiva: que el certificado sea CSD (no FIEL), que el RFC + // coincida con el contribuyente y que no esté vencido. Facturapi también + // valida, pero su mensaje de error es poco específico ("Certificado no + // válido") — el nuestro dice exactamente qué pasa. + const cerData = Buffer.from(cerFile, 'base64'); + const keyData = Buffer.from(keyFile, 'base64'); + + let credential: Credential; + try { + credential = Credential.create(cerData.toString('binary'), keyData.toString('binary'), password); + } catch { + return { success: false, message: 'Los archivos .cer/.key no son válidos o la contraseña es incorrecta' }; + } + + // Debe ser CSD (sello digital para facturar), no FIEL (e.firma para trámites). + if (credential.isFiel()) { + return { success: false, message: 'El certificado es una FIEL (e.firma), no un CSD. Sube el Certificado de Sello Digital.' }; + } + + const certRfc = credential.certificate().rfc().toUpperCase(); + if (certRfc !== contribuyenteRfc.toUpperCase()) { + return { + success: false, + message: `El RFC del CSD (${certRfc}) no coincide con el del contribuyente (${contribuyenteRfc}). Verifica que estés subiendo los archivos correctos.`, + }; + } + + const validUntil = new Date(String(credential.certificate().validToDateTime())); + if (new Date() > validUntil) { + return { success: false, message: `El CSD está vencido desde ${validUntil.toLocaleDateString('es-MX')}. Solicita al SAT uno nuevo.` }; + } + + const client = getUserClient(); + try { + await client.organizations.uploadCertificate( + facturapi_org_id, + cerData, + keyData, + password, + ); + await pool.query( + 'UPDATE facturapi_orgs SET csd_uploaded = true WHERE contribuyente_id = $1', + [contribuyenteId] + ); + return { success: true, message: 'CSD subido correctamente' }; + } catch (error: any) { + return { success: false, message: error.message || 'Error al subir CSD a Facturapi' }; + } +} + +export async function getOrgClientContribuyente( + pool: Pool, + contribuyenteId: string +): Promise { + const { rows } = await pool.query( + 'SELECT facturapi_org_id FROM facturapi_orgs WHERE contribuyente_id = $1 AND active = true', + [contribuyenteId] + ); + if (rows.length === 0) throw new Error('Contribuyente no tiene organización Facturapi configurada'); + + const apiKey = await getOrgApiKey(pool, rows[0].facturapi_org_id); + return new Facturapi(apiKey); +} + +export async function cancelInvoiceContribuyente( + pool: Pool, + contribuyenteId: string, + facturapiId: string, + motive: '01' | '02' | '03' | '04' = '02', + substitution?: string, +): Promise { + const client = await getOrgClientContribuyente(pool, contribuyenteId); + const cancelData: any = { motive }; + if (motive === '01' && substitution) cancelData.substitution = substitution; + return client.invoices.cancel(facturapiId, cancelData); +} + +function streamToBuffer(stream: any): Promise { + return new Promise((resolve, reject) => { + if (Buffer.isBuffer(stream)) return resolve(stream); + const chunks: Buffer[] = []; + stream.on('data', (chunk: Buffer) => chunks.push(chunk)); + stream.on('end', () => resolve(Buffer.concat(chunks))); + stream.on('error', reject); + }); +} + +export async function downloadPdfContribuyente( + pool: Pool, + contribuyenteId: string, + facturapiId: string, +): Promise { + const client = await getOrgClientContribuyente(pool, contribuyenteId); + const stream = await client.invoices.downloadPdf(facturapiId); + return streamToBuffer(stream); +} + +export async function downloadXmlContribuyente( + pool: Pool, + contribuyenteId: string, + facturapiId: string, +): Promise { + const client = await getOrgClientContribuyente(pool, contribuyenteId); + const stream = await client.invoices.downloadXml(facturapiId); + return streamToBuffer(stream); +} + +export async function sendInvoiceByEmailContribuyente( + pool: Pool, + contribuyenteId: string, + facturapiId: string, + email: string, +): Promise { + const client = await getOrgClientContribuyente(pool, contribuyenteId); + await client.invoices.sendByEmail(facturapiId, { email }); +} + +export async function createInvoiceContribuyente( + pool: Pool, + contribuyenteId: string, + data: any +): Promise { + const client = await getOrgClientContribuyente(pool, contribuyenteId); + + // Create/update customer in Facturapi + const isForiegn = !!data.customer?.country && data.customer.country !== 'MEX'; + const customerData: any = { + legal_name: data.customer?.legalName, + tax_id: data.customer?.taxId, + email: data.customer?.email, + address: { zip: data.customer?.zip, ...(isForiegn ? { country: data.customer.country } : {}) }, + }; + if (!isForiegn && data.customer?.taxSystem) customerData.tax_system = data.customer.taxSystem; + + let customerId: string; + try { + const existing = await client.customers.list({ search: data.customer?.taxId }); + const match = existing.data?.find((c: any) => c.tax_id === data.customer?.taxId); + if (match) { + await client.customers.update(match.id, customerData); + customerId = match.id; + } else { + const created = await client.customers.create(customerData); + customerId = created.id; + } + } catch { + const created = await client.customers.create(customerData); + customerId = created.id; + } + + // Build invoice payload (mirrors createInvoice logic in facturapi.service.ts) + const tipo = data.type || 'I'; + const invoicePayload: any = { customer: customerId }; + + if (tipo !== 'I') invoicePayload.type = tipo; + + if (data.items?.length) { + invoicePayload.items = data.items.map((item: any) => ({ + quantity: item.quantity, + product: { + description: item.description, + product_key: item.productKey, + unit_key: item.unitKey || 'E48', + unit_name: item.unitName || 'Servicio', + price: item.price, + tax_included: item.taxIncluded ?? true, + taxes: item.taxes?.map((t: any) => ({ + type: t.type, + rate: t.rate, + factor: t.factor || 'Tasa', + ...(t.withholding ? { withholding: true } : {}), + })) || [{ type: 'IVA', rate: 0.16 }], + }, + })); + } + + if (tipo === 'I' || tipo === 'E') { + invoicePayload.use = data.use || 'G01'; + invoicePayload.payment_form = data.paymentForm || '99'; + invoicePayload.payment_method = data.paymentMethod || 'PUE'; + invoicePayload.currency = data.currency || 'MXN'; + if (data.exchangeRate && data.currency !== 'MXN') invoicePayload.exchange = data.exchangeRate; + if (data.conditions) invoicePayload.conditions = data.conditions; + } + + if (data.series) invoicePayload.series = data.series; + if (data.folioNumber) invoicePayload.folio_number = data.folioNumber; + + if (data.relatedDocuments?.length) { + // Estructura SAT 4.0: agrupa N uuids por tipo de relación. Acepta tanto + // el formato nuevo {relationship, uuids[]} como el legacy {relationship, + // uuid} para compat durante transición de callers frontend. + invoicePayload.related_documents = data.relatedDocuments.map((r: any) => ({ + relationship: r.relationship, + documents: Array.isArray(r.uuids) ? r.uuids : (r.uuid ? [r.uuid] : []), + })); + } + + if (data.complements?.length) invoicePayload.complements = data.complements; + if (data.global) invoicePayload.global = data.global; + + // Régimen fiscal del emisor: Facturapi NO acepta override per-invoice via + // campo `issuer` (rechaza con "issuer is not allowed"). La única forma es + // actualizar el `legal.tax_system` de la organización antes del emit. + // Para contribuyentes con múltiples regímenes, esto significa un sync en + // cada emit cuando el seleccionado difiere del actual en la org. + if (data.issuerTaxSystem) { + const { rows } = await pool.query<{ facturapi_org_id: string }>( + `SELECT facturapi_org_id FROM facturapi_orgs WHERE contribuyente_id = $1 AND active = true`, + [contribuyenteId], + ); + if (rows.length > 0) { + await ensureOrgLegalForEmit(pool, contribuyenteId, rows[0].facturapi_org_id, data.issuerTaxSystem); + } + } + + return client.invoices.create(invoicePayload); +} + +/** + * Sincroniza los datos fiscales de la organización Facturapi con la + * información del contribuyente, usando el régimen seleccionado. Se llama + * antes de cada emit cuando el user elige un régimen en el form, porque + * Facturapi toma el TaxSystem del CFDI del `legal.tax_system` de la org + * (no acepta override per-invoice). No-op si el `legal` ya coincide. + */ +async function ensureOrgLegalForEmit( + pool: Pool, + contribuyenteId: string, + orgId: string, + chosenTaxSystem: string, +): Promise { + const userKey = env.FACTURAPI_USER_KEY; + if (!userKey) throw new Error('FACTURAPI_USER_KEY no configurada'); + + // Datos fiscales del contribuyente (razón social + domicilio) + const { rows } = await pool.query<{ + rfc: string; + razon_social: string | null; + regimen_fiscal: string | null; + codigo_postal: string | null; + domicilio: any; + }>( + `SELECT 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 c.entidad_id = $1`, + [contribuyenteId], + ); + if (rows.length === 0) throw new Error('Contribuyente no encontrado'); + const contrib = rows[0]; + + // Validar que el régimen elegido esté entre los registrados del contrib + const allowed = (contrib.regimen_fiscal || '') + .split(',') + .map(s => s.trim()) + .filter(Boolean); + if (allowed.length > 0 && !allowed.includes(chosenTaxSystem)) { + throw new Error( + `El régimen ${chosenTaxSystem} no está registrado para este contribuyente ` + + `(registrados: ${allowed.join(', ')})`, + ); + } + + // Leer el legal actual de la org en Facturapi + const getRes = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}`, { + headers: { 'Authorization': `Bearer ${userKey}` }, + }); + if (!getRes.ok) { + throw new Error(`No se pudo leer organización Facturapi (${getRes.status})`); + } + const org = (await getRes.json()) as any; + const currentLegal = org.legal || {}; + + // Si el tax_system ya coincide y la razón social está seteada, no tocar + // (evita updates innecesarios con latencia extra). + if ( + currentLegal.tax_system === chosenTaxSystem && + currentLegal.legal_name && + currentLegal.legal_name === contrib.razon_social + ) { + return; + } + + const domicilio = (contrib.domicilio || {}) as any; + const legalPayload = { + name: contrib.razon_social || currentLegal.name || '', + legal_name: contrib.razon_social || currentLegal.legal_name || '', + tax_system: chosenTaxSystem, + address: { + street: domicilio.calle || currentLegal.address?.street || '', + exterior: domicilio.numExterior || currentLegal.address?.exterior || '', + interior: domicilio.numInterior || currentLegal.address?.interior || '', + neighborhood: domicilio.colonia || currentLegal.address?.neighborhood || '', + city: domicilio.ciudad || currentLegal.address?.city || '', + municipality: domicilio.municipio || currentLegal.address?.municipality || '', + state: domicilio.estado || currentLegal.address?.state || '', + zip: contrib.codigo_postal || domicilio.codigoPostal || currentLegal.address?.zip || '', + }, + }; + + const putRes = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}/legal`, { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${userKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(legalPayload), + }); + + if (!putRes.ok) { + const errText = await putRes.text(); + throw new Error( + `Error actualizando datos fiscales de la organización Facturapi (${putRes.status}): ${errText}`, + ); + } +} diff --git a/apps/api/src/services/contribuyente-fiel.service.ts b/apps/api/src/services/contribuyente-fiel.service.ts new file mode 100644 index 0000000..0958e23 --- /dev/null +++ b/apps/api/src/services/contribuyente-fiel.service.ts @@ -0,0 +1,202 @@ +import { Credential } from '@nodecfdi/credentials/node'; +import type { Pool } from 'pg'; +import { encryptFielCredentials, decryptFielCredentials } from './sat/sat-crypto.service.js'; +import type { FielStatus } from '@horux/shared'; + +export async function uploadFielContribuyente( + pool: Pool, + contribuyenteId: string, + cerBase64: string, + keyBase64: string, + password: string +): Promise<{ success: boolean; message: string; status?: FielStatus }> { + try { + const cerData = Buffer.from(cerBase64, 'base64'); + const keyData = Buffer.from(keyBase64, 'base64'); + + let credential: Credential; + try { + credential = Credential.create(cerData.toString('binary'), keyData.toString('binary'), password); + } catch { + return { success: false, message: 'Los archivos de la FIEL no son válidos o la contraseña es incorrecta' }; + } + + if (!credential.isFiel()) { + return { success: false, message: 'El certificado proporcionado no es una FIEL (e.firma). Parece ser un CSD.' }; + } + + const certificate = credential.certificate(); + const rfc = certificate.rfc(); + const serialNumber = certificate.serialNumber().bytes(); + const validFrom = new Date(String(certificate.validFromDateTime())); + const validUntil = new Date(String(certificate.validToDateTime())); + + if (new Date() > validUntil) { + return { success: false, message: 'La FIEL está vencida desde ' + validUntil.toLocaleDateString() }; + } + + const enc = encryptFielCredentials(cerData, keyData, password); + + // Check whether this contribuyente already had an active FIEL (to decide auto-sync) + const { rows: existingRows } = await pool.query( + `SELECT 1 FROM fiel_contribuyente WHERE contribuyente_id = $1 AND is_active = true`, + [contribuyenteId] + ); + const isFirstUpload = existingRows.length === 0; + + await pool.query(` + INSERT INTO fiel_contribuyente ( + contribuyente_id, rfc, cer_data, key_data, key_password_enc, + cer_iv, cer_tag, key_iv, key_tag, password_iv, password_tag, + serial_number, valid_from, valid_until, is_active + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, true) + ON CONFLICT (contribuyente_id) DO UPDATE SET + rfc = $2, cer_data = $3, key_data = $4, key_password_enc = $5, + cer_iv = $6, cer_tag = $7, key_iv = $8, key_tag = $9, + password_iv = $10, password_tag = $11, + serial_number = $12, valid_from = $13, valid_until = $14, + is_active = true, updated_at = now() + `, [ + contribuyenteId, rfc, + enc.encryptedCer, enc.encryptedKey, enc.encryptedPassword, + enc.cerIv, enc.cerTag, enc.keyIv, enc.keyTag, enc.passwordIv, enc.passwordTag, + serialNumber, validFrom, validUntil, + ]); + + // Trigger auto-sync on first upload (fire-and-forget) + if (isFirstUpload) { + import('./opinion-cumplimiento.service.js').then(async ({ consultarOpinionContribuyente }) => { + try { + await consultarOpinionContribuyente(pool, contribuyenteId); + } catch (err: any) { + console.error(`[FIEL first-upload] Opinión falló para contribuyente ${contribuyenteId}:`, err.message || err); + } + }).catch(() => {}); + + import('./constancia.service.js').then(async ({ consultarConstanciaContribuyente }) => { + try { + await consultarConstanciaContribuyente(pool, contribuyenteId); + } catch (err: any) { + console.error(`[FIEL first-upload] CSF falló para contribuyente ${contribuyenteId}:`, err.message || err); + } + }).catch(() => {}); + } + + const daysUntilExpiration = Math.ceil((validUntil.getTime() - Date.now()) / (1000 * 60 * 60 * 24)); + + return { + success: true, + message: 'FIEL configurada correctamente', + status: { configured: true, rfc, serialNumber, validFrom: validFrom.toISOString(), validUntil: validUntil.toISOString(), isExpired: false, daysUntilExpiration }, + }; + } catch (error: any) { + console.error('[FIEL Contribuyente Upload Error]', error); + return { success: false, message: error.message || 'Error al procesar la FIEL' }; + } +} + +export async function getFielStatusContribuyente(pool: Pool, contribuyenteId: string): Promise { + // Try per-contribuyente first (tenant BD) + const { rows } = await pool.query(` + SELECT rfc, serial_number AS "serialNumber", valid_from AS "validFrom", valid_until AS "validUntil", is_active AS "isActive" + FROM fiel_contribuyente WHERE contribuyente_id = $1 + `, [contribuyenteId]); + + if (rows.length === 0 || !rows[0].isActive) { + // Fallback: check legacy tenant-level FIEL by matching RFC + const { rows: contribRows } = await pool.query('SELECT rfc FROM contribuyentes WHERE entidad_id = $1', [contribuyenteId]); + const rfc = contribRows[0]?.rfc; + if (rfc) { + const { getFielStatus } = await import('./fiel.service.js'); + // getFielStatus reads by tenantId — check if the legacy FIEL matches this RFC + // We need prisma access, so import it + const { prisma } = await import('../config/database.js'); + const legacyFiel = await prisma.fielCredential.findFirst({ + where: { rfc, isActive: true }, + select: { rfc: true, serialNumber: true, validFrom: true, validUntil: true, isActive: true }, + }); + if (legacyFiel) { + const now = new Date(); + const isExpired = now > legacyFiel.validUntil; + const daysUntilExpiration = Math.ceil((legacyFiel.validUntil.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); + return { + configured: true, + rfc: legacyFiel.rfc, + serialNumber: legacyFiel.serialNumber || undefined, + validFrom: legacyFiel.validFrom.toISOString(), + validUntil: legacyFiel.validUntil.toISOString(), + isExpired, + daysUntilExpiration: isExpired ? 0 : daysUntilExpiration, + }; + } + } + return { configured: false }; + } + + const fiel = rows[0]; + const now = new Date(); + const isExpired = now > new Date(fiel.validUntil); + const daysUntilExpiration = Math.ceil((new Date(fiel.validUntil).getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); + + return { + configured: true, + rfc: fiel.rfc, + serialNumber: fiel.serialNumber || undefined, + validFrom: new Date(fiel.validFrom).toISOString(), + validUntil: new Date(fiel.validUntil).toISOString(), + isExpired, + daysUntilExpiration: isExpired ? 0 : daysUntilExpiration, + }; +} + +export async function getDecryptedFielContribuyente(pool: Pool, contribuyenteId: string): Promise<{ + cerContent: string; keyContent: string; password: string; rfc: string; +} | null> { + const { rows } = await pool.query(` + SELECT * FROM fiel_contribuyente WHERE contribuyente_id = $1 AND is_active = true + `, [contribuyenteId]); + + if (rows.length === 0) { + // Fallback: check legacy FIEL by matching RFC + const { rows: contribRows } = await pool.query('SELECT rfc FROM contribuyentes WHERE entidad_id = $1', [contribuyenteId]); + const rfc = contribRows[0]?.rfc; + if (rfc) { + const { prisma } = await import('../config/database.js'); + const legacyFiel = await prisma.fielCredential.findFirst({ + where: { rfc, isActive: true }, + }); + if (legacyFiel && new Date() <= legacyFiel.validUntil) { + try { + const { decryptFielCredentials } = await import('./sat/sat-crypto.service.js'); + const { cerData, keyData, password } = decryptFielCredentials( + Buffer.from(legacyFiel.cerData), Buffer.from(legacyFiel.keyData), Buffer.from(legacyFiel.keyPasswordEncrypted), + Buffer.from(legacyFiel.cerIv), Buffer.from(legacyFiel.cerTag), + Buffer.from(legacyFiel.keyIv), Buffer.from(legacyFiel.keyTag), + Buffer.from(legacyFiel.passwordIv), Buffer.from(legacyFiel.passwordTag) + ); + return { cerContent: cerData.toString('binary'), keyContent: keyData.toString('binary'), password, rfc: legacyFiel.rfc }; + } catch (err) { + console.error('[FIEL Contribuyente] Legacy decrypt failed:', err); + return null; + } + } + } + return null; + } + const fiel = rows[0]; + + if (new Date() > new Date(fiel.valid_until)) return null; + + try { + const { cerData, keyData, password } = decryptFielCredentials( + Buffer.from(fiel.cer_data), Buffer.from(fiel.key_data), Buffer.from(fiel.key_password_enc), + Buffer.from(fiel.cer_iv), Buffer.from(fiel.cer_tag), + Buffer.from(fiel.key_iv), Buffer.from(fiel.key_tag), + Buffer.from(fiel.password_iv), Buffer.from(fiel.password_tag) + ); + return { cerContent: cerData.toString('binary'), keyContent: keyData.toString('binary'), password, rfc: fiel.rfc }; + } catch (error) { + console.error('[FIEL Contribuyente Decrypt Error]', error); + return null; + } +} diff --git a/apps/api/src/services/contribuyente.service.ts b/apps/api/src/services/contribuyente.service.ts new file mode 100644 index 0000000..251804e --- /dev/null +++ b/apps/api/src/services/contribuyente.service.ts @@ -0,0 +1,187 @@ +import type { Pool } from 'pg'; + +export interface CreateContribuyenteData { + rfc: string; + razonSocial: string; + regimenFiscal?: string; + codigoPostal?: string; + domicilio?: Record; + supervisorUserId?: string; +} + +export interface ContribuyenteRow { + id: string; + tipo: string; + nombre: string; + identificador: string; + supervisorUserId: string | null; + active: boolean; + createdAt: string; + rfc: string; + regimenFiscal: string | null; + codigoPostal: string | null; + domicilio: Record | null; +} + +export async function listContribuyentes(pool: Pool, entidadIds?: string[]): Promise { + let query = ` + SELECT + e.id, e.tipo, e.nombre, e.identificador, + e.supervisor_user_id AS "supervisorUserId", + e.active, e.created_at AS "createdAt", + c.rfc, c.regimen_fiscal AS "regimenFiscal", + c.codigo_postal AS "codigoPostal", c.domicilio + FROM entidades_gestionadas e + JOIN contribuyentes c ON c.entidad_id = e.id + WHERE e.active = true + `; + const params: unknown[] = []; + + if (entidadIds !== undefined) { + if (entidadIds.length === 0) return []; // No access = empty list + query += ` AND e.id = ANY($1)`; + params.push(entidadIds); + } + + query += ' ORDER BY e.created_at DESC'; + const { rows } = await pool.query(query, params); + return rows; +} + +export async function getContribuyenteById(pool: Pool, id: string): Promise { + const { rows } = await pool.query(` + SELECT + e.id, e.tipo, e.nombre, e.identificador, + e.supervisor_user_id AS "supervisorUserId", + e.active, e.created_at AS "createdAt", + c.rfc, c.regimen_fiscal AS "regimenFiscal", + c.codigo_postal AS "codigoPostal", c.domicilio + FROM entidades_gestionadas e + JOIN contribuyentes c ON c.entidad_id = e.id + WHERE e.id = $1 + `, [id]); + return rows[0] ?? null; +} + +export async function createContribuyente(pool: Pool, data: CreateContribuyenteData): Promise { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const { rows: [entidad] } = await client.query(` + INSERT INTO entidades_gestionadas (tipo, nombre, identificador, supervisor_user_id) + VALUES ('CONTRIBUYENTE', $1, $2, $3) + RETURNING id + `, [data.razonSocial, data.rfc.toUpperCase(), data.supervisorUserId ?? null]); + + await client.query(` + INSERT INTO contribuyentes (entidad_id, rfc, regimen_fiscal, codigo_postal, domicilio) + VALUES ($1, $2, $3, $4, $5) + `, [entidad.id, data.rfc.toUpperCase(), data.regimenFiscal ?? null, data.codigoPostal ?? null, data.domicilio ? JSON.stringify(data.domicilio) : null]); + + await client.query('COMMIT'); + + // Backfill: claim existing CFDIs that match this RFC + await backfillCfdiContribuyente(pool, entidad.id, data.rfc.toUpperCase()).catch( + (err) => console.error('[Contribuyente] Backfill CFDIs failed (non-blocking):', err) + ); + + return (await getContribuyenteById(pool, entidad.id))!; + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } +} + +export async function updateContribuyente(pool: Pool, id: string, data: Partial): Promise { + const existing = await getContribuyenteById(pool, id); + if (!existing) return null; + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + // Update entidades_gestionadas if needed + const entidadSets: string[] = []; + const entidadVals: unknown[] = []; + let idx = 1; + + if (data.razonSocial) { + entidadSets.push(`nombre = $${idx}`, `identificador = $${idx}`); + entidadVals.push(data.razonSocial); + idx++; + } + if (data.supervisorUserId !== undefined) { + entidadSets.push(`supervisor_user_id = $${idx}`); + entidadVals.push(data.supervisorUserId); + idx++; + } + if (entidadSets.length > 0) { + entidadSets.push('updated_at = now()'); + entidadVals.push(id); + await client.query(`UPDATE entidades_gestionadas SET ${entidadSets.join(', ')} WHERE id = $${idx}`, entidadVals); + } + + // Update contribuyentes if needed + const contribSets: string[] = []; + const contribVals: unknown[] = []; + idx = 1; + + if (data.regimenFiscal !== undefined) { contribSets.push(`regimen_fiscal = $${idx}`); contribVals.push(data.regimenFiscal); idx++; } + if (data.codigoPostal !== undefined) { contribSets.push(`codigo_postal = $${idx}`); contribVals.push(data.codigoPostal); idx++; } + if (data.domicilio !== undefined) { contribSets.push(`domicilio = $${idx}`); contribVals.push(JSON.stringify(data.domicilio)); idx++; } + + if (contribSets.length > 0) { + contribVals.push(id); + await client.query(`UPDATE contribuyentes SET ${contribSets.join(', ')} WHERE entidad_id = $${idx}`, contribVals); + } + + await client.query('COMMIT'); + return (await getContribuyenteById(pool, id))!; + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } +} + +export async function deactivateContribuyente(pool: Pool, id: string): Promise { + const { rowCount } = await pool.query( + 'UPDATE entidades_gestionadas SET active = false, updated_at = now() WHERE id = $1', + [id] + ); + return (rowCount ?? 0) > 0; +} + +/** + * Assigns contribuyente_id to CFDIs that match the RFC (emisor or receptor). + * Runs after contribuyente creation and can be called manually for backfill. + * Only updates CFDIs where contribuyente_id IS NULL (doesn't override). + */ +export async function backfillCfdiContribuyente(pool: Pool, contribuyenteId: string, rfc: string): Promise { + const { rowCount } = await pool.query(` + UPDATE cfdis + SET contribuyente_id = $1 + WHERE contribuyente_id IS NULL + AND (rfc_emisor = $2 OR rfc_receptor = $2) + `, [contribuyenteId, rfc]); + const count = rowCount ?? 0; + if (count > 0) { + console.log(`[Backfill] Assigned ${count} CFDIs to contribuyente ${rfc} (${contribuyenteId})`); + } + return count; +} + +/** + * Backfills ALL contribuyentes in the tenant BD. Useful after initial SAT sync. + */ +export async function backfillAllContribuyentes(pool: Pool): Promise { + const { rows } = await pool.query('SELECT entidad_id, rfc FROM contribuyentes'); + let total = 0; + for (const { entidad_id, rfc } of rows) { + total += await backfillCfdiContribuyente(pool, entidad_id, rfc); + } + return total; +} diff --git a/apps/api/src/services/dashboard.service.ts b/apps/api/src/services/dashboard.service.ts new file mode 100644 index 0000000..37a3e2b --- /dev/null +++ b/apps/api/src/services/dashboard.service.ts @@ -0,0 +1,1253 @@ +import type { Pool } from 'pg'; +import type { KpiData, IngresoRegimen, EgresoRegimen, IvaBalanceRegimen, IngresosEgresosData, ResumenFiscal, Alerta } from '@horux/shared'; +import { getRegimenesIgnoradosClaves } from './regimen.service.js'; +import { prisma } from '../config/database.js'; +import { planCache, type CacheRange } from '../utils/metricas-cache.js'; +import { resolveContribuyenteContext } from '../utils/contribuyente-context.js'; +import { buildExtraFilters } from './_shared/cfdi-filters.js'; + +// Status vigente +const VIGENTE = `status NOT IN ('Cancelado', '0')`; + +// Impuestos trasladados del comprobante +const IMP_TRAS = `COALESCE(iva_traslado_mxn,0) + COALESCE(ieps_traslado_mxn,0) + COALESCE(impuestos_locales_trasladado_mxn,0)`; + +// Impuestos trasladados del pago. +// El IVA se clampa a `monto_pago_mxn × 0.16` (tasa SAT máxima) como defensa +// contra XMLs malformados donde el proveedor reporta el IVA de la factura +// original completa en vez del proporcional al pago parcial. Caso real: +// CFDI 079ace7d con monto_pago=$43,611 e iva_traslado_pago=$30,076 cuando +// el proporcional sería ~$6,017. IEPS NO se clampa (rates van hasta 53%). +const IVA_TRAS_PAGO_CLAMPED = `LEAST(COALESCE(iva_traslado_pago_mxn, 0), COALESCE(monto_pago_mxn, 0) * 0.16)`; +const IVA_RET_PAGO_CLAMPED = `LEAST(COALESCE(iva_retencion_pago_mxn, 0), COALESCE(monto_pago_mxn, 0) * 0.16)`; +const IMP_TRAS_PAGO = `${IVA_TRAS_PAGO_CLAMPED} + COALESCE(ieps_traslado_pago_mxn, 0)`; + +// Claves de producto/servicio excluidas de cálculos fiscales +const CLAVES_EXCLUIDAS = `('84121603','93161608','85101501','85121800')`; + +// Art. 27 fracción III LISR — gastos > $2,000 pagados en efectivo NO son +// deducibles. Aplica al lado RECEPTOR. Se filtran de las deducciones y se +// surface aparte en card "No Deducibles". +// +// Para CFDIs tipo I PUE: el monto a comparar es `total_mxn`. +// Para complementos P: el monto a comparar es `monto_pago_mxn` (cada P es +// pago independiente; un P de $3k efectivo aplicado a una I PPD bloquea +// solo esos $3k, no la I PPD entera). +// +// `forma_pago = '01'` = Efectivo (catálogo SAT c_FormaPago). +// +// EXPORTADOS — `impuestos.service.ts` los reutiliza para excluir el IVA +// acreditable de esos mismos gastos (Art. 5 LIVA fracción I: el IVA solo es +// acreditable si el gasto cumple los requisitos de deducibilidad ISR). +// +// IMPORTANTE: COALESCE(forma_pago, '') hace el predicado NULL-safe. Sin esto, +// `forma_pago = '01'` retorna NULL cuando forma_pago es NULL, y `NOT (NULL)` +// también es NULL — Postgres trata WHERE NULL como exclusión del row. Eso +// haría que TODOS los CFDIs sin forma_pago se excluyeran de las deducciones +// vía `AND NOT NO_DEDUCIBLE_EFECTIVO_*`. Los CFDIs de complemento P sin +// forma_pago explícito son comunes; sin el COALESCE, deducciones colapsa. +export const NO_DEDUCIBLE_EFECTIVO_I_PUE = `(COALESCE(forma_pago, '') = '01' AND COALESCE(total_mxn, 0) > 2000)`; +export const NO_DEDUCIBLE_EFECTIVO_P = `(COALESCE(forma_pago, '') = '01' AND COALESCE(monto_pago_mxn, 0) > 2000)`; + +// Subtotal de conceptos excluidos por CFDI (importe - descuento) +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_EXCLUIDAS}), 0)`; + +// IVA trasladado de conceptos excluidos +const EXCL_IVA_TRAS = `COALESCE((SELECT SUM(COALESCE(cc.iva_traslado_mxn,0)) FROM cfdi_conceptos cc WHERE cc.cfdi_id = cfdis.id AND cc.clave_prod_serv IN ${CLAVES_EXCLUIDAS}), 0)`; + +// IVA retenido de conceptos excluidos +const EXCL_IVA_RET = `COALESCE((SELECT SUM(COALESCE(cc.iva_retencion_mxn,0)) FROM cfdi_conceptos cc WHERE cc.cfdi_id = cfdis.id AND cc.clave_prod_serv IN ${CLAVES_EXCLUIDAS}), 0)`; + +// IVA neto excluido (trasladado - retenido) +const EXCL_IVA_NETO = `(${EXCL_IVA_TRAS}) - (${EXCL_IVA_RET})`; + +// "Total sin impuestos" custom usado para I/07 (aplicación de anticipo) en +// Grupo 1. Fórmula: total − traslados + retenciones. Semánticamente +// representa la base gravable del anticipo considerando que las retenciones +// sí son ingreso (aunque el retenedor se las haya quedado). +const NETO_CUSTOM = (alias: string) => `( + COALESCE(${alias}.total_mxn, 0) + - COALESCE(${alias}.iva_traslado_mxn, 0) + COALESCE(${alias}.iva_retencion_mxn, 0) + + COALESCE(${alias}.isr_retencion_mxn, 0) + - COALESCE(${alias}.ieps_traslado_mxn, 0) + COALESCE(${alias}.ieps_retencion_mxn, 0) + - COALESCE(${alias}.impuestos_locales_trasladado_mxn, 0) + COALESCE(${alias}.impuestos_locales_retenidos_mxn, 0) +)`; + +// EXCL_MONTO parametrizado por alias — para aplicarlo tanto al CFDI base +// como a las facturas relacionadas dentro de un subquery. +const EXCL_MONTO_ALIAS = (alias: string) => `COALESCE(( + SELECT SUM(COALESCE(cc.importe_mxn, 0) - COALESCE(cc.descuento_mxn, 0)) + FROM cfdi_conceptos cc + WHERE cc.cfdi_id = ${alias}.id + AND cc.clave_prod_serv IN ${CLAVES_EXCLUIDAS} +), 0)`; + +// EXCL_IVA parametrizado por alias (para compensación I/07 en IVA). +const EXCL_IVA_TRAS_ALIAS = (alias: string) => `COALESCE(( + SELECT SUM(COALESCE(cc.iva_traslado_mxn, 0)) + FROM cfdi_conceptos cc + WHERE cc.cfdi_id = ${alias}.id + AND cc.clave_prod_serv IN ${CLAVES_EXCLUIDAS} +), 0)`; +const EXCL_IVA_RET_ALIAS = (alias: string) => `COALESCE(( + SELECT SUM(COALESCE(cc.iva_retencion_mxn, 0)) + FROM cfdi_conceptos cc + WHERE cc.cfdi_id = ${alias}.id + AND cc.clave_prod_serv IN ${CLAVES_EXCLUIDAS} +), 0)`; +const EXCL_IVA_NETO_ALIAS = (alias: string) => + `((${EXCL_IVA_TRAS_ALIAS(alias)}) - (${EXCL_IVA_RET_ALIAS(alias)}))`; + +// IVA neto por fila, parametrizado por alias (iva_traslado - iva_retencion). +const IVA_NETO_ALIAS = (alias: string) => + `(COALESCE(${alias}.iva_traslado_mxn, 0) - COALESCE(${alias}.iva_retencion_mxn, 0))`; + +// Grupos de regímenes por lógica de cálculo +export const GRUPO_PF_EMPRESARIAL = ['606', '612', '621', '625', '626']; +export const GRUPO_SUELDOS = ['605']; +export const GRUPO_PM_OTROS = ['601', '603', '607', '608', '610', '611', '614', '615', '620', '622', '623', '624']; +const TODOS_REGIMENES = [...GRUPO_PF_EMPRESARIAL, ...GRUPO_SUELDOS, ...GRUPO_PM_OTROS]; + +// Filtro de fecha por rango — normal o conciliación +const FECHA_RANGO = `fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')`; +// Para CFDIs tipo P (complementos de pago): el ingreso/gasto se reconoce en la +// fecha_pago_p (cuándo el cliente realmente pagó), no cuando se emitió el +// complemento — el CFDI P puede emitirse hasta el día 5 del mes siguiente al +// pago, o incluso después, y cruzar meses (ej. pago de noviembre 2024 con +// complemento emitido en mayo 2025). +const FECHA_PAGO_RANGO = `fecha_pago_p >= $1::date AND fecha_pago_p < ($2::date + interval '1 day')`; +const FECHA_RANGO_CONCILIACION = `id_conciliacion IS NOT NULL AND id_conciliacion IN ( + SELECT id FROM conciliaciones WHERE fecha_de_pago >= $1::date AND fecha_de_pago < ($2::date + interval '1 day') +)`; + +function getFechaRango(conciliacion?: boolean): string { + return conciliacion ? FECHA_RANGO_CONCILIACION : FECHA_RANGO; +} + +/** Igual que getFechaRango pero para CFDIs tipo P: filtra por fecha_pago_p. */ +function getFechaPagoRango(conciliacion?: boolean): string { + return conciliacion ? FECHA_RANGO_CONCILIACION : FECHA_PAGO_RANGO; +} + +/** + * Filtro de contribuyente **inclusivo**: matchea por `contribuyente_id` + * directo O por RFC en cualquiera de los dos lados (emisor/receptor). + * + * Necesario porque cuando dos contribuyentes del mismo tenant tienen + * relación emisor-receptor, el sync SAT del primero inserta el CFDI con + * su `contribuyente_id` y el sync del segundo (UPSERT) NO actualiza el + * campo. Resultado: el CFDI queda asignado al primer contribuyente aunque + * desde la perspectiva del segundo es "su" CFDI con el `type` inverso. + * + * Solución: en vez de filtrar `contribuyente_id = X`, filtrar por RFC del + * contribuyente en ambos lados. El `type` del CFDI + el lado del query + * (EMITIDO vs RECIBIDO) ya determina si es ingreso o gasto de ese + * contribuyente — no requiere `contribuyente_id` para la correcta + * atribución. + * + * Devuelve fragmento SQL con `AND` prefijo; string vacío si no hay + * `contribuyenteId` provisto. + */ +async function getContribFilter(pool: Pool, contribuyenteId: string | null | undefined): Promise { + if (!contribuyenteId) return ''; + const safeId = contribuyenteId.replace(/[^a-f0-9-]/gi, ''); + if (!safeId) return ''; + + const { rows } = await pool.query<{ rfc: string | null }>( + `SELECT rfc FROM contribuyentes WHERE entidad_id = $1`, + [safeId], + ); + if (rows.length === 0 || !rows[0].rfc) { + // Fallback: solo contribuyente_id (contribuyente sin RFC registrado). + return `AND contribuyente_id = '${safeId}'`; + } + const rfc = rows[0].rfc.replace(/[^A-Z0-9]/gi, '').toUpperCase(); + return `AND (contribuyente_id = '${safeId}' OR UPPER(rfc_emisor) = '${rfc}' OR UPPER(rfc_receptor) = '${rfc}')`; +} + +/** + * Calcula "Ingresos del Mes" desglosados por régimen fiscal. + */ +async function getDescMap(cache?: Map): Promise> { + if (cache) return cache; + const catalogo = await prisma.regimen.findMany({ where: { activo: true } }); + return new Map(catalogo.map(r => [r.clave, r.descripcion])); +} + +async function getIgnorados(tenantId: string, cache?: string[]): Promise { + if (cache) return cache; + return getRegimenesIgnoradosClaves(tenantId); +} + +// ──────────────────────────────────────────────────────────────────── +// Read-through cache (Tanda B hot/cold) +// +// Para contribuyentes con datos en `metricas_mensuales`, un rango que cubre +// meses completos dentro de años pasados (previos al actual) se puede leer +// directo de la tabla en vez de recomputar desde raw CFDIs. El año actual y +// los rangos parciales siguen on-the-fly. +// +// Requisitos para usar cache: +// - `contribuyenteId` presente (sin él no hay filas en la tabla) +// - `conciliacion` desactivada (la tabla guarda flujo normal, no usa id_conciliacion) +// - `fechaFin` antes del primer día del año actual +// - `fechaInicio` es día 1 del mes, `fechaFin` es último día del mes (rango +// de meses completos; parciales no mapean a filas de la tabla) +// ──────────────────────────────────────────────────────────────────── + +/** Lee ingresos_cobrados agregados por régimen desde metricas_mensuales. */ +async function readIngresosFromCache( + pool: Pool, + range: CacheRange, + ignorados: string[], + descMap: Map, +): Promise<{ total: number; porRegimen: IngresoRegimen[] } | null> { + const { rows } = await pool.query<{ regimen: string; monto: string; rows_n: string }>(` + SELECT regimen_fiscal AS regimen, + COALESCE(SUM(ingresos_cobrados), 0)::numeric(14,2) AS monto, + COUNT(*) AS rows_n + FROM metricas_mensuales + WHERE contribuyente_id = $1 + AND make_date(anio, mes, 1) BETWEEN $2::date AND $3::date + AND regimen_fiscal IS NOT NULL + GROUP BY regimen_fiscal + `, [range.contribuyenteId, range.startDate, range.endDate]); + + // Si no hay filas cacheadas, señal al caller de hacer fallback on-the-fly. + if (rows.length === 0) return null; + + const porRegimen: IngresoRegimen[] = []; + for (const r of rows) { + if (ignorados.includes(r.regimen)) continue; + const monto = Number(r.monto); + if (monto !== 0) { + porRegimen.push({ + regimenClave: r.regimen, + regimenDescripcion: descMap.get(r.regimen) || r.regimen, + monto, + }); + } + } + return { total: porRegimen.reduce((s, r) => s + r.monto, 0), porRegimen }; +} + +/** Lee egresos_pagados agregados por régimen desde metricas_mensuales. */ +async function readEgresosFromCache( + pool: Pool, + range: CacheRange, + ignorados: string[], + descMap: Map, +): Promise<{ total: number; porRegimen: EgresoRegimen[] } | null> { + const { rows } = await pool.query<{ regimen: string; monto: string }>(` + SELECT regimen_fiscal AS regimen, + COALESCE(SUM(egresos_pagados), 0)::numeric(14,2) AS monto + FROM metricas_mensuales + WHERE contribuyente_id = $1 + AND make_date(anio, mes, 1) BETWEEN $2::date AND $3::date + AND regimen_fiscal IS NOT NULL + GROUP BY regimen_fiscal + `, [range.contribuyenteId, range.startDate, range.endDate]); + + if (rows.length === 0) return null; + + const porRegimen: EgresoRegimen[] = []; + for (const r of rows) { + if (ignorados.includes(r.regimen)) continue; + const monto = Number(r.monto); + if (monto !== 0) { + porRegimen.push({ + regimenClave: r.regimen, + regimenDescripcion: descMap.get(r.regimen) || r.regimen, + monto, + }); + } + } + return { total: porRegimen.reduce((s, r) => s + r.monto, 0), porRegimen }; +} + +/** + * Lee IVA balance por régimen desde metricas_mensuales. + * Fórmula: monto = iva_trasladado_total − iva_acreditable − iva_retenido_cobrado + * (alineada con impuestos.resultado tras el refactor que separó retención). + */ +async function readIvaBalanceFromCache( + pool: Pool, + range: CacheRange, + ignorados: string[], + descMap: Map, +): Promise<{ total: number; porRegimen: IvaBalanceRegimen[] } | null> { + const { rows } = await pool.query<{ regimen: string; causado: string; acreditable: string; retenido: string }>(` + SELECT regimen_fiscal AS regimen, + COALESCE(SUM(iva_trasladado_total), 0)::numeric(14,2) AS causado, + COALESCE(SUM(iva_acreditable), 0)::numeric(14,2) AS acreditable, + COALESCE(SUM(iva_retenido_cobrado), 0)::numeric(14,2) AS retenido + FROM metricas_mensuales + WHERE contribuyente_id = $1 + AND make_date(anio, mes, 1) BETWEEN $2::date AND $3::date + AND regimen_fiscal IS NOT NULL + GROUP BY regimen_fiscal + `, [range.contribuyenteId, range.startDate, range.endDate]); + + if (rows.length === 0) return null; + + const porRegimen: IvaBalanceRegimen[] = []; + for (const r of rows) { + if (ignorados.includes(r.regimen)) continue; + const causado = Number(r.causado); + const acreditable = Number(r.acreditable); + const retenido = Number(r.retenido); + const monto = causado - acreditable - retenido; + if (monto !== 0 || causado !== 0 || acreditable !== 0 || retenido !== 0) { + porRegimen.push({ + regimenClave: r.regimen, + regimenDescripcion: descMap.get(r.regimen) || r.regimen, + monto, + }); + } + } + return { total: porRegimen.reduce((s, r) => s + r.monto, 0), porRegimen }; +} + +export async function calcularIngresosPorRegimen( + pool: Pool, + tenantId: string, + fechaInicio: string, + fechaFin: string, + _ignorados?: string[], + _descMap?: Map, + conciliacion?: boolean, + contribuyenteId?: string | null, + considerarActivos: boolean = true, + considerarNCs: boolean = true, +): Promise<{ total: number; porRegimen: IngresoRegimen[] }> { + const ignorados = await getIgnorados(tenantId, _ignorados); + const descMap = await getDescMap(_descMap); + + // Read-through cache: si el rango cae en años pasados con meses completos + // y hay un contribuyente seleccionado, lee de metricas_mensuales. Si hit, + // retorna de inmediato (evita ~3 queries SQL por régimen). Solo aplica + // cuando los toggles están en default (true) — el cache se escribió con + // esos valores y aplicar otra combinación devolvería datos stale. + const cacheRange = considerarActivos && considerarNCs + ? planCache(fechaInicio, fechaFin, conciliacion, contribuyenteId) + : null; + if (cacheRange) { + const cached = await readIngresosFromCache(pool, cacheRange, ignorados, descMap); + if (cached) return cached; + } + + const FR = getFechaRango(conciliacion); + const FR_PAGO = getFechaPagoRango(conciliacion); + const ctx = await resolveContribuyenteContext(pool, tenantId, contribuyenteId); + const esEmisor = ctx.esEmisor; + const esReceptor = ctx.esReceptor; + const extra = buildExtraFilters(considerarActivos, considerarNCs); + + const porRegimen: IngresoRegimen[] = []; + + // ─── GRUPO 1: PF Empresarial (606, 612, 621, 625, 626) ─── + // Suman I PUE + P (pagos) + I/07 PPD compensación. Las notas de crédito tipo E + // se contabilizan del lado del receptor (gastos) y se exhiben aparte como + // "Egresos Emitidos" en /impuestos > ISR (surface-only) — no restan aquí. + // + // I/07 PPD compensación (lado EMISOR): cuando el contribuyente emite I/07 PPD + // (aplicación de anticipo) y emite también una E en el mismo mes/año cuya + // cfdis_relacionados contiene esa I/07 PPD, la I/07 PPD aporta el equivalente + // de la base de la E. Se preserva por interpretación fiscal explícita aunque + // la E ya no se reste — refleja que la porción del servicio asociada al + // anticipo se reconoce como ingreso al emitir la I/07 PPD. + // + // Filtro por RFC del emisor (`${esEmisor}`) en vez de `type='EMITIDO' AND + // contribuyente_id=X` — el RFC es fuente de verdad, type/contribuyente_id + // pueden ser inconsistentes cuando dos contribuyentes del tenant se facturan. + const { rows: g1Facturas } = await pool.query(` + SELECT regimen_fiscal_emisor as regimen, + COALESCE(SUM(COALESCE(total_mxn, 0) - (${IMP_TRAS}) - (${EXCL_MONTO})), 0) as monto + FROM cfdis + WHERE ${esEmisor} AND tipo_comprobante = 'I' AND metodo_pago = 'PUE' + AND ${VIGENTE} AND ${FR}${extra} + AND regimen_fiscal_emisor = ANY($3) + GROUP BY regimen_fiscal_emisor + `, [fechaInicio, fechaFin, GRUPO_PF_EMPRESARIAL]); + + const { rows: g1Pagos } = await pool.query(` + SELECT regimen_fiscal_emisor as regimen, + COALESCE(SUM(COALESCE(monto_pago_mxn,0) - (${IMP_TRAS_PAGO})), 0) as monto + FROM cfdis + WHERE ${esEmisor} AND tipo_comprobante = 'P' + AND ${VIGENTE} AND ${FR_PAGO}${extra} + AND regimen_fiscal_emisor = ANY($3) + GROUP BY regimen_fiscal_emisor + `, [fechaInicio, fechaFin, GRUPO_PF_EMPRESARIAL]); + + // NOTA: la compensación I/07 PPD ↔ E se eliminó por decisión del cliente + // (2026-05-02). No es un cálculo oficial del SAT y confundía a contadores — + // el contador hace la conciliación manual del ciclo anticipo→I/07 PPD→E si + // aplica. Mantener esta nota para evitar reintroducirla por intuición fiscal. + for (const clave of GRUPO_PF_EMPRESARIAL) { + if (ignorados.includes(clave)) continue; + const facturas = Number(g1Facturas.find((r: any) => r.regimen === clave)?.monto || 0); + const pagos = Number(g1Pagos.find((r: any) => r.regimen === clave)?.monto || 0); + const monto = facturas + pagos; + if (monto !== 0 || facturas !== 0 || pagos !== 0) { + porRegimen.push({ regimenClave: clave, regimenDescripcion: descMap.get(clave) || clave, monto }); + } + } + + // ─── GRUPO 2: Sueldos y Salarios (605) ─── + // Nómina recibida por el contribuyente (lado RECEPTOR). + if (!ignorados.includes('605')) { + const { rows: g2 } = await pool.query(` + SELECT COALESCE(SUM(COALESCE(total_mxn,0)), 0) as monto + FROM cfdis + WHERE ${esReceptor} AND tipo_comprobante = 'N' AND metodo_pago = 'PUE' + AND ${VIGENTE} AND ${FR}${extra} + AND regimen_fiscal_receptor = '605' + `, [fechaInicio, fechaFin]); + const monto = Number(g2[0]?.monto || 0); + if (monto !== 0) { + porRegimen.push({ regimenClave: '605', regimenDescripcion: descMap.get('605') || 'Sueldos y Salarios', monto }); + } + } + + // ─── GRUPO 3: Resto de regímenes (PM y otros) ─── + // Suman I (PUE+PPD) sin restar E. Las notas de crédito tipo E se contabilizan + // del lado del receptor (gastos), no como reducción del ingreso del emisor — + // criterio fiscal vigente para PMs y otros regímenes en este grupo. + const { rows: g3Facturas } = await pool.query(` + SELECT regimen_fiscal_emisor as regimen, + COALESCE(SUM(COALESCE(total_mxn,0) - (${IMP_TRAS}) - (${EXCL_MONTO})), 0) as monto + FROM cfdis + WHERE ${esEmisor} AND tipo_comprobante = 'I' AND metodo_pago IN ('PUE', 'PPD') + AND ${VIGENTE} AND ${FR}${extra} + AND regimen_fiscal_emisor = ANY($3) + GROUP BY regimen_fiscal_emisor + `, [fechaInicio, fechaFin, GRUPO_PM_OTROS]); + + for (const clave of GRUPO_PM_OTROS) { + if (ignorados.includes(clave)) continue; + const facturas = Number(g3Facturas.find((r: any) => r.regimen === clave)?.monto || 0); + if (facturas !== 0) { + porRegimen.push({ regimenClave: clave, regimenDescripcion: descMap.get(clave) || clave, monto: facturas }); + } + } + + return { total: porRegimen.reduce((s, r) => s + r.monto, 0), porRegimen }; +} + +/** + * Calcula el monto neto de notas de crédito tipo E PUE emitidas por el + * contribuyente en el período, agrupado por régimen del emisor. + * + * Misma fórmula neta que ingresos (`total_mxn − IMP_TRAS − EXCL_MONTO`), + * excluyendo conceptos con `clave_prod_serv` en `CLAVES_EXCLUIDAS`. + * + * No participa en el cálculo de ISR ni de ingresos — surface-only para que + * el contador vea las NCs emitidas (que ya no se restan del ingreso) sin + * perder visibilidad de la información. Mirror de `calcularNcsRecibidasPorRegimen`. + */ +export async function calcularNcsEmitidasPorRegimen( + pool: Pool, + tenantId: string, + fechaInicio: string, + fechaFin: string, + _ignorados?: string[], + _descMap?: Map, + conciliacion?: boolean, + contribuyenteId?: string | null, + considerarActivos: boolean = true, + considerarNCs: boolean = true, +): Promise<{ total: number; porRegimen: IngresoRegimen[] }> { + const ignorados = await getIgnorados(tenantId, _ignorados); + const descMap = await getDescMap(_descMap); + const FR = getFechaRango(conciliacion); + const ctx = await resolveContribuyenteContext(pool, tenantId, contribuyenteId); + const esEmisor = ctx.esEmisor; + const extra = buildExtraFilters(considerarActivos, considerarNCs); + + const { rows } = await pool.query(` + SELECT regimen_fiscal_emisor as regimen, + COALESCE(SUM(COALESCE(total_mxn,0) - (${IMP_TRAS}) - (${EXCL_MONTO})), 0) as monto + FROM cfdis + WHERE ${esEmisor} AND tipo_comprobante = 'E' AND metodo_pago = 'PUE' + AND ${VIGENTE} AND ${FR}${extra} + GROUP BY regimen_fiscal_emisor + `, [fechaInicio, fechaFin]); + + const porRegimen: IngresoRegimen[] = []; + for (const row of rows) { + const clave = row.regimen as string | null; + if (!clave || ignorados.includes(clave)) continue; + const monto = Number(row.monto || 0); + if (monto === 0) continue; + porRegimen.push({ regimenClave: clave, regimenDescripcion: descMap.get(clave) || clave, monto }); + } + + return { total: porRegimen.reduce((s, r) => s + r.monto, 0), porRegimen }; +} + +/** + * Calcula el monto neto de notas de crédito tipo E PUE RECIBIDAS por el + * contribuyente en el período, agrupado por régimen del receptor. + * + * Misma fórmula neta que ingresos/deducciones (`total_mxn − IMP_TRAS − EXCL_MONTO`), + * excluyendo conceptos con `clave_prod_serv` en `CLAVES_EXCLUIDAS`. + * + * No participa en el cálculo de ISR ni de deducciones — surface-only para que + * el contador vea las E recibidas (que ya no se restan de la deducción) sin + * perder visibilidad de la información. Mirror del lado receptor de + * `calcularEgresosEmitidosPorRegimen`. + */ +export async function calcularNcsRecibidasPorRegimen( + pool: Pool, + tenantId: string, + fechaInicio: string, + fechaFin: string, + _ignorados?: string[], + _descMap?: Map, + conciliacion?: boolean, + contribuyenteId?: string | null, + considerarActivos: boolean = true, + considerarNCs: boolean = true, +): Promise<{ total: number; porRegimen: IngresoRegimen[] }> { + const ignorados = await getIgnorados(tenantId, _ignorados); + const descMap = await getDescMap(_descMap); + const FR = getFechaRango(conciliacion); + const ctx = await resolveContribuyenteContext(pool, tenantId, contribuyenteId); + const esReceptor = ctx.esReceptor; + const extra = buildExtraFilters(considerarActivos, considerarNCs); + + const { rows } = await pool.query(` + SELECT regimen_fiscal_receptor as regimen, + COALESCE(SUM(COALESCE(total_mxn,0) - (${IMP_TRAS}) - (${EXCL_MONTO})), 0) as monto + FROM cfdis + WHERE ${esReceptor} AND tipo_comprobante = 'E' AND metodo_pago = 'PUE' + AND ${VIGENTE} AND ${FR}${extra} + GROUP BY regimen_fiscal_receptor + `, [fechaInicio, fechaFin]); + + const porRegimen: IngresoRegimen[] = []; + for (const row of rows) { + const clave = row.regimen as string | null; + if (!clave || ignorados.includes(clave)) continue; + const monto = Number(row.monto || 0); + if (monto === 0) continue; + porRegimen.push({ regimenClave: clave, regimenDescripcion: descMap.get(clave) || clave, monto }); + } + + return { total: porRegimen.reduce((s, r) => s + r.monto, 0), porRegimen }; +} + +/** + * Calcula gastos NO deducibles por Art. 27 fracción III LISR — facturas + * recibidas pagadas en efectivo con monto > $2,000. Por régimen del receptor. + * + * Suma I PUE recibidas (forma_pago='01' AND total_mxn > 2000) + complementos + * P recibidos (forma_pago='01' AND monto_pago_mxn > 2000), monto neto sin + * impuestos. Misma fórmula que deducciones (que las EXCLUYE), por lo que + * deducciones + noDeducibles = gastos brutos del periodo (excluyendo NCs). + * + * Surface-only — no entra en cálculo de ISR; sirve para que el contador vea + * cuánto está "perdiendo" por pagos en efectivo. + */ +export async function calcularGastosNoDeduciblesEfectivoPorRegimen( + pool: Pool, + tenantId: string, + fechaInicio: string, + fechaFin: string, + _ignorados?: string[], + _descMap?: Map, + conciliacion?: boolean, + contribuyenteId?: string | null, + considerarActivos: boolean = true, + considerarNCs: boolean = true, +): Promise<{ total: number; porRegimen: IngresoRegimen[] }> { + const ignorados = await getIgnorados(tenantId, _ignorados); + const descMap = await getDescMap(_descMap); + const FR = getFechaRango(conciliacion); + const FR_PAGO = getFechaPagoRango(conciliacion); + const ctx = await resolveContribuyenteContext(pool, tenantId, contribuyenteId); + const esReceptor = ctx.esReceptor; + const extra = buildExtraFilters(considerarActivos, considerarNCs); + + const [{ rows: facturas }, { rows: pagos }] = await Promise.all([ + pool.query(` + SELECT regimen_fiscal_receptor as regimen, + COALESCE(SUM(COALESCE(total_mxn, 0) - (${IMP_TRAS}) - (${EXCL_MONTO})), 0) as monto + FROM cfdis + WHERE ${esReceptor} AND tipo_comprobante = 'I' AND metodo_pago = 'PUE' + AND ${VIGENTE} AND ${FR}${extra} + AND ${NO_DEDUCIBLE_EFECTIVO_I_PUE} + AND regimen_fiscal_receptor IS NOT NULL + GROUP BY regimen_fiscal_receptor + `, [fechaInicio, fechaFin]), + pool.query(` + SELECT regimen_fiscal_receptor as regimen, + COALESCE(SUM(COALESCE(monto_pago_mxn, 0) - (${IMP_TRAS_PAGO})), 0) as monto + FROM cfdis + WHERE ${esReceptor} AND tipo_comprobante = 'P' + AND ${VIGENTE} AND ${FR_PAGO}${extra} + AND ${NO_DEDUCIBLE_EFECTIVO_P} + AND regimen_fiscal_receptor IS NOT NULL + GROUP BY regimen_fiscal_receptor + `, [fechaInicio, fechaFin]), + ]); + + const map = new Map(); + for (const r of facturas) { + const k = r.regimen as string; + if (ignorados.includes(k)) continue; + map.set(k, (map.get(k) || 0) + Number(r.monto || 0)); + } + for (const r of pagos) { + const k = r.regimen as string; + if (ignorados.includes(k)) continue; + map.set(k, (map.get(k) || 0) + Number(r.monto || 0)); + } + + const porRegimen: IngresoRegimen[] = []; + for (const [k, v] of map.entries()) { + if (v === 0) continue; + porRegimen.push({ regimenClave: k, regimenDescripcion: descMap.get(k) || k, monto: v }); + } + + return { total: porRegimen.reduce((s, r) => s + r.monto, 0), porRegimen }; +} + +/** + * Calcula el IVA NO acreditable por Art. 5 LIVA fracción I + Art. 27 fracción + * III LISR — IVA neto (trasladado − retenido) de las facturas recibidas + * pagadas en efectivo > $2,000. + * + * Mirror del lado IVA de `calcularGastosNoDeduciblesEfectivoPorRegimen`. Se + * excluye del IVA Acreditable (vía filtro en `calcularIvaBalancePorRegimen` + * y `getResumenIva`) y se exhibe aparte como card "IVA No Acreditable". + * + * Surface-only — no entra en cálculo de IVA Resultado; sirve para que el + * contador vea cuánto IVA está "perdiendo" por pagos en efectivo. + */ +export async function calcularIvaNoAcreditableEfectivoPorRegimen( + pool: Pool, + tenantId: string, + fechaInicio: string, + fechaFin: string, + _ignorados?: string[], + _descMap?: Map, + conciliacion?: boolean, + contribuyenteId?: string | null, + considerarActivos: boolean = true, + considerarNCs: boolean = true, +): Promise<{ total: number; porRegimen: IngresoRegimen[] }> { + const ignorados = await getIgnorados(tenantId, _ignorados); + const descMap = await getDescMap(_descMap); + const FR = getFechaRango(conciliacion); + const FR_PAGO = getFechaPagoRango(conciliacion); + const ctx = await resolveContribuyenteContext(pool, tenantId, contribuyenteId); + const esReceptor = ctx.esReceptor; + const extra = buildExtraFilters(considerarActivos, considerarNCs); + + const [{ rows: facturas }, { rows: pagos }] = await Promise.all([ + pool.query(` + SELECT regimen_fiscal_receptor as regimen, + COALESCE(SUM(${IVA_NETO} - (${EXCL_IVA_NETO})), 0) as monto + FROM cfdis + WHERE ${esReceptor} AND tipo_comprobante = 'I' AND metodo_pago = 'PUE' + AND ${VIGENTE} AND ${FR}${extra} + AND ${NO_DEDUCIBLE_EFECTIVO_I_PUE} + AND regimen_fiscal_receptor IS NOT NULL + GROUP BY regimen_fiscal_receptor + `, [fechaInicio, fechaFin]), + pool.query(` + SELECT regimen_fiscal_receptor as regimen, + COALESCE(SUM(${IVA_NETO_PAGO}), 0) as monto + FROM cfdis + WHERE ${esReceptor} AND tipo_comprobante = 'P' + AND ${VIGENTE} AND ${FR_PAGO}${extra} + AND ${NO_DEDUCIBLE_EFECTIVO_P} + AND regimen_fiscal_receptor IS NOT NULL + GROUP BY regimen_fiscal_receptor + `, [fechaInicio, fechaFin]), + ]); + + const map = new Map(); + for (const r of facturas) { + const k = r.regimen as string; + if (ignorados.includes(k)) continue; + map.set(k, (map.get(k) || 0) + Number(r.monto || 0)); + } + for (const r of pagos) { + const k = r.regimen as string; + if (ignorados.includes(k)) continue; + map.set(k, (map.get(k) || 0) + Number(r.monto || 0)); + } + + const porRegimen: IngresoRegimen[] = []; + for (const [k, v] of map.entries()) { + if (v === 0) continue; + porRegimen.push({ regimenClave: k, regimenDescripcion: descMap.get(k) || k, monto: v }); + } + + return { total: porRegimen.reduce((s, r) => s + r.monto, 0), porRegimen }; +} + +/** + * Calcula "Gastos del Mes" desglosados por régimen fiscal del receptor. + */ +export async function calcularEgresosPorRegimen( + pool: Pool, + tenantId: string, + fechaInicio: string, + fechaFin: string, + _ignorados?: string[], + _descMap?: Map, + conciliacion?: boolean, + contribuyenteId?: string | null, + considerarActivos: boolean = true, + considerarNCs: boolean = true, +): Promise<{ total: number; porRegimen: EgresoRegimen[] }> { + const ignorados = await getIgnorados(tenantId, _ignorados); + const descMap = await getDescMap(_descMap); + + // Read-through cache: ver nota en calcularIngresosPorRegimen. Solo cachea + // cuando los toggles están en default — escribir/leer con flags distintos + // devolvería valores stale. + const cacheRange = considerarActivos && considerarNCs + ? planCache(fechaInicio, fechaFin, conciliacion, contribuyenteId) + : null; + if (cacheRange) { + const cached = await readEgresosFromCache(pool, cacheRange, ignorados, descMap); + if (cached) return cached; + } + + const FR = getFechaRango(conciliacion); + const FR_PAGO = getFechaPagoRango(conciliacion); + const ctx = await resolveContribuyenteContext(pool, tenantId, contribuyenteId); + const esReceptor = ctx.esReceptor; + const extra = buildExtraFilters(considerarActivos, considerarNCs); + + const porRegimen: EgresoRegimen[] = []; + + // Gastos: lado RECEPTOR (el contribuyente recibe). Suman I PUE + P (pagos) + // + I/07 PPD compensación + nómina emitida. Las notas de crédito tipo E que + // el contribuyente recibe ya NO se restan — simétrico con el cambio en + // ingresos. + // + // I/07 PPD compensación (lado RECEPTOR): cuando el contribuyente recibe una + // I/07 PPD (aplicación de anticipo) y recibe también una E en el mismo + // mes/año cuya cfdis_relacionados contiene el UUID de esa I/07 PPD, la I/07 + // PPD aporta el equivalente de la base de la E. Se preserva por + // interpretación fiscal explícita aunque la E ya no se reste — refleja que + // la porción del servicio asociada al anticipo se reconoce como gasto al + // recibir la I/07 PPD. + // + // Filtro por RFC (esReceptor) en vez de `type` — el RFC es fuente de verdad. + // Art. 27 fracción III LISR: excluimos del cálculo de deducciones las I PUE + // y los P recibidos pagados en efectivo > $2,000. Esos gastos se exhiben + // aparte en card "No Deducibles" (calcularGastosNoDeduciblesEfectivoPorRegimen). + const { rows: facturas } = await pool.query(` + SELECT regimen_fiscal_receptor as regimen, + COALESCE(SUM(COALESCE(total_mxn, 0) - (${IMP_TRAS}) - (${EXCL_MONTO})), 0) as monto + FROM cfdis + WHERE ${esReceptor} AND tipo_comprobante = 'I' AND metodo_pago = 'PUE' + AND ${VIGENTE} AND ${FR}${extra} + AND NOT ${NO_DEDUCIBLE_EFECTIVO_I_PUE} + AND regimen_fiscal_receptor = ANY($3) + GROUP BY regimen_fiscal_receptor + `, [fechaInicio, fechaFin, TODOS_REGIMENES]); + + const { rows: pagos } = await pool.query(` + SELECT regimen_fiscal_receptor as regimen, + COALESCE(SUM(COALESCE(monto_pago_mxn,0) - (${IMP_TRAS_PAGO})), 0) as monto + FROM cfdis + WHERE ${esReceptor} AND tipo_comprobante = 'P' + AND ${VIGENTE} AND ${FR_PAGO}${extra} + AND NOT ${NO_DEDUCIBLE_EFECTIVO_P} + AND regimen_fiscal_receptor = ANY($3) + GROUP BY regimen_fiscal_receptor + `, [fechaInicio, fechaFin, TODOS_REGIMENES]); + + // NOTA: la compensación I/07 PPD ↔ E se eliminó por decisión del cliente + // (2026-05-02). No es un cálculo oficial del SAT y confundía a contadores — + // el contador hace la conciliación manual del ciclo anticipo→I/07 PPD→E si + // aplica. Mantener esta nota para evitar reintroducirla por intuición fiscal. + + // Nómina emitida (lado EMISOR — el contribuyente como patrón paga a empleados). + // Suma `total_mxn` completo: sin restar impuestos trasladados (la nómina típicamente + // no lleva IVA), sin restar conceptos excluidos (los códigos excluidos no aplican + // a nómina), sin filtros `considerarActivos`/`considerarNCs` (no aplican al + // concepto). Siempre por `fecha_emision` (no toca toggle de Conciliación). + // Agrupa por `regimen_fiscal_emisor` — el régimen donde está catalogado el + // contribuyente al emitir, que es donde se imputan estas deducciones. + const esEmisor = ctx.esEmisor; + const { rows: nomina } = await pool.query(` + SELECT regimen_fiscal_emisor as regimen, + COALESCE(SUM(COALESCE(total_mxn, 0)), 0) as monto + FROM cfdis + WHERE ${esEmisor} AND tipo_comprobante = 'N' + AND ${VIGENTE} AND ${FECHA_RANGO} + AND regimen_fiscal_emisor = ANY($3) + GROUP BY regimen_fiscal_emisor + `, [fechaInicio, fechaFin, TODOS_REGIMENES]); + + for (const clave of TODOS_REGIMENES) { + if (ignorados.includes(clave)) continue; + const montoF = Number(facturas.find((r: any) => r.regimen === clave)?.monto || 0); + const montoP = Number(pagos.find((r: any) => r.regimen === clave)?.monto || 0); + const montoN = Number(nomina.find((r: any) => r.regimen === clave)?.monto || 0); + const monto = montoF + montoP + montoN; + if (monto !== 0 || montoF !== 0 || montoP !== 0 || montoN !== 0) { + porRegimen.push({ regimenClave: clave, regimenDescripcion: descMap.get(clave) || clave, monto }); + } + } + + return { total: porRegimen.reduce((s, r) => s + r.monto, 0), porRegimen }; +} + +/** + * Calcula "Adquisición de Mercancías" — misma lógica que egresos pero solo CFDIs con uso_cfdi = 'G01' + */ +export async function calcularAdquisicionesMercancias( + pool: Pool, + tenantId: string, + fechaInicio: string, + fechaFin: string, + conciliacion?: boolean, + contribuyenteId?: string | null, +): Promise<{ total: number; porRegimen: { regimenClave: string; monto: number }[] }> { + const FR = getFechaRango(conciliacion); + const ctx = await resolveContribuyenteContext(pool, tenantId, contribuyenteId); + const esReceptor = ctx.esReceptor; + + // Adquisiciones G01 = subset de gastos con uso_cfdi='G01'. Lado receptor. + // Método A (ingenuo), consistente con calcularEgresosPorRegimen. + const { rows: facturas } = await pool.query(` + SELECT regimen_fiscal_receptor as regimen, + COALESCE(SUM(COALESCE(total_mxn, 0) - (${IMP_TRAS}) - (${EXCL_MONTO})), 0) as monto + FROM cfdis + WHERE ${esReceptor} AND tipo_comprobante = 'I' AND metodo_pago = 'PUE' + AND uso_cfdi = 'G01' + AND ${VIGENTE} AND ${FR} + GROUP BY regimen_fiscal_receptor + `, [fechaInicio, fechaFin]); + + const { rows: nc } = await pool.query(` + SELECT regimen_fiscal_receptor as regimen, + COALESCE(SUM(COALESCE(total_mxn,0) - (${IMP_TRAS}) - (${EXCL_MONTO})), 0) as monto + FROM cfdis + WHERE ${esReceptor} AND tipo_comprobante = 'E' AND metodo_pago = 'PUE' + AND uso_cfdi = 'G01' + AND ${VIGENTE} AND ${FR} + GROUP BY regimen_fiscal_receptor + `, [fechaInicio, fechaFin]); + + // Consolidar por régimen + const regimenMap = new Map(); + for (const r of facturas) { + const clave = r.regimen || 'sin'; + regimenMap.set(clave, (regimenMap.get(clave) || 0) + Number(r.monto)); + } + for (const r of nc) { + const clave = r.regimen || 'sin'; + regimenMap.set(clave, (regimenMap.get(clave) || 0) - Number(r.monto)); + } + + const porRegimen = Array.from(regimenMap.entries()).map(([regimenClave, monto]) => ({ regimenClave, monto })); + const total = porRegimen.reduce((s, r) => s + r.monto, 0); + + return { total, porRegimen }; +} + +// IVA neto del comprobante: trasladado - retenido +const IVA_NETO = `COALESCE(iva_traslado_mxn,0) - COALESCE(iva_retencion_mxn,0)`; +// IVA neto del pago. Refactor 2026-04-26: campos directos, sin clamp. +// Alineado con impuestos.service.ts post-refactor (ver doc 2026-04-26-iva-refactor.md). +const IVA_NETO_PAGO = `COALESCE(iva_traslado_pago_mxn, 0) - COALESCE(iva_retencion_pago_mxn, 0)`; + +/** + * Calcula "Balance IVA" desglosado por régimen fiscal del receptor. + */ +export async function calcularIvaBalancePorRegimen( + pool: Pool, + tenantId: string, + fechaInicio: string, + fechaFin: string, + _ignorados?: string[], + _descMap?: Map, + conciliacion?: boolean, + contribuyenteId?: string | null, +): Promise<{ total: number; porRegimen: IvaBalanceRegimen[] }> { + const ignorados = await getIgnorados(tenantId, _ignorados); + const descMap = await getDescMap(_descMap); + + // Read-through cache: años pasados con contribuyente seleccionado leen de + // metricas_mensuales (iva_trasladado_total, iva_acreditable). El año actual + // y rangos parciales siguen on-the-fly. + const cacheRange = planCache(fechaInicio, fechaFin, conciliacion, contribuyenteId); + if (cacheRange) { + const cached = await readIvaBalanceFromCache(pool, cacheRange, ignorados, descMap); + if (cached) return cached; + } + + const FR = getFechaRango(conciliacion); + const FR_PAGO = getFechaPagoRango(conciliacion); + const ctx = await resolveContribuyenteContext(pool, tenantId, contribuyenteId); + const esEmisor = ctx.esEmisor; + const esReceptor = ctx.esReceptor; + + const porRegimen: IvaBalanceRegimen[] = []; + + // 6 buckets — 3 causados (emisor) + 3 acreditables (receptor). + // Filtro por RFC (esEmisor/esReceptor) en vez de type. + + // s1 — Emisor + I + PUE. + // Refactor 2026-04-26: removida la compensación I PUE/07. Las I PUE/07 + // ahora aportan IVA neto completo. La E (cualquier tipoRelación) que + // las cancele resta vía s3/r3 — fidelidad al XML, sin interpretación. + const { rows: s1 } = await pool.query(` + SELECT regimen_fiscal_emisor as regimen, + COALESCE(SUM(${IVA_NETO} - (${EXCL_IVA_NETO})), 0) as monto + FROM cfdis + WHERE ${esEmisor} AND tipo_comprobante = 'I' AND metodo_pago = 'PUE' + AND ${VIGENTE} AND ${FR} + AND regimen_fiscal_emisor = ANY($3) + GROUP BY regimen_fiscal_emisor + `, [fechaInicio, fechaFin, TODOS_REGIMENES]); + + // s2 — Emisor + P + const { rows: s2 } = await pool.query(` + SELECT regimen_fiscal_emisor as regimen, + COALESCE(SUM(${IVA_NETO_PAGO}), 0) as monto + FROM cfdis + WHERE ${esEmisor} AND tipo_comprobante = 'P' + AND ${VIGENTE} AND ${FR_PAGO} + AND regimen_fiscal_emisor = ANY($3) + GROUP BY regimen_fiscal_emisor + `, [fechaInicio, fechaFin, TODOS_REGIMENES]); + + // s3 — Receptor + E + PUE (NC recibida) — resta de acreditable. + // Refactor 2026-04-26: removido filtro `<> '07'`. Todas las E PUE entran. + const { rows: s3 } = await pool.query(` + SELECT regimen_fiscal_receptor as regimen, + COALESCE(SUM(${IVA_NETO} - (${EXCL_IVA_NETO})), 0) as monto + FROM cfdis + WHERE ${esReceptor} AND tipo_comprobante = 'E' AND metodo_pago = 'PUE' + AND ${VIGENTE} AND ${FR} + AND regimen_fiscal_receptor = ANY($3) + GROUP BY regimen_fiscal_receptor + `, [fechaInicio, fechaFin, TODOS_REGIMENES]); + + // r1 — Receptor + I + PUE. Excluye gastos en efectivo > $2k (Art. 5 LIVA + // fracción I — el IVA acreditable requiere que el gasto cumpla los requisitos + // de deducibilidad ISR; gastos en efectivo > $2k no son deducibles ni su IVA + // acreditable). + const { rows: r1 } = await pool.query(` + SELECT regimen_fiscal_receptor as regimen, + COALESCE(SUM(${IVA_NETO} - (${EXCL_IVA_NETO})), 0) as monto + FROM cfdis + WHERE ${esReceptor} AND tipo_comprobante = 'I' AND metodo_pago = 'PUE' + AND ${VIGENTE} AND ${FR} + AND NOT ${NO_DEDUCIBLE_EFECTIVO_I_PUE} + AND regimen_fiscal_receptor = ANY($3) + GROUP BY regimen_fiscal_receptor + `, [fechaInicio, fechaFin, TODOS_REGIMENES]); + + // r2 — Receptor + P. Excluye P en efectivo > $2k (mismo razonamiento r1). + const { rows: r2 } = await pool.query(` + SELECT regimen_fiscal_receptor as regimen, + COALESCE(SUM(${IVA_NETO_PAGO}), 0) as monto + FROM cfdis + WHERE ${esReceptor} AND tipo_comprobante = 'P' + AND ${VIGENTE} AND ${FR_PAGO} + AND NOT ${NO_DEDUCIBLE_EFECTIVO_P} + AND regimen_fiscal_receptor = ANY($3) + GROUP BY regimen_fiscal_receptor + `, [fechaInicio, fechaFin, TODOS_REGIMENES]); + + // r3 — Emisor + E + PUE (NC emitida resta de causado). + // Refactor 2026-04-26: removido filtro `<> '07'`. Todas las E PUE entran. + const { rows: r3 } = await pool.query(` + SELECT regimen_fiscal_emisor as regimen, + COALESCE(SUM(${IVA_NETO} - (${EXCL_IVA_NETO})), 0) as monto + FROM cfdis + WHERE ${esEmisor} AND tipo_comprobante = 'E' AND metodo_pago = 'PUE' + AND ${VIGENTE} AND ${FR} + AND regimen_fiscal_emisor = ANY($3) + GROUP BY regimen_fiscal_emisor + `, [fechaInicio, fechaFin, TODOS_REGIMENES]); + + // s4 — Emisor I PPD/07 hereda IVA neto de E que la cancelan en mismo mes. + // Mirror de SUM_E_REFERENCING en impuestos.service.ts. La I PPD/07 normalmente + // no aporta IVA (espera al P), pero si una E la referencia en su mismo mes + // hereda el IVA de la E para netear el efecto del NEG (caso PPD ↔ E). + const { rows: s4 } = await pool.query(` + SELECT i.regimen_fiscal_emisor as regimen, + COALESCE(SUM(( + SELECT COALESCE(SUM(${IVA_NETO_ALIAS('e')} - (${EXCL_IVA_NETO_ALIAS('e')})), 0) + FROM cfdis e + WHERE e.tipo_comprobante = 'E' + AND e.metodo_pago = 'PUE' + AND e.status NOT IN ('Cancelado', '0') + AND ${esEmisor.replace(/\brfc_emisor\b/g, 'e.rfc_emisor')} + AND LOWER(i.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|')) + AND date_trunc('month', e.fecha_emision) = date_trunc('month', i.fecha_emision) + )), 0) as monto + FROM cfdis i + WHERE ${esEmisor.replace(/\brfc_emisor\b/g, 'i.rfc_emisor')} + AND i.tipo_comprobante = 'I' AND i.metodo_pago = 'PPD' + AND COALESCE(i.cfdi_tipo_relacion, '') = '07' + AND i.status NOT IN ('Cancelado','0') + AND ${FR.replace(/\bfecha_emision\b/g, 'i.fecha_emision')} + AND i.regimen_fiscal_emisor = ANY($3) + GROUP BY i.regimen_fiscal_emisor + `, [fechaInicio, fechaFin, TODOS_REGIMENES]); + + // r4 — Receptor I PPD/07 hereda IVA neto de E recibidas que la cancelan. + const { rows: r4 } = await pool.query(` + SELECT i.regimen_fiscal_receptor as regimen, + COALESCE(SUM(( + SELECT COALESCE(SUM(${IVA_NETO_ALIAS('e')} - (${EXCL_IVA_NETO_ALIAS('e')})), 0) + FROM cfdis e + WHERE e.tipo_comprobante = 'E' + AND e.metodo_pago = 'PUE' + AND e.status NOT IN ('Cancelado', '0') + AND ${esReceptor.replace(/\brfc_receptor\b/g, 'e.rfc_receptor')} + AND LOWER(i.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|')) + AND date_trunc('month', e.fecha_emision) = date_trunc('month', i.fecha_emision) + )), 0) as monto + FROM cfdis i + WHERE ${esReceptor.replace(/\brfc_receptor\b/g, 'i.rfc_receptor')} + AND i.tipo_comprobante = 'I' AND i.metodo_pago = 'PPD' + AND COALESCE(i.cfdi_tipo_relacion, '') = '07' + AND i.status NOT IN ('Cancelado','0') + AND ${FR.replace(/\bfecha_emision\b/g, 'i.fecha_emision')} + AND i.regimen_fiscal_receptor = ANY($3) + GROUP BY i.regimen_fiscal_receptor + `, [fechaInicio, fechaFin, TODOS_REGIMENES]); + + const find = (rows: any[], clave: string) => Number(rows.find((r: any) => r.regimen === clave)?.monto || 0); + + // Atribución directa por lado (refactor 2026-04-26): + // Causado = (EMIT I PUE) + (EMIT P) + (EMIT I PPD/07 hereda E) − (EMIT E PUE) + // Acreditable = (RECIB I PUE) + (RECIB P) + (RECIB I PPD/07 hereda E) − (RECIB E PUE) + // Balance = Causado − Acreditable. Sin compensación I PUE/07; sin filtro + // tipoRel en E. Las I PPD/07 con E que las cancelan heredan el IVA neto de + // la E para netear dentro del mes. + for (const clave of TODOS_REGIMENES) { + if (ignorados.includes(clave)) continue; + + const causado = find(s1, clave) + find(s2, clave) + find(s4, clave) - find(r3, clave); + const acreditable = find(r1, clave) + find(r2, clave) + find(r4, clave) - find(s3, clave); + const monto = causado - acreditable; + + if (monto !== 0 || causado !== 0 || acreditable !== 0) { + porRegimen.push({ regimenClave: clave, regimenDescripcion: descMap.get(clave) || clave, monto }); + } + } + + return { total: porRegimen.reduce((s, r) => s + r.monto, 0), porRegimen }; +} + +/** + * Calcula IVA a favor acumulado mes a mes desde añoDesde/enero hasta fechaFin. + * Lógica SAT: saldo positivo se paga (no acumula), saldo negativo se arrastra. + */ +async function calcularIvaAFavorAcumulado( + pool: Pool, + tenantId: string, + fechaFin: string, + añoDesde?: number, + conciliacion?: boolean, + contribuyenteId?: string | null, +): Promise { + const añoFin = new Date(fechaFin + 'T00:00:00').getFullYear(); + const mesFin = new Date(fechaFin + 'T00:00:00').getMonth() + 1; + const inicio = añoDesde ?? añoFin; + + // Precachear + const ignorados = await getRegimenesIgnoradosClaves(tenantId); + const catalogo = await prisma.regimen.findMany({ where: { activo: true } }); + const descMap = new Map(catalogo.map(r => [r.clave, r.descripcion])); + + let saldoAFavor = 0; + + for (let y = inicio; y <= añoFin; y++) { + const ultimoMes = y === añoFin ? mesFin : 12; + for (let m = 1; m <= ultimoMes; m++) { + const lastDay = new Date(y, m, 0).getDate(); + const fi = `${y}-${String(m).padStart(2, '0')}-01`; + const ff = `${y}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`; + + const ivaMes = await calcularIvaBalancePorRegimen(pool, tenantId, fi, ff, ignorados, descMap, conciliacion, contribuyenteId); + const balanceMes = ivaMes.total; + + if (balanceMes >= 0) { + if (saldoAFavor >= balanceMes) { + saldoAFavor = saldoAFavor - balanceMes; + } else { + saldoAFavor = 0; + } + } else { + saldoAFavor = saldoAFavor + Math.abs(balanceMes); + } + } + } + + return saldoAFavor; +} + +export async function getKpis( + pool: Pool, + fechaInicio: string, + fechaFin: string, + tenantId: string, + conciliacion?: boolean, + contribuyenteId?: string | null, +): Promise { + const FR = getFechaRango(conciliacion); + const ctx = await resolveContribuyenteContext(pool, tenantId, contribuyenteId); + const esEmisor = ctx.esEmisor; + const esReceptor = ctx.esReceptor; + const ingresosData = await calcularIngresosPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId); + const egresosData = await calcularEgresosPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId); + const adquisicionData = await calcularAdquisicionesMercancias(pool, tenantId, fechaInicio, fechaFin, conciliacion, contribuyenteId); + const ivaData = await calcularIvaBalancePorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId); + + // IVA a favor año actual: desde enero del año en curso + const ivaAFavorAcumulado = await calcularIvaAFavorAcumulado(pool, tenantId, fechaFin, undefined, conciliacion, contribuyenteId); + + // IVA a favor histórico: desde 5 años atrás + const añoFin = new Date(fechaFin + 'T00:00:00').getFullYear(); + const ivaAFavorHistorico = await calcularIvaAFavorAcumulado(pool, tenantId, fechaFin, añoFin - 5, conciliacion, contribuyenteId); + + // Conteos por lado: derivamos el "type" efectivo del RFC del contribuyente + // en vez de la columna `type` (que puede ser inconsistente). + const { rows: countRows } = await pool.query(` + SELECT + CASE WHEN ${esEmisor} THEN 'EMITIDO' + WHEN ${esReceptor} THEN 'RECIBIDO' + ELSE NULL END AS type, + COALESCE(CASE WHEN ${esEmisor} THEN regimen_fiscal_emisor ELSE regimen_fiscal_receptor END, '') as regimen, + COUNT(*)::int as total + FROM cfdis + WHERE ${VIGENTE} AND ${FR} + AND (${esEmisor} OR ${esReceptor}) + GROUP BY 1, regimen + `, [fechaInicio, fechaFin]); + + const ingresosVal = ingresosData.total; + const egresosVal = egresosData.total; + const utilidad = ingresosVal - egresosVal; + const margen = ingresosVal > 0 ? (utilidad / ingresosVal) * 100 : 0; + + const emitidosPorRegimen = countRows + .filter((r: any) => r.type === 'EMITIDO') + .map((r: any) => ({ regimen: r.regimen, total: r.total })); + const recibidosPorRegimen = countRows + .filter((r: any) => r.type === 'RECIBIDO') + .map((r: any) => ({ regimen: r.regimen, total: r.total })); + + return { + ingresos: ingresosVal, + ingresosPorRegimen: ingresosData.porRegimen, + egresos: egresosVal, + egresosPorRegimen: egresosData.porRegimen, + adquisicionMercancias: adquisicionData.total, + adquisicionMercanciasPorRegimen: adquisicionData.porRegimen, + utilidad, + margen: Math.round(margen * 100) / 100, + ivaBalance: ivaData.total, + ivaBalancePorRegimen: ivaData.porRegimen, + ivaAFavorAcumulado, + ivaAFavorHistorico, + cfdisEmitidos: emitidosPorRegimen.reduce((s: number, r: any) => s + r.total, 0), + cfdisEmitidosPorRegimen: emitidosPorRegimen, + cfdisRecibidos: recibidosPorRegimen.reduce((s: number, r: any) => s + r.total, 0), + cfdisRecibidosPorRegimen: recibidosPorRegimen, + }; +} + +export async function getIngresosEgresos(pool: Pool, año: number, tenantId: string, conciliacion?: boolean, contribuyenteId?: string | null): Promise { + const mesesLabel = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic']; + + // Precachear catálogo e ignorados para no consultar 24 veces + const ignorados = await getRegimenesIgnoradosClaves(tenantId); + const catalogo = await prisma.regimen.findMany({ where: { activo: true } }); + const descMap = new Map(catalogo.map(r => [r.clave, r.descripcion])); + + const result: IngresosEgresosData[] = []; + + for (let m = 1; m <= 12; m++) { + const lastDay = new Date(año, m, 0).getDate(); + const fi = `${año}-${String(m).padStart(2, '0')}-01`; + const ff = `${año}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`; + + const ing = await calcularIngresosPorRegimen(pool, tenantId, fi, ff, ignorados, descMap, conciliacion, contribuyenteId); + const egr = await calcularEgresosPorRegimen(pool, tenantId, fi, ff, ignorados, descMap, conciliacion, contribuyenteId); + + result.push({ + mes: mesesLabel[m - 1], + ingresos: ing.total, + egresos: egr.total, + }); + } + + return result; +} + + +/** + * Devuelve los regímenes fiscales presentes en los CFDIs del rango de fechas. + */ +export async function getRegimenesDelPeriodo( + pool: Pool, + fechaInicio: string, + fechaFin: string, + conciliacion?: boolean, + contribuyenteId?: string | null, + tenantId?: string, +): Promise<{ clave: string; descripcion: string }[]> { + const FR = getFechaRango(conciliacion); + const ctx = await resolveContribuyenteContext(pool, tenantId || '', contribuyenteId); + const esEmisor = ctx.esEmisor; + const esReceptor = ctx.esReceptor; + // Régimen del contribuyente: emisor cuando él emitió, receptor cuando él recibió. + const { rows } = await pool.query(` + SELECT DISTINCT regimen FROM ( + SELECT regimen_fiscal_emisor AS regimen + FROM cfdis + WHERE regimen_fiscal_emisor IS NOT NULL AND ${esEmisor} AND ${FR} + UNION + SELECT regimen_fiscal_receptor AS regimen + FROM cfdis + WHERE regimen_fiscal_receptor IS NOT NULL AND ${esReceptor} AND ${FR} + ) sub + ORDER BY regimen + `, [fechaInicio, fechaFin]); + + if (rows.length === 0) return []; + + const claves = rows.map((r: any) => r.regimen); + const catalogo = await prisma.regimen.findMany({ + where: { clave: { in: claves }, activo: true }, + orderBy: { clave: 'asc' }, + }); + + return catalogo.map(r => ({ clave: r.clave, descripcion: r.descripcion })); +} + +export async function getAlertas(pool: Pool, limit = 5): Promise { + const { rows } = await pool.query(` + SELECT id, tipo, titulo, mensaje, prioridad, + fecha_vencimiento as "fechaVencimiento", + leida, resuelta, + created_at as "createdAt" + FROM alertas + WHERE resuelta = false + ORDER BY + CASE prioridad WHEN 'alta' THEN 1 WHEN 'media' THEN 2 ELSE 3 END, + created_at DESC + LIMIT $1 + `, [limit]); + + return rows; +} diff --git a/apps/api/src/services/declaraciones.service.ts b/apps/api/src/services/declaraciones.service.ts new file mode 100644 index 0000000..a0b860a --- /dev/null +++ b/apps/api/src/services/declaraciones.service.ts @@ -0,0 +1,399 @@ +import type { Pool } from 'pg'; + +// Mapeo: impuesto de la declaración → reglas para matchear obligaciones del +// contribuyente. `include` son substrings que DEBE contener el nombre de la +// obligación; `exclude` son substrings que NO debe contener. El exclude +// resuelve ambigüedades como "IVA" matcheando "Declaración de proveedores +// de IVA" (DIOT) — cuando subes pago de IVA normal, NO debe cerrar DIOT. +const IMPUESTO_A_OBLIGACION_KEYWORDS: Record = { + IVA: { include: ['iva'], exclude: ['diot', 'proveedores de iva', 'informativa'] }, + ISR: { include: ['isr'], exclude: ['retenciones', 'asimilados a salarios'] }, + IEPS: { include: ['ieps'], exclude: [] }, + SUELDOS: { include: ['sueldos', 'salarios', 'nómina'], exclude: [] }, + DIOT: { include: ['diot', 'proveedores de iva'], exclude: [] }, + OTRO: { include: [], exclude: [] }, +}; + +/** + * After uploading a declaration, find matching obligations for the contribuyente + * and mark them as completed for the period. Also resolve the ob-* alerts. + * + * Guarda `declaracion_id` en `obligacion_periodos` para que la UI pueda + * mostrar "Completada via Declaración #123" y permitir cross-link. Si la + * declaración se borra, el FK pasa a NULL (ON DELETE SET NULL) y el + * periodo sigue marcado completado — el usuario decidirá si re-abrirlo + * manualmente. + */ +async function completarObligacionesPorDeclaracion( + pool: Pool, + contribuyenteId: string, + impuestos: string[], + periodo: string, + /** UUID del usuario que subió la declaración (obligacion_periodos.completada_por es uuid). */ + completadaPor: string, + declaracionId: number, + /** Periodicidad de la declaración. Si no se provee, se asume 'mensual'. */ + periodicidad: string = 'mensual', +): Promise { + // Get active obligations for this contribuyente (incluye frecuencia para filtrar) + const { rows: obligaciones } = await pool.query<{ id: string; nombre: string; frecuencia: string | null }>( + `SELECT id, nombre, frecuencia FROM obligaciones_contribuyente WHERE contribuyente_id = $1 AND activa = true`, + [contribuyenteId], + ); + + let count = 0; + + for (const impuesto of impuestos) { + const rules = IMPUESTO_A_OBLIGACION_KEYWORDS[impuesto]; + if (!rules || rules.include.length === 0) continue; + + for (const ob of obligaciones) { + const nombreLower = ob.nombre.toLowerCase(); + const matches = rules.include.some(kw => nombreLower.includes(kw)) + && !rules.exclude.some(kw => nombreLower.includes(kw)); + if (!matches) continue; + + // Filtro por periodicidad/frecuencia: una declaración mensual no debe + // cerrar obligaciones anuales del mismo impuesto (ej. ISR mensual no + // cubre "Declaración anual de ISR"). Si la obligación tiene frecuencia + // explícita y no coincide con la periodicidad de la declaración, skip. + // `eventual` obligaciones no se tocan automáticamente. + const obFrec = (ob.frecuencia || '').toLowerCase(); + if (obFrec === 'eventual') continue; + if (obFrec && obFrec !== periodicidad.toLowerCase()) continue; + + // Mark obligation as completed for this period, with FK a la declaración + await pool.query(` + INSERT INTO obligacion_periodos (obligacion_id, periodo, completada, completada_at, completada_por, notas, declaracion_id) + VALUES ($1, $2, true, now(), $3, $4, $5) + ON CONFLICT (obligacion_id, periodo) + DO UPDATE SET completada = true, completada_at = now(), completada_por = $3, declaracion_id = $5 + `, [ob.id, periodo, completadaPor, `Declaración ${impuesto} subida`, declaracionId]); + + // Resolve the ob-* alert for this obligation+period + await pool.query( + `UPDATE alertas SET resuelta = true WHERE tipo = $1 AND resuelta = false`, + [`ob-${ob.id}-${periodo}`], + ); + + count++; + } + } + + return count; +} + +/** + * Declaraciones provisionales: PDF subido por el contador con la declaración + * presentada al SAT + opcionalmente comprobante de pago. Al subir, se marcan + * como resueltas las alertas correspondientes en la tabla `alertas` del tenant. + * + * El método legacy "marcar como realizado" desde /alertas sigue funcionando + * para usuarios que no quieran subir el documento. Esta automatización es + * adicional, no reemplaza. + */ + +export type Impuesto = 'IVA' | 'ISR' | 'IEPS' | 'SUELDOS' | 'DIOT' | 'OTRO'; + +export type Periodicidad = 'mensual' | 'bimestral' | 'trimestral' | 'semestral' | 'anual'; + +export interface DeclaracionRow { + id: number; + año: number; + mes: number; + tipo: 'normal' | 'complementaria'; + periodicidad: Periodicidad; + impuestos: string[]; + montoPago: number | null; + pdfFilename: string | null; + ligaPagoFilename: string | null; + pdfPagoFilename: string | null; + pagadoAt: string | null; + creadoPor: string | null; + notas: string | null; + createdAt: string; + updatedAt: string; + tieneLigaPago: boolean; + tienePagoPdf: boolean; +} + +// Mapeo Impuesto → prefijo de tipo de alerta (debe coincidir con +// EVENTO_A_ALERTA en alertas-manuales.service.ts). +const IMPUESTO_A_PREFIJO_DECL: Record = { + IVA: ['decl-iva'], + ISR: ['decl-isr'], + IEPS: ['decl-ieps'], + SUELDOS: ['decl-sueldos'], + DIOT: ['diot'], + OTRO: [], +}; +const IMPUESTO_A_PREFIJO_PAGO: Record = { + IVA: ['pago-iva'], + ISR: ['pago-isr'], + IEPS: ['pago-ieps'], + SUELDOS: [], // sueldos solo es declaración informativa, no tiene pago provisional + DIOT: [], + OTRO: [], +}; + +/** + * Marca como resueltas las alertas cuyo `tipo` empieza con cualquiera de los + * prefijos dados Y cuyo `fecha_vencimiento` cae en el mes/año dados. + * Idempotente: re-llamar no crea efectos secundarios extra. + */ +async function resolverAlertasPorPeriodo( + pool: Pool, + prefijos: string[], + año: number, + mes: number, +): Promise { + if (prefijos.length === 0) return 0; + // El tipo es `prefijo-YYYY-MM-DD`. Buscar por LIKE prefijo-año-mes-% + const mesStr = String(mes).padStart(2, '0'); + const conditions = prefijos.map((_, i) => `tipo LIKE $${i + 1}`).join(' OR '); + const params = prefijos.map(p => `${p}-${año}-${mesStr}-%`); + const { rowCount } = await pool.query( + `UPDATE alertas SET resuelta = true + WHERE (${conditions}) AND resuelta = false`, + params, + ); + return rowCount ?? 0; +} + +function rowToDeclaracion(r: any): DeclaracionRow { + return { + id: r.id, + año: r.año, + mes: r.mes, + tipo: r.tipo, + periodicidad: r.periodicidad || 'mensual', + impuestos: r.impuestos || [], + montoPago: r.monto_pago != null ? Number(r.monto_pago) : null, + pdfFilename: r.pdf_filename, + ligaPagoFilename: r.pdf_liga_pago_filename, + pdfPagoFilename: r.pdf_pago_filename, + pagadoAt: r.pagado_at?.toISOString() ?? null, + creadoPor: r.creado_por, + notas: r.notas, + createdAt: r.created_at.toISOString(), + updatedAt: r.updated_at.toISOString(), + tieneLigaPago: !!r.pdf_liga_pago_filename, + tienePagoPdf: !!r.pdf_pago_filename, + }; +} + +export async function listDeclaraciones( + pool: Pool, + fechaDesde?: string, + fechaHasta?: string, + contribuyenteId?: string | null, +): Promise { + const conditions: string[] = []; + const params: unknown[] = []; + + if (fechaDesde) { + params.push(fechaDesde); + conditions.push(`created_at >= $${params.length}::date`); + } + if (fechaHasta) { + params.push(fechaHasta); + conditions.push(`created_at < ($${params.length}::date + interval '1 day')`); + } + if (contribuyenteId) { + // Sanitize UUID (hex + hyphens only) + const safe = contribuyenteId.replace(/[^a-f0-9-]/gi, ''); + if (safe) { + params.push(safe); + conditions.push(`contribuyente_id = $${params.length}`); + } + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + + const { rows } = await pool.query( + `SELECT id, año, mes, tipo, periodicidad, impuestos, monto_pago, pdf_filename, + pdf_liga_pago_filename, pdf_pago_filename, pagado_at, creado_por, notas, + created_at, updated_at + FROM declaraciones_provisionales + ${where} + ORDER BY created_at DESC, año DESC, mes DESC`, + params, + ); + return rows.map(rowToDeclaracion); +} + +export async function createDeclaracion( + pool: Pool, + data: { + año: number; + mes: number; + tipo: 'normal' | 'complementaria'; + periodicidad?: Periodicidad; + impuestos: string[]; + montoPago?: number | null; + pdfBase64: string; // PDF de la declaración (base64) + pdfFilename: string; + ligaPagoBase64?: string; // PDF de la liga de pago (opcional, base64) + ligaPagoFilename?: string; + notas?: string; + /** Email del usuario (para declaraciones_provisionales.creado_por VARCHAR). */ + creadoPor: string; + /** UUID del usuario (para obligacion_periodos.completada_por UUID). Opcional. */ + creadoPorUserId?: string; + contribuyenteId?: string; + }, +): Promise<{ declaracion: DeclaracionRow; alertasResueltas: number }> { + const buf = Buffer.from(data.pdfBase64, 'base64'); + const ligaBuf = data.ligaPagoBase64 ? Buffer.from(data.ligaPagoBase64, 'base64') : null; + const periodicidad = data.periodicidad || 'mensual'; + const montoPago = data.montoPago ?? null; + // If monto_pago is exactly 0, auto-mark as paid (no payment receipt needed) + const pagadoAt = montoPago === 0 ? new Date() : null; + + try { + const { rows } = await pool.query( + `INSERT INTO declaraciones_provisionales + (año, mes, tipo, periodicidad, impuestos, monto_pago, pdf_declaracion, pdf_filename, + pdf_liga_pago, pdf_liga_pago_filename, notas, creado_por, pagado_at, contribuyente_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + RETURNING id, año, mes, tipo, periodicidad, impuestos, monto_pago, pdf_filename, + pdf_liga_pago_filename, pdf_pago_filename, pagado_at, creado_por, notas, + created_at, updated_at`, + [data.año, data.mes, data.tipo, periodicidad, data.impuestos, montoPago, + buf, data.pdfFilename, ligaBuf, data.ligaPagoFilename ?? null, + data.notas ?? null, data.creadoPor, pagadoAt, data.contribuyenteId ?? null], + ); + + const declaracion = rowToDeclaracion(rows[0]); + + // Auto-resolver alertas. Reglas: + // - tipo='normal': resuelve alertas de declaración (decl-*) del mes. + // El pago se resuelve por separado al subir comprobante. + // - tipo='complementaria': sustituye a la normal en términos de + // obligación de pago — al subirla se resuelven AMBAS (decl-* y + // pago-*) porque el cliente pagará usando la complementaria, + // no la normal. La alerta de declaración ya estaría resuelta + // si la normal se subió antes; el resolver es idempotente. + const prefijosDecl = data.impuestos.flatMap(i => IMPUESTO_A_PREFIJO_DECL[i] || []); + let alertasResueltas = await resolverAlertasPorPeriodo(pool, prefijosDecl, data.año, data.mes); + if (data.tipo === 'complementaria' || montoPago === 0) { + // complementaria: sustituye normal para pago → resolver ambas + // monto 0: nada que pagar → resolver alertas de pago también + const prefijosPago = data.impuestos.flatMap(i => IMPUESTO_A_PREFIJO_PAGO[i] || []); + alertasResueltas += await resolverAlertasPorPeriodo(pool, prefijosPago, data.año, data.mes); + } + + // Auto-complete obligaciones del contribuyente SOLO si la declaración + // también cubre el pago (complementaria sustituye a la normal para el + // pago; monto=0 significa "nada que pagar"). Una declaración normal con + // monto>0 solo presenta el acuse — la obligación de pago sigue abierta + // y se marca completada hasta que se suba el comprobante via + // `uploadComprobantePago`. Esto mantiene las alertas `pago-*` y `ob-*` + // visibles hasta que realmente se cierre el ciclo. + const cubrePago = data.tipo === 'complementaria' || montoPago === 0; + if (data.contribuyenteId && cubrePago) { + if (!data.creadoPorUserId) { + console.warn('[createDeclaracion] Sin creadoPorUserId — no se auto-completan obligaciones del contribuyente'); + } else { + const periodo = `${data.año}-${String(data.mes).padStart(2, '0')}`; + alertasResueltas += await completarObligacionesPorDeclaracion( + pool, data.contribuyenteId, data.impuestos, periodo, data.creadoPorUserId, declaracion.id, periodicidad, + ); + } + } + + return { declaracion, alertasResueltas }; + } catch (err: any) { + if (err?.code === '23505') { + throw new Error(`Ya existe una declaración tipo "normal" para ${data.mes}/${data.año}. Solo se permite una normal por mes; agrega una complementaria si necesitas corregirla.`); + } + throw err; + } +} + +export async function uploadComprobantePago( + pool: Pool, + id: number, + data: { + pdfBase64: string; + pdfFilename: string; + /** UUID del usuario que sube el comprobante (para obligacion_periodos.completada_por). */ + uploadedByUserId?: string; + }, +): Promise<{ declaracion: DeclaracionRow; alertasResueltas: number }> { + const buf = Buffer.from(data.pdfBase64, 'base64'); + + const { rows } = await pool.query( + `UPDATE declaraciones_provisionales + SET pdf_pago = $1, pdf_pago_filename = $2, pagado_at = NOW(), updated_at = NOW() + WHERE id = $3 + RETURNING id, año, mes, tipo, periodicidad, impuestos, pdf_filename, pdf_liga_pago_filename, + pdf_pago_filename, pagado_at, creado_por, notas, created_at, updated_at, + contribuyente_id`, + [buf, data.pdfFilename, id], + ); + + if (rows.length === 0) throw new Error('Declaración no encontrada'); + const row = rows[0]; + const declaracion = rowToDeclaracion(row); + + // Auto-resolver alertas de pago para los impuestos del periodo + const prefijosPago = declaracion.impuestos.flatMap(i => IMPUESTO_A_PREFIJO_PAGO[i] || []); + let alertasResueltas = await resolverAlertasPorPeriodo(pool, prefijosPago, declaracion.año, declaracion.mes); + + // Al subirse el comprobante de pago, la obligación ahora SÍ está completada + // (declaración + pago). Marcar `obligacion_periodos.completada=true` y + // resolver los `ob-*` alerts. Requires contribuyenteId (guardado en la + // declaración) y userId (del caller). + if (row.contribuyente_id && data.uploadedByUserId) { + const periodo = `${declaracion.año}-${String(declaracion.mes).padStart(2, '0')}`; + const periodicidad = row.periodicidad || 'mensual'; + alertasResueltas += await completarObligacionesPorDeclaracion( + pool, row.contribuyente_id, declaracion.impuestos, periodo, data.uploadedByUserId, declaracion.id, periodicidad, + ); + } + + return { declaracion, alertasResueltas }; +} + +export async function deleteDeclaracion(pool: Pool, id: number): Promise { + const { rowCount } = await pool.query( + `DELETE FROM declaraciones_provisionales WHERE id = $1`, + [id], + ); + if (rowCount === 0) throw new Error('Declaración no encontrada'); +} + +/** + * Cleanup: borra declaraciones con created_at < hoy - 5 años. Cumple con + * el plazo de retención del Art. 30 del CFF (contabilidad por 5 años). + * Llamado por cron diario. Idempotente: si no hay viejas, no-op. + * + * Se ejecuta por-tenant (caller pasa el pool). Returns { deleted } para log. + */ +export async function purgeDeclaracionesAntiguas(pool: Pool): Promise<{ deleted: number }> { + const { rowCount } = await pool.query( + `DELETE FROM declaraciones_provisionales + WHERE created_at < NOW() - INTERVAL '5 years'`, + ); + return { deleted: rowCount ?? 0 }; +} + +export async function getDeclaracionPdf( + pool: Pool, + id: number, + variant: 'declaracion' | 'liga' | 'pago', +): Promise<{ buffer: Buffer; filename: string } | null> { + const col = + variant === 'declaracion' ? 'pdf_declaracion' : + variant === 'liga' ? 'pdf_liga_pago' : 'pdf_pago'; + const colName = + variant === 'declaracion' ? 'pdf_filename' : + variant === 'liga' ? 'pdf_liga_pago_filename' : 'pdf_pago_filename'; + const { rows } = await pool.query( + `SELECT ${col} as data, ${colName} as filename FROM declaraciones_provisionales WHERE id = $1`, + [id], + ); + if (rows.length === 0 || !rows[0].data) return null; + return { buffer: Buffer.from(rows[0].data), filename: rows[0].filename || `declaracion-${id}.pdf` }; +} diff --git a/apps/api/src/services/despacho-stats.service.ts b/apps/api/src/services/despacho-stats.service.ts new file mode 100644 index 0000000..8fbd5c7 --- /dev/null +++ b/apps/api/src/services/despacho-stats.service.ts @@ -0,0 +1,487 @@ +import type { Pool } from 'pg'; +import { prisma } from '../config/database.js'; + +export interface ContribuyentesStats { + totalContribuyentes: number; + ultimaExtraccion: Date | null; + /** % global del despacho de obligaciones+tareas del periodo seleccionado completadas vs total. */ + progresoDelMes: number; + /** Declaraciones cuyo `created_at` cae en el periodo seleccionado. */ + declaracionesPresentadas: number; + /** Subset del anterior con `pdf_pago` no nulo. */ + declaracionesPagadas: number; + /** Obligaciones de declaración pendientes de periodos anteriores al seleccionado. */ + declaracionesAtrasadas: number; + /** Tareas pendientes con fecha_limite anterior al inicio del periodo seleccionado. */ + tareasAtrasadas: number; +} + +/** + * Métricas para la pestaña "Contribuyentes" del módulo Despacho (owner-only). + * + * Periodo: si se pasa `año`/`mes`, las métricas mensuales se calculan para + * ese periodo. Default = mes en curso. + * + * - totalContribuyentes / ultimaExtraccion: independientes del periodo. + * - progresoDelMes: % global obligaciones+tareas del periodo completadas. + * - declaracionesPresentadas/pagadas: declaraciones con `created_at` en el + * periodo seleccionado (presentación, no devengo). + * - declaracionesAtrasadas: obligaciones-periodo NO completadas con periodo + * anterior al seleccionado, donde la obligación sea de tipo declaración + * (categoría contiene 'mensual'/'anual'/'declaración' — heurística laxa + * ya que el catálogo no tiene un flag explícito). + * - tareasAtrasadas: tareas-periodo NO completadas con fecha_limite anterior + * al primer día del periodo seleccionado. + */ +export async function getContribuyentesStats( + pool: Pool, + tenantId: string, + año?: number, + mes?: number, +): Promise { + const { rows: [{ count }] } = await pool.query<{ count: number }>( + `SELECT COUNT(*)::int AS count + FROM contribuyentes c + JOIN entidades_gestionadas e ON e.id = c.entidad_id + WHERE e.active = true`, + ); + + const last = await prisma.satSyncJob.findFirst({ + where: { tenantId, status: 'completed' }, + orderBy: { completedAt: 'desc' }, + select: { completedAt: true }, + }); + + // Periodo: usa el filtrado o cae al mes en curso. + const now = new Date(); + const _año = año ?? now.getFullYear(); + const _mes = mes ?? now.getMonth() + 1; + const periodoMes = `${_año}-${String(_mes).padStart(2, '0')}`; + const inicioMes = `${_año}-${String(_mes).padStart(2, '0')}-01`; + const finMes = new Date(_año, _mes, 0).toISOString().split('T')[0]; + + const { rows: [progresoRow] } = await pool.query<{ total: number; completadas: number }>( + `SELECT + (SELECT COUNT(*)::int FROM obligacion_periodos op + JOIN obligaciones_contribuyente oc ON oc.id = op.obligacion_id + WHERE oc.activa = true AND op.periodo = $1) + + + (SELECT COUNT(*)::int FROM tarea_periodos tp + JOIN tareas_catalogo tc ON tc.id = tp.tarea_id + WHERE tc.active = true AND tp.fecha_limite BETWEEN $2::date AND $3::date) + AS total, + (SELECT COUNT(*)::int FROM obligacion_periodos op + JOIN obligaciones_contribuyente oc ON oc.id = op.obligacion_id + WHERE oc.activa = true AND op.periodo = $1 AND op.completada = true) + + + (SELECT COUNT(*)::int FROM tarea_periodos tp + JOIN tareas_catalogo tc ON tc.id = tp.tarea_id + WHERE tc.active = true AND tp.fecha_limite BETWEEN $2::date AND $3::date AND tp.completada = true) + AS completadas`, + [periodoMes, inicioMes, finMes], + ); + const progresoDelMes = progresoRow.total > 0 + ? Math.round((progresoRow.completadas / progresoRow.total) * 100) + : 0; + + const { rows: [decRow] } = await pool.query<{ presentadas: number; pagadas: number }>( + `SELECT + COUNT(*)::int AS presentadas, + COUNT(*) FILTER (WHERE pdf_pago IS NOT NULL)::int AS pagadas + FROM declaraciones_provisionales + WHERE created_at >= $1::date AND created_at < ($2::date + interval '1 day')`, + [inicioMes, finMes], + ); + + // Atrasadas de periodos anteriores al seleccionado. + // Para declaraciones (obligaciones) usamos `op.periodo < periodoMes`. + // Heurística "es declaración": categoría contiene 'mensual', 'anual', + // 'declaración' o el nombre incluye 'declaración' (case insensitive). + const { rows: [atrRow] } = await pool.query<{ decl_atr: number; tar_atr: number }>( + `SELECT + (SELECT COUNT(*)::int FROM obligacion_periodos op + JOIN obligaciones_contribuyente oc ON oc.id = op.obligacion_id + WHERE oc.activa = true + AND op.completada = false + AND op.periodo < $1 + AND ( + LOWER(COALESCE(oc.categoria, '')) ~ 'mensual|anual|declarac' + OR LOWER(oc.nombre) LIKE '%declarac%' + ) + ) AS decl_atr, + (SELECT COUNT(*)::int FROM tarea_periodos tp + JOIN tareas_catalogo tc ON tc.id = tp.tarea_id + WHERE tc.active = true + AND tp.completada = false + AND tp.fecha_limite < $2::date + ) AS tar_atr`, + [periodoMes, inicioMes], + ); + + return { + totalContribuyentes: count, + ultimaExtraccion: last?.completedAt ?? null, + progresoDelMes, + declaracionesPresentadas: decRow.presentadas, + declaracionesPagadas: decRow.pagadas, + declaracionesAtrasadas: atrRow.decl_atr, + tareasAtrasadas: atrRow.tar_atr, + }; +} + +export interface ContribuyenteAsignado { + contribuyenteId: string; + rfc: string; + nombre: string; + carteraNombre: string | null; + obligacionesPendientes: number; + obligacionesAtrasadas: number; + obligacionesCompletadas: number; + tareasPendientes: number; + tareasAtrasadas: number; + tareasCompletadas: number; +} + +/** + * Resuelve los contribuyentes asignados al usuario actual según su rol y la + * estructura de carteras: + * + * - **owner / cfo**: TODOS los contribuyentes del despacho. + * - **supervisor**: contribuyentes que están en una cartera donde + * `c.supervisor_user_id = userId` o en una subcartera de tales carteras. + * - **auxiliar**: contribuyentes en carteras donde `c.auxiliar_user_id = userId`. + * - **otros (contador, cliente, etc.)**: vacío. + * + * Las métricas se calculan usando el periodo `año`/`mes` como pivote: lo + * "atrasado" es lo NO completado de periodos anteriores al filtrado. + */ +export async function getMisAsignados( + pool: Pool, + userId: string, + userRole: string, + año?: number, + mes?: number, +): Promise { + let baseFilter: string; + const params: unknown[] = []; + if (userRole === 'owner' || userRole === 'cfo') { + baseFilter = `e.active = true`; + } else if (userRole === 'supervisor') { + params.push(userId); + baseFilter = `e.active = true AND ce.cartera_id IN ( + SELECT id FROM carteras WHERE supervisor_user_id = $1 + UNION + SELECT id FROM carteras WHERE parent_id IN ( + SELECT id FROM carteras WHERE supervisor_user_id = $1 + ) + )`; + } else if (userRole === 'auxiliar') { + params.push(userId); + baseFilter = `e.active = true AND ce.cartera_id IN ( + SELECT id FROM carteras WHERE auxiliar_user_id = $1 + )`; + } else { + return []; + } + + const { rows } = await pool.query( + `SELECT DISTINCT c.entidad_id AS contribuyente_id, c.rfc, e.nombre, + (SELECT cart.nombre FROM carteras cart + JOIN cartera_entidades cee ON cee.cartera_id = cart.id + WHERE cee.entidad_id = c.entidad_id LIMIT 1) AS cartera_nombre + FROM contribuyentes c + JOIN entidades_gestionadas e ON e.id = c.entidad_id + LEFT JOIN cartera_entidades ce ON ce.entidad_id = c.entidad_id + WHERE ${baseFilter} + ORDER BY e.nombre`, + params, + ); + + // Para cada contribuyente, contar pendientes/atrasados/completados de obligaciones+tareas. + // Hacemos una sola query agregada por contribuyente con CTEs. + const ids = rows.map(r => r.contribuyente_id); + if (ids.length === 0) return []; + + // Periodo pivote: usa el filtrado o cae al mes en curso. + const now = new Date(); + const _año = año ?? now.getFullYear(); + const _mes = mes ?? now.getMonth() + 1; + const periodoMes = `${_año}-${String(_mes).padStart(2, '0')}`; + const inicioMes = `${_año}-${String(_mes).padStart(2, '0')}-01`; + const finMes = new Date(_año, _mes, 0).toISOString().split('T')[0]; + + const { rows: stats } = await pool.query( + `WITH obl AS ( + SELECT oc.contribuyente_id, + COUNT(*) FILTER (WHERE op.completada = false AND op.periodo = $1)::int AS pendientes, + COUNT(*) FILTER (WHERE op.completada = false AND op.periodo < $1)::int AS atrasadas, + COUNT(*) FILTER (WHERE op.completada = true AND op.periodo = $1)::int AS completadas + FROM obligaciones_contribuyente oc + LEFT JOIN obligacion_periodos op ON op.obligacion_id = oc.id + WHERE oc.contribuyente_id = ANY($4::uuid[]) AND oc.activa = true + GROUP BY oc.contribuyente_id + ), + tar AS ( + SELECT tc.contribuyente_id, + COUNT(*) FILTER (WHERE tp.completada = false AND tp.fecha_limite BETWEEN $2::date AND $3::date)::int AS pendientes, + COUNT(*) FILTER (WHERE tp.completada = false AND tp.fecha_limite < $2::date)::int AS atrasadas, + COUNT(*) FILTER (WHERE tp.completada = true AND tp.fecha_limite BETWEEN $2::date AND $3::date)::int AS completadas + FROM tareas_catalogo tc + LEFT JOIN tarea_periodos tp ON tp.tarea_id = tc.id + WHERE tc.contribuyente_id = ANY($4::uuid[]) AND tc.active = true + GROUP BY tc.contribuyente_id + ) + SELECT + obl.contribuyente_id AS obl_id, obl.pendientes AS obl_pen, obl.atrasadas AS obl_atr, obl.completadas AS obl_com, + tar.contribuyente_id AS tar_id, tar.pendientes AS tar_pen, tar.atrasadas AS tar_atr, tar.completadas AS tar_com + FROM obl + FULL OUTER JOIN tar ON tar.contribuyente_id = obl.contribuyente_id`, + [periodoMes, inicioMes, finMes, ids], + ); + + const statsMap = new Map(); + for (const s of stats) { + const id = s.obl_id || s.tar_id; + if (!id) continue; + statsMap.set(id, { + obl: { pen: s.obl_pen ?? 0, atr: s.obl_atr ?? 0, com: s.obl_com ?? 0 }, + tar: { pen: s.tar_pen ?? 0, atr: s.tar_atr ?? 0, com: s.tar_com ?? 0 }, + }); + } + + const result = rows.map(r => { + const s = statsMap.get(r.contribuyente_id); + return { + contribuyenteId: r.contribuyente_id, + rfc: r.rfc, + nombre: r.nombre, + carteraNombre: r.cartera_nombre, + obligacionesPendientes: s?.obl.pen ?? 0, + obligacionesAtrasadas: s?.obl.atr ?? 0, + obligacionesCompletadas: s?.obl.com ?? 0, + tareasPendientes: s?.tar.pen ?? 0, + tareasAtrasadas: s?.tar.atr ?? 0, + tareasCompletadas: s?.tar.com ?? 0, + }; + }); + // Ordena por atrasos descendente — los más rezagados arriba. + result.sort((a, b) => { + const atrasoA = a.obligacionesAtrasadas + a.tareasAtrasadas; + const atrasoB = b.obligacionesAtrasadas + b.tareasAtrasadas; + if (atrasoA !== atrasoB) return atrasoB - atrasoA; + return a.nombre.localeCompare(b.nombre); + }); + return result; +} + +export interface MiembroEquipo { + userId: string; + nombre: string; + email: string; + rol: 'supervisor' | 'auxiliar'; + contribuyentes: number; + obligacionesAtrasadas: number; + tareasAtrasadas: number; + totalPendientes: number; + /** completadas + pendientes del periodo filtrado (sin atrasos). */ + totalPeriodo: number; + completadasPeriodo: number; + avancePct: number | null; +} + +export interface SupervisorConAuxiliares extends MiembroEquipo { + auxiliares: MiembroEquipo[]; +} + +export interface EquipoStatsResponse { + supervisores: SupervisorConAuxiliares[]; + /** Auxiliares activos sin entrada en `auxiliar_supervisores`. Solo owner los ve. */ + huerfanos: MiembroEquipo[]; +} + +/** + * Resumen de avance por miembro del equipo, en estructura jerárquica + * supervisor → auxiliares + lista de auxiliares "huérfanos" (sin supervisor). + * + * - **owner / cfo**: ve TODOS los supervisores + auxiliares sin supervisor. + * - **supervisor**: ve solo a sí mismo con sus auxiliares (sin huérfanos). + * + * Métricas calculadas por periodo (`año`/`mes` o mes en curso). + */ +export async function getEquipoStats( + pool: Pool, + userId: string, + userRole: string, + tenantId: string, + año?: number, + mes?: number, +): Promise { + // 1. Construir mapa supervisor → auxiliares. + // + // La relación se infiere desde `carteras`: + // - Si una cartera tiene `auxiliar_user_id` Y `supervisor_user_id` no nulos, + // ese par directo cuenta. + // - Si una subcartera (parent_id no nulo) tiene `auxiliar_user_id`, su + // supervisor es el `supervisor_user_id` del parent. + // + // Fallback: tabla legacy `auxiliar_supervisores`. La unión con DISTINCT + // evita duplicados si un auxiliar aparece en ambas fuentes. + const { rows: paresRows } = await pool.query<{ supervisor_user_id: string; auxiliar_user_id: string }>( + `SELECT DISTINCT supervisor_user_id, auxiliar_user_id FROM ( + SELECT c.supervisor_user_id, c.auxiliar_user_id + FROM carteras c + WHERE c.auxiliar_user_id IS NOT NULL + AND c.supervisor_user_id IS NOT NULL + UNION + SELECT p.supervisor_user_id, sub.auxiliar_user_id + FROM carteras sub + JOIN carteras p ON p.id = sub.parent_id + WHERE sub.auxiliar_user_id IS NOT NULL + AND p.supervisor_user_id IS NOT NULL + UNION + SELECT supervisor_user_id, auxiliar_user_id FROM auxiliar_supervisores + ) t WHERE supervisor_user_id IS NOT NULL AND auxiliar_user_id IS NOT NULL`, + ); + + let pares = paresRows.map(r => ({ supervisorId: r.supervisor_user_id, auxiliarId: r.auxiliar_user_id })); + if (userRole === 'supervisor') { + pares = pares.filter(p => p.supervisorId === userId); + } else if (userRole !== 'owner' && userRole !== 'cfo') { + return { supervisores: [], huerfanos: [] }; + } + + // 2. Agrupar auxiliares por supervisor. + const supervisorIds = [...new Set(pares.map(p => p.supervisorId))]; + const auxiliaresPorSup = new Map(); + for (const p of pares) { + if (!auxiliaresPorSup.has(p.supervisorId)) auxiliaresPorSup.set(p.supervisorId, []); + auxiliaresPorSup.get(p.supervisorId)!.push(p.auxiliarId); + } + + // 3. Para cada user (supervisor o auxiliar), calcular su miembro. + const result: SupervisorConAuxiliares[] = []; + for (const supId of supervisorIds) { + const supMiembro = await calcularMiembro(pool, supId, 'supervisor', año, mes); + if (!supMiembro) continue; + const auxiliares: MiembroEquipo[] = []; + for (const auxId of auxiliaresPorSup.get(supId) ?? []) { + const auxMiembro = await calcularMiembro(pool, auxId, 'auxiliar', año, mes); + if (auxMiembro) auxiliares.push(auxMiembro); + } + auxiliares.sort((a, b) => b.totalPendientes - a.totalPendientes); + result.push({ ...supMiembro, auxiliares }); + } + + result.sort((a, b) => b.totalPendientes - a.totalPendientes); + + // 4. Auxiliares "huérfanos" (sin entrada en auxiliar_supervisores). Solo + // el owner los ve para que pueda asignarles supervisor. + let huerfanos: MiembroEquipo[] = []; + if (userRole === 'owner' || userRole === 'cfo') { + const auxiliaresMapeados = new Set(pares.map(p => p.auxiliarId)); + const auxiliaresActivos = await prisma.tenantMembership.findMany({ + where: { tenantId, active: true, rol: { nombre: 'auxiliar' } }, + include: { user: { select: { id: true, active: true } } }, + }); + const huerfanosIds = auxiliaresActivos + .filter(m => m.user.active && !auxiliaresMapeados.has(m.userId)) + .map(m => m.userId); + for (const auxId of huerfanosIds) { + const aux = await calcularMiembro(pool, auxId, 'auxiliar', año, mes); + if (aux) huerfanos.push(aux); + } + huerfanos.sort((a, b) => b.totalPendientes - a.totalPendientes); + } + + return { supervisores: result, huerfanos }; +} + +async function calcularMiembro( + pool: Pool, + uId: string, + rol: 'supervisor' | 'auxiliar', + año?: number, + mes?: number, +): Promise { + const userInfo = await prisma.user.findUnique({ + where: { id: uId }, + select: { nombre: true, email: true, active: true }, + }); + if (!userInfo || !userInfo.active) return null; + + const filter = rol === 'supervisor' + ? `ce.cartera_id IN (SELECT id FROM carteras WHERE supervisor_user_id = $1 + UNION SELECT id FROM carteras WHERE parent_id IN (SELECT id FROM carteras WHERE supervisor_user_id = $1))` + : `ce.cartera_id IN (SELECT id FROM carteras WHERE auxiliar_user_id = $1)`; + + const { rows: contribRows } = await pool.query<{ entidad_id: string }>( + `SELECT DISTINCT ce.entidad_id FROM cartera_entidades ce WHERE ${filter}`, + [uId], + ); + const contribIds = contribRows.map(r => r.entidad_id); + + // Periodo pivote + const now = new Date(); + const _año = año ?? now.getFullYear(); + const _mes = mes ?? now.getMonth() + 1; + const periodoMes = `${_año}-${String(_mes).padStart(2, '0')}`; + const inicioMes = `${_año}-${String(_mes).padStart(2, '0')}-01`; + const finMes = new Date(_año, _mes, 0).toISOString().split('T')[0]; + + let obl = 0, tar = 0, total = 0, completadas = 0; + if (contribIds.length > 0) { + const { rows: [agg] } = await pool.query<{ + obl_atr: number; tar_atr: number; + obl_pen: number; obl_com: number; + tar_pen: number; tar_com: number; + }>( + `SELECT + (SELECT COUNT(*)::int FROM obligacion_periodos op + JOIN obligaciones_contribuyente oc ON oc.id = op.obligacion_id + WHERE oc.contribuyente_id = ANY($1::uuid[]) + AND oc.activa = true AND op.completada = false AND op.periodo < $2) AS obl_atr, + (SELECT COUNT(*)::int FROM tarea_periodos tp + JOIN tareas_catalogo tc ON tc.id = tp.tarea_id + WHERE tc.contribuyente_id = ANY($1::uuid[]) + AND tc.active = true AND tp.completada = false AND tp.fecha_limite < $3::date) AS tar_atr, + (SELECT COUNT(*)::int FROM obligacion_periodos op + JOIN obligaciones_contribuyente oc ON oc.id = op.obligacion_id + WHERE oc.contribuyente_id = ANY($1::uuid[]) + AND oc.activa = true AND op.periodo = $2 AND op.completada = false) AS obl_pen, + (SELECT COUNT(*)::int FROM obligacion_periodos op + JOIN obligaciones_contribuyente oc ON oc.id = op.obligacion_id + WHERE oc.contribuyente_id = ANY($1::uuid[]) + AND oc.activa = true AND op.periodo = $2 AND op.completada = true) AS obl_com, + (SELECT COUNT(*)::int FROM tarea_periodos tp + JOIN tareas_catalogo tc ON tc.id = tp.tarea_id + WHERE tc.contribuyente_id = ANY($1::uuid[]) + AND tc.active = true AND tp.fecha_limite BETWEEN $3::date AND $4::date AND tp.completada = false) AS tar_pen, + (SELECT COUNT(*)::int FROM tarea_periodos tp + JOIN tareas_catalogo tc ON tc.id = tp.tarea_id + WHERE tc.contribuyente_id = ANY($1::uuid[]) + AND tc.active = true AND tp.fecha_limite BETWEEN $3::date AND $4::date AND tp.completada = true) AS tar_com`, + [contribIds, periodoMes, inicioMes, finMes], + ); + obl = agg.obl_atr; + tar = agg.tar_atr; + completadas = agg.obl_com + agg.tar_com; + total = completadas + agg.obl_pen + agg.tar_pen; + } + + return { + userId: uId, + nombre: userInfo.nombre, + email: userInfo.email, + rol, + contribuyentes: contribIds.length, + obligacionesAtrasadas: obl, + tareasAtrasadas: tar, + totalPendientes: obl + tar, + totalPeriodo: total, + completadasPeriodo: completadas, + avancePct: total > 0 ? Math.round((completadas / total) * 100) : null, + }; +} diff --git a/apps/api/src/services/despacho.service.ts b/apps/api/src/services/despacho.service.ts new file mode 100644 index 0000000..8a59add --- /dev/null +++ b/apps/api/src/services/despacho.service.ts @@ -0,0 +1,147 @@ +import { prisma, tenantDb } from '../config/database.js'; +import { hashPassword } from '../auth/passwords.js'; +import { generateAccessToken, generateRefreshToken } from '../auth/tokens.js'; +import type { DespachoSignupRequest } from '@horux/shared'; +import type { JWTPayload, Role } from '@horux/shared'; +import { emailService } from './email/email.service.js'; + +export async function signupDespacho(data: DespachoSignupRequest) { + const { despacho, owner } = data; + + const existingUser = await prisma.user.findUnique({ where: { email: owner.email } }); + if (existingUser) { + throw new Error('Ya existe un usuario con este email'); + } + + const passwordHash = await hashPassword(owner.password); + + const tenantSlug = `despacho_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`; + const databaseName = `horux_${tenantSlug}`; + + const result = await prisma.$transaction(async (tx) => { + const tenant = await tx.tenant.create({ + data: { + nombre: despacho.nombre, + rfc: tenantSlug.toUpperCase(), + plan: 'trial', + databaseName: databaseName, + verticalProfile: despacho.verticalProfile as any, + dbMode: (despacho.plan === 'business_control' ? 'BYO' : 'MANAGED') as any, + dbSchemaVersion: 0, + trialEndsAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + codigoPostal: despacho.codigoPostal, + }, + }); + + const user = await tx.user.create({ + data: { + email: owner.email.toLowerCase(), + passwordHash, + nombre: owner.nombre, + lastTenantId: tenant.id, + }, + }); + + const ownerRole = await tx.rol.findUnique({ where: { nombre: 'owner' } }); + if (!ownerRole) throw new Error('Rol owner no encontrado en BD'); + + await tx.tenantMembership.create({ + data: { + userId: user.id, + tenantId: tenant.id, + rolId: ownerRole.id, + isOwner: true, + }, + }); + + return { tenant, user }; + }); + + try { + await tenantDb.provisionDatabase(tenantSlug, databaseName); + } catch (err: any) { + await prisma.tenant.delete({ where: { id: result.tenant.id } }).catch(() => {}); + await prisma.user.delete({ where: { id: result.user.id } }).catch(() => {}); + throw new Error(`Error al crear base de datos del despacho: ${err.message}`); + } + + const payload: Omit = { + userId: result.user.id, + email: result.user.email, + role: 'owner' as Role, + tenantId: result.tenant.id, + tokenVersion: 0, + }; + + const accessToken = generateAccessToken(payload); + const refreshToken = generateRefreshToken(payload); + + await prisma.refreshToken.create({ + data: { + userId: result.user.id, + token: refreshToken, + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + }, + }); + + // Send welcome email (fire-and-forget) + emailService.sendDespachoWelcome(owner.email, { + nombre: result.user.nombre, + despachoNombre: result.tenant.nombre, + email: result.user.email, + }).catch(err => console.error('[Signup] Welcome email failed:', err)); + + // If paid plan, create MP checkout via subscriptionService.subscribe() + // que también crea la fila Subscription en BD (clave para que el webhook + // pueda aplicar la dualidad firstYear→renewal tras el primer cobro aprobado). + let paymentUrl: string | undefined; + if (data.despacho.plan && data.despacho.plan !== 'trial') { + try { + const subscriptionService = await import('./payment/subscription.service.js'); + const result2 = await subscriptionService.subscribe({ + tenantId: result.tenant.id, + plan: data.despacho.plan as any, + // mi_empresa(+) acepta monthly/annual; los demás solo annual + // — el subscribe valida y rechaza monthly cuando no aplica. + frequency: data.despacho.frequency || 'annual', + payerEmail: owner.email, + }); + paymentUrl = result2.paymentUrl; + } catch (err: any) { + // Rollback: delete tenant + user since payment couldn't be set up + await prisma.tenantMembership.deleteMany({ where: { tenantId: result.tenant.id } }).catch(() => {}); + await prisma.refreshToken.deleteMany({ where: { userId: result.user.id } }).catch(() => {}); + await prisma.tenant.delete({ where: { id: result.tenant.id } }).catch(() => {}); + await prisma.user.delete({ where: { id: result.user.id } }).catch(() => {}); + const msg = err?.message || ''; + if (msg.includes('MercadoPago no está configurado') || msg.includes('Unauthorized access')) { + throw new Error('No se pudo procesar el cobro. Verifica que el sistema de pagos esté configurado o selecciona el plan Trial.'); + } + throw new Error(msg || 'No se pudo procesar el cobro.'); + } + } + + return { + accessToken, + refreshToken, + paymentUrl, + user: { + id: result.user.id, + email: result.user.email, + nombre: result.user.nombre, + role: 'owner' as Role, + tenantId: result.tenant.id, + tenantName: result.tenant.nombre, + tenantRfc: result.tenant.rfc, + plan: result.tenant.plan, + tenants: [{ + id: result.tenant.id, + nombre: result.tenant.nombre, + rfc: result.tenant.rfc, + plan: result.tenant.plan, + role: 'owner' as Role, + isOwner: true, + }], + }, + }; +} diff --git a/apps/api/src/services/documentos-extras.service.ts b/apps/api/src/services/documentos-extras.service.ts new file mode 100644 index 0000000..332392a --- /dev/null +++ b/apps/api/src/services/documentos-extras.service.ts @@ -0,0 +1,129 @@ +import type { Pool } from 'pg'; + +export interface DocumentoExtra { + id: number; + contribuyenteId: string | null; + nombre: string; + descripcion: string | null; + categoria: string | null; + pdfFilename: string; + subidoPor: string | null; + createdAt: string; +} + +interface CreateExtraInput { + contribuyenteId?: string | null; + nombre: string; + descripcion?: string | null; + categoria?: string | null; + pdfBase64: string; + pdfFilename: string; + subidoPor: string; +} + +function sanitizeUuid(id: string): string { + return id.replace(/[^a-f0-9-]/gi, ''); +} + +export async function createExtra( + pool: Pool, + data: CreateExtraInput, +): Promise { + const pdfBuffer = Buffer.from(data.pdfBase64, 'base64'); + const contribuyenteId = data.contribuyenteId + ? sanitizeUuid(data.contribuyenteId) + : null; + const { rows } = await pool.query( + `INSERT INTO documentos_extras + (contribuyente_id, nombre, descripcion, categoria, pdf, pdf_filename, subido_por) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id, contribuyente_id AS "contribuyenteId", nombre, descripcion, + categoria, pdf_filename AS "pdfFilename", subido_por AS "subidoPor", + created_at AS "createdAt"`, + [ + contribuyenteId, + data.nombre, + data.descripcion ?? null, + data.categoria ?? null, + pdfBuffer, + data.pdfFilename, + data.subidoPor, + ], + ); + const r = rows[0]; + return { ...r, createdAt: r.createdAt?.toISOString?.() ?? r.createdAt }; +} + +export async function listExtras( + pool: Pool, + contribuyenteId?: string | null, + categoria?: string | null, +): Promise { + const params: any[] = []; + const where: string[] = []; + if (contribuyenteId) { + params.push(sanitizeUuid(contribuyenteId)); + where.push(`contribuyente_id = $${params.length}`); + } + if (categoria) { + params.push(categoria); + where.push(`categoria = $${params.length}`); + } + const whereClause = where.length ? `WHERE ${where.join(' AND ')}` : ''; + const { rows } = await pool.query( + `SELECT id, contribuyente_id AS "contribuyenteId", nombre, descripcion, + categoria, pdf_filename AS "pdfFilename", subido_por AS "subidoPor", + created_at AS "createdAt" + FROM documentos_extras + ${whereClause} + ORDER BY created_at DESC + LIMIT 500`, + params, + ); + return rows.map((r: any) => ({ + ...r, + createdAt: r.createdAt?.toISOString?.() ?? r.createdAt, + })); +} + +export async function getExtraPdf( + pool: Pool, + id: number, +): Promise<{ buffer: Buffer; filename: string; nombre: string } | null> { + const { rows } = await pool.query( + `SELECT pdf, pdf_filename AS "pdfFilename", nombre + FROM documentos_extras WHERE id = $1`, + [id], + ); + if (rows.length === 0) return null; + return { + buffer: rows[0].pdf, + filename: rows[0].pdfFilename, + nombre: rows[0].nombre, + }; +} + +export async function deleteExtra(pool: Pool, id: number): Promise { + const { rowCount } = await pool.query( + `DELETE FROM documentos_extras WHERE id = $1`, + [id], + ); + return (rowCount ?? 0) > 0; +} + +export async function listCategorias( + pool: Pool, + contribuyenteId?: string | null, +): Promise { + const params: any[] = []; + let where = `WHERE categoria IS NOT NULL AND categoria != ''`; + if (contribuyenteId) { + params.push(sanitizeUuid(contribuyenteId)); + where += ` AND contribuyente_id = $${params.length}`; + } + const { rows } = await pool.query( + `SELECT DISTINCT categoria FROM documentos_extras ${where} ORDER BY categoria`, + params, + ); + return rows.map((r: any) => r.categoria); +} diff --git a/apps/api/src/services/email/email.service.ts b/apps/api/src/services/email/email.service.ts new file mode 100644 index 0000000..6ceb8ed --- /dev/null +++ b/apps/api/src/services/email/email.service.ts @@ -0,0 +1,210 @@ +import { createEmailTransport } from '@horux/core'; +import { env } from '../../config/env.js'; + +const transport = createEmailTransport( + env.SMTP_USER && env.SMTP_PASS + ? { + host: env.SMTP_HOST, + port: parseInt(env.SMTP_PORT), + user: env.SMTP_USER, + pass: env.SMTP_PASS, + from: env.SMTP_FROM, + } + : null +); + +async function sendEmail(to: string, subject: string, html: string) { + await transport.send(to, subject, html); +} + +export const emailService = { + sendWelcome: async (to: string, data: { nombre: string; email: string; tempPassword: string }) => { + const { welcomeEmail } = await import('./templates/welcome.js'); + await sendEmail(to, 'Bienvenido a Horux360', welcomeEmail(data)); + }, + + sendPasswordReset: async (to: string, data: { nombre: string; resetUrl: string }) => { + const { passwordResetEmail } = await import('./templates/password-reset.js'); + await sendEmail(to, 'Recuperación de contraseña - Horux360', passwordResetEmail(data)); + }, + + sendFielNotification: async (data: { clienteNombre: string; clienteRfc: string }) => { + const { fielNotificationEmail } = await import('./templates/fiel-notification.js'); + await sendEmail(env.ADMIN_EMAIL, `[${data.clienteNombre}] subió su FIEL`, fielNotificationEmail(data)); + }, + + sendPaymentConfirmed: async (to: string, data: { nombre: string; amount: number; plan: string; date: string }) => { + const { paymentConfirmedEmail } = await import('./templates/payment-confirmed.js'); + await sendEmail(to, 'Confirmación de pago - Horux360', paymentConfirmedEmail(data)); + }, + + sendPaymentFailed: async (to: string, data: { nombre: string; amount: number; plan: string }) => { + const { paymentFailedEmail } = await import('./templates/payment-failed.js'); + await sendEmail(to, 'Problema con tu pago - Horux360', paymentFailedEmail(data)); + await sendEmail(env.ADMIN_EMAIL, `Pago fallido: ${data.nombre}`, paymentFailedEmail(data)); + }, + + sendSubscriptionExpiring: async (to: string, data: { nombre: string; plan: string; expiresAt: string }) => { + const { subscriptionExpiringEmail } = await import('./templates/subscription-expiring.js'); + await sendEmail(to, 'Tu suscripción vence en 5 días', subscriptionExpiringEmail(data)); + }, + + sendSubscriptionCancelled: async (to: string, data: { nombre: string; plan: string }) => { + const { subscriptionCancelledEmail } = await import('./templates/subscription-cancelled.js'); + await sendEmail(to, 'Suscripción cancelada - Horux360', subscriptionCancelledEmail(data)); + await sendEmail(env.ADMIN_EMAIL, `Suscripción cancelada: ${data.nombre}`, subscriptionCancelledEmail(data)); + }, + + sendNewClientAdmin: async (data: { + clienteNombre: string; + clienteRfc: string; + adminEmail: string; + adminNombre: string; + tempPassword: string; + databaseName: string; + plan: string; + }) => { + const { newClientAdminEmail } = await import('./templates/new-client-admin.js'); + await sendEmail(env.ADMIN_EMAIL, `Nuevo cliente: ${data.clienteNombre} (${data.clienteRfc})`, newClientAdminEmail(data)); + }, + + sendWeeklyUpdate: async (to: string, data: import('./templates/weekly-update.js').WeeklyUpdateData) => { + const { weeklyUpdateEmail } = await import('./templates/weekly-update.js'); + await sendEmail(to, `Actualización semanal — ${data.empresa}`, weeklyUpdateEmail(data)); + }, + + sendDespachoWelcome: async (to: string, data: { nombre: string; despachoNombre: string; email: string }) => { + const { despachoWelcomeEmail } = await import('./templates/despacho-welcome.js'); + await sendEmail(to, `Bienvenido a Horux Despachos — ${data.despachoNombre}`, despachoWelcomeEmail(data)); + }, + + sendTrialReminder: async (to: string, data: { nombre: string; despachoNombre: string; diasRestantes: number; wizardCompleto: boolean }) => { + const { trialReminderEmail } = await import('./templates/trial-reminder.js'); + const subject = data.diasRestantes <= 3 + ? `⚠️ Tu trial termina en ${data.diasRestantes} días — ${data.despachoNombre}` + : `Quedan ${data.diasRestantes} días de trial — ${data.despachoNombre}`; + await sendEmail(to, subject, trialReminderEmail(data)); + }, + + sendTrialExpired: async (to: string, data: { nombre: string; despachoNombre: string }) => { + const { trialExpiredEmail } = await import('./templates/trial-reminder.js'); + await sendEmail(to, `Prueba finalizada — ${data.despachoNombre}`, trialExpiredEmail(data)); + }, + + /** + * Notifica la subida de una declaración o documento extra al despacho. + * `recipients` debe venir deduplicado por el caller. El subject se + * genera a partir del kind y RFC del contribuyente. + */ + sendDocumentoSubido: async ( + recipients: string[], + data: import('./templates/documento-subido.js').DocumentoSubidoData, + ) => { + if (recipients.length === 0) return; + const { documentoSubidoEmail } = await import('./templates/documento-subido.js'); + const html = documentoSubidoEmail(data); + const subject = data.kind === 'declaracion' + ? `📄 Declaración subida — ${data.contribuyenteRfc}${data.declaracion ? ` (${data.declaracion.impuestos.join('/')} ${data.declaracion.periodo})` : ''}` + : `📎 Documento subido — ${data.contribuyenteRfc}${data.extra ? `: ${data.extra.nombre}` : ''}`; + // Envío secuencial; fire-and-forget a nivel del caller. Un error en un + // destinatario NO debe impedir enviar al siguiente. + for (const to of recipients) { + try { + await sendEmail(to, subject, html); + } catch (err: any) { + console.error(`[Email] Fallo enviando documento-subido a ${to}:`, err?.message || err); + } + } + }, + + /** + * Notifica al auxiliar de la cartera que un supervisor/owner marcó como + * completada una tarea con `solo_supervisor_completa=true`. + */ + sendTareaCompletada: async ( + to: string, + data: import('./templates/tarea-completada.js').TareaCompletadaData, + ) => { + const { tareaCompletadaEmail } = await import('./templates/tarea-completada.js'); + await sendEmail( + to, + `✓ ${data.tareaNombre} — ${data.contribuyenteRfc}`, + tareaCompletadaEmail(data), + ); + }, + + /** Aprobadores reciben aviso cuando se sube papelería que requiere aprobación. */ + sendPapeleriaAprobacionRequerida: async ( + to: string, + data: import('./templates/papeleria.js').PapeleriaAprobacionRequeridaData, + ) => { + const { papeleriaAprobacionRequeridaEmail } = await import('./templates/papeleria.js'); + await sendEmail( + to, + `📋 Papelería pendiente — ${data.contribuyenteRfc} (${data.periodo})`, + papeleriaAprobacionRequeridaEmail(data), + ); + }, + + /** Uploader recibe aviso cuando aprueban o rechazan su papelería. */ + sendPapeleriaDecision: async ( + to: string, + data: import('./templates/papeleria.js').PapeleriaDecisionData, + ) => { + const { papeleriaDecisionEmail } = await import('./templates/papeleria.js'); + const icon = data.estado === 'aprobado' ? '✅' : '❌'; + await sendEmail( + to, + `${icon} Documento ${data.estado} — ${data.contribuyenteRfc}`, + papeleriaDecisionEmail(data), + ); + }, + + /** + * Cron 8:30 AM — alertas fiscales nuevas activadas hoy. Envía un solo + * correo por destinatario con el batch completo. Caller debe deduplicar + * recipients antes. Una alerta solo se notifica una vez (tracking en + * `alertas_notificadas`). + */ + sendAlertasNuevas: async ( + recipients: string[], + data: import('./templates/alertas-nuevas.js').AlertasNuevasData, + ) => { + if (recipients.length === 0 || data.alertas.length === 0) return; + const { alertasNuevasEmail } = await import('./templates/alertas-nuevas.js'); + const html = alertasNuevasEmail(data); + const total = data.alertas.length; + const subject = `🚨 ${total} alerta${total === 1 ? '' : 's'} nueva${total === 1 ? '' : 's'} — ${data.contribuyenteRfc}`; + for (const to of recipients) { + try { + await sendEmail(to, subject, html); + } catch (err: any) { + console.error(`[Email] Fallo enviando alertas-nuevas a ${to}:`, err?.message || err); + } + } + }, + + /** + * Cron 8:30 AM — recordatorio próximo a vencer. Envía un correo por + * destinatario. Caller dedupea. Cada ventana (3d/1d/0d) se envía a lo más + * una vez por recordatorio (tracking en columnas `email_Xd_at`). + */ + sendRecordatorioProximo: async ( + recipients: string[], + data: import('./templates/recordatorio-proximo.js').RecordatorioProximoData, + ) => { + if (recipients.length === 0) return; + const { recordatorioProximoEmail } = await import('./templates/recordatorio-proximo.js'); + const html = recordatorioProximoEmail(data); + const prefix = data.ventana === '0d' ? '⏰' : data.ventana === '1d' ? '⚠️' : '🗓'; + const ventanaLabel = data.ventana === '0d' ? 'HOY' : data.ventana === '1d' ? 'mañana' : 'en 3 días'; + const subject = `${prefix} Recordatorio ${ventanaLabel}: ${data.titulo}`; + for (const to of recipients) { + try { + await sendEmail(to, subject, html); + } catch (err: any) { + console.error(`[Email] Fallo enviando recordatorio-proximo a ${to}:`, err?.message || err); + } + } + }, +}; diff --git a/apps/api/src/services/email/templates/alertas-nuevas.ts b/apps/api/src/services/email/templates/alertas-nuevas.ts new file mode 100644 index 0000000..ed54949 --- /dev/null +++ b/apps/api/src/services/email/templates/alertas-nuevas.ts @@ -0,0 +1,90 @@ +import { baseTemplate, heading, infoBox, primaryButton, BRAND_COLORS as C } from './base.js'; + +export interface AlertaItem { + /** Identificador interno (e.g., 'lista-negra-propia'). */ + alertaId: string; + /** Nivel: 'high' | 'medium' | 'low'. */ + nivel: 'high' | 'medium' | 'low'; + /** Título corto. */ + titulo: string; + /** Mensaje descriptivo. */ + mensaje: string; +} + +export interface AlertasNuevasData { + /** RFC y nombre del contribuyente al que pertenecen las alertas. */ + contribuyenteRfc: string; + contribuyenteNombre: string; + /** Nombre del despacho. */ + despachoNombre: string; + /** Lista de alertas nuevas (no se reportan resoluciones). */ + alertas: AlertaItem[]; + /** URL al sistema (ej. https://despachos.horuxfin.com/alertas). */ + link: string; +} + +const NIVEL_BADGE: Record = { + high: { label: 'Alta', bg: '#FEE2E2', fg: '#991B1B' }, + medium: { label: 'Media', bg: '#FEF3C7', fg: '#92400E' }, + low: { label: 'Baja', bg: '#DBEAFE', fg: '#1E40AF' }, +}; + +export function alertasNuevasEmail(data: AlertasNuevasData): string { + const total = data.alertas.length; + const conteoNivel = data.alertas.reduce>( + (acc, a) => ({ ...acc, [a.nivel]: (acc[a.nivel] ?? 0) + 1 }), + { high: 0, medium: 0, low: 0 }, + ); + + const itemsHtml = data.alertas.map((a) => alertaItemHtml(a)).join(''); + + return baseTemplate(` + ${heading(`${total} alerta${total === 1 ? '' : 's'} nueva${total === 1 ? '' : 's'}`)} +

+ Detectamos alertas fiscales nuevas para + ${escapeHtml(data.contribuyenteNombre)} + (RFC ${escapeHtml(data.contribuyenteRfc)}) + en el despacho ${escapeHtml(data.despachoNombre)}. +

+ ${infoBox(` +

Resumen

+

+ ${conteoNivel.high > 0 ? `${conteoNivel.high} alta · ` : ''} + ${conteoNivel.medium > 0 ? `${conteoNivel.medium} media · ` : ''} + ${conteoNivel.low > 0 ? `${conteoNivel.low} baja` : ''} +

+ `)} +
+ ${itemsHtml} +
+
+ ${primaryButton('Ver alertas en el sistema', data.link)} +
+

+ Recibes este correo porque eres responsable del contribuyente. Estas alertas + ya fueron registradas — solo te avisaremos cuando aparezcan nuevas, no se + repetirá esta notificación si la misma alerta sigue activa. +

+ `); +} + +function alertaItemHtml(a: AlertaItem): string { + const badge = NIVEL_BADGE[a.nivel]; + return ` +
+
+ ${escapeHtml(a.titulo)} + + ${badge.label} + +
+

${escapeHtml(a.mensaje)}

+
+ `; +} + +function escapeHtml(s: string): string { + return s.replace(/[&<>"']/g, (ch) => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', + })[ch]!); +} diff --git a/apps/api/src/services/email/templates/base.ts b/apps/api/src/services/email/templates/base.ts new file mode 100644 index 0000000..6fa91ee --- /dev/null +++ b/apps/api/src/services/email/templates/base.ts @@ -0,0 +1,113 @@ +/** + * Layout base de todos los emails. Espejea la identidad visual de horux360.com: + * - Gradiente primary→secondary (azul→morado) en el header + * - Tipografías Montserrat (headings) e Inter (body) cargadas vía Google Fonts + * (Apple Mail, Outlook desktop las respetan; Gmail las ignora y cae al + * stack sans-serif del sistema — ambos se ven correctos). + * - Ancho 600px, bordes redondeados, sombra suave + * - Footer en gris claro con color muted del design system + * + * Diseño limitado por restricciones de email HTML: usamos tablas + inline + * styles, nada de flexbox/grid ni CSS externo. Las variables de CSS no + * funcionan (Gmail las ignora), por eso los hex están hardcoded. + */ + +// Tokens del design system de horux360.com — replicados aquí para los emails +const BRAND = { + primary: '#2563EB', + secondary: '#7C3AED', + accent: '#10B981', + textPrimary: '#1E293B', + textMuted: '#64748B', + bgLight: '#F8FAFC', + bgWhite: '#FFFFFF', + border: '#E2E8F0', + gradient: 'linear-gradient(135deg, #2563EB 0%, #7C3AED 100%)', +}; + +// Una sola tipografia (Inter) para mantener consistencia con el design system +// del sitio. Stack fallback cubre los clientes que no soportan webfonts (Gmail). +// IMPORTANTE: usar comillas simples para envolver familias con espacios. +// Comillas dobles dentro de un style="..." rompen el atributo HTML. +const FONT = "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"; + +export function baseTemplate(content: string): string { + const year = new Date().getFullYear(); + return ` + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + +
+ + + + + +
+ Horux 360 + + Plataforma fiscal inteligente +
+
+ ${content} +
+

+ horux360.com +  ·  + horuxfin.com +

+

© ${year} Horux 360 — Soluciones financieras y tecnológicas para empresas

+
+
+ +`; +} + +/** + * Helper para botón primario consistente con el design system. Usa color + * sólido primary Y gradiente; el cliente que no soporte gradientes cae al + * sólido. Sombra ligera para dar profundidad. + */ +export function primaryButton(label: string, href: string): string { + return `${label}`; +} + +/** Caja destacada para info secundaria (credenciales, totales, etc). */ +export function infoBox(content: string): string { + return `
${content}
`; +} + +/** Heading H2 con tipografía de marca. */ +export function heading(text: string): string { + return `

${text}

`; +} + +export const BRAND_COLORS = BRAND; diff --git a/apps/api/src/services/email/templates/despacho-welcome.ts b/apps/api/src/services/email/templates/despacho-welcome.ts new file mode 100644 index 0000000..ba19419 --- /dev/null +++ b/apps/api/src/services/email/templates/despacho-welcome.ts @@ -0,0 +1,31 @@ +import { baseTemplate, primaryButton, heading } from './base.js'; + +export function despachoWelcomeEmail(data: { + nombre: string; + despachoNombre: string; + email: string; +}): string { + return baseTemplate(` + ${heading('¡Bienvenido a Horux Despachos!')} +

+ Hola ${data.nombre}, +

+

+ Tu despacho ${data.despachoNombre} ha sido creado exitosamente. + Tienes 30 días de prueba gratis para explorar todas las funcionalidades. +

+

+ Próximos pasos: +

+
    +
  1. Agrega tu primer contribuyente (RFC)
  2. +
  3. Sube la FIEL del contribuyente
  4. +
  5. Sube el CSD para emitir facturas
  6. +
  7. Invita a tu equipo (supervisores y auxiliares)
  8. +
+ ${primaryButton('Ir a mi despacho', 'https://horuxfin.com/onboarding')} +

+ Tu cuenta: ${data.email} +

+ `); +} diff --git a/apps/api/src/services/email/templates/documento-subido.ts b/apps/api/src/services/email/templates/documento-subido.ts new file mode 100644 index 0000000..137960d --- /dev/null +++ b/apps/api/src/services/email/templates/documento-subido.ts @@ -0,0 +1,102 @@ +import { baseTemplate, heading, infoBox, primaryButton, BRAND_COLORS as C } from './base.js'; + +export interface DocumentoSubidoData { + /** Kind: para el título/subject. */ + kind: 'declaracion' | 'extra'; + /** Quién subió el documento (email). */ + subidoPor: string; + /** RFC del contribuyente. */ + contribuyenteRfc: string; + /** Razón social / nombre del contribuyente. */ + contribuyenteNombre: string; + /** Nombre del despacho (opcional, se incluye en el body cuando existe). */ + despachoNombre?: string; + /** Si es declaración: periodo + tipo + impuestos + monto. */ + declaracion?: { + periodo: string; // "Abril 2026" + tipo: 'normal' | 'complementaria'; + impuestos: string[]; // ['IVA', 'ISR'] + montoPago: number | null; + }; + /** Si es extra: nombre del documento + categoria. */ + extra?: { + nombre: string; + descripcion?: string | null; + categoria?: string | null; + }; + /** URL al sistema (ej. https://despachos.horuxfin.com/documentos). */ + link: string; +} + +export function documentoSubidoEmail(data: DocumentoSubidoData): string { + const titulo = data.kind === 'declaracion' + ? 'Nueva declaración subida' + : 'Nuevo documento subido'; + + const contenidoEspecifico = data.kind === 'declaracion' && data.declaracion + ? declaracionBlock(data.declaracion) + : data.extra + ? extraBlock(data.extra) + : ''; + + return baseTemplate(` + ${heading(titulo)} +

+ ${escapeHtml(data.subidoPor)} subió un ${data.kind === 'declaracion' ? 'acuse de declaración' : 'documento'} + para ${escapeHtml(data.contribuyenteNombre)}. +

+ ${infoBox(` +

Contribuyente

+

${escapeHtml(data.contribuyenteNombre)}

+

RFC

+

${escapeHtml(data.contribuyenteRfc)}

+ ${contenidoEspecifico} +

Fecha

+

${new Date().toLocaleString('es-MX')}

+ `)} +
+ ${primaryButton('Ver en el sistema', data.link)} +
+ `); +} + +function declaracionBlock(d: NonNullable): string { + const impuestosStr = d.impuestos.join(', '); + const tipoLabel = d.tipo === 'complementaria' ? 'Complementaria' : 'Normal'; + const montoLabel = d.montoPago == null ? '—' : d.montoPago === 0 ? 'Sin pago' : formatCurrency(d.montoPago); + return ` +

Periodo

+

${escapeHtml(d.periodo)}

+

Tipo

+

${tipoLabel}

+

Impuestos

+

${escapeHtml(impuestosStr)}

+

Monto a pagar

+

${montoLabel}

+ `; +} + +function extraBlock(e: NonNullable): string { + return ` +

Documento

+

${escapeHtml(e.nombre)}

+ ${e.categoria ? ` +

Categoría

+

${escapeHtml(e.categoria)}

+ ` : ''} + ${e.descripcion ? ` +

Descripción

+

${escapeHtml(e.descripcion)}

+ ` : ''} + `; +} + +function formatCurrency(n: number): string { + return n.toLocaleString('es-MX', { style: 'currency', currency: 'MXN', minimumFractionDigits: 2 }); +} + +function escapeHtml(s: string): string { + return s.replace(/[&<>"']/g, (ch) => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', + })[ch]!); +} diff --git a/apps/api/src/services/email/templates/fiel-notification.ts b/apps/api/src/services/email/templates/fiel-notification.ts new file mode 100644 index 0000000..58e963f --- /dev/null +++ b/apps/api/src/services/email/templates/fiel-notification.ts @@ -0,0 +1,17 @@ +import { baseTemplate, heading, infoBox, BRAND_COLORS as C } from './base.js'; + +export function fielNotificationEmail(data: { clienteNombre: string; clienteRfc: string }): string { + return baseTemplate(` + ${heading('e.firma cargada')} +

El cliente ${data.clienteNombre} ha subido su e.firma (FIEL).

+ ${infoBox(` +

Empresa

+

${data.clienteNombre}

+

RFC

+

${data.clienteRfc}

+

Fecha

+

${new Date().toLocaleString('es-MX')}

+ `)} +

Ya puedes iniciar la sincronización de CFDIs para este cliente.

+ `); +} diff --git a/apps/api/src/services/email/templates/new-client-admin.ts b/apps/api/src/services/email/templates/new-client-admin.ts new file mode 100644 index 0000000..d9894f8 --- /dev/null +++ b/apps/api/src/services/email/templates/new-client-admin.ts @@ -0,0 +1,58 @@ +import { baseTemplate, heading, BRAND_COLORS as C } from './base.js'; + +function escapeHtml(str: string): string { + return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +function sectionHeader(label: string, accentColor: string) { + return ` + + ${label} + + `; +} + +function row(label: string, value: string, isLast = false) { + const border = isLast ? '' : `border-bottom:1px solid ${C.border};`; + return ` + ${label} + ${value} + `; +} + +export function newClientAdminEmail(data: { + clienteNombre: string; + clienteRfc: string; + adminEmail: string; + adminNombre: string; + tempPassword: string; + databaseName: string; + plan: string; +}): string { + return baseTemplate(` + ${heading('Nuevo cliente registrado')} +

+ Se ha dado de alta un nuevo cliente en Horux 360. Detalles: +

+ + + ${sectionHeader('Datos del cliente', C.primary)} + ${row('Empresa', `${escapeHtml(data.clienteNombre)}`)} + ${row('RFC', `${escapeHtml(data.clienteRfc)}`)} + ${row('Plan', escapeHtml(data.plan), true)} +
+ + + ${sectionHeader('Credenciales del usuario', C.secondary)} + ${row('Nombre', escapeHtml(data.adminNombre))} + ${row('Email', `${escapeHtml(data.adminEmail)}`)} + ${row('Contraseña temporal', `${escapeHtml(data.tempPassword)}`, true)} +
+ +
+

+ ⚠️ Confidencial: este correo contiene credenciales. No lo reenvíes ni lo compartas. +

+
+ `); +} diff --git a/apps/api/src/services/email/templates/papeleria.ts b/apps/api/src/services/email/templates/papeleria.ts new file mode 100644 index 0000000..580a929 --- /dev/null +++ b/apps/api/src/services/email/templates/papeleria.ts @@ -0,0 +1,57 @@ +import { baseTemplate, primaryButton, infoBox, heading } from './base.js'; + +export interface PapeleriaAprobacionRequeridaData { + contribuyenteRfc: string; + contribuyenteNombre: string; + despachoNombre?: string; + nombreDocumento: string; + descripcion: string | null; + periodo: string; + subidoPor: string; + link: string; +} + +export function papeleriaAprobacionRequeridaEmail(d: PapeleriaAprobacionRequeridaData): string { + const body = ` + ${heading('Papelería pendiente de aprobación')} +

${d.subidoPor} subió un documento de papelería de trabajo que requiere tu aprobación:

+
    +
  • Documento: ${d.nombreDocumento}
  • +
  • Contribuyente: ${d.contribuyenteNombre} (${d.contribuyenteRfc})
  • +
  • Periodo: ${d.periodo}
  • + ${d.descripcion ? `
  • Descripción: ${d.descripcion}
  • ` : ''} +
+ ${infoBox('Revisa el documento y márcalo como aprobado o rechazado desde la sección de Documentos del despacho.')} +
+ ${primaryButton('Ver documento', d.link)} +
+ `; + return baseTemplate(body); +} + +export interface PapeleriaDecisionData { + contribuyenteRfc: string; + contribuyenteNombre: string; + nombreDocumento: string; + estado: 'aprobado' | 'rechazado'; + revisor: string; + comentario: string | null; + periodo: string; + link: string; +} + +export function papeleriaDecisionEmail(d: PapeleriaDecisionData): string { + const verbo = d.estado === 'aprobado' ? 'aprobó' : 'rechazó'; + const body = ` + ${heading(`Documento ${d.estado}`)} +

${d.revisor} ${verbo} el documento ${d.nombreDocumento} + del contribuyente ${d.contribuyenteNombre} (${d.contribuyenteRfc}), periodo ${d.periodo}.

+ ${d.estado === 'rechazado' && d.comentario + ? infoBox(`Comentario: ${d.comentario}`) + : ''} +
+ ${primaryButton('Ver documento', d.link)} +
+ `; + return baseTemplate(body); +} diff --git a/apps/api/src/services/email/templates/password-reset.ts b/apps/api/src/services/email/templates/password-reset.ts new file mode 100644 index 0000000..bfa2da0 --- /dev/null +++ b/apps/api/src/services/email/templates/password-reset.ts @@ -0,0 +1,16 @@ +import { baseTemplate, heading, primaryButton, BRAND_COLORS as C } from './base.js'; + +export function passwordResetEmail(data: { nombre: string; resetUrl: string }): string { + return baseTemplate(` + ${heading('Recuperación de contraseña')} +

Hola ${data.nombre},

+

Recibimos una solicitud para restablecer tu contraseña en Horux 360. Haz clic en el botón para crear una nueva:

+ ${primaryButton('Restablecer contraseña', data.resetUrl)} +

O copia este enlace en tu navegador:

+

${data.resetUrl}

+
+

Este enlace expira en 1 hora.

+

Si tú no solicitaste este cambio, ignora este correo — tu contraseña no cambiará a menos que sigas el enlace.

+
+ `); +} diff --git a/apps/api/src/services/email/templates/payment-confirmed.ts b/apps/api/src/services/email/templates/payment-confirmed.ts new file mode 100644 index 0000000..e98c8b9 --- /dev/null +++ b/apps/api/src/services/email/templates/payment-confirmed.ts @@ -0,0 +1,16 @@ +import { baseTemplate, heading, BRAND_COLORS as C } from './base.js'; + +export function paymentConfirmedEmail(data: { nombre: string; amount: number; plan: string; date: string }): string { + return baseTemplate(` + ${heading('Pago confirmado')} +

Hola ${data.nombre},

+

Hemos recibido tu pago correctamente.

+
+

Monto

+

$${data.amount.toLocaleString('es-MX', { minimumFractionDigits: 2 })} MXN

+

Plan: ${data.plan}

+

Fecha: ${data.date}

+
+

Tu suscripción está activa. Gracias por confiar en Horux 360.

+ `); +} diff --git a/apps/api/src/services/email/templates/payment-failed.ts b/apps/api/src/services/email/templates/payment-failed.ts new file mode 100644 index 0000000..c013782 --- /dev/null +++ b/apps/api/src/services/email/templates/payment-failed.ts @@ -0,0 +1,17 @@ +import { baseTemplate, heading, primaryButton, BRAND_COLORS as C } from './base.js'; + +export function paymentFailedEmail(data: { nombre: string; amount: number; plan: string }): string { + return baseTemplate(` + ${heading('Problema con tu pago')} +

Hola ${data.nombre},

+

No pudimos procesar tu pago. Esto suele deberse a fondos insuficientes, tarjeta vencida o rechazo del banco emisor.

+
+

Monto pendiente

+

$${data.amount.toLocaleString('es-MX', { minimumFractionDigits: 2 })} MXN

+

Plan: ${data.plan}

+
+

Verifica tu método de pago en la plataforma para que tu servicio no se interrumpa:

+ ${primaryButton('Actualizar método de pago', 'https://horuxfin.com/configuracion/suscripcion')} +

Si necesitas ayuda, responde a este correo y nuestro equipo te contactará.

+ `); +} diff --git a/apps/api/src/services/email/templates/recordatorio-proximo.ts b/apps/api/src/services/email/templates/recordatorio-proximo.ts new file mode 100644 index 0000000..edd453e --- /dev/null +++ b/apps/api/src/services/email/templates/recordatorio-proximo.ts @@ -0,0 +1,68 @@ +import { baseTemplate, heading, infoBox, primaryButton, BRAND_COLORS as C } from './base.js'; + +export type VentanaRecordatorio = '3d' | '1d' | '0d'; + +export interface RecordatorioProximoData { + /** Título del recordatorio. */ + titulo: string; + /** Descripción opcional. */ + descripcion?: string | null; + /** Notas opcionales. */ + notas?: string | null; + /** Fecha límite (YYYY-MM-DD). */ + fechaLimite: string; + /** Ventana de aviso: 3d / 1d / 0d. */ + ventana: VentanaRecordatorio; + /** Nombre del despacho. */ + despachoNombre: string; + /** URL al calendario. */ + link: string; +} + +const VENTANA_LABEL: Record = { + '3d': { titulo: 'Recordatorio en 3 días', subtitulo: 'Tienes 3 días para atender este pendiente.', color: '#3B82F6' }, + '1d': { titulo: 'Recordatorio mañana', subtitulo: 'Esto vence mañana — prepara la documentación con tiempo.', color: '#F59E0B' }, + '0d': { titulo: 'Recordatorio HOY', subtitulo: 'Vence hoy. Revisa y atiende cuanto antes.', color: '#EF4444' }, +}; + +export function recordatorioProximoEmail(data: RecordatorioProximoData): string { + const v = VENTANA_LABEL[data.ventana]; + const fechaFormateada = formatFecha(data.fechaLimite); + + return baseTemplate(` + ${heading(v.titulo)} +

${escapeHtml(v.subtitulo)}

+ ${infoBox(` +

Recordatorio

+

${escapeHtml(data.titulo)}

+

Fecha límite

+

${escapeHtml(fechaFormateada)}

+ ${data.descripcion ? ` +

Descripción

+

${escapeHtml(data.descripcion)}

+ ` : ''} + ${data.notas ? ` +

Notas

+

${escapeHtml(data.notas)}

+ ` : ''} +

Despacho

+

${escapeHtml(data.despachoNombre)}

+ `)} +
+ ${primaryButton('Ver en el calendario', data.link)} +
+ `); +} + +function formatFecha(fecha: string): string { + const [y, m, d] = fecha.split('-').map(Number); + if (!y || !m || !d) return fecha; + const dt = new Date(y, m - 1, d); + return dt.toLocaleDateString('es-MX', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }); +} + +function escapeHtml(s: string): string { + return s.replace(/[&<>"']/g, (ch) => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', + })[ch]!); +} diff --git a/apps/api/src/services/email/templates/subscription-cancelled.ts b/apps/api/src/services/email/templates/subscription-cancelled.ts new file mode 100644 index 0000000..210d105 --- /dev/null +++ b/apps/api/src/services/email/templates/subscription-cancelled.ts @@ -0,0 +1,15 @@ +import { baseTemplate, heading, primaryButton, infoBox, BRAND_COLORS as C } from './base.js'; + +export function subscriptionCancelledEmail(data: { nombre: string; plan: string }): string { + return baseTemplate(` + ${heading('Suscripción cancelada')} +

Hola ${data.nombre},

+

Tu suscripción al plan ${data.plan} ha sido cancelada.

+ ${infoBox(` +

Tu acceso continuará activo hasta el final del período actual de facturación.

+

Después de eso, solo tendrás acceso de lectura a tus datos.

+ `)} +

¿Cambiaste de opinión? Puedes reactivar tu suscripción antes del cierre del período sin cobro extra:

+ ${primaryButton('Reactivar suscripción', 'https://horuxfin.com/configuracion/suscripcion')} + `); +} diff --git a/apps/api/src/services/email/templates/subscription-expiring.ts b/apps/api/src/services/email/templates/subscription-expiring.ts new file mode 100644 index 0000000..e735e74 --- /dev/null +++ b/apps/api/src/services/email/templates/subscription-expiring.ts @@ -0,0 +1,14 @@ +import { baseTemplate, heading, primaryButton, BRAND_COLORS as C } from './base.js'; + +export function subscriptionExpiringEmail(data: { nombre: string; plan: string; expiresAt: string }): string { + return baseTemplate(` + ${heading('Tu suscripción vence pronto')} +

Hola ${data.nombre},

+

Tu suscripción al plan ${data.plan} vence el ${data.expiresAt}.

+
+

Para evitar interrupciones en el servicio, verifica que tu método de pago esté actualizado.

+
+ ${primaryButton('Revisar suscripción', 'https://horuxfin.com/configuracion/suscripcion')} +

Si tienes alguna pregunta, responde a este correo.

+ `); +} diff --git a/apps/api/src/services/email/templates/tarea-completada.ts b/apps/api/src/services/email/templates/tarea-completada.ts new file mode 100644 index 0000000..a5520b0 --- /dev/null +++ b/apps/api/src/services/email/templates/tarea-completada.ts @@ -0,0 +1,32 @@ +import { baseTemplate, primaryButton, infoBox, heading } from './base.js'; + +export interface TareaCompletadaData { + destinatarioNombre: string; + contribuyenteNombre: string; + contribuyenteRfc: string; + tareaNombre: string; + tareaDescripcion: string | null; + completadaPor: string; + notas: string | null; + fechaLimite: string; + link: string; +} + +export function tareaCompletadaEmail(data: TareaCompletadaData): string { + const body = ` + ${heading(`Tarea revisada: ${data.tareaNombre}`)} +

Hola ${data.destinatarioNombre},

+

+ ${data.completadaPor} marcó como completada la tarea + ${data.tareaNombre} del contribuyente + ${data.contribuyenteNombre} (${data.contribuyenteRfc}), + con fecha límite ${data.fechaLimite}. +

+ ${data.tareaDescripcion ? infoBox(data.tareaDescripcion) : ''} + ${data.notas ? `

Notas del supervisor: ${data.notas}

` : ''} +
+ ${primaryButton('Ver tareas del contribuyente', data.link)} +
+ `; + return baseTemplate(body); +} diff --git a/apps/api/src/services/email/templates/trial-reminder.ts b/apps/api/src/services/email/templates/trial-reminder.ts new file mode 100644 index 0000000..ae108c4 --- /dev/null +++ b/apps/api/src/services/email/templates/trial-reminder.ts @@ -0,0 +1,58 @@ +import { baseTemplate, primaryButton, heading, infoBox } from './base.js'; + +export function trialReminderEmail(data: { + nombre: string; + despachoNombre: string; + diasRestantes: number; + wizardCompleto: boolean; +}): string { + const urgency = data.diasRestantes <= 3; + + const message = data.diasRestantes > 7 + ? `Te quedan ${data.diasRestantes} días de prueba gratuita.` + : urgency + ? `⚠️ Tu prueba gratuita termina en ${data.diasRestantes} días.` + : `Tu prueba gratuita termina en ${data.diasRestantes} días.`; + + const wizardNote = !data.wizardCompleto + ? infoBox('Aún no has completado la configuración inicial de tu despacho. Visita la página de onboarding para terminar.') + : ''; + + return baseTemplate(` + ${heading(urgency ? '⚠️ Tu trial está por terminar' : 'Actualización de tu trial')} +

+ Hola ${data.nombre}, +

+

+ ${message} en ${data.despachoNombre}. +

+ ${wizardNote} +

+ Para seguir usando Horux Despachos sin interrupción, elige un plan antes de que termine tu prueba. +

+ ${primaryButton('Elegir plan', 'https://horuxfin.com/configuracion/suscripcion')} +

+ Si tienes dudas, responde a este correo o contáctanos en soporte@horuxfin.com. +

+ `); +} + +export function trialExpiredEmail(data: { + nombre: string; + despachoNombre: string; +}): string { + return baseTemplate(` + ${heading('Tu prueba gratuita ha terminado')} +

+ Hola ${data.nombre}, +

+

+ El período de prueba de ${data.despachoNombre} ha finalizado. + Tu información sigue segura, pero el acceso está suspendido hasta que elijas un plan. +

+ ${primaryButton('Reactivar mi despacho', 'https://horuxfin.com/configuracion/suscripcion')} +

+ Tus datos se conservarán durante 30 días adicionales. Después de ese período, serán archivados. +

+ `); +} diff --git a/apps/api/src/services/email/templates/weekly-update.ts b/apps/api/src/services/email/templates/weekly-update.ts new file mode 100644 index 0000000..8ef2e83 --- /dev/null +++ b/apps/api/src/services/email/templates/weekly-update.ts @@ -0,0 +1,122 @@ +import { baseTemplate, heading, primaryButton, infoBox, BRAND_COLORS as C } from './base.js'; + +export interface WeeklyUpdateData { + nombre: string; + empresa: string; + periodoLabel: string; // ej: "Abril 2026" + kpis: { + ingresos: number; + egresos: number; + utilidad: number; + margen: number; // porcentaje + ivaBalance: number; // positivo = a pagar, negativo = a favor + ivaAFavorAcumulado: number; + cfdisEmitidos: number; + cfdisRecibidos: number; + }; + alertas: Array<{ + titulo: string; + mensaje: string; + prioridad: 'alta' | 'media' | 'baja'; + }>; + discrepanciasPorMes: Array<{ label: string; count: number }>; + fechaGeneracion: string; +} + +function fmtMoney(n: number): string { + return `$${n.toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; +} + +function kpiBox(label: string, value: string, sublabel?: string, color?: string): string { + const borderColor = color || C.border; + const valueColor = color || C.textPrimary; + return ` +
+

${label}

+

${value}

+ ${sublabel ? `

${sublabel}

` : ''} +
+ `; +} + +const PRIORIDAD_COLOR: Record = { + alta: { bg: '#fef2f2', border: '#ef4444', text: '#991b1b' }, + media: { bg: '#fffbeb', border: '#f59e0b', text: '#92400e' }, + baja: { bg: '#eff6ff', border: '#3b82f6', text: '#1e40af' }, +}; + +export function weeklyUpdateEmail(data: WeeklyUpdateData): string { + const { kpis } = data; + const ivaLabel = kpis.ivaBalance >= 0 ? 'IVA a pagar' : 'IVA a favor'; + const ivaValor = Math.abs(kpis.ivaBalance); + const ivaColor = kpis.ivaBalance >= 0 ? '#dc2626' : C.accent; + const utilidadColor = kpis.utilidad >= 0 ? C.accent : '#dc2626'; + + const alertasHtml = data.alertas.length === 0 + ? `
+

✓ No hay alertas activas esta semana. Tu operación está en orden.

+
` + : data.alertas.map(a => { + const colors = PRIORIDAD_COLOR[a.prioridad] || PRIORIDAD_COLOR.baja; + return `
+

${a.titulo}

+

${a.mensaje}

+
`; + }).join(''); + + const discrepanciasHtml = data.discrepanciasPorMes.length === 0 + ? `

Sin discrepancias en los últimos meses. ✓

` + : ` + + + + + + + + ${data.discrepanciasPorMes.map(d => ` + + + `).join('')} + +
MesFacturas con discrepancia
${d.label}${d.count}
`; + + return baseTemplate(` + ${heading('Actualización semanal')} +

Hola ${data.nombre},

+

Aquí tienes el resumen de tu actividad fiscal en ${data.empresa} correspondiente al periodo ${data.periodoLabel}.

+ +

Indicadores del periodo

+ + + ${kpiBox('Ingresos', fmtMoney(kpis.ingresos), `${kpis.cfdisEmitidos} CFDIs emitidos`)} + ${kpiBox('Egresos', fmtMoney(kpis.egresos), `${kpis.cfdisRecibidos} CFDIs recibidos`)} + + + ${kpiBox('Utilidad', fmtMoney(kpis.utilidad), `Margen ${kpis.margen.toFixed(1)}%`, utilidadColor)} + ${kpiBox(ivaLabel, fmtMoney(ivaValor), undefined, ivaColor)} + +
+ + ${kpis.ivaAFavorAcumulado > 0 ? infoBox(` +

IVA a favor acumulado del año

+

${fmtMoney(kpis.ivaAFavorAcumulado)}

+ `) : ''} + +

Alertas activas

+ ${alertasHtml} + +

Discrepancias de régimen por mes

+

Facturas recibidas donde el régimen fiscal del receptor (tu RFC) no coincide con tus regímenes activos. Cada discrepancia podría representar una factura mal emitida que el SAT podría rechazar.

+ ${discrepanciasHtml} + +
+ ${primaryButton('Ver dashboard completo', 'https://horuxfin.com/dashboard')} +
+ +

+ Reporte generado el ${data.fechaGeneracion}.
+ Recibes este correo porque eres dueño o CFO de ${data.empresa} en Horux 360. +

+ `); +} diff --git a/apps/api/src/services/email/templates/welcome.ts b/apps/api/src/services/email/templates/welcome.ts new file mode 100644 index 0000000..279025f --- /dev/null +++ b/apps/api/src/services/email/templates/welcome.ts @@ -0,0 +1,17 @@ +import { baseTemplate, heading, primaryButton, infoBox, BRAND_COLORS as C } from './base.js'; + +export function welcomeEmail(data: { nombre: string; email: string; tempPassword: string }): string { + return baseTemplate(` + ${heading('Bienvenido a Horux 360')} +

Hola ${data.nombre},

+

Tu cuenta ha sido creada exitosamente. Estas son tus credenciales de acceso:

+ ${infoBox(` +

Email

+

${data.email}

+

Contraseña temporal

+

${data.tempPassword}

+ `)} +

Por seguridad, te recomendamos cambiar tu contraseña la primera vez que inicies sesión.

+ ${primaryButton('Iniciar sesión', 'https://horuxfin.com/login')} + `); +} diff --git a/apps/api/src/services/export.service.ts b/apps/api/src/services/export.service.ts new file mode 100644 index 0000000..367dfcc --- /dev/null +++ b/apps/api/src/services/export.service.ts @@ -0,0 +1,125 @@ +import ExcelJS from 'exceljs'; +import type { Pool } from 'pg'; + +export async function exportCfdisToExcel( + pool: Pool, + filters: { tipo?: string; estado?: string; fechaInicio?: string; fechaFin?: string } +): Promise { + let whereClause = 'WHERE 1=1'; + const params: any[] = []; + let paramIndex = 1; + + if (filters.tipo) { + whereClause += ` AND type = $${paramIndex++}`; + params.push(filters.tipo); + } + if (filters.estado) { + whereClause += ` AND status = $${paramIndex++}`; + params.push(filters.estado); + } + if (filters.fechaInicio) { + whereClause += ` AND fecha_emision >= $${paramIndex++}`; + params.push(filters.fechaInicio); + } + if (filters.fechaFin) { + whereClause += ` AND fecha_emision <= $${paramIndex++}`; + params.push(filters.fechaFin); + } + + const { rows: cfdis } = await pool.query(` + SELECT uuid, type, serie, folio, fecha_emision, status, + rfc_emisor, nombre_emisor, rfc_receptor, nombre_receptor, + subtotal, subtotal_mxn, descuento, descuento_mxn, + iva_traslado, iva_traslado_mxn, isr_retencion, isr_retencion_mxn, + iva_retencion, iva_retencion_mxn, + total, total_mxn, moneda, tipo_cambio, + metodo_pago, forma_pago, uso_cfdi + FROM cfdis + ${whereClause} + ORDER BY fecha_emision DESC + `, params); + + const workbook = new ExcelJS.Workbook(); + const sheet = workbook.addWorksheet('CFDIs'); + + sheet.columns = [ + { header: 'UUID', key: 'uuid', width: 40 }, + { header: 'Tipo', key: 'type', width: 10 }, + { header: 'Serie', key: 'serie', width: 10 }, + { header: 'Folio', key: 'folio', width: 10 }, + { header: 'Fecha Emisión', key: 'fecha_emision', width: 15 }, + { header: 'RFC Emisor', key: 'rfc_emisor', width: 15 }, + { header: 'Nombre Emisor', key: 'nombre_emisor', width: 30 }, + { header: 'RFC Receptor', key: 'rfc_receptor', width: 15 }, + { header: 'Nombre Receptor', key: 'nombre_receptor', width: 30 }, + { header: 'Subtotal', key: 'subtotal', width: 15 }, + { header: 'Subtotal MXN', key: 'subtotal_mxn', width: 15 }, + { header: 'IVA Trasladado', key: 'iva_traslado', width: 15 }, + { header: 'IVA Trasladado MXN', key: 'iva_traslado_mxn', width: 15 }, + { header: 'Total', key: 'total', width: 15 }, + { header: 'Total MXN', key: 'total_mxn', width: 15 }, + { header: 'Moneda', key: 'moneda', width: 8 }, + { header: 'T.C.', key: 'tipo_cambio', width: 10 }, + { header: 'Estado', key: 'status', width: 12 }, + ]; + + sheet.getRow(1).font = { bold: true }; + sheet.getRow(1).fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FF4472C4' }, + }; + sheet.getRow(1).font = { bold: true, color: { argb: 'FFFFFFFF' } }; + + cfdis.forEach((cfdi: any) => { + sheet.addRow({ + ...cfdi, + fecha_emision: new Date(cfdi.fecha_emision).toLocaleDateString('es-MX'), + subtotal: Number(cfdi.subtotal), + subtotal_mxn: Number(cfdi.subtotal_mxn), + iva_traslado: Number(cfdi.iva_traslado), + iva_traslado_mxn: Number(cfdi.iva_traslado_mxn), + total: Number(cfdi.total), + total_mxn: Number(cfdi.total_mxn), + }); + }); + + const buffer = await workbook.xlsx.writeBuffer(); + return Buffer.from(buffer); +} + +export async function exportReporteToExcel( + pool: Pool, + tipo: 'estado-resultados' | 'flujo-efectivo', + fechaInicio: string, + fechaFin: string +): Promise { + const workbook = new ExcelJS.Workbook(); + const sheet = workbook.addWorksheet(tipo === 'estado-resultados' ? 'Estado de Resultados' : 'Flujo de Efectivo'); + + if (tipo === 'estado-resultados') { + const { rows: [totales] } = await pool.query(` + SELECT + COALESCE(SUM(CASE WHEN type = 'EMITIDO' AND tipo_comprobante = 'I' THEN subtotal_mxn ELSE 0 END), 0) as ingresos, + COALESCE(SUM(CASE WHEN type = 'RECIBIDO' AND tipo_comprobante = 'I' THEN subtotal_mxn ELSE 0 END), 0) as egresos + FROM cfdis + WHERE status NOT IN ('Cancelado', '0') AND fecha_emision BETWEEN $1 AND $2 + `, [fechaInicio, fechaFin]); + + sheet.columns = [ + { header: 'Concepto', key: 'concepto', width: 40 }, + { header: 'Monto', key: 'monto', width: 20 }, + ]; + + sheet.addRow({ concepto: 'INGRESOS', monto: '' }); + sheet.addRow({ concepto: 'Total Ingresos', monto: Number(totales?.ingresos || 0) }); + sheet.addRow({ concepto: '', monto: '' }); + sheet.addRow({ concepto: 'EGRESOS', monto: '' }); + sheet.addRow({ concepto: 'Total Egresos', monto: Number(totales?.egresos || 0) }); + sheet.addRow({ concepto: '', monto: '' }); + sheet.addRow({ concepto: 'UTILIDAD NETA', monto: Number(totales?.ingresos || 0) - Number(totales?.egresos || 0) }); + } + + const buffer = await workbook.xlsx.writeBuffer(); + return Buffer.from(buffer); +} diff --git a/apps/api/src/services/facturapi.service.ts b/apps/api/src/services/facturapi.service.ts new file mode 100644 index 0000000..a729d52 --- /dev/null +++ b/apps/api/src/services/facturapi.service.ts @@ -0,0 +1,898 @@ +import Facturapi from 'facturapi'; +import { env } from '../config/env.js'; +import { prisma } from '../config/database.js'; +import * as mpService from './payment/mercadopago.service.js'; +import { getTenantOwnerEmail } from '../utils/memberships.js'; +import { encryptString, decryptToString } from './sat/sat-crypto.service.js'; + +/** + * Cliente Facturapi con User Key (nivel cuenta, para gestión de organizaciones). + */ +function getUserClient(): Facturapi { + if (!env.FACTURAPI_USER_KEY) { + throw new Error('FACTURAPI_USER_KEY no configurada'); + } + return new Facturapi(env.FACTURAPI_USER_KEY); +} + +/** + * Genera una Live Secret Key vía PUT idempotente. Devuelve la existente + * si la org ya tiene una; crea nueva si no. + */ +async function generateLiveKey(orgId: string): Promise { + const userKey = env.FACTURAPI_USER_KEY!; + const res = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}/apikeys/live`, { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${userKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }); + if (!res.ok) { + const errBody = await res.text().catch(() => ''); + throw new Error(`Facturapi PUT /apikeys/live falló (${res.status}): ${errBody}`); + } + const key = (await res.text()).replace(/"/g, '').trim(); + if (!key.startsWith('sk_live_')) { + throw new Error(`Respuesta inesperada de Facturapi (no es sk_live_*): ${key.slice(0, 10)}...`); + } + return key; +} + +/** + * Cliente Facturapi con la Live Secret Key de la organización del tenant. + * Cache cifrada en `Tenant.facturapiOrgKeyEnc/Iv/Tag` (AES-256-GCM con + * derivación FIEL_ENCRYPTION_KEY). Si no hay cache, genera vía PUT y persiste. + */ +async function getOrgClient(tenantId: string): Promise { + const tenant = await prisma.tenant.findUnique({ + where: { id: tenantId }, + select: { + facturapiOrgId: true, + facturapiOrgKeyEnc: true, + facturapiOrgKeyIv: true, + facturapiOrgKeyTag: true, + }, + }); + + if (!tenant?.facturapiOrgId) { + throw new Error('Tenant no tiene organización Facturapi configurada'); + } + + // 1. Reutilizar Live Secret Key cacheada (descifrar de BD). + if (tenant.facturapiOrgKeyEnc && tenant.facturapiOrgKeyIv && tenant.facturapiOrgKeyTag) { + const apiKey = decryptToString(tenant.facturapiOrgKeyEnc, tenant.facturapiOrgKeyIv, tenant.facturapiOrgKeyTag); + return new Facturapi(apiKey); + } + + // 2. Generar Live Secret Key vía PUT y persistir cifrada (lazy fallback + // para tenants legacy creados antes del refactor live). + const apiKey = await generateLiveKey(tenant.facturapiOrgId); + const { encrypted, iv, tag } = encryptString(apiKey); + await prisma.tenant.update({ + where: { id: tenantId }, + data: { + facturapiOrgKeyEnc: encrypted, + facturapiOrgKeyIv: iv, + facturapiOrgKeyTag: tag, + }, + }); + return new Facturapi(apiKey); +} + +// ============================================ +// Organizaciones +// ============================================ + +export async function createOrganization(tenantId: string): Promise<{ orgId: string }> { + const tenant = await prisma.tenant.findUnique({ + where: { id: tenantId }, + select: { nombre: true, rfc: true, facturapiOrgId: true }, + }); + + if (!tenant) throw new Error('Tenant no encontrado'); + if (tenant.facturapiOrgId) throw new Error('Tenant ya tiene organización Facturapi'); + + const client = getUserClient(); + const org = await client.organizations.create({ name: tenant.nombre }); + + // Eager: generar Live Secret Key inmediatamente y persistirla cifrada + // para que la org quede lista para emitir desde el primer momento sin un + // PUT extra al primer emit. + const apiKey = await generateLiveKey(org.id); + const { encrypted, iv, tag } = encryptString(apiKey); + + await prisma.tenant.update({ + where: { id: tenantId }, + data: { + facturapiOrgId: org.id, + facturapiOrgKeyEnc: encrypted, + facturapiOrgKeyIv: iv, + facturapiOrgKeyTag: tag, + }, + }); + + return { orgId: org.id }; +} + +export async function getOrganizationStatus(tenantId: string): Promise<{ + configured: boolean; + orgId?: string; + legalName?: string; + hasCsd?: boolean; +}> { + const tenant = await prisma.tenant.findUnique({ + where: { id: tenantId }, + select: { facturapiOrgId: true }, + }); + + if (!tenant?.facturapiOrgId) { + return { configured: false }; + } + + try { + const client = getUserClient(); + const org = await client.organizations.retrieve(tenant.facturapiOrgId); + + return { + configured: true, + orgId: org.id, + legalName: org.legal?.name || undefined, + hasCsd: !!org.certificate?.has_certificate, + }; + } catch { + return { configured: false }; + } +} + +// ============================================ +// CSD (Certificado de Sello Digital) +// ============================================ + +export async function uploadCsd( + tenantId: string, + cerFile: string, // base64 + keyFile: string, // base64 + password: string +): Promise<{ success: boolean; message: string }> { + const tenant = await prisma.tenant.findUnique({ + where: { id: tenantId }, + select: { facturapiOrgId: true }, + }); + + if (!tenant?.facturapiOrgId) { + throw new Error('Primero debe crearse la organización en Facturapi'); + } + + const client = getUserClient(); + + try { + await client.organizations.uploadCertificate( + tenant.facturapiOrgId, + Buffer.from(cerFile, 'base64'), + Buffer.from(keyFile, 'base64'), + password, + ); + + return { success: true, message: 'CSD subido correctamente' }; + } catch (error: any) { + return { success: false, message: error.message || 'Error al subir CSD' }; + } +} + +// ============================================ +// Clientes +// ============================================ + +export interface FacturapiCustomerData { + legalName: string; + taxId: string; // RFC o Tax ID extranjero + taxSystem?: string; // clave régimen fiscal (no aplica para extranjeros) + email?: string; + zip: string; + country?: string; // ISO 3166 alpha-3 (solo extranjeros, ej: USA, SWE) +} + +export async function createOrUpdateCustomer( + tenantId: string, + data: FacturapiCustomerData +): Promise { + const client = await getOrgClient(tenantId); + + // Buscar si ya existe por búsqueda de texto + let existingId: string | null = null; + try { + const existing = await client.customers.list({ search: data.taxId }); + if (existing.data && existing.data.length > 0) { + const match = existing.data.find((c: any) => c.tax_id === data.taxId); + if (match) existingId = match.id; + } + } catch { /* no existing */ } + + const isForiegn = !!data.country && data.country !== 'MEX'; + + if (existingId) { + const updateData: any = { + legal_name: data.legalName, + email: data.email, + address: { zip: data.zip, ...(isForiegn ? { country: data.country } : {}) }, + }; + if (!isForiegn && data.taxSystem) updateData.tax_system = data.taxSystem; + await client.customers.update(existingId, updateData); + return existingId; + } + + const createData: any = { + legal_name: data.legalName, + email: data.email, + address: { zip: data.zip, ...(isForiegn ? { country: data.country } : {}) }, + }; + if (isForiegn) { + createData.tax_id = data.taxId; // Tax ID extranjero (NumRegIdTrib) + } else { + createData.tax_id = data.taxId; // RFC mexicano + if (data.taxSystem) createData.tax_system = data.taxSystem; + } + + const customer = await client.customers.create(createData); + + return customer.id; +} + +// ============================================ +// Facturas (Emisión) +// ============================================ + +export interface FacturapiLineItem { + description: string; + productKey: string; // ClaveProdServ SAT + unitKey?: string; // ClaveUnidad SAT + unitName?: string; + quantity: number; + price: number; + taxIncluded?: boolean; + taxes?: Array<{ + type: string; // 'IVA', 'ISR', 'IEPS' + rate: number; // 0.16, 0.10, etc. + factor?: string; // 'Tasa', 'Cuota', 'Exento' + withholding?: boolean; // true = retención, false/undefined = traslado + }>; +} + +export interface FacturapiInvoiceData { + // Receptor + customer: FacturapiCustomerData; + // Conceptos + items: FacturapiLineItem[]; + // Campos CFDI + use: string; // UsoCFDI: G01, G03, etc. + paymentForm: string; // FormaPago: 01, 03, 28, etc. + paymentMethod?: string; // MetodoPago: PUE, PPD + currency?: string; // MXN, USD + exchangeRate?: number; + // Opcionales + series?: string; + folioNumber?: number; + conditions?: string; + /** + * Documentos CFDI relacionados. Estructura SAT 4.0: una entrada por tipo + * de relación, agrupando N UUIDs. Facturapi en modo Live valida la + * estructura estricta — el formato {uuid, relationship} suelto es rechazado. + */ + relatedDocuments?: Array<{ relationship: string; uuids: string[] }>; + /** + * Régimen fiscal del emisor (override del default de la organización). + * Requerido cuando el contribuyente tiene múltiples régimenes y Facturapi + * necesita saber cuál usar para esta factura específica. Se envía como + * `issuer.tax_system` a Facturapi. + */ + issuerTaxSystem?: string; +} + +export async function createInvoice( + tenantId: string, + data: FacturapiInvoiceData +): Promise { + const client = await getOrgClient(tenantId); + + // Crear/actualizar cliente en Facturapi + const customerId = await createOrUpdateCustomer(tenantId, data.customer); + + const tipo = (data as any).type || 'I'; + const invoiceData: any = { customer: customerId }; + + // Tipo de comprobante + if (tipo !== 'I') invoiceData.type = tipo; + + // Items (solo para I, E, T — P no lleva conceptos) + if (data.items?.length) { + invoiceData.items = data.items.map(item => ({ + quantity: item.quantity, + product: { + description: item.description, + product_key: item.productKey, + unit_key: item.unitKey || 'E48', + unit_name: item.unitName || 'Servicio', + price: item.price, + tax_included: item.taxIncluded ?? true, + taxes: item.taxes?.map(t => ({ + type: t.type, + rate: t.rate, + factor: t.factor || 'Tasa', + ...(t.withholding ? { withholding: true } : {}), + })) || [{ type: 'IVA', rate: 0.16 }], + }, + })); + } + + // Campos del comprobante (no aplican para tipo P) + if (tipo === 'I' || tipo === 'E') { + invoiceData.use = data.use || 'G01'; + invoiceData.payment_form = data.paymentForm || '99'; + invoiceData.payment_method = data.paymentMethod || 'PUE'; + invoiceData.currency = data.currency || 'MXN'; + if (data.exchangeRate && data.currency !== 'MXN') { + invoiceData.exchange = data.exchangeRate; + } + if (data.conditions) invoiceData.conditions = data.conditions; + } + + if (data.series) invoiceData.series = data.series; + if (data.folioNumber) invoiceData.folio_number = data.folioNumber; + + // Documentos relacionados (Ingreso / Egreso / Pago / Traslado). + if (data.relatedDocuments?.length) { + invoiceData.related_documents = data.relatedDocuments.map(r => ({ + relationship: r.relationship, + documents: r.uuids, + })); + } + + // Complemento de pago (tipo P) + if ((data as any).complements?.length) { + invoiceData.complements = (data as any).complements; + } + + // Factura global + if ((data as any).global) { + invoiceData.global = (data as any).global; + } + + // El régimen fiscal del emisor lo toma Facturapi del `legal.tax_system` de + // la organización — NO acepta override per-invoice via campo `issuer` (la + // API rechaza con "issuer is not allowed"). Si se pasa `issuerTaxSystem`, + // debe actualizarse el `legal` de la org ANTES de crear el invoice. Para + // el path tenant-level no lo hacemos (la org comparte régimen único); solo + // el path contribuyente (contribuyente-facturapi.service.ts) implementa + // el sync legal porque cada contribuyente tiene sus propios regímenes. + + const invoice = await client.invoices.create(invoiceData); + return invoice; +} + +// ============================================ +// Cancelación +// ============================================ + +// ============================================ +// Personalización (logo, color) +// ============================================ + +export async function uploadLogo(tenantId: string, logoBase64: string): Promise<{ success: boolean; message: string }> { + const tenant = await prisma.tenant.findUnique({ + where: { id: tenantId }, + select: { facturapiOrgId: true }, + }); + if (!tenant?.facturapiOrgId) throw new Error('Organización no configurada'); + + const userClient = getUserClient(); + try { + const buffer = Buffer.from(logoBase64, 'base64'); + await userClient.organizations.uploadLogo(tenant.facturapiOrgId, buffer); + return { success: true, message: 'Logo subido correctamente' }; + } catch (error: any) { + return { success: false, message: error.message || 'Error al subir logo' }; + } +} + +export async function updateColor(tenantId: string, color: string): Promise<{ success: boolean; message: string }> { + const tenant = await prisma.tenant.findUnique({ + where: { id: tenantId }, + select: { facturapiOrgId: true }, + }); + if (!tenant?.facturapiOrgId) throw new Error('Organización no configurada'); + + const userClient = getUserClient(); + try { + await userClient.organizations.updateCustomization(tenant.facturapiOrgId, { color: color.replace('#', '') }); + return { success: true, message: 'Color actualizado' }; + } catch (error: any) { + return { success: false, message: error.message || 'Error al actualizar color' }; + } +} + +export async function getCustomization(tenantId: string): Promise<{ logoUrl?: string; color?: string } | null> { + const tenant = await prisma.tenant.findUnique({ + where: { id: tenantId }, + select: { facturapiOrgId: true }, + }); + if (!tenant?.facturapiOrgId) return null; + + const userClient = getUserClient(); + try { + const org = await userClient.organizations.retrieve(tenant.facturapiOrgId); + return { + logoUrl: org.customization?.has_logo ? (org.logo_url ?? undefined) : undefined, + color: org.customization?.color || undefined, + }; + } catch { return null; } +} + +export async function sendInvoiceByEmail( + tenantId: string, + facturapiId: string, + email: string +): Promise { + const client = await getOrgClient(tenantId); + await client.invoices.sendByEmail(facturapiId, { email }); +} + +export async function cancelInvoice( + tenantId: string, + facturapiId: string, + motive: '01' | '02' | '03' | '04' = '02', + substitution?: string +): Promise { + const client = await getOrgClient(tenantId); + + const cancelData: any = { motive }; + if (motive === '01' && substitution) { + cancelData.substitution = substitution; + } + + return client.invoices.cancel(facturapiId, cancelData); +} + +// ============================================ +// Descargas +// ============================================ + +export async function downloadPdf(tenantId: string, facturapiId: string): Promise { + const client = await getOrgClient(tenantId); + const stream = await client.invoices.downloadPdf(facturapiId); + return streamToBuffer(stream); +} + +export async function downloadXml(tenantId: string, facturapiId: string): Promise { + const client = await getOrgClient(tenantId); + const stream = await client.invoices.downloadXml(facturapiId); + return streamToBuffer(stream); +} + +export async function downloadZip(tenantId: string, facturapiId: string): Promise { + const client = await getOrgClient(tenantId); + const stream = await client.invoices.downloadZip(facturapiId); + return streamToBuffer(stream); +} + +function streamToBuffer(stream: any): Promise { + return new Promise((resolve, reject) => { + if (Buffer.isBuffer(stream)) return resolve(stream); + const chunks: Buffer[] = []; + stream.on('data', (chunk: Buffer) => chunks.push(chunk)); + stream.on('end', () => resolve(Buffer.concat(chunks))); + stream.on('error', reject); + }); +} + +// ============================================ +// Timbres +// ============================================ + +export interface TimbreStatus { + configured: boolean; + // Campos flat — backward compat con UI existente que lee `limite/usados/disponibles` + // al top level. Representan SOLO el pool mensual (no suman los paquetes). + tipo?: string; + limite?: number; + usados?: number; + disponibles?: number; + periodoFin?: string; + // Shape nuevo (fase B): separa mensual vs adicionales para la UI detallada + mensual?: { + tipo: string; + limite: number; + usados: number; + disponibles: number; + periodoFin: string; + }; + adicionales?: { + total: number; // suma de cantidades originales de paquetes vigentes + usados: number; // suma de usados + disponibles: number; // total - usados + paquetes: Array<{ + id: number; + cantidad: number; + usados: number; + disponibles: number; + adquiridoEn: string; + expiraEn: string; + }>; + }; + /** suma total disponible (mensual + adicionales vigentes). */ + totalDisponibles: number; +} + +export async function getTimbreStatus(tenantId: string): Promise { + const now = new Date(); + + const [suscripcion, paquetes] = await Promise.all([ + prisma.timbreSuscripcion.findUnique({ where: { tenantId } }), + prisma.timbrePaquete.findMany({ + where: { tenantId, expiraEn: { gt: now } }, + orderBy: { expiraEn: 'asc' }, + }), + ]); + + if (!suscripcion && paquetes.length === 0) { + return { configured: false, totalDisponibles: 0 }; + } + + const mensualVigente = suscripcion && now <= suscripcion.periodoFin; + const mensual = mensualVigente + ? { + tipo: suscripcion.tipo, + limite: suscripcion.timbresLimite, + usados: suscripcion.timbresUsados, + disponibles: Math.max(0, suscripcion.timbresLimite - suscripcion.timbresUsados), + periodoFin: suscripcion.periodoFin.toISOString().split('T')[0], + } + : undefined; + + const paquetesDetail = paquetes.map(p => ({ + id: p.id, + cantidad: p.cantidad, + usados: p.usados, + disponibles: Math.max(0, p.cantidad - p.usados), + adquiridoEn: p.adquiridoEn.toISOString(), + expiraEn: p.expiraEn.toISOString(), + })); + + const adicionales = paquetesDetail.length > 0 + ? { + total: paquetesDetail.reduce((s, p) => s + p.cantidad, 0), + usados: paquetesDetail.reduce((s, p) => s + p.usados, 0), + disponibles: paquetesDetail.reduce((s, p) => s + p.disponibles, 0), + paquetes: paquetesDetail, + } + : undefined; + + return { + configured: true, + // backward-compat flat: refleja el mensual si existe, sino deja undefined + tipo: mensual?.tipo, + limite: mensual?.limite, + usados: mensual?.usados, + disponibles: mensual?.disponibles, + periodoFin: mensual?.periodoFin, + // nuevo shape nested + mensual, + adicionales, + totalDisponibles: (mensual?.disponibles || 0) + (adicionales?.disponibles || 0), + }; +} + +/** + * Consume 1 timbre respetando las reglas del feature: + * 1) Intenta contra TimbreSuscripcion si está en periodo vigente y queda cupo. + * 2) Si mensual agotado/vencido, consume del TimbrePaquete con menor expiraEn + * (FIFO para no desperdiciar los próximos a vencer). + * 3) Si no hay nada disponible, lanza error. + * + * La transacción protege contra race conditions en emisiones concurrentes. + */ +export async function consumeTimbre(tenantId: string): Promise<{ source: 'mensual' | 'paquete'; paqueteId?: number }> { + return await prisma.$transaction(async (tx) => { + const now = new Date(); + + // 1) Intenta mensual + const suscripcion = await tx.timbreSuscripcion.findUnique({ where: { tenantId } }); + if (suscripcion && now <= suscripcion.periodoFin && suscripcion.timbresUsados < suscripcion.timbresLimite) { + await tx.timbreSuscripcion.update({ + where: { tenantId }, + data: { timbresUsados: { increment: 1 } }, + }); + return { source: 'mensual' as const }; + } + + // 2) Fallback a paquetes adicionales FIFO por expiraEn + const paquete = await tx.timbrePaquete.findFirst({ + where: { + tenantId, + expiraEn: { gt: now }, + usados: { lt: prisma.timbrePaquete.fields.cantidad }, + }, + orderBy: { expiraEn: 'asc' }, + }); + + if (!paquete) { + // Diferencia los mensajes de error para que el frontend sepa qué ofrecer + if (!suscripcion) { + throw new Error('No hay suscripción de timbres configurada'); + } + if (now > suscripcion.periodoFin) { + throw new Error('La suscripción de timbres ha expirado. Compra timbres adicionales o renueva tu plan.'); + } + throw new Error('Se agotaron los timbres del plan mensual y no tienes paquetes adicionales. Compra un paquete para continuar.'); + } + + await tx.timbrePaquete.update({ + where: { id: paquete.id }, + data: { usados: { increment: 1 } }, + }); + + return { source: 'paquete' as const, paqueteId: paquete.id }; + }); +} + +/** + * Revierte un consumo previo de timbre. Idempotente por fuente: + * - mensual → decrementa timbresUsados (no baja de 0 gracias al guard) + * - paquete → decrementa usados del paquete específico (mismo guard) + * + * Se invoca cuando la emisión en Facturapi falla después de haber consumido + * (SAT nunca selló → el timbre no debe cobrarse). + */ +export async function refundTimbre( + tenantId: string, + consumed: { source: 'mensual' | 'paquete'; paqueteId?: number }, +): Promise { + await prisma.$transaction(async (tx) => { + if (consumed.source === 'mensual') { + const sub = await tx.timbreSuscripcion.findUnique({ where: { tenantId } }); + if (sub && sub.timbresUsados > 0) { + await tx.timbreSuscripcion.update({ + where: { tenantId }, + data: { timbresUsados: { decrement: 1 } }, + }); + } + return; + } + if (consumed.source === 'paquete' && consumed.paqueteId != null) { + const pkg = await tx.timbrePaquete.findUnique({ where: { id: consumed.paqueteId } }); + if (pkg && pkg.usados > 0) { + await tx.timbrePaquete.update({ + where: { id: consumed.paqueteId }, + data: { usados: { decrement: 1 } }, + }); + } + } + }); +} + +/** + * Reset mensual de TimbreSuscripcion: para cada tenant cuyo periodoFin ya pasó, + * resetea `timbresUsados=0` y avanza la ventana un mes (tipo='mensual') o un año + * (tipo='anual'). Usado por cron diario. Idempotente: si no hay vencidas, no-op. + * + * Los paquetes adicionales NO se tocan aquí — su vigencia es 1 año fijo desde + * la compra y el filtro `expiraEn > now` los excluye automáticamente cuando + * caducan. + */ +export async function resetExpiredMonthlyTimbres(): Promise<{ reset: number }> { + const now = new Date(); + const vencidas = await prisma.timbreSuscripcion.findMany({ + where: { periodoFin: { lt: now } }, + }); + + let count = 0; + for (const s of vencidas) { + const nextInicio = new Date(s.periodoFin); + nextInicio.setDate(nextInicio.getDate() + 1); + const nextFin = new Date(nextInicio); + if (s.tipo === 'anual') { + nextFin.setFullYear(nextFin.getFullYear() + 1); + nextFin.setDate(nextFin.getDate() - 1); + } else { + nextFin.setMonth(nextFin.getMonth() + 1); + nextFin.setDate(nextFin.getDate() - 1); + } + + await prisma.timbreSuscripcion.update({ + where: { id: s.id }, + data: { + timbresUsados: 0, + periodoInicio: nextInicio, + periodoFin: nextFin, + }, + }); + count++; + console.log(`[Timbres] Reset mensual tenant ${s.tenantId}: nuevo periodo ${nextInicio.toISOString().split('T')[0]} → ${nextFin.toISOString().split('T')[0]}`); + } + + return { reset: count }; +} + +// ============================================ +// Paquetes adicionales: catálogo + compra + activación +// ============================================ + +/** Lista los paquetes activos del catálogo, ordenados por cantidad ASC. */ +export async function listPaquetesCatalogo() { + const rows = await prisma.timbrePaqueteCatalogo.findMany({ + where: { active: true }, + orderBy: { cantidad: 'asc' }, + }); + return rows.map(r => ({ + id: r.id, + cantidad: r.cantidad, + precio: Number(r.precio), + })); +} + +/** + * Lista todos los paquetes del catálogo (incluyendo inactivos). Para admin + * global que edita precios — necesita ver los dados de baja también. + */ +export async function listAllPaquetesCatalogo() { + const rows = await prisma.timbrePaqueteCatalogo.findMany({ + orderBy: { cantidad: 'asc' }, + }); + return rows.map(r => ({ + id: r.id, + cantidad: r.cantidad, + precio: Number(r.precio), + active: r.active, + updatedAt: r.updatedAt.toISOString(), + })); +} + +/** + * Actualiza precio y/o estado activo de un paquete del catálogo. Solo admin + * global. Los cambios NO afectan paquetes ya vendidos (TimbrePaquete guarda + * snapshot del precio al momento de compra). + */ +export async function updatePaqueteCatalogo(params: { + id: number; + precio?: number; + active?: boolean; +}) { + const data: { precio?: number; active?: boolean } = {}; + if (params.precio !== undefined) { + if (params.precio <= 0) throw new Error('El precio debe ser mayor a 0'); + data.precio = params.precio; + } + if (params.active !== undefined) data.active = params.active; + + if (Object.keys(data).length === 0) { + throw new Error('Nada que actualizar'); + } + + const updated = await prisma.timbrePaqueteCatalogo.update({ + where: { id: params.id }, + data, + }); + + return { + id: updated.id, + cantidad: updated.cantidad, + precio: Number(updated.precio), + active: updated.active, + updatedAt: updated.updatedAt.toISOString(), + }; +} + +/** + * Inicia la compra de un paquete. Crea un Payment con status=pending y una + * MP Preference (checkout one-shot). Retorna la URL a la que redirigir al user. + * + * Flujo post-pago: + * 1. User paga en MP + * 2. MP dispara webhook `payment.approved` con external_reference = `timbres-pack:{paymentId}` + * 3. `applyApprovedTimbrePack` (fase webhook) crea el TimbrePaquete y emite factura + */ +export async function iniciarCompraPaquete(params: { + tenantId: string; + catalogoId: number; + callerEmail: string; // email del user que inicia la compra (caller) +}): Promise<{ paymentId: string; checkoutUrl: string }> { + const paquete = await prisma.timbrePaqueteCatalogo.findUnique({ + where: { id: params.catalogoId }, + }); + if (!paquete || !paquete.active) { + throw new Error('Paquete no disponible'); + } + + // Email de pago: preferencia al owner del tenant (para continuidad del flujo + // normal de facturación). Si no hay owner activo (caso edge: tenant sin + // membership owner por ahora), caemos al email del caller para no bloquear. + const ownerEmail = await getTenantOwnerEmail(params.tenantId); + const payerEmail = ownerEmail || params.callerEmail; + if (!payerEmail) { + throw new Error('No se pudo determinar un email para el cobro'); + } + + // Payment pre-creado como pending. El webhook lo aprueba. Guardamos cantidad + // en un campo que podamos recuperar — usamos paymentMethod como placeholder + // para carry-over del cantidad mientras no haya un campo metadata dedicado. + // Mejor: el paqueteId del catálogo es único y persistente, no hace falta + // guardar cantidad aparte. + const payment = await prisma.payment.create({ + data: { + tenantId: params.tenantId, + amount: paquete.precio, + status: 'pending', + kind: 'timbres_pack', + paymentMethod: `catalogo:${paquete.id}`, // marker para recuperar cantidad en webhook + }, + }); + + const { preferenceId, checkoutUrl } = await mpService.createTimbrePackPreference({ + paymentId: payment.id, + tenantId: params.tenantId, + cantidad: paquete.cantidad, + amount: Number(paquete.precio), + payerEmail, + }); + + // Guardamos el preferenceId por si lo necesitamos para debugging o cancel + await prisma.payment.update({ + where: { id: payment.id }, + data: { mpPaymentId: preferenceId }, // temporal hasta que llegue el paymentId real + }); + + return { paymentId: payment.id, checkoutUrl }; +} + +/** + * Activa el paquete una vez que MP confirmó el pago. Idempotente: si ya hay un + * TimbrePaquete para este paymentId, no-op. + * + * Llamado desde el webhook cuando external_reference = timbres-pack:{paymentId} + * Y status=approved. + */ +export async function activarPaqueteTrasPago(paymentId: string): Promise<{ created: boolean; paqueteId?: number }> { + const existing = await prisma.timbrePaquete.findUnique({ + where: { paymentId }, + }); + if (existing) { + console.log(`[Timbres] Paquete ya activado para payment ${paymentId} (idempotente)`); + return { created: false, paqueteId: existing.id }; + } + + const payment = await prisma.payment.findUnique({ where: { id: paymentId } }); + if (!payment) throw new Error(`Payment ${paymentId} no encontrado`); + if (payment.kind !== 'timbres_pack') { + throw new Error(`Payment ${paymentId} no es de tipo timbres_pack`); + } + + // Recupera cantidad del marker paymentMethod (catalogo:ID) + const match = /^catalogo:(\d+)$/.exec(payment.paymentMethod || ''); + if (!match) { + throw new Error(`Payment ${paymentId} sin marker de catálogo válido`); + } + const catalogoId = Number(match[1]); + const catalogo = await prisma.timbrePaqueteCatalogo.findUnique({ where: { id: catalogoId } }); + if (!catalogo) { + throw new Error(`Catálogo ${catalogoId} referenciado por Payment ${paymentId} ya no existe`); + } + + const adquiridoEn = new Date(); + const expiraEn = new Date(adquiridoEn); + expiraEn.setFullYear(expiraEn.getFullYear() + 1); + + const paquete = await prisma.timbrePaquete.create({ + data: { + tenantId: payment.tenantId, + paymentId: payment.id, + cantidad: catalogo.cantidad, + precio: payment.amount, // precio pagado (historial, snapshot del momento) + adquiridoEn, + expiraEn, + }, + }); + + console.log(`[Timbres] Activado paquete ${paquete.id} (${catalogo.cantidad} timbres) para tenant ${payment.tenantId}, expira ${expiraEn.toISOString().split('T')[0]}`); + return { created: true, paqueteId: paquete.id }; +} diff --git a/apps/api/src/services/fiel.service.ts b/apps/api/src/services/fiel.service.ts new file mode 100644 index 0000000..b16c35f --- /dev/null +++ b/apps/api/src/services/fiel.service.ts @@ -0,0 +1,313 @@ +import { Credential } from '@nodecfdi/credentials/node'; +import { writeFile, mkdir } from 'fs/promises'; +import { join } from 'path'; +import { prisma } from '../config/database.js'; +import { env } from '../config/env.js'; +import { encryptFielCredentials, encrypt, decryptFielCredentials } from './sat/sat-crypto.service.js'; +import { emailService } from './email/email.service.js'; +import type { FielStatus } from '@horux/shared'; + +/** + * Sube y valida credenciales FIEL + */ +export async function uploadFiel( + tenantId: string, + cerBase64: string, + keyBase64: string, + password: string +): Promise<{ success: boolean; message: string; status?: FielStatus }> { + try { + // Decodificar archivos de Base64 + const cerData = Buffer.from(cerBase64, 'base64'); + const keyData = Buffer.from(keyBase64, 'base64'); + + // Validar que los archivos sean válidos y coincidan + let credential: Credential; + try { + credential = Credential.create( + cerData.toString('binary'), + keyData.toString('binary'), + password + ); + } catch (error: any) { + return { + success: false, + message: 'Los archivos de la FIEL no son válidos o la contraseña es incorrecta', + }; + } + + // Verificar que sea una FIEL (no CSD) + if (!credential.isFiel()) { + return { + success: false, + message: 'El certificado proporcionado no es una FIEL (e.firma). Parece ser un CSD.', + }; + } + + // Obtener información del certificado + const certificate = credential.certificate(); + const rfc = certificate.rfc(); + const serialNumber = certificate.serialNumber().bytes(); + // validFromDateTime() y validToDateTime() retornan strings ISO o objetos DateTime + const validFromRaw = certificate.validFromDateTime(); + const validUntilRaw = certificate.validToDateTime(); + const validFrom = new Date(String(validFromRaw)); + const validUntil = new Date(String(validUntilRaw)); + + // Verificar que no esté vencida + if (new Date() > validUntil) { + return { + success: false, + message: 'La FIEL está vencida desde ' + validUntil.toLocaleDateString(), + }; + } + + // Encriptar credenciales (per-component IV/tag) + const { + encryptedCer, + encryptedKey, + encryptedPassword, + cerIv, + cerTag, + keyIv, + keyTag, + passwordIv, + passwordTag, + } = encryptFielCredentials(cerData, keyData, password); + + // Detectar si es la primera subida (no existe fielCredential previo activo) + // — se usa abajo para disparar Opinión de Cumplimiento + CSF iniciales. + const existingFiel = await prisma.fielCredential.findUnique({ + where: { tenantId }, + select: { isActive: true }, + }); + const esPrimeraSubida = !existingFiel || !existingFiel.isActive; + + // Guardar o actualizar en BD + await prisma.fielCredential.upsert({ + where: { tenantId }, + create: { + tenantId, + rfc, + cerData: encryptedCer, + keyData: encryptedKey, + keyPasswordEncrypted: encryptedPassword, + cerIv, + cerTag, + keyIv, + keyTag, + passwordIv, + passwordTag, + serialNumber, + validFrom, + validUntil, + isActive: true, + }, + update: { + rfc, + cerData: encryptedCer, + keyData: encryptedKey, + keyPasswordEncrypted: encryptedPassword, + cerIv, + cerTag, + keyIv, + keyTag, + passwordIv, + passwordTag, + serialNumber, + validFrom, + validUntil, + isActive: true, + updatedAt: new Date(), + }, + }); + + // Save encrypted files to filesystem (dual storage) + try { + const fielDir = join(env.FIEL_STORAGE_PATH, rfc.toUpperCase()); + await mkdir(fielDir, { recursive: true, mode: 0o700 }); + + // Re-encrypt for filesystem (independent keys from DB) + const fsEncrypted = encryptFielCredentials(cerData, keyData, password); + + await writeFile(join(fielDir, 'certificate.cer.enc'), fsEncrypted.encryptedCer, { mode: 0o600 }); + await writeFile(join(fielDir, 'certificate.cer.iv'), fsEncrypted.cerIv, { mode: 0o600 }); + await writeFile(join(fielDir, 'certificate.cer.tag'), fsEncrypted.cerTag, { mode: 0o600 }); + await writeFile(join(fielDir, 'private_key.key.enc'), fsEncrypted.encryptedKey, { mode: 0o600 }); + await writeFile(join(fielDir, 'private_key.key.iv'), fsEncrypted.keyIv, { mode: 0o600 }); + await writeFile(join(fielDir, 'private_key.key.tag'), fsEncrypted.keyTag, { mode: 0o600 }); + + // Encrypt and store metadata + const metadata = JSON.stringify({ + serial: serialNumber, + validFrom: validFrom.toISOString(), + validUntil: validUntil.toISOString(), + uploadedAt: new Date().toISOString(), + rfc: rfc.toUpperCase(), + }); + const metaEncrypted = encrypt(Buffer.from(metadata, 'utf-8')); + await writeFile(join(fielDir, 'metadata.json.enc'), metaEncrypted.encrypted, { mode: 0o600 }); + await writeFile(join(fielDir, 'metadata.json.iv'), metaEncrypted.iv, { mode: 0o600 }); + await writeFile(join(fielDir, 'metadata.json.tag'), metaEncrypted.tag, { mode: 0o600 }); + } catch (fsError) { + console.error('[FIEL] Filesystem storage failed (DB storage OK):', fsError); + } + + // Notify admin that client uploaded FIEL + const tenant = await prisma.tenant.findUnique({ + where: { id: tenantId }, + select: { nombre: true, rfc: true }, + }); + if (tenant) { + emailService.sendFielNotification({ + clienteNombre: tenant.nombre, + clienteRfc: tenant.rfc, + }).catch(err => console.error('[EMAIL] FIEL notification failed:', err)); + } + + // Al primer upload de FIEL, disparar Opinión de Cumplimiento + CSF en + // background. Fire-and-forget — no bloqueamos la respuesta porque ambos + // procesos abren Playwright y tardan minutos. La CSF además autocompleta + // domicilio y regímenes activos del tenant. + if (esPrimeraSubida) { + import('./opinion-cumplimiento.service.js').then(({ consultarOpinion }) => + consultarOpinion(tenantId), + ).catch(err => console.error(`[FIEL first-upload] Opinión falló para tenant ${tenantId}:`, err.message || err)); + + import('./constancia.service.js').then(({ consultarConstancia }) => + consultarConstancia(tenantId), + ).catch(err => console.error(`[FIEL first-upload] CSF falló para tenant ${tenantId}:`, err.message || err)); + } + + const daysUntilExpiration = Math.ceil( + (validUntil.getTime() - Date.now()) / (1000 * 60 * 60 * 24) + ); + + return { + success: true, + message: 'FIEL configurada correctamente', + status: { + configured: true, + rfc, + serialNumber, + validFrom: validFrom.toISOString(), + validUntil: validUntil.toISOString(), + isExpired: false, + daysUntilExpiration, + }, + }; + } catch (error: any) { + console.error('[FIEL Upload Error]', error); + return { + success: false, + message: error.message || 'Error al procesar la FIEL', + }; + } +} + +/** + * Obtiene el estado de la FIEL de un tenant + */ +export async function getFielStatus(tenantId: string): Promise { + const fiel = await prisma.fielCredential.findUnique({ + where: { tenantId }, + select: { + rfc: true, + serialNumber: true, + validFrom: true, + validUntil: true, + isActive: true, + }, + }); + + if (!fiel || !fiel.isActive) { + return { configured: false }; + } + + const now = new Date(); + const isExpired = now > fiel.validUntil; + const daysUntilExpiration = Math.ceil( + (fiel.validUntil.getTime() - now.getTime()) / (1000 * 60 * 60 * 24) + ); + + return { + configured: true, + rfc: fiel.rfc, + serialNumber: fiel.serialNumber || undefined, + validFrom: fiel.validFrom.toISOString(), + validUntil: fiel.validUntil.toISOString(), + isExpired, + daysUntilExpiration: isExpired ? 0 : daysUntilExpiration, + }; +} + +/** + * Elimina la FIEL de un tenant + */ +export async function deleteFiel(tenantId: string): Promise { + try { + await prisma.fielCredential.delete({ + where: { tenantId }, + }); + return true; + } catch { + return false; + } +} + +/** + * Obtiene las credenciales desencriptadas para usar en sincronización + * Solo debe usarse internamente por el servicio de SAT + */ +export async function getDecryptedFiel(tenantId: string): Promise<{ + cerContent: string; + keyContent: string; + password: string; + rfc: string; +} | null> { + const fiel = await prisma.fielCredential.findUnique({ + where: { tenantId }, + }); + + if (!fiel || !fiel.isActive) { + return null; + } + + // Verificar que no esté vencida + if (new Date() > fiel.validUntil) { + return null; + } + + try { + // Desencriptar credenciales (per-component IV/tag) + const { cerData, keyData, password } = decryptFielCredentials( + Buffer.from(fiel.cerData), + Buffer.from(fiel.keyData), + Buffer.from(fiel.keyPasswordEncrypted), + Buffer.from(fiel.cerIv), + Buffer.from(fiel.cerTag), + Buffer.from(fiel.keyIv), + Buffer.from(fiel.keyTag), + Buffer.from(fiel.passwordIv), + Buffer.from(fiel.passwordTag) + ); + + return { + cerContent: cerData.toString('binary'), + keyContent: keyData.toString('binary'), + password, + rfc: fiel.rfc, + }; + } catch (error) { + console.error('[FIEL Decrypt Error]', error); + return null; + } +} + +/** + * Verifica si un tenant tiene FIEL configurada y válida + */ +export async function hasFielConfigured(tenantId: string): Promise { + const status = await getFielStatus(tenantId); + return status.configured && !status.isExpired; +} diff --git a/apps/api/src/services/impuestos.service.ts b/apps/api/src/services/impuestos.service.ts new file mode 100644 index 0000000..446df7e --- /dev/null +++ b/apps/api/src/services/impuestos.service.ts @@ -0,0 +1,1150 @@ +import type { Pool } from 'pg'; +import type { IvaMensual, IsrMensual, ResumenIva, IvaRegimenDetalle, ResumenIsr } from '@horux/shared'; +import { getRegimenesIgnoradosClaves } from './regimen.service.js'; +import { + calcularIngresosPorRegimen, + calcularEgresosPorRegimen, + calcularNcsEmitidasPorRegimen, + calcularNcsRecibidasPorRegimen, + calcularGastosNoDeduciblesEfectivoPorRegimen, + calcularIvaNoAcreditableEfectivoPorRegimen, + NO_DEDUCIBLE_EFECTIVO_I_PUE, + NO_DEDUCIBLE_EFECTIVO_P, +} from './dashboard.service.js'; +import { prisma } from '../config/database.js'; +import { resolveContribuyenteContext } from '../utils/contribuyente-context.js'; +import { planCache, type CacheRange } from '../utils/metricas-cache.js'; +import { buildExtraFilters, buildExtraFiltersAlias } from './_shared/cfdi-filters.js'; + +const VIGENTE = `status NOT IN ('Cancelado', '0')`; +// Para cálculos de IVA/ISR la fecha efectiva depende del tipo de comprobante: +// - tipo P (complemento de pago): fecha real del cobro (fecha_pago_p) +// - otros tipos (I, E, T, N): fecha_emision del comprobante +// El CASE se evalúa por fila, garantizando que un P emitido en mayo por un pago +// real de noviembre quede contabilizado en noviembre. +const FECHA_EFECTIVA = `CASE WHEN tipo_comprobante = 'P' THEN fecha_pago_p ELSE fecha_emision END`; +const FECHA_RANGO = `${FECHA_EFECTIVA} >= $1::date AND ${FECHA_EFECTIVA} < ($2::date + interval '1 day')`; +const FECHA_RANGO_CONCILIACION = `id_conciliacion IS NOT NULL AND id_conciliacion IN ( + SELECT id FROM conciliaciones WHERE fecha_de_pago >= $1::date AND fecha_de_pago < ($2::date + interval '1 day') +)`; +function getFR(conciliacion?: boolean): string { + return conciliacion ? FECHA_RANGO_CONCILIACION : FECHA_RANGO; +} +const TODOS_REGIMENES = ['605', '606', '612', '621', '625', '626', '601', '603', '607', '608', '610', '611', '614', '615', '620', '622', '623', '624']; + +// Claves de producto/servicio excluidas de cálculos fiscales +const CLAVES_EXCLUIDAS = `('84121603','93161608','85101501','85121800')`; +const EXCL_IVA_TRAS = `COALESCE((SELECT SUM(COALESCE(cc.iva_traslado_mxn,0)) FROM cfdi_conceptos cc WHERE cc.cfdi_id = cfdis.id AND cc.clave_prod_serv IN ${CLAVES_EXCLUIDAS}), 0)`; +const EXCL_IVA_RET = `COALESCE((SELECT SUM(COALESCE(cc.iva_retencion_mxn,0)) FROM cfdi_conceptos cc WHERE cc.cfdi_id = cfdis.id AND cc.clave_prod_serv IN ${CLAVES_EXCLUIDAS}), 0)`; +const EXCL_ISR_RET = `COALESCE((SELECT SUM(COALESCE(cc.isr_retencion_mxn,0)) FROM cfdi_conceptos cc WHERE cc.cfdi_id = cfdis.id AND cc.clave_prod_serv IN ${CLAVES_EXCLUIDAS}), 0)`; + +// Fragmentos IVA: CFDIs tipo P usan campos `_pago_mxn` directos; los demás +// tipos usan los campos base del CFDI restando IVA de conceptos excluidos +// (claves prod/serv 84121603, 93161608, 85101501, 85121800). +// +// Los campos `_pago_mxn` se usan tal cual sin clamp — el spec del usuario +// los toma directos. (El clamp `LEAST(iva, monto*0.16)` defendía contra XMLs +// que reportaban IVA de la factura completa en P parciales; se removió a +// petición del owner; ver doc 2026-04-26-iva-refactor.md.) +const IVA_TRAS_EXPR = `CASE WHEN tipo_comprobante = 'P' + THEN COALESCE(iva_traslado_pago_mxn, 0) + ELSE COALESCE(iva_traslado_mxn, 0) - (${EXCL_IVA_TRAS}) +END`; +const IVA_RET_EXPR = `CASE WHEN tipo_comprobante = 'P' + THEN COALESCE(iva_retencion_pago_mxn, 0) + ELSE COALESCE(iva_retencion_mxn, 0) - (${EXCL_IVA_RET}) +END`; + +// Versiones parametrizadas por alias — usadas en subqueries que resuelven +// las E que cancelan I PPD/07 (rama nueva). Mismo tratamiento sin clamp. +const EXCL_IVA_TRAS_ALIAS = (alias: string) => + `COALESCE((SELECT SUM(COALESCE(cc.iva_traslado_mxn, 0)) FROM cfdi_conceptos cc WHERE cc.cfdi_id = ${alias}.id AND cc.clave_prod_serv IN ${CLAVES_EXCLUIDAS}), 0)`; +const EXCL_IVA_RET_ALIAS = (alias: string) => + `COALESCE((SELECT SUM(COALESCE(cc.iva_retencion_mxn, 0)) FROM cfdi_conceptos cc WHERE cc.cfdi_id = ${alias}.id AND cc.clave_prod_serv IN ${CLAVES_EXCLUIDAS}), 0)`; +const IVA_TRAS_EXPR_ALIAS = (alias: string) => `CASE WHEN ${alias}.tipo_comprobante = 'P' + THEN COALESCE(${alias}.iva_traslado_pago_mxn, 0) + ELSE COALESCE(${alias}.iva_traslado_mxn, 0) - (${EXCL_IVA_TRAS_ALIAS(alias)}) +END`; +const IVA_RET_EXPR_ALIAS = (alias: string) => `CASE WHEN ${alias}.tipo_comprobante = 'P' + THEN COALESCE(${alias}.iva_retencion_pago_mxn, 0) + ELSE COALESCE(${alias}.iva_retencion_mxn, 0) - (${EXCL_IVA_RET_ALIAS(alias)}) +END`; + +/** + * Condición que identifica I PPD con TipoRelacion=07 (aplicación de anticipo + * en operación PPD). La PPD no aporta IVA en su mes de emisión (se causa al + * cobrar via tipo P). Cuando existe una E del mismo mes/año que la referencia + * y la cancela, la E resta IVA en su mes pero la I PPD/07 no aportó nada que + * compensar. La regla (mirror de `i07PpdComp` en dashboard.service.ts) es + * darle a la I PPD/07 el IVA de la E que la cancela, así I PPD + E netean a 0 + * dentro del mes. + */ +const IS_I_PPD_07 = `(tipo_comprobante = 'I' AND metodo_pago = 'PPD' AND COALESCE(cfdi_tipo_relacion, '') = '07')`; + +/** + * Subqueries que suman el IVA_TRAS (ó IVA_RET) de las E del **mismo lado** + * (emisor o receptor del contribuyente) que referencian este CFDI (la I PPD/07) + * en `cfdis_relacionados` y caen en el **mismo mes/año**. Aplicado a I PPD/07 + * via las ramas nuevas en signed exprs: la I PPD/07 hereda como aporte el + * IVA de TODAS las E que la cancelan (sin importar su tipoRelación), + * igualando lo que esas mismas E restan en NEG. + * + * No filtra por tipoRelación: en PPD, las E/07 que referencian la I PPD/07 + * SÍ entran al NEG (vía la condición `E_REFERENCIA_I_PPD_07_MISMO_MES` agregada + * al `bucketCausadoNeg`/`bucketAcreditableNeg`). Como ambas patas — la E + * resta en NEG y la I PPD hereda — usan el mismo set de E's, los IVAs se + * cancelan exactamente dentro del mes. Esto es distinto al triángulo PUE + * (anticipo + I PUE/07 + E/07) donde la E/07 sigue excluida del NEG porque + * apunta al anticipo, no a una I PPD/07. + * + * `esLadoE` es la cláusula `UPPER(rfc_emisor)='X'` o `UPPER(rfc_receptor)='X'` + * traducida al alias `e` (el caller hace el rewrite). Filtrar por mismo + * lado evita capturar E's de otros contribuyentes del tenant que casualmente + * referencien el mismo UUID. + */ +const SUM_E_REFERENCING_TRAS = ( + esLadoE: string, + considerarActivos: boolean, + considerarNCs: boolean, +) => `COALESCE(( + SELECT SUM(${IVA_TRAS_EXPR_ALIAS('e')}) + FROM cfdis e + WHERE e.tipo_comprobante = 'E' + AND e.metodo_pago = 'PUE' + AND e.status NOT IN ('Cancelado', '0') + AND ${esLadoE} + AND LOWER(cfdis.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|')) + AND date_trunc('month', e.fecha_emision) + = date_trunc('month', cfdis.fecha_emision)${buildExtraFiltersAlias('e', considerarActivos, considerarNCs)} +), 0)`; +const SUM_E_REFERENCING_RET = ( + esLadoE: string, + considerarActivos: boolean, + considerarNCs: boolean, +) => `COALESCE(( + SELECT SUM(${IVA_RET_EXPR_ALIAS('e')}) + FROM cfdis e + WHERE e.tipo_comprobante = 'E' + AND e.metodo_pago = 'PUE' + AND e.status NOT IN ('Cancelado', '0') + AND ${esLadoE} + AND LOWER(cfdis.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|')) + AND date_trunc('month', e.fecha_emision) + = date_trunc('month', cfdis.fecha_emision)${buildExtraFiltersAlias('e', considerarActivos, considerarNCs)} +), 0)`; +// Régimen del contribuyente según su lado: emisor/receptor del CFDI. +// Usa el RFC del contribuyente (via `ctx.esEmisor`/`ctx.esReceptor`) para +// determinar el lado, no el `type` de BD. +const regimenTenantExpr = (ctx: { esEmisor: string; esReceptor: string }) => + `CASE WHEN ${ctx.esEmisor} THEN regimen_fiscal_emisor + WHEN ${ctx.esReceptor} THEN regimen_fiscal_receptor + ELSE NULL END`; + +/** + * Predicado EXISTS que detecta si el CFDI actual (alias implícito `cfdis`) es + * referenciado en `cfdis_relacionados` por al menos una E del **mismo lado** + * y **mismo mes/año**. Usado para incluir I PPD/07 en los buckets Any — sin + * esto, las I PPD/07 quedan fuera del WHERE y las ramas nuevas en signed + * exprs nunca se evalúan. No filtra tipoRelación: en PPD cualquier E que + * referencie la I PPD/07 cuenta (incluyendo las 07, fiscalmente correctas). + */ +const HAS_E_REFERENCING_MISMO_MES = ( + esLadoE: string, + considerarActivos: boolean, + considerarNCs: boolean, +) => `EXISTS ( + SELECT 1 FROM cfdis e + WHERE e.tipo_comprobante = 'E' + AND e.metodo_pago = 'PUE' + AND e.status NOT IN ('Cancelado', '0') + AND ${esLadoE} + AND LOWER(cfdis.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|')) + AND date_trunc('month', e.fecha_emision) + = date_trunc('month', cfdis.fecha_emision)${buildExtraFiltersAlias('e', considerarActivos, considerarNCs)} +)`; + +// Atribución por lado usando RFC en lugar de `type`. Los buckets son +// factories que reciben el context del contribuyente: +// POS: CFDIs que suman (I PUE + P del lado correcto) +// NEG: NC del mismo lado (todas las E PUE, sin filtrar TipoRelación — +// las E/07 también restan, en línea con la lógica del owner que asume +// que el contador emite la E/07 cuando aplica el anticipo). +// Queries deben sumar signed: CASE WHEN POS THEN +X WHEN NEG THEN -X. +// Balance final = Causado − Acreditable. +const bucketCausadoPos = (ctx: { esEmisor: string }) => `( + ${ctx.esEmisor} AND ( + (tipo_comprobante = 'I' AND metodo_pago = 'PUE') + OR tipo_comprobante = 'P' + ) +)`; +const bucketCausadoNeg = (ctx: { esEmisor: string }) => `( + ${ctx.esEmisor} AND tipo_comprobante = 'E' AND metodo_pago = 'PUE' +)`; +// Art. 5 LIVA fracción I: el IVA acreditable requiere que el gasto cumpla los +// requisitos de deducibilidad ISR. Por Art. 27 fracción III LISR, gastos > $2k +// pagados en efectivo NO son deducibles → su IVA tampoco es acreditable. +// Excluimos esas filas del bucket acreditable POS. Para complementos P, +// comparación con monto_pago_mxn (cada P es pago independiente). +const bucketAcreditablePos = (ctx: { esReceptor: string }) => `( + ${ctx.esReceptor} AND ( + (tipo_comprobante = 'I' AND metodo_pago = 'PUE' AND NOT ${NO_DEDUCIBLE_EFECTIVO_I_PUE}) + OR (tipo_comprobante = 'P' AND NOT ${NO_DEDUCIBLE_EFECTIVO_P}) + ) +)`; +// El bucket NEG (NCs) NO se filtra por la regla del efectivo — una NC recibida +// reduce el universo acreditable independiente de cómo se pagó. Si la NC es +// del proveedor cancelando una factura no-acreditable original, el efecto neto +// sigue correcto porque la factura nunca aportó IVA. +const bucketAcreditableNeg = (ctx: { esReceptor: string }) => `( + ${ctx.esReceptor} AND tipo_comprobante = 'E' AND metodo_pago = 'PUE' +)`; + +const bucketCausadoAny = ( + ctx: { esEmisor: string }, + considerarActivos: boolean, + considerarNCs: boolean, +) => { + const esEmisorE = ctx.esEmisor.replace(/\brfc_(emisor|receptor)\b/g, 'e.rfc_$1'); + return `(${bucketCausadoPos(ctx)} OR ${bucketCausadoNeg(ctx)} OR ( + ${ctx.esEmisor} AND ${IS_I_PPD_07} AND ${HAS_E_REFERENCING_MISMO_MES(esEmisorE, considerarActivos, considerarNCs)} + ))`; +}; +const bucketAcreditableAny = ( + ctx: { esReceptor: string }, + considerarActivos: boolean, + considerarNCs: boolean, +) => { + const esReceptorE = ctx.esReceptor.replace(/\brfc_(emisor|receptor)\b/g, 'e.rfc_$1'); + return `(${bucketAcreditablePos(ctx)} OR ${bucketAcreditableNeg(ctx)} OR ( + ${ctx.esReceptor} AND ${IS_I_PPD_07} AND ${HAS_E_REFERENCING_MISMO_MES(esReceptorE, considerarActivos, considerarNCs)} + ))`; +}; + +// Signed SUM expressions. La compensación I PUE/07 se removió a petición del +// owner — las I PUE/07 ahora aportan IVA completo y la E/07 (si se emite) +// resta normalmente vía bucket NEG (que ya no filtra TipoRelación). El owner +// asume que la E/07 se emitirá; si el contador la omite, el IVA del anticipo +// se sobrecausa (vs el flujo previo, que era robusto a E/07 ausente). +// +// Rama I PPD/07 con E del mismo mes: la I PPD/07 hereda el IVA de la E que +// la cancela. Sin esto, la E resta IVA en su mes pero la I PPD/07 nunca +// aportó nada (PPD espera al P). Mirror de `i07PpdComp` en dashboard.service.ts. +// El subquery se filtra por mismo lado (emisor↔emisor o receptor↔receptor) +// usando el predicado `esEmisor`/`esReceptor` reescrito al alias `e`. +const signedCausadoTras = ( + ctx: { esEmisor: string }, + considerarActivos: boolean, + considerarNCs: boolean, +) => { + const esEmisorE = ctx.esEmisor.replace(/\brfc_(emisor|receptor)\b/g, 'e.rfc_$1'); + return `CASE + WHEN ${bucketCausadoPos(ctx)} THEN ${IVA_TRAS_EXPR} + WHEN ${ctx.esEmisor} AND ${IS_I_PPD_07} THEN ${SUM_E_REFERENCING_TRAS(esEmisorE, considerarActivos, considerarNCs)} + WHEN ${bucketCausadoNeg(ctx)} THEN -(${IVA_TRAS_EXPR}) + ELSE 0 + END`; +}; +const signedCausadoRet = ( + ctx: { esEmisor: string }, + considerarActivos: boolean, + considerarNCs: boolean, +) => { + const esEmisorE = ctx.esEmisor.replace(/\brfc_(emisor|receptor)\b/g, 'e.rfc_$1'); + return `CASE + WHEN ${bucketCausadoPos(ctx)} THEN ${IVA_RET_EXPR} + WHEN ${ctx.esEmisor} AND ${IS_I_PPD_07} THEN ${SUM_E_REFERENCING_RET(esEmisorE, considerarActivos, considerarNCs)} + WHEN ${bucketCausadoNeg(ctx)} THEN -(${IVA_RET_EXPR}) + ELSE 0 + END`; +}; +const signedAcreditableTras = ( + ctx: { esReceptor: string }, + considerarActivos: boolean, + considerarNCs: boolean, +) => { + const esReceptorE = ctx.esReceptor.replace(/\brfc_(emisor|receptor)\b/g, 'e.rfc_$1'); + return `CASE + WHEN ${bucketAcreditablePos(ctx)} THEN ${IVA_TRAS_EXPR} + WHEN ${ctx.esReceptor} AND ${IS_I_PPD_07} THEN ${SUM_E_REFERENCING_TRAS(esReceptorE, considerarActivos, considerarNCs)} + WHEN ${bucketAcreditableNeg(ctx)} THEN -(${IVA_TRAS_EXPR}) + ELSE 0 + END`; +}; +const signedAcreditableRet = ( + ctx: { esReceptor: string }, + considerarActivos: boolean, + considerarNCs: boolean, +) => { + const esReceptorE = ctx.esReceptor.replace(/\brfc_(emisor|receptor)\b/g, 'e.rfc_$1'); + return `CASE + WHEN ${bucketAcreditablePos(ctx)} THEN ${IVA_RET_EXPR} + WHEN ${ctx.esReceptor} AND ${IS_I_PPD_07} THEN ${SUM_E_REFERENCING_RET(esReceptorE, considerarActivos, considerarNCs)} + WHEN ${bucketAcreditableNeg(ctx)} THEN -(${IVA_RET_EXPR}) + ELSE 0 + END`; +}; + +// Regímenes que SIEMPRE restan deducciones para ISR, sin importar PF/PM. +const REGIMENES_RESTA_DEDUCCIONES = ['606', '612']; + +/** + * Determina la fórmula de base gravable para un régimen fiscal dado el tipo + * de persona (PF o PM via rfcLength). + * + * - `ingresos-deducciones`: base = max(0, ingresos − deducciones) + * - `ingresos`: base = max(0, ingresos) (tasa plana en RESICO PF) + * + * Los regímenes 606 (Arrendamiento) y 612 (PF Empresarial) siempre restan. + * El régimen 626 (RESICO) distingue por tipo de persona: PM (RFC 12) resta + * deducciones, PF (RFC 13) usa tasa plana sobre ingresos. Otros regímenes PM + * (601, 603, 607…) no restan aquí — sus deducciones se consideran vía el + * coeficiente de utilidad en el cálculo del ISR causado (Art. 14 LISR). + * + * Single source of truth — usada por `calcularResumenIsr` (KPI del periodo) + * y `getIsrMensual` (tabla histórico). Duplicar esto antes causó que el + * histórico mostrara `base = ingresos` para RESICO PM. + */ +export function determinarFormulaBaseGravable( + clave: string, + rfcLength: number, +): 'ingresos-deducciones' | 'ingresos' { + if (REGIMENES_RESTA_DEDUCCIONES.includes(clave)) return 'ingresos-deducciones'; + if (clave === '626' && rfcLength === 12) return 'ingresos-deducciones'; + return 'ingresos'; +} + +// Régimen 605 (Sueldos y Salarios): el patrón ya retuvo ISR, no genera +// ingreso/deducción para ISR del contribuyente. Se muestra en Dashboard +// como ingreso general, pero se excluye de cálculos de ISR. +const ISR_EXCLUIR_REGIMEN = `AND regimen_fiscal_emisor != '605'`; +const ISR_EXCLUIR_REGIMEN_REC = `AND regimen_fiscal_receptor != '605'`; + +/** + * Lee IVA mensual agregado por mes desde metricas_mensuales. Solo aplica a + * años cerrados (< año actual) y contribuyente seleccionado. Agrega los 3 + * campos canónicos: iva_trasladado_total, iva_acreditable, iva_retenido_cobrado + * (todos post-refactor, alineados con dashboard). Retorna null si no hay + * filas cacheadas (caller debe caer a on-the-fly). + */ +async function readIvaMensualFromCache( + pool: Pool, + año: number, + contribuyenteId: string, +): Promise | null> { + const safe = contribuyenteId.replace(/[^a-f0-9-]/gi, ''); + if (!safe) return null; + const { rows } = await pool.query<{ mes: number; t: string; a: string; r: string }>(` + SELECT mes, + COALESCE(SUM(iva_trasladado_total), 0)::numeric(14,2) as t, + COALESCE(SUM(iva_acreditable), 0)::numeric(14,2) as a, + COALESCE(SUM(iva_retenido_cobrado), 0)::numeric(14,2) as r + FROM metricas_mensuales + WHERE contribuyente_id = $1 AND anio = $2 + GROUP BY mes + `, [safe, año]); + if (rows.length === 0) return null; + const map = new Map(); + for (const row of rows) { + map.set(Number(row.mes), { t: Number(row.t), a: Number(row.a), r: Number(row.r) }); + } + return map; +} + +/** + * IVA Mensual desglosado: trasladado, acreditable, retenido, resultado por mes. + * + * Usa la misma fórmula canónica que `getResumenIva` (6 buckets, retención neta): + * Trasladado = causado bruto (Emit+I+PUE + Emit+P + Recib+E+PUE) + * Acreditable = acreditable bruto (Recib+I+PUE + Recib+P + Emit+E+PUE) + * Retenido = retención(causado) − retención(acreditable) + * Resultado = T − A − R + * + * Read-through cache: años pasados con contribuyente seleccionado leen de + * `metricas_mensuales`. El año actual y sin-contribuyente siguen on-the-fly. + */ +export async function getIvaMensual( + pool: Pool, + año: number, + tenantId: string, + conciliacion?: boolean, + contribuyenteId?: string | null, + considerarActivos: boolean = true, + considerarNCs: boolean = true, +): Promise { + // Cache read-through: solo si año pasado, sin conciliación, con contribuyente y flags default + const currentYear = new Date().getFullYear(); + const cacheable = + process.env.METRICAS_BYPASS_CACHE !== '1' && + año < currentYear && + !conciliacion && + considerarActivos && + considerarNCs && + !!contribuyenteId; + + let perMes: Map | null = null; + if (cacheable) { + perMes = await readIvaMensualFromCache(pool, año, contribuyenteId!); + } + + if (!perMes) { + // On-the-fly: dos queries agregadas por mes (causado y acreditable), + // mismos buckets que getResumenIva. Filtro por RFC (ctx.esEmisor/esReceptor) + // en vez de `type` para evitar inconsistencias multi-contribuyente. + const FR = getFR(conciliacion); + const ctx = await resolveContribuyenteContext(pool, tenantId, contribuyenteId); + const REGIMEN_TENANT = regimenTenantExpr(ctx); + const añoStart = `${año}-01-01`; + const añoEnd = `${año}-12-31`; + const extra = buildExtraFilters(considerarActivos, considerarNCs); + + const [{ rows: causadoRows }, { rows: acreditableRows }] = await Promise.all([ + pool.query<{ mes: number; trasladado: string; retencion: string }>(` + SELECT EXTRACT(MONTH FROM ${FECHA_EFECTIVA})::int as mes, + COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) as trasladado, + COALESCE(SUM(${signedCausadoRet(ctx, considerarActivos, considerarNCs)}), 0) as retencion + FROM cfdis + WHERE ${bucketCausadoAny(ctx, considerarActivos, considerarNCs)} + AND ${VIGENTE} AND ${FR}${extra} + AND (${REGIMEN_TENANT}) = ANY($3) + GROUP BY mes + `, [añoStart, añoEnd, TODOS_REGIMENES]), + pool.query<{ mes: number; trasladado: string; retencion: string }>(` + SELECT EXTRACT(MONTH FROM ${FECHA_EFECTIVA})::int as mes, + COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) as trasladado, + COALESCE(SUM(${signedAcreditableRet(ctx, considerarActivos, considerarNCs)}), 0) as retencion + FROM cfdis + WHERE ${bucketAcreditableAny(ctx, considerarActivos, considerarNCs)} + AND ${VIGENTE} AND ${FR}${extra} + AND (${REGIMEN_TENANT}) = ANY($3) + GROUP BY mes + `, [añoStart, añoEnd, TODOS_REGIMENES]), + ]); + + perMes = new Map(); + for (const row of causadoRows) { + const acc = perMes.get(Number(row.mes)) || { t: 0, a: 0, r: 0 }; + acc.t += Number(row.trasladado); + acc.r += Number(row.retencion); + perMes.set(Number(row.mes), acc); + } + for (const row of acreditableRows) { + const acc = perMes.get(Number(row.mes)) || { t: 0, a: 0, r: 0 }; + acc.a += Number(row.trasladado); + acc.r -= Number(row.retencion); + perMes.set(Number(row.mes), acc); + } + } + + const result: IvaMensual[] = []; + let acumulado = 0; + + for (let m = 1; m <= 12; m++) { + const monthData = perMes.get(m) || { t: 0, a: 0, r: 0 }; + const t = monthData.t; + const a = monthData.a; + const r = monthData.r; + const resultado = t - a - r; + acumulado += resultado; + + result.push({ + id: 0, + año, + mes: m, + ivaTrasladado: t, + ivaAcreditable: a, + ivaRetenido: r, + resultado, + acumulado, + estado: 'pendiente', + fechaDeclaracion: null, + }); + } + + return result; +} + +/** + * ISR Mensual desglosado: ingresos, deducciones, base gravable por mes del año. + */ +export async function getIsrMensual( + pool: Pool, + año: number, + tenantId: string, + conciliacion?: boolean, + contribuyenteId?: string | null, + regimenClave?: string | null, + considerarActivos: boolean = true, + considerarNCs: boolean = true, +): Promise { + // Reutiliza la misma lógica que las cards (calcular{Ingresos,Egresos}PorRegimen) + // aplicada a cada mes del año. Esto garantiza que la tabla "Histórico ISR" cuadre + // célula a célula con los KPIs del periodo activo (reglas por grupo de régimen, + // resta de notas de crédito, pagos P solo cuentan lo cobrado, etc.). + // El RFC del contribuyente determina si régimen 626 resta deducciones (PM) o no (PF). + const ctx = await resolveContribuyenteContext(pool, tenantId, contribuyenteId); + const rfcLength = ctx.rfcLength; + const result: IsrMensual[] = []; + + for (let m = 1; m <= 12; m++) { + const lastDay = new Date(año, m, 0).getDate(); + const mm = String(m).padStart(2, '0'); + const dd = String(lastDay).padStart(2, '0'); + const fi = `${año}-${mm}-01`; + const ff = `${año}-${mm}-${dd}`; + + const [ingresosData, egresosData, ncsEmData, ncsRecData] = await Promise.all([ + calcularIngresosPorRegimen(pool, tenantId, fi, ff, undefined, undefined, conciliacion, contribuyenteId, considerarActivos, considerarNCs), + calcularEgresosPorRegimen(pool, tenantId, fi, ff, undefined, undefined, conciliacion, contribuyenteId, considerarActivos, considerarNCs), + calcularNcsEmitidasPorRegimen(pool, tenantId, fi, ff, undefined, undefined, conciliacion, contribuyenteId, considerarActivos, considerarNCs), + calcularNcsRecibidasPorRegimen(pool, tenantId, fi, ff, undefined, undefined, conciliacion, contribuyenteId, considerarActivos, considerarNCs), + ]); + + let ing: number; + let ded: number; + let ncsEm: number; + let ncsRec: number; + let base: number; + + if (regimenClave) { + ing = ingresosData.porRegimen.find(r => r.regimenClave === regimenClave)?.monto || 0; + ded = egresosData.porRegimen.find(r => r.regimenClave === regimenClave)?.monto || 0; + ncsEm = ncsEmData.porRegimen.find(r => r.regimenClave === regimenClave)?.monto || 0; + ncsRec = ncsRecData.porRegimen.find(r => r.regimenClave === regimenClave)?.monto || 0; + const formula = determinarFormulaBaseGravable(regimenClave, rfcLength); + base = formula === 'ingresos-deducciones' ? Math.max(0, ing - ded) : Math.max(0, ing); + } else { + // Sin régimen: agregar por régimen aplicando la fórmula correcta a cada + // uno y sumar las bases (no aplicar ing − ded global, porque algunos + // regímenes no restan deducciones — ej. RESICO PF, otros PM). + const regimenesConDatos = new Set([ + ...ingresosData.porRegimen.map(r => r.regimenClave).filter(c => c !== '605'), + ...egresosData.porRegimen.map(r => r.regimenClave).filter(c => c !== '605'), + ]); + ing = 0; + ded = 0; + ncsEm = ncsEmData.porRegimen.filter(r => r.regimenClave !== '605').reduce((s, r) => s + r.monto, 0); + ncsRec = ncsRecData.porRegimen.filter(r => r.regimenClave !== '605').reduce((s, r) => s + r.monto, 0); + base = 0; + for (const clave of regimenesConDatos) { + const ingReg = ingresosData.porRegimen.find(r => r.regimenClave === clave)?.monto || 0; + const dedReg = egresosData.porRegimen.find(r => r.regimenClave === clave)?.monto || 0; + const formula = determinarFormulaBaseGravable(clave, rfcLength); + ing += ingReg; + ded += dedReg; + base += formula === 'ingresos-deducciones' + ? Math.max(0, ingReg - dedReg) + : Math.max(0, ingReg); + } + } + + result.push({ + id: 0, + año, + mes: m, + ingresosAcumulados: ing, + deducciones: ded, + baseGravable: base, + ncsEmitidas: ncsEm, + ncsRecibidas: ncsRec, + ncsEmitidasAcum: 0, // se llena en el segundo pase abajo + ncsRecibidasAcum: 0, + ingresosAcum: 0, + deduccionesAcum: 0, + baseGravableAcum: 0, + isrCausado: 0, + isrRetenido: 0, + isrAPagar: 0, + estado: 'pendiente', + fechaDeclaracion: null, + }); + } + + // Running totals: para cada mes, acumular ingresos y deducciones desde enero + // hasta ese mes inclusive. baseGravableAcum NO se clampa — los déficits se + // muestran negativos en la UI y solo se clampan al pasar a ISR causado. + let ingAcum = 0; + let dedAcum = 0; + let ncsEmAcum = 0; + let ncsRecAcum = 0; + for (const row of result) { + ingAcum += row.ingresosAcumulados; // (campo mensual, naming heredado) + dedAcum += row.deducciones; + ncsEmAcum += row.ncsEmitidas; + ncsRecAcum += row.ncsRecibidas; + row.ingresosAcum = ingAcum; + row.deduccionesAcum = dedAcum; + row.ncsEmitidasAcum = ncsEmAcum; + row.ncsRecibidasAcum = ncsRecAcum; + row.baseGravableAcum = ingAcum - dedAcum; + } + + return result; +} + +/** + * Read-through cache para ResumenIva: lee `iva_trasladado_total`, + * `iva_acreditable`, `iva_retenido_cobrado` desde `metricas_mensuales` cuando + * el rango cae en años pasados con contribuyente seleccionado. Calcula el + * acumulado anual on-the-fly (su rango difiere de fechaInicio-fechaFin). + * + * Si no hay filas cacheadas, retorna `null` y el caller cae al path on-the-fly. + */ +async function readResumenIvaFromCache( + pool: Pool, + range: CacheRange, + fechaInicio: string, + fechaFin: string, + conciliacion: boolean | undefined, + ctx: { esEmisor: string; esReceptor: string }, + considerarActivos: boolean = true, + considerarNCs: boolean = true, +): Promise { + const { rows } = await pool.query<{ + regimen: string; + trasladado: string; + acreditable: string; + retenido: string; + }>(` + SELECT regimen_fiscal AS regimen, + COALESCE(SUM(iva_trasladado_total), 0)::numeric(14,2) AS trasladado, + COALESCE(SUM(iva_acreditable), 0)::numeric(14,2) AS acreditable, + COALESCE(SUM(iva_retenido_cobrado), 0)::numeric(14,2) AS retenido + FROM metricas_mensuales + WHERE contribuyente_id = $1 + AND make_date(anio, mes, 1) BETWEEN $2::date AND $3::date + AND regimen_fiscal IS NOT NULL + GROUP BY regimen_fiscal + `, [range.contribuyenteId, range.startDate, range.endDate]); + + if (rows.length === 0) return null; + + const catalogo = await prisma.regimen.findMany({ where: { activo: true } }); + const descMap = new Map(catalogo.map(r => [r.clave, r.descripcion])); + + let trasladado = 0; + let acreditable = 0; + let retenido = 0; + const trasladadoPorRegimen: IvaRegimenDetalle[] = []; + const acreditablePorRegimen: IvaRegimenDetalle[] = []; + const retenidoPorRegimen: IvaRegimenDetalle[] = []; + + for (const r of rows) { + const tras = Number(r.trasladado); + const acr = Number(r.acreditable); + const ret = Number(r.retenido); + trasladado += tras; + acreditable += acr; + retenido += ret; + const desc = descMap.get(r.regimen) || r.regimen; + if (tras !== 0) trasladadoPorRegimen.push({ regimenClave: r.regimen, regimenDescripcion: desc, monto: tras }); + if (acr !== 0) acreditablePorRegimen.push({ regimenClave: r.regimen, regimenDescripcion: desc, monto: acr }); + if (ret !== 0) retenidoPorRegimen.push({ regimenClave: r.regimen, regimenDescripcion: desc, monto: ret }); + } + + const resultado = trasladado - acreditable - retenido; + + // Acumulado anual: su rango (year-01-01 → fechaFin) difiere del rango cacheado; + // se calcula on-the-fly contra raw cfdis. Una sola query. + const añoInicio = new Date(fechaInicio + 'T00:00:00').getFullYear(); + const acumFR = conciliacion ? FECHA_RANGO_CONCILIACION : FECHA_RANGO; + const REGIMEN_TENANT = regimenTenantExpr(ctx); + const acumRow = (await pool.query(` + SELECT + COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) - + COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) - + ( + COALESCE(SUM(${signedCausadoRet(ctx, considerarActivos, considerarNCs)}), 0) - + COALESCE(SUM(${signedAcreditableRet(ctx, considerarActivos, considerarNCs)}), 0) + ) as total + FROM cfdis + WHERE ${VIGENTE} + AND (${REGIMEN_TENANT}) = ANY($3) + AND ${acumFR} + AND (${ctx.esEmisor} OR ${ctx.esReceptor}) + `, [`${añoInicio}-01-01`, fechaFin, TODOS_REGIMENES])).rows[0]; + + // Cache hit retorna 0/empty para los surface IVA No Acreditable. El cache + // aún no persiste esos campos — si se hace crítico para BI, agregar columna + // `iva_no_acreditable_efectivo` a metricas_mensuales y poblarla en + // `metricas-compute.service.ts`. + return { + trasladado, + trasladadoPorRegimen, + acreditable, + acreditablePorRegimen, + retenido, + retenidoPorRegimen, + resultado, + acumuladoAnual: Number(acumRow?.total || 0), + ivaNoAcreditableEfectivo: 0, + ivaNoAcreditableEfectivoPorRegimen: [], + }; +} + +/** + * Resumen IVA para un rango de fechas, desglosado por régimen. + * + * Alineado con la fórmula del dashboard `calcularIvaBalancePorRegimen`: + * Resultado = Trasladado − Acreditable − Retenido + * + * Donde cada tarjeta usa los mismos 6 buckets del dashboard, pero con + * retención **separada** (en el dashboard la retención está embebida en + * el IVA neto de cada bucket; aquí se exhibe en su propia tarjeta para + * Control de Impuestos). + * + * Trasladado = causado bruto (Emit+I+PUE) + (Emit+P) + (Recib+E+PUE) + * Acreditable = acreditable bruto (Recib+I+PUE) + (Recib+P) + (Emit+E+PUE) + * Retenido = retención(causado) − retención(acreditable) + * + * Algebraicamente: T − A − R == dashboard.balance, céntimo por céntimo. + */ +export async function getResumenIva( + pool: Pool, + fechaInicio: string, + fechaFin: string, + tenantId: string, + conciliacion?: boolean, + contribuyenteId?: string | null, + considerarActivos: boolean = true, + considerarNCs: boolean = true, +): Promise { + const FR = getFR(conciliacion); + const ctx = await resolveContribuyenteContext(pool, tenantId, contribuyenteId); + const REGIMEN_TENANT = regimenTenantExpr(ctx); + const extra = buildExtraFilters(considerarActivos, considerarNCs); + + // Read-through cache (pasa ctx completo para derivar regimen + signed exprs). + // Solo aplica cuando flags son default (true) para garantizar que las queries + // filtradas no lean datos del caché completo. + const cacheRange = + considerarActivos && considerarNCs + ? planCache(fechaInicio, fechaFin, conciliacion, contribuyenteId) + : null; + if (cacheRange) { + const cached = await readResumenIvaFromCache(pool, cacheRange, fechaInicio, fechaFin, conciliacion, ctx, considerarActivos, considerarNCs); + if (cached) return cached; + } + + // Una query por lado (causado / acreditable). Filtro por RFC via + // ctx.esEmisor/esReceptor (embedded en buckets/signed exprs). + const [{ rows: causadoRows }, { rows: acreditableRows }] = await Promise.all([ + pool.query<{ regimen: string | null; trasladado: string; retencion: string }>(` + SELECT ${REGIMEN_TENANT} as regimen, + COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) as trasladado, + COALESCE(SUM(${signedCausadoRet(ctx, considerarActivos, considerarNCs)}), 0) as retencion + FROM cfdis + WHERE ${bucketCausadoAny(ctx, considerarActivos, considerarNCs)} + AND ${VIGENTE} AND ${FR}${extra} + AND (${REGIMEN_TENANT}) = ANY($3) + GROUP BY ${REGIMEN_TENANT} + `, [fechaInicio, fechaFin, TODOS_REGIMENES]), + pool.query<{ regimen: string | null; trasladado: string; retencion: string }>(` + SELECT ${REGIMEN_TENANT} as regimen, + COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) as trasladado, + COALESCE(SUM(${signedAcreditableRet(ctx, considerarActivos, considerarNCs)}), 0) as retencion + FROM cfdis + WHERE ${bucketAcreditableAny(ctx, considerarActivos, considerarNCs)} + AND ${VIGENTE} AND ${FR}${extra} + AND (${REGIMEN_TENANT}) = ANY($3) + GROUP BY ${REGIMEN_TENANT} + `, [fechaInicio, fechaFin, TODOS_REGIMENES]), + ]); + + // Combinar por régimen: el set de régimenes posibles es la unión de ambos lados. + type Acc = { trasCausado: number; retCausado: number; trasAcreditable: number; retAcreditable: number }; + const porRegimen = new Map(); + const ensure = (k: string): Acc => { + let v = porRegimen.get(k); + if (!v) { v = { trasCausado: 0, retCausado: 0, trasAcreditable: 0, retAcreditable: 0 }; porRegimen.set(k, v); } + return v; + }; + for (const r of causadoRows) { + if (!r.regimen) continue; + const acc = ensure(r.regimen); + acc.trasCausado += Number(r.trasladado); + acc.retCausado += Number(r.retencion); + } + for (const r of acreditableRows) { + if (!r.regimen) continue; + const acc = ensure(r.regimen); + acc.trasAcreditable += Number(r.trasladado); + acc.retAcreditable += Number(r.retencion); + } + + const catalogo = await prisma.regimen.findMany({ where: { activo: true } }); + const descMap = new Map(catalogo.map(r => [r.clave, r.descripcion])); + + let trasladado = 0; + let acreditable = 0; + let retenido = 0; + const trasladadoPorRegimen: IvaRegimenDetalle[] = []; + const acreditablePorRegimen: IvaRegimenDetalle[] = []; + const retenidoPorRegimen: IvaRegimenDetalle[] = []; + + for (const [regimen, acc] of porRegimen) { + const tras = acc.trasCausado; + const acre = acc.trasAcreditable; + const ret = acc.retCausado - acc.retAcreditable; + trasladado += tras; + acreditable += acre; + retenido += ret; + const desc = descMap.get(regimen) || regimen; + if (tras !== 0) trasladadoPorRegimen.push({ regimenClave: regimen, regimenDescripcion: desc, monto: tras }); + if (acre !== 0) acreditablePorRegimen.push({ regimenClave: regimen, regimenDescripcion: desc, monto: acre }); + if (ret !== 0) retenidoPorRegimen.push({ regimenClave: regimen, regimenDescripcion: desc, monto: ret }); + } + + const resultado = trasladado - acreditable - retenido; + + // Acumulado anual (misma fórmula T − A − R, pero rango = enero → fechaFin). + const añoInicio = new Date(fechaInicio + 'T00:00:00').getFullYear(); + const acumFR = conciliacion ? FECHA_RANGO_CONCILIACION : FECHA_RANGO; + const { rows: [acumRow] } = await pool.query(` + SELECT + COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) - + COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) - + ( + COALESCE(SUM(${signedCausadoRet(ctx, considerarActivos, considerarNCs)}), 0) - + COALESCE(SUM(${signedAcreditableRet(ctx, considerarActivos, considerarNCs)}), 0) + ) as total + FROM cfdis + WHERE ${VIGENTE} + AND (${REGIMEN_TENANT}) = ANY($3) + AND ${acumFR}${extra} + AND (${ctx.esEmisor} OR ${ctx.esReceptor}) + `, [`${añoInicio}-01-01`, fechaFin, TODOS_REGIMENES]); + + // IVA No Acreditable surface (Art. 5 LIVA fracción I + Art. 27 fracción III LISR). + // No participa en `resultado` — ya excluido del `acreditable` arriba via filtro + // en `bucketAcreditablePos`. Aquí solo se exhibe el monto excluido para el contador. + const noAcreditableData = await calcularIvaNoAcreditableEfectivoPorRegimen( + pool, tenantId, fechaInicio, fechaFin, undefined, undefined, + conciliacion, contribuyenteId, considerarActivos, considerarNCs, + ); + + return { + trasladado, + trasladadoPorRegimen, + acreditable, + acreditablePorRegimen, + retenido, + retenidoPorRegimen, + resultado, + acumuladoAnual: Number(acumRow?.total || 0), + ivaNoAcreditableEfectivo: noAcreditableData.total, + ivaNoAcreditableEfectivoPorRegimen: noAcreditableData.porRegimen, + }; +} + +/** + * Calcula ISR progresivo según tarifa del Art. 96 + */ +async function calcularIsrProgresivo(baseGravable: number, anio: number): Promise { + if (baseGravable <= 0) return 0; + + const tarifas = await prisma.isrTarifa.findMany({ + where: { anio }, + orderBy: { limiteInferior: 'asc' }, + }); + + if (tarifas.length === 0) return 0; + + // Encontrar el rango correcto + let tarifa = tarifas[tarifas.length - 1]; // default: último rango + for (const t of tarifas) { + const ls = t.limiteSuperior ? Number(t.limiteSuperior) : Infinity; + if (baseGravable >= Number(t.limiteInferior) && baseGravable <= ls) { + tarifa = t; + break; + } + } + + const excedente = baseGravable - Number(tarifa.limiteInferior); + const impuestoMarginal = excedente * (Number(tarifa.porcentajeExcedente) / 100); + return Number(tarifa.cuotaFija) + impuestoMarginal; +} + +/** + * Calcula ISR RESICO PF según Art. 113-E + */ +async function calcularIsrResicoPF(ingresos: number, anio: number): Promise { + if (ingresos <= 0) return 0; + + const tasas = await prisma.isrResicoTasa.findMany({ + where: { anio }, + orderBy: { montoMaximo: 'asc' }, + }); + + if (tasas.length === 0) return 0; + + for (const t of tasas) { + if (ingresos <= Number(t.montoMaximo)) { + return ingresos * (Number(t.porcentaje) / 100); + } + } + + // Si supera todos los rangos, usar el último + const ultima = tasas[tasas.length - 1]; + return ingresos * (Number(ultima.porcentaje) / 100); +} + +/** + * Resumen ISR con cálculo por régimen, coeficiente de utilidad, ISR progresivo/RESICO. + */ +export async function getResumenIsr( + pool: Pool, + fechaInicio: string, + fechaFin: string, + tenantId: string, + conciliacion?: boolean, + contribuyenteId?: string | null, + considerarActivos: boolean = true, + considerarNCs: boolean = true, +): Promise { + const FR = getFR(conciliacion); + const extra = buildExtraFilters(considerarActivos, considerarNCs); + + // Ingresos + Deducciones + NCs (emitidas/recibidas) + Gastos no deducibles + // (efectivo > $2k) en paralelo. Las NCs se necesitan en el cálculo de base + // gravable para regímenes con fórmula `ingresos-deducciones` (ver loop abajo). + const [ingresosData, egresosData, ncsEmitidasData, ncsRecibidasData, gastosNoDedData] = await Promise.all([ + calcularIngresosPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId, considerarActivos, considerarNCs), + calcularEgresosPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId, considerarActivos, considerarNCs), + calcularNcsEmitidasPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId, considerarActivos, considerarNCs), + calcularNcsRecibidasPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId, considerarActivos, considerarNCs), + calcularGastosNoDeduciblesEfectivoPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId, considerarActivos, considerarNCs), + ]); + + // RFC del contribuyente (o tenant) para determinar persona moral/física + const ctx = await resolveContribuyenteContext(pool, tenantId, contribuyenteId); + const rfcLength = ctx.rfcLength; + + // Base gravable por régimen + const baseGravablePorRegimen: import('@horux/shared').BaseGravableRegimen[] = []; + + // Todos los regímenes que tienen ingresos/egresos/NCs (excluir 605 — sueldos, + // ISR retenido por patrón) + const regimenesConDatos = new Set([ + ...ingresosData.porRegimen.map(r => r.regimenClave).filter(c => c !== '605'), + ...egresosData.porRegimen.map(r => r.regimenClave).filter(c => c !== '605'), + ...ncsEmitidasData.porRegimen.map(r => r.regimenClave).filter(c => c !== '605'), + ...ncsRecibidasData.porRegimen.map(r => r.regimenClave).filter(c => c !== '605'), + ]); + + const catalogo = await prisma.regimen.findMany({ where: { activo: true } }); + const descMap = new Map(catalogo.map(r => [r.clave, r.descripcion])); + + for (const clave of regimenesConDatos) { + const ing = ingresosData.porRegimen.find(r => r.regimenClave === clave)?.monto || 0; + const ded = egresosData.porRegimen.find(r => r.regimenClave === clave)?.monto || 0; + const ncsEm = ncsEmitidasData.porRegimen.find(r => r.regimenClave === clave)?.monto || 0; + const ncsRec = ncsRecibidasData.porRegimen.find(r => r.regimenClave === clave)?.monto || 0; + + const formula = determinarFormulaBaseGravable(clave, rfcLength); + // Para `ingresos-deducciones` (606, 612, 626 RESICO PM): la base gravable + // ajusta por NCs de ambos lados: + // ingresoNeto = ingresosNominales − ncsEmitidas + // deduccionNeta = deducciones − ncsRecibidas + // base = max(0, ingresoNeto − deduccionNeta) + // = max(0, ingNominales − ncsEm − ded + ncsRec) + // Para `ingresos` (RESICO PF, RIF, Plataformas, PMs Grupo 3): no se aplica + // ajuste de NCs — esos regímenes no restan deducciones aquí (ver + // determinarFormulaBaseGravable). + const baseGravable = formula === 'ingresos-deducciones' + ? Math.max(0, ing - ncsEm - ded + ncsRec) + : Math.max(0, ing); + + if (baseGravable !== 0 || ing !== 0) { + baseGravablePorRegimen.push({ + regimenClave: clave, + regimenDescripcion: descMap.get(clave) || clave, + ingresos: ing, + deducciones: formula === 'ingresos-deducciones' ? ded : 0, + baseGravable, + isrCausado: 0, // calculated below + formula, + }); + } + } + + // Exclude 605 from ISR totals (sueldos — ISR already withheld by employer) + const ingresosPorRegimen = ingresosData.porRegimen + .filter(r => r.regimenClave !== '605') + .map(r => ({ regimenClave: r.regimenClave, regimenDescripcion: r.regimenDescripcion, monto: r.monto })); + const ingresosAcumulados = ingresosPorRegimen.reduce((s, r) => s + r.monto, 0); + const deduccionesPorRegimen = egresosData.porRegimen + .filter(r => r.regimenClave !== '605') + .map(r => ({ regimenClave: r.regimenClave, regimenDescripcion: r.regimenDescripcion, monto: r.monto })); + const deducciones = deduccionesPorRegimen.reduce((s, r) => s + r.monto, 0); + const baseGravableTotal = baseGravablePorRegimen.reduce((s, r) => s + r.baseGravable, 0); + + // ISR Retenido — filtro por RFC del contribuyente (cualquier lado). + const { rows: [retRow] } = await pool.query(` + SELECT COALESCE(SUM(COALESCE(isr_retencion_mxn,0) - (${EXCL_ISR_RET})), 0) as total + FROM cfdis + WHERE ${VIGENTE} AND ${FR}${extra} + AND (${ctx.esEmisor} OR ${ctx.esReceptor}) + `, [fechaInicio, fechaFin]); + + // ISR Causado por régimen + const anio = new Date(fechaFin + 'T00:00:00').getFullYear(); + + // Coeficiente de utilidad del tenant (para PM y otros) + const coefData = await prisma.coeficienteUtilidad.findUnique({ + where: { tenantId_anio: { tenantId, anio } }, + }); + const coeficiente = coefData ? Number(coefData.coeficiente) : 0; + + let isrCausado = 0; + + for (const reg of baseGravablePorRegimen) { + let regIsrCausado = 0; + if (reg.regimenClave === '626' && rfcLength === 13) { + // RESICO PF: tasa plana por bracket (Art. 113-E LISR) + regIsrCausado = await calcularIsrResicoPF(reg.baseGravable, anio); + } else if (reg.regimenClave === '626' && rfcLength === 12) { + // 626 RESICO PM: tasa fija 30% directa sobre la base gravable. + // Comparte la fórmula de base gravable con PF Empresarial + // (`ingresos − ncsEm − ded + ncsRec`) pero NO usa Art. 96 ni + // coeficiente de utilidad — aplicación directa del 30% por decisión + // del cliente (2026-05-02). + regIsrCausado = reg.baseGravable * 0.30; + } else if (['606', '612', '621', '625'].includes(reg.regimenClave)) { + // PF Empresarial: tarifa progresiva Art. 96 + regIsrCausado = await calcularIsrProgresivo(reg.baseGravable, anio); + } else { + // PM Grupo 3: base × coeficiente × tasa (30%) + const basePM = reg.baseGravable * (coeficiente || 0.30); + regIsrCausado = basePM * 0.30; + } + reg.isrCausado = Math.round(regIsrCausado * 100) / 100; + isrCausado += regIsrCausado; + } + + const isrRetenido = Number(retRow?.total || 0); + const isrAPagar = Math.max(0, isrCausado - isrRetenido); + + // ncsEmitidasData + ncsRecibidasData se calcularon up-front en el Promise.all + // de arriba (necesarias para la fórmula de base gravable). Aquí solo se + // exponen en la respuesta para los KPIs surface-only "NCs Emitidas" / + // "NCs Recibidas". + + return { + ingresosAcumulados, + ingresosPorRegimen, + deducciones, + deduccionesPorRegimen, + baseGravable: baseGravableTotal, + baseGravablePorRegimen, + isrCausado: Math.round(isrCausado * 100) / 100, + isrRetenido, + isrAPagar: Math.round(isrAPagar * 100) / 100, + ncsEmitidas: ncsEmitidasData.total, + ncsEmitidasPorRegimen: ncsEmitidasData.porRegimen, + ncsRecibidas: ncsRecibidasData.total, + ncsRecibidasPorRegimen: ncsRecibidasData.porRegimen, + gastosNoDeduciblesEfectivo: gastosNoDedData.total, + gastosNoDeduciblesEfectivoPorRegimen: gastosNoDedData.porRegimen, + }; +} + +/** + * Desglose del cálculo provisional ISR para el mes final del filtro. + * + * Tres llamadas a getResumenIsr con rangos distintos: + * - delPeriodo: solo el mes final del filtro (1 mes calendario) + * - anteriores: enero hasta el mes anterior al final (vacío si mesFinal=1) + * - total: enero hasta el mes final inclusive + * + * Si mesFinal === 1, la rama "anteriores" no llama al backend — retorna ceros + * para evitar un query inútil. + */ +export async function getResumenIsrDesglosado( + pool: Pool, + fechaFin: string, + tenantId: string, + conciliacion?: boolean, + contribuyenteId?: string | null, + considerarActivos: boolean = true, + considerarNCs: boolean = true, +): Promise { + const fechaFinDate = new Date(fechaFin + 'T00:00:00'); + const anio = fechaFinDate.getFullYear(); + const mesFinal = fechaFinDate.getMonth() + 1; // 1-12 + + // Helper para construir rango fin de mes + const mmFinal = String(mesFinal).padStart(2, '0'); + const ultDiaFinal = new Date(anio, mesFinal, 0).getDate(); + const ultDiaFinalStr = String(ultDiaFinal).padStart(2, '0'); + + // delPeriodo: 1er a último día del mes final + const fiPeriodo = `${anio}-${mmFinal}-01`; + const ffPeriodo = `${anio}-${mmFinal}-${ultDiaFinalStr}`; + + // anteriores: enero 1 al último día del (mesFinal - 1). Vacío si mesFinal=1. + let anteriores: import('@horux/shared').ResumenIsr; + if (mesFinal === 1) { + anteriores = emptyResumenIsr(); + } else { + const mesAntes = mesFinal - 1; + const mmAntes = String(mesAntes).padStart(2, '0'); + const ultDiaAntes = new Date(anio, mesAntes, 0).getDate(); + const ultDiaAntesStr = String(ultDiaAntes).padStart(2, '0'); + const fiAnt = `${anio}-01-01`; + const ffAnt = `${anio}-${mmAntes}-${ultDiaAntesStr}`; + anteriores = await getResumenIsr(pool, fiAnt, ffAnt, tenantId, conciliacion, contribuyenteId, considerarActivos, considerarNCs); + } + + // total: enero 1 al último día del mes final + const fiTotal = `${anio}-01-01`; + const ffTotal = `${anio}-${mmFinal}-${ultDiaFinalStr}`; + + const [delPeriodo, total] = await Promise.all([ + getResumenIsr(pool, fiPeriodo, ffPeriodo, tenantId, conciliacion, contribuyenteId, considerarActivos, considerarNCs), + getResumenIsr(pool, fiTotal, ffTotal, tenantId, conciliacion, contribuyenteId, considerarActivos, considerarNCs), + ]); + + return { delPeriodo, anteriores, total, mesFinal, anio }; +} + +function emptyResumenIsr(): import('@horux/shared').ResumenIsr { + return { + ingresosAcumulados: 0, + ingresosPorRegimen: [], + deducciones: 0, + deduccionesPorRegimen: [], + baseGravable: 0, + baseGravablePorRegimen: [], + isrCausado: 0, + isrRetenido: 0, + isrAPagar: 0, + ncsEmitidas: 0, + ncsEmitidasPorRegimen: [], + ncsRecibidas: 0, + ncsRecibidasPorRegimen: [], + gastosNoDeduciblesEfectivo: 0, + gastosNoDeduciblesEfectivoPorRegimen: [], + }; +} + +/** + * Obtener coeficiente de utilidad del tenant para un año. + */ +export async function getCoeficiente(tenantId: string, anio: number) { + const data = await prisma.coeficienteUtilidad.findUnique({ + where: { tenantId_anio: { tenantId, anio } }, + }); + return { anio, coeficiente: data ? Number(data.coeficiente) : null }; +} + +/** + * Establecer coeficiente de utilidad del tenant para un año. + */ +export async function setCoeficiente(tenantId: string, anio: number, coeficiente: number) { + const data = await prisma.coeficienteUtilidad.upsert({ + where: { tenantId_anio: { tenantId, anio } }, + update: { coeficiente }, + create: { tenantId, anio, coeficiente }, + }); + return { anio: data.anio, coeficiente: Number(data.coeficiente) }; +} diff --git a/apps/api/src/services/metabase.service.ts b/apps/api/src/services/metabase.service.ts new file mode 100644 index 0000000..86ab04e --- /dev/null +++ b/apps/api/src/services/metabase.service.ts @@ -0,0 +1,179 @@ +/** + * Metabase integration service. + * Automatically registers newly-provisioned tenant databases in Metabase + * (al crear tenant) y las elimina (al desactivar tenant). Auth via session + * token con cache de 13 días (Metabase los expira a 14). + * + * Variables de entorno (todas opcionales — si METABASE_PASSWORD o + * METABASE_PG_PASSWORD faltan, las llamadas se logean y skipean sin romper): + * METABASE_URL (default http://192.168.10.170:3000) + * METABASE_USERNAME (default ialcarazsalazar@consultoria-as.com) + * METABASE_PASSWORD password de la cuenta admin Metabase + * METABASE_PG_HOST (default 192.168.10.90) + * METABASE_PG_PORT (default 5432) + * METABASE_PG_USER (default postgres) + * METABASE_PG_PASSWORD password Postgres que Metabase usa para conectar + */ + +const METABASE_URL = process.env.METABASE_URL || 'http://192.168.10.170:3000'; +const METABASE_USERNAME = process.env.METABASE_USERNAME || 'ialcarazsalazar@consultoria-as.com'; +const METABASE_PASSWORD = process.env.METABASE_PASSWORD || ''; + +// PostgreSQL connection details exposed to Metabase +const PG_HOST = process.env.METABASE_PG_HOST || '192.168.10.90'; +const PG_PORT = parseInt(process.env.METABASE_PG_PORT || '5432', 10); +const PG_USER = process.env.METABASE_PG_USER || 'postgres'; +const PG_PASSWORD = process.env.METABASE_PG_PASSWORD || ''; + +let cachedSessionToken: string | null = null; +let tokenExpiresAt = 0; + +async function getSessionToken(): Promise { + // Re-use cached token if still valid (Metabase sessions last 2 weeks by default) + if (cachedSessionToken && Date.now() < tokenExpiresAt) { + return cachedSessionToken; + } + + if (!METABASE_PASSWORD) { + console.error('[METABASE] METABASE_PASSWORD not configured'); + return null; + } + + try { + const res = await fetch(`${METABASE_URL}/api/session`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: METABASE_USERNAME, + password: METABASE_PASSWORD, + }), + }); + + if (!res.ok) { + const text = await res.text(); + console.error(`[METABASE] Auth failed: ${res.status} ${text}`); + return null; + } + + const data = await res.json() as { id?: string }; + if (!data.id) { + console.error('[METABASE] Auth response missing session id'); + return null; + } + + cachedSessionToken = data.id; + tokenExpiresAt = Date.now() + 13 * 24 * 60 * 60 * 1000; // 13 days + return cachedSessionToken; + } catch (err) { + console.error('[METABASE] Error fetching session token:', err); + return null; + } +} + +interface RegisterDatabaseInput { + nombre: string; + dbName: string; +} + +export async function registerDatabase(input: RegisterDatabaseInput): Promise { + const sessionToken = await getSessionToken(); + if (!sessionToken) { + console.error('[METABASE] Skipping database registration — no session token'); + return; + } + + if (!PG_PASSWORD) { + console.error('[METABASE] METABASE_PG_PASSWORD not configured'); + return; + } + + const payload = { + name: input.nombre, + engine: 'postgres', + details: { + host: PG_HOST, + port: PG_PORT, + dbname: input.dbName, + user: PG_USER, + password: PG_PASSWORD, + ssl: false, + 'tunnel-enabled': false, + 'advanced-options': false, + 'schema-filters-type': 'all', + }, + auto_run_queries: true, + is_full_sync: true, + is_on_demand: false, + }; + + try { + const res = await fetch(`${METABASE_URL}/api/database`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Metabase-Session': sessionToken, + }, + body: JSON.stringify(payload), + }); + + if (!res.ok) { + const text = await res.text(); + // 409 or duplicate name is not fatal — log and continue + if (res.status === 400 && text.includes('already exists')) { + console.log(`[METABASE] Database "${input.nombre}" already registered`); + return; + } + console.error(`[METABASE] Register database failed: ${res.status} ${text}`); + return; + } + + const data = await res.json() as { id?: number }; + console.log(`[METABASE] Database "${input.nombre}" registered with id=${data.id}`); + } catch (err) { + console.error('[METABASE] Error registering database:', err); + } +} + +export async function deleteDatabase(databaseName: string): Promise { + const sessionToken = await getSessionToken(); + if (!sessionToken) { + console.error('[METABASE] Skipping database deletion — no session token'); + return; + } + + try { + // Find database by name + const listRes = await fetch(`${METABASE_URL}/api/database`, { + headers: { 'X-Metabase-Session': sessionToken }, + }); + + if (!listRes.ok) { + console.error(`[METABASE] Failed to list databases: ${listRes.status}`); + return; + } + + const listData = await listRes.json() as { data?: Array<{ id: number; name: string; details?: { dbname?: string } }> }; + const db = listData.data?.find( + (d) => d.details?.dbname === databaseName || d.name.includes(databaseName) + ); + + if (!db) { + console.log(`[METABASE] No database found for ${databaseName}`); + return; + } + + const deleteRes = await fetch(`${METABASE_URL}/api/database/${db.id}`, { + method: 'DELETE', + headers: { 'X-Metabase-Session': sessionToken }, + }); + + if (!deleteRes.ok) { + console.error(`[METABASE] Delete database failed: ${deleteRes.status}`); + return; + } + + console.log(`[METABASE] Database ${db.id} (${databaseName}) deleted`); + } catch (err) { + console.error('[METABASE] Error deleting database:', err); + } +} diff --git a/apps/api/src/services/metricas-compute.service.ts b/apps/api/src/services/metricas-compute.service.ts new file mode 100644 index 0000000..455d82d --- /dev/null +++ b/apps/api/src/services/metricas-compute.service.ts @@ -0,0 +1,342 @@ +import type { Pool } from 'pg'; +import { prisma, tenantDb } from '../config/database.js'; +import { + calcularIngresosPorRegimen, + calcularEgresosPorRegimen, + calcularNcsEmitidasPorRegimen, + calcularNcsRecibidasPorRegimen, + calcularGastosNoDeduciblesEfectivoPorRegimen, +} from './dashboard.service.js'; +import { getResumenIva } from './impuestos.service.js'; +import { + upsertMetricaMensual, + getPendingInvalidations, + clearInvalidation, +} from './metricas.service.js'; + +/** + * Tanda A — Cimientos del sistema hot/cold de métricas pre-calculadas. + * + * Este módulo calcula las métricas mensuales agregando desde `cfdis` raw, y las + * guarda en la tabla `metricas_mensuales` del tenant para que los consumers las + * lean sin recomputar. Los consumers aún NO leen de la tabla (Tanda B), esto + * solo llena y mantiene la tabla. + * + * Alcance Tanda A (campos poblados): + * - ingresos_cobrados, egresos_pagados (flujo efectivo, respeta grupo de régimen) + * - iva_trasladado_total, iva_acreditable, iva_retenido_cobrado, iva_resultado + * - utilidad_realizada, flujo_entradas/salidas/neto + * - cfdis_emitidos_count, cfdis_recibidos_count, cfdis_cancelados_count + * + * Fuera de alcance Tanda A (quedan en 0 — iteraciones futuras): + * - Desglose IVA por tasa (16/8/0/exento) + * - ISR causado/retenido/a_pagar (requiere tablas progresivas + coeficiente) + * - IEPS trasladado/acreditable + * - CxC/CxP saldo final + counts + * - ingresos_devengados, egresos_devengados, utilidad_devengada (split PF vs PM) + */ + +// ─────────────────────────────────────────────────────────────── +// Compute para UN (contribuyente, anio, mes) +// ─────────────────────────────────────────────────────────────── + +/** + * Computa y hace upsert de métricas mensuales para un contribuyente en un mes. + * Crea una fila por cada régimen detectado en los CFDIs del mes. Si no hay + * CFDIs en el mes, no inserta nada (filas ausentes = mes sin actividad). + */ +export async function computeMetricaMensual( + pool: Pool, + tenantId: string, + contribuyenteId: string, + anio: number, + mes: number, +): Promise<{ filasEscritas: number }> { + const safeContrib = contribuyenteId.replace(/[^a-f0-9-]/gi, ''); + const fi = `${anio}-${String(mes).padStart(2, '0')}-01`; + const lastDay = new Date(anio, mes, 0).getDate(); + const ff = `${anio}-${String(mes).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`; + + // DELETE del cache del periodo ANTES de llamar a calcular{Ingresos,Egresos}. + // Crítico: esas funciones hacen read-through cache, así que si encuentran + // filas en metricas_mensuales leen valores viejos y el recompute propaga + // datos stale. Al borrar primero, el read-through no encuentra nada y cae + // al path on-the-fly (que es lo que queremos al recomputar). + await pool.query( + `DELETE FROM metricas_mensuales WHERE contribuyente_id = $1 AND anio = $2 AND mes = $3`, + [safeContrib, anio, mes], + ); + + // Reusa la lógica canónica de los servicios existentes. Paso `_ignorados=[]` + // para que NO filtre régimenes ignorados por el tenant — en la tabla + // almacenamos todos los datos; el consumer decide si filtrar ignorados. + const [ingresos, egresos, resumenIva, ncsEmitidas, ncsRecibidas, noDeducibles] = await Promise.all([ + calcularIngresosPorRegimen(pool, tenantId, fi, ff, [], undefined, false, contribuyenteId), + calcularEgresosPorRegimen(pool, tenantId, fi, ff, [], undefined, false, contribuyenteId), + getResumenIva(pool, fi, ff, tenantId, false, contribuyenteId), + calcularNcsEmitidasPorRegimen(pool, tenantId, fi, ff, [], undefined, false, contribuyenteId), + calcularNcsRecibidasPorRegimen(pool, tenantId, fi, ff, [], undefined, false, contribuyenteId), + calcularGastosNoDeduciblesEfectivoPorRegimen(pool, tenantId, fi, ff, [], undefined, false, contribuyenteId), + ]); + + // Counts de CFDIs del mes (por régimen, usando FECHA_EFECTIVA del fix P) + const { rows: countsRows } = await pool.query<{ + regimen: string | null; + direction: 'E' | 'R'; + vigentes: string; + cancelados: string; + }>(` + SELECT + CASE WHEN type='EMITIDO' THEN regimen_fiscal_emisor ELSE regimen_fiscal_receptor END AS regimen, + CASE WHEN type='EMITIDO' THEN 'E' ELSE 'R' END AS direction, + COUNT(*) FILTER (WHERE status = 'Vigente') AS vigentes, + COUNT(*) FILTER (WHERE status IN ('Cancelado','0')) AS cancelados + FROM cfdis + WHERE EXTRACT(YEAR FROM (CASE WHEN tipo_comprobante='P' THEN fecha_pago_p ELSE fecha_emision END)) = $1 + AND EXTRACT(MONTH FROM (CASE WHEN tipo_comprobante='P' THEN fecha_pago_p ELSE fecha_emision END)) = $2 + AND contribuyente_id = $3 + GROUP BY 1, 2 + `, [anio, mes, safeContrib]); + + // Indexa counts por régimen para lookup + const emitidosPorReg = new Map(); + const recibidosPorReg = new Map(); + for (const r of countsRows) { + const target = r.direction === 'E' ? emitidosPorReg : recibidosPorReg; + target.set(r.regimen, { + vigentes: Number(r.vigentes) || 0, + cancelados: Number(r.cancelados) || 0, + }); + } + + // Régimenes a procesar = unión de los que aparecen en ingresos, egresos, + // NCs emitidas/recibidas o counts. + const regimenes = new Set(); + ingresos.porRegimen.forEach(r => regimenes.add(r.regimenClave)); + egresos.porRegimen.forEach(r => regimenes.add(r.regimenClave)); + ncsEmitidas.porRegimen.forEach(r => regimenes.add(r.regimenClave)); + ncsRecibidas.porRegimen.forEach(r => regimenes.add(r.regimenClave)); + noDeducibles.porRegimen.forEach(r => regimenes.add(r.regimenClave)); + emitidosPorReg.forEach((_, k) => { if (k) regimenes.add(k); }); + recibidosPorReg.forEach((_, k) => { if (k) regimenes.add(k); }); + + // (DELETE ya se hizo al inicio, ver comentario arriba.) + if (regimenes.size === 0) { + return { filasEscritas: 0 }; + } + + let filasEscritas = 0; + for (const regimen of regimenes) { + const ing = ingresos.porRegimen.find(r => r.regimenClave === regimen)?.monto || 0; + const egr = egresos.porRegimen.find(r => r.regimenClave === regimen)?.monto || 0; + const ivaTras = resumenIva.trasladadoPorRegimen.find(r => r.regimenClave === regimen)?.monto || 0; + const ivaAcr = resumenIva.acreditablePorRegimen.find(r => r.regimenClave === regimen)?.monto || 0; + const ivaRet = resumenIva.retenidoPorRegimen.find(r => r.regimenClave === regimen)?.monto || 0; + const ivaResultado = ivaTras - ivaAcr - ivaRet; + const emitidos = emitidosPorReg.get(regimen) || { vigentes: 0, cancelados: 0 }; + const recibidos = recibidosPorReg.get(regimen) || { vigentes: 0, cancelados: 0 }; + const ncsEm = ncsEmitidas.porRegimen.find(r => r.regimenClave === regimen)?.monto || 0; + const ncsRec = ncsRecibidas.porRegimen.find(r => r.regimenClave === regimen)?.monto || 0; + const noDed = noDeducibles.porRegimen.find(r => r.regimenClave === regimen)?.monto || 0; + + await upsertMetricaMensual(pool, contribuyenteId, anio, mes, regimen, { + ivaTrasladadoTotal: ivaTras, + ivaAcreditable: ivaAcr, + ivaRetenidoCobrado: ivaRet, + ivaResultado, + ingresosCobrados: ing, + egresosPagados: egr, + utilidadRealizada: ing - egr, + flujoEntradas: ing, + flujoSalidas: egr, + flujoNeto: ing - egr, + ncsEmitidas: ncsEm, + ncsRecibidas: ncsRec, + gastosNoDeduciblesEfectivo: noDed, + cfdisEmitidosCount: emitidos.vigentes, + cfdisRecibidosCount: recibidos.vigentes, + cfdisCanceladosCount: emitidos.cancelados + recibidos.cancelados, + }); + filasEscritas++; + } + + return { filasEscritas }; +} + +// ─────────────────────────────────────────────────────────────── +// Backfill completo para un tenant +// ─────────────────────────────────────────────────────────────── + +export interface BackfillOptions { + /** Si es true, solo hace dry-run (log), no escribe. */ + dryRun?: boolean; + /** Año desde el cual backfillear. Default: año del CFDI más antiguo. */ + desdeAnio?: number; + /** Año hasta el cual (inclusive). Default: año actual - 1 (el año actual se calcula on-the-fly). */ + hastaAnio?: number; +} + +export interface BackfillResult { + tenantId: string; + contribuyentesProcesados: number; + mesesProcesados: number; + filasEscritas: number; + errores: Array<{ contribuyenteId: string; anio: number; mes: number; error: string }>; +} + +/** + * Itera contribuyentes × años × meses y llena `metricas_mensuales`. Diseñado + * para correrse una vez (bootstrap) o ad-hoc cuando se detecten huecos. + */ +export async function backfillTenant( + tenantId: string, + opts: BackfillOptions = {}, +): Promise { + const tenant = await prisma.tenant.findUnique({ + where: { id: tenantId }, + select: { databaseName: true, rfc: true }, + }); + if (!tenant) throw new Error(`Tenant ${tenantId} no encontrado`); + + const pool = await tenantDb.getPool(tenantId, tenant.databaseName); + + const { rows: contribs } = await pool.query<{ entidad_id: string }>( + `SELECT entidad_id FROM contribuyentes`, + ); + if (contribs.length === 0) { + return { + tenantId, + contribuyentesProcesados: 0, + mesesProcesados: 0, + filasEscritas: 0, + errores: [], + }; + } + + const currentYear = new Date().getFullYear(); + const hastaAnio = opts.hastaAnio ?? currentYear - 1; + + // Para cada contribuyente, determinar rango de años desde el CFDI más antiguo + const result: BackfillResult = { + tenantId, + contribuyentesProcesados: 0, + mesesProcesados: 0, + filasEscritas: 0, + errores: [], + }; + + for (const c of contribs) { + const { rows: [rango] } = await pool.query<{ min_anio: number | null }>( + `SELECT EXTRACT(YEAR FROM MIN(fecha_emision))::int AS min_anio + FROM cfdis WHERE contribuyente_id = $1`, + [c.entidad_id], + ); + if (!rango?.min_anio) continue; // sin CFDIs, skip + + const desdeAnio = opts.desdeAnio ?? rango.min_anio; + result.contribuyentesProcesados++; + + for (let anio = desdeAnio; anio <= hastaAnio; anio++) { + for (let mes = 1; mes <= 12; mes++) { + result.mesesProcesados++; + if (opts.dryRun) continue; + try { + const { filasEscritas } = await computeMetricaMensual(pool, tenantId, c.entidad_id, anio, mes); + result.filasEscritas += filasEscritas; + } catch (err: any) { + result.errores.push({ + contribuyenteId: c.entidad_id, + anio, + mes, + error: err?.message || String(err), + }); + } + } + } + } + + return result; +} + +// ─────────────────────────────────────────────────────────────── +// Procesamiento de invalidaciones (cron) +// ─────────────────────────────────────────────────────────────── + +export interface ProcessResult { + procesadas: number; + filasEscritas: number; + errores: number; +} + +/** + * Lee `metricas_invalidaciones`, recomputa cada (contribuyente, anio, mes) + * marcado, y limpia la invalidación al terminar. Fail-safe: si una entrada + * falla, loguea y continúa con la siguiente. + */ +export async function processInvalidations(pool: Pool, tenantId: string): Promise { + const pending = await getPendingInvalidations(pool); + if (pending.length === 0) return { procesadas: 0, filasEscritas: 0, errores: 0 }; + + let procesadas = 0; + let filasEscritas = 0; + let errores = 0; + + for (const inv of pending) { + try { + const { filasEscritas: fe } = await computeMetricaMensual( + pool, tenantId, inv.contribuyenteId, inv.anio, inv.mes, + ); + filasEscritas += fe; + await clearInvalidation(pool, inv.contribuyenteId, inv.anio, inv.mes); + procesadas++; + } catch (err: any) { + console.error( + `[Metricas] Error computando (tenant=${tenantId}, contrib=${inv.contribuyenteId}, ${inv.anio}-${String(inv.mes).padStart(2, '0')}):`, + err?.message || err, + ); + errores++; + } + } + + return { procesadas, filasEscritas, errores }; +} + +/** + * Itera todos los tenants activos y procesa sus invalidaciones pendientes. + * Usado por el cron job. + */ +export async function processAllTenantsInvalidations(): Promise<{ + tenantsRevisados: number; + totalProcesadas: number; + totalFilasEscritas: number; + totalErrores: number; +}> { + const tenants = await prisma.tenant.findMany({ + where: { active: true }, + select: { id: true, databaseName: true }, + }); + + let totalProcesadas = 0; + let totalFilasEscritas = 0; + let totalErrores = 0; + + for (const t of tenants) { + try { + const pool = await tenantDb.getPool(t.id, t.databaseName); + const r = await processInvalidations(pool, t.id); + totalProcesadas += r.procesadas; + totalFilasEscritas += r.filasEscritas; + totalErrores += r.errores; + } catch (err: any) { + console.error(`[Metricas] Error procesando tenant ${t.id}:`, err?.message || err); + totalErrores++; + } + } + + return { + tenantsRevisados: tenants.length, + totalProcesadas, + totalFilasEscritas, + totalErrores, + }; +} diff --git a/apps/api/src/services/metricas.service.ts b/apps/api/src/services/metricas.service.ts new file mode 100644 index 0000000..57f7867 --- /dev/null +++ b/apps/api/src/services/metricas.service.ts @@ -0,0 +1,225 @@ +import type { Pool } from 'pg'; + +export interface MetricaMensual { + anio: number; + mes: number; + regimenFiscal: string | null; + ivaTrasladado16: number; + ivaTrasladado8: number; + ivaTrasladado0: number; + ivaTrasladadoExento: number; + ivaTrasladadoTotal: number; + ivaAcreditable: number; + ivaRetenidoCobrado: number; + ivaRetenidoPagado: number; + ivaResultado: number; + ivaAFavorMes: number; + isrIngresosBrutos: number; + isrDeduccionesAutoriz: number; + isrBase: number; + isrCausado: number; + isrRetenido: number; + isrAPagar: number; + cfdisEmitidosCount: number; + cfdisRecibidosCount: number; + cfdisCanceladosCount: number; + ingresosDevengados: number; + ingresosCobrados: number; + egresosDevengados: number; + egresosPagados: number; + utilidadDevengada: number; + utilidadRealizada: number; + flujoEntradas: number; + flujoSalidas: number; + flujoNeto: number; + cxcSaldoFinal: number; + cxpSaldoFinal: number; + // Surface-only — totales de notas de crédito tipo E PUE en el mes/régimen. + // No participan en cálculos de ingresos/deducciones (ya no se restan); se + // persisten para visibilidad y BI directo sin recomputar. + ncsEmitidas: number; + ncsRecibidas: number; + // Art. 27 fracción III LISR — facturas recibidas pagadas en efectivo > $2,000. + // Surface-only, no afecta deducciones (que ya las EXCLUYE). + gastosNoDeduciblesEfectivo: number; + cerrado: boolean; + computedAt: string; +} + +export async function getMetricasMensuales( + pool: Pool, + contribuyenteId: string, + anio: number, + regimenFiscal?: string +): Promise { + const currentYear = new Date().getFullYear(); + + if (anio < currentYear) { + // COLD: read pre-computed + const params: unknown[] = [contribuyenteId, anio]; + let query = ` + SELECT + anio, mes, + regimen_fiscal AS "regimenFiscal", + iva_trasladado_16 AS "ivaTrasladado16", + iva_trasladado_8 AS "ivaTrasladado8", + iva_trasladado_0 AS "ivaTrasladado0", + iva_trasladado_exento AS "ivaTrasladadoExento", + iva_trasladado_total AS "ivaTrasladadoTotal", + iva_acreditable AS "ivaAcreditable", + iva_retenido_cobrado AS "ivaRetenidoCobrado", + iva_retenido_pagado AS "ivaRetenidoPagado", + iva_resultado AS "ivaResultado", + iva_a_favor_mes AS "ivaAFavorMes", + isr_ingresos_brutos AS "isrIngresosBrutos", + isr_deducciones_autoriz AS "isrDeduccionesAutoriz", + isr_base AS "isrBase", + isr_causado AS "isrCausado", + isr_retenido AS "isrRetenido", + isr_a_pagar AS "isrAPagar", + cfdis_emitidos_count AS "cfdisEmitidosCount", + cfdis_recibidos_count AS "cfdisRecibidosCount", + cfdis_cancelados_count AS "cfdisCanceladosCount", + ingresos_devengados AS "ingresosDevengados", + ingresos_cobrados AS "ingresosCobrados", + egresos_devengados AS "egresosDevengados", + egresos_pagados AS "egresosPagados", + utilidad_devengada AS "utilidadDevengada", + utilidad_realizada AS "utilidadRealizada", + flujo_entradas AS "flujoEntradas", + flujo_salidas AS "flujoSalidas", + flujo_neto AS "flujoNeto", + cxc_saldo_final AS "cxcSaldoFinal", + cxp_saldo_final AS "cxpSaldoFinal", + ncs_emitidas AS "ncsEmitidas", + ncs_recibidas AS "ncsRecibidas", + gastos_no_deducibles_efectivo AS "gastosNoDeduciblesEfectivo", + cerrado, + computed_at AS "computedAt" + FROM metricas_mensuales + WHERE contribuyente_id = $1 AND anio = $2 + `; + if (regimenFiscal) { + query += ' AND regimen_fiscal = $3'; + params.push(regimenFiscal); + } + query += ' ORDER BY mes'; + + const { rows } = await pool.query(query, params); + return rows.map(r => ({ ...r, cerrado: r.cerrado ?? false, computedAt: r.computedAt?.toISOString?.() ?? '' })); + } + + // HOT: current year — return empty (caller should use dashboard/impuestos services for on-the-fly computation) + return []; +} + +export async function markForInvalidation( + pool: Pool, + contribuyenteId: string, + anio: number, + mes: number, + reason: string +): Promise { + await pool.query(` + INSERT INTO metricas_invalidaciones (contribuyente_id, anio, mes, reason) + VALUES ($1, $2, $3, $4) + ON CONFLICT (contribuyente_id, anio, mes) DO UPDATE SET marcado_at = now(), reason = $4 + `, [contribuyenteId, anio, mes, reason]); +} + +export async function getPendingInvalidations(pool: Pool): Promise> { + const { rows } = await pool.query(` + SELECT contribuyente_id AS "contribuyenteId", anio, mes, reason + FROM metricas_invalidaciones + ORDER BY anio, mes + `); + return rows; +} + +export async function clearInvalidation(pool: Pool, contribuyenteId: string, anio: number, mes: number): Promise { + await pool.query( + 'DELETE FROM metricas_invalidaciones WHERE contribuyente_id = $1 AND anio = $2 AND mes = $3', + [contribuyenteId, anio, mes] + ); +} + +export async function upsertMetricaMensual( + pool: Pool, + contribuyenteId: string, + anio: number, + mes: number, + regimenFiscal: string | null, + data: Partial +): Promise { + await pool.query(` + INSERT INTO metricas_mensuales ( + contribuyente_id, anio, mes, regimen_fiscal, + iva_trasladado_total, iva_acreditable, iva_retenido_cobrado, iva_retenido_pagado, iva_resultado, + isr_ingresos_brutos, isr_deducciones_autoriz, isr_base, isr_causado, isr_retenido, isr_a_pagar, + cfdis_emitidos_count, cfdis_recibidos_count, cfdis_cancelados_count, + ingresos_devengados, ingresos_cobrados, egresos_devengados, egresos_pagados, + utilidad_devengada, utilidad_realizada, + flujo_entradas, flujo_salidas, flujo_neto, + cxc_saldo_final, cxp_saldo_final, + ncs_emitidas, ncs_recibidas, + gastos_no_deducibles_efectivo, + computed_at, source_max_cfdi_at + ) 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, $27, + $28, $29, + $30, $31, + $32, + now(), now() + ) + ON CONFLICT (contribuyente_id, anio, mes, regimen_fiscal) + DO UPDATE SET + iva_trasladado_total = $5, iva_acreditable = $6, iva_retenido_cobrado = $7, iva_retenido_pagado = $8, iva_resultado = $9, + isr_ingresos_brutos = $10, isr_deducciones_autoriz = $11, isr_base = $12, + isr_causado = $13, isr_retenido = $14, isr_a_pagar = $15, + cfdis_emitidos_count = $16, cfdis_recibidos_count = $17, cfdis_cancelados_count = $18, + ingresos_devengados = $19, ingresos_cobrados = $20, egresos_devengados = $21, egresos_pagados = $22, + utilidad_devengada = $23, utilidad_realizada = $24, + flujo_entradas = $25, flujo_salidas = $26, flujo_neto = $27, + cxc_saldo_final = $28, cxp_saldo_final = $29, + ncs_emitidas = $30, ncs_recibidas = $31, + gastos_no_deducibles_efectivo = $32, + computed_at = now(), source_max_cfdi_at = now() + `, [ + contribuyenteId, anio, mes, regimenFiscal, + data.ivaTrasladadoTotal ?? 0, data.ivaAcreditable ?? 0, data.ivaRetenidoCobrado ?? 0, data.ivaRetenidoPagado ?? 0, data.ivaResultado ?? 0, + data.isrIngresosBrutos ?? 0, data.isrDeduccionesAutoriz ?? 0, data.isrBase ?? 0, + data.isrCausado ?? 0, data.isrRetenido ?? 0, data.isrAPagar ?? 0, + data.cfdisEmitidosCount ?? 0, data.cfdisRecibidosCount ?? 0, data.cfdisCanceladosCount ?? 0, + data.ingresosDevengados ?? 0, data.ingresosCobrados ?? 0, data.egresosDevengados ?? 0, data.egresosPagados ?? 0, + data.utilidadDevengada ?? 0, data.utilidadRealizada ?? 0, + data.flujoEntradas ?? 0, data.flujoSalidas ?? 0, data.flujoNeto ?? 0, + data.cxcSaldoFinal ?? 0, data.cxpSaldoFinal ?? 0, + data.ncsEmitidas ?? 0, data.ncsRecibidas ?? 0, + data.gastosNoDeduciblesEfectivo ?? 0, + ]); +} + +export async function closeMonth(pool: Pool, anio: number, mes: number): Promise { + await pool.query( + 'UPDATE metricas_mensuales SET cerrado = true WHERE anio = $1 AND mes = $2', + [anio, mes] + ); +} + +export async function closeYear(pool: Pool, anio: number): Promise { + await pool.query( + 'UPDATE metricas_mensuales SET cerrado = true WHERE anio = $1', + [anio] + ); +} diff --git a/apps/api/src/services/notification-preferences.service.ts b/apps/api/src/services/notification-preferences.service.ts new file mode 100644 index 0000000..0b3fc54 --- /dev/null +++ b/apps/api/src/services/notification-preferences.service.ts @@ -0,0 +1,110 @@ +import type { Pool } from 'pg'; + +/** + * Tipos de correos informativos cuyo envío puede desactivarse por + * contribuyente. NO incluye correos transaccionales críticos + * (welcome, password-reset, payment-*) — esos siempre se envían. + * + * Estado de implementación: + * - documento_subido: ✅ implementado (notify-upload.service.ts) + * - weekly_update: ⏳ pendiente (job es tenant-wide hoy) + * - subscription_expiring: ⏳ pendiente (no es per-contribuyente hoy) + * - recordatorio_fiscal: ⏳ placeholder para futuras alertas + */ +export const EMAIL_TYPES = [ + 'documento_subido', + 'weekly_update', + 'subscription_expiring', + 'recordatorio_fiscal', +] as const; + +export type EmailType = (typeof EMAIL_TYPES)[number]; + +export type EmailPreferences = Record; + +/** + * Default: todo activado. Si el JSONB en BD viene vacío o falta una + * key, asumimos `true` para preservar el comportamiento previo. + */ +function applyDefaults(raw: Partial>): EmailPreferences { + const out = {} as EmailPreferences; + for (const t of EMAIL_TYPES) { + out[t] = raw[t] === false ? false : true; + } + return out; +} + +function sanitizeUuid(id: string): string { + return id.replace(/[^a-f0-9-]/gi, ''); +} + +/** + * Lee las preferencias de un contribuyente. Devuelve defaults (todo + * activado) si no hay fila o la columna está vacía. + */ +export async function getContribuyenteEmailPreferences( + pool: Pool, + contribuyenteId: string, +): Promise { + const safeId = sanitizeUuid(contribuyenteId); + const { rows } = await pool.query<{ email_preferences: Record | null }>( + `SELECT email_preferences FROM contribuyentes WHERE entidad_id = $1`, + [safeId], + ); + const raw = rows[0]?.email_preferences ?? {}; + return applyDefaults(raw); +} + +/** + * Actualiza las preferencias de un contribuyente. Solo persiste las + * keys conocidas (filtra extras maliciosos). Merge sobre la columna + * existente (no sobreescribe keys no enviadas). + */ +export async function setContribuyenteEmailPreferences( + pool: Pool, + contribuyenteId: string, + partial: Partial, +): Promise { + const safeId = sanitizeUuid(contribuyenteId); + const merged: Record = {}; + for (const t of EMAIL_TYPES) { + if (t in partial) merged[t] = partial[t] === true; + } + + await pool.query( + `UPDATE contribuyentes + SET email_preferences = COALESCE(email_preferences, '{}'::jsonb) || $2::jsonb + WHERE entidad_id = $1`, + [safeId, JSON.stringify(merged)], + ); + + return getContribuyenteEmailPreferences(pool, contribuyenteId); +} + +/** + * Lee preferencias para múltiples contribuyentes en una sola query. + * Útil para la UI de `/configuracion/notificaciones` que lista todos. + */ +export async function getEmailPreferencesPorContribuyente( + pool: Pool, +): Promise> { + const { rows } = await pool.query<{ + entidad_id: string; + rfc: string; + nombre: string; + email_preferences: Record | null; + }>( + `SELECT c.entidad_id, c.rfc, e.nombre, c.email_preferences + FROM contribuyentes c + JOIN entidades_gestionadas e ON e.id = c.entidad_id + WHERE e.active = true + ORDER BY e.nombre`, + ); + + return rows.map(r => ({ + contribuyenteId: r.entidad_id, + rfc: r.rfc, + nombre: r.nombre, + preferences: applyDefaults(r.email_preferences ?? {}), + })); +} diff --git a/apps/api/src/services/notifications.service.ts b/apps/api/src/services/notifications.service.ts new file mode 100644 index 0000000..f5df95b --- /dev/null +++ b/apps/api/src/services/notifications.service.ts @@ -0,0 +1,390 @@ +/** + * Notificaciones email automáticas (Option B — por evento). + * + * Cron diario 8:30 AM (`notifications.job.ts`) llama a las dos funciones + * principales de este servicio para cada tenant activo: + * + * - `processNewAlertas(pool, tenantId)`: detecta alertas que aparecen por + * primera vez (no están en `alertas_notificadas`) y manda un email + * batched al supervisor + auxiliares + clientes del contribuyente. + * Las alertas que dejaron de estar activas se marcan `resuelta_at`. + * + * - `processProximosRecordatorios(pool, tenantId)`: detecta recordatorios + * cuya `fecha_limite` cae en las ventanas 3 días / 1 día / mismo día + * y manda email a los responsables (cliente + auxiliar; si no hay + * auxiliar también supervisor; si owner es supervisor sin auxiliares + * también owner). Cada ventana se envía una sola vez (columnas + * `email_3d_at`, `email_1d_at`, `email_0d_at`). + * + * Decisión MVP: una alerta solo se notifica una vez. Si vuelve a activarse + * después de resolverse, no re-notifica (sería opt-in al borrar la fila + * cuando `resuelta_at` se setea). + */ +import type { Pool } from 'pg'; +import { prisma } from '../config/database.js'; +import { generarAlertasAutomaticas, type AlertaAuto } from './alertas-auto.service.js'; +import { emailService } from './email/email.service.js'; +import type { AlertaItem } from './email/templates/alertas-nuevas.js'; +import type { VentanaRecordatorio } from './email/templates/recordatorio-proximo.js'; + +const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:3000'; + +// ──────────────────────────────────────────────────────────────────────── +// Resolución de destinatarios +// ──────────────────────────────────────────────────────────────────────── + +interface UserContact { + userId: string; + email: string; + active: boolean; +} + +/** + * Resuelve user IDs ligados a un contribuyente (supervisor + auxiliares de + * carteras donde aparece + clientes con acceso). Retorna lista deduplicada. + */ +async function getUserIdsContribuyente( + pool: Pool, + contribuyenteId: string, +): Promise<{ supervisor: string | null; auxiliares: string[]; clientes: string[] }> { + const safeId = contribuyenteId.replace(/[^a-f0-9-]/gi, ''); + const { rows } = await pool.query<{ + supervisor_user_id: string | null; + auxiliar_user_ids: string[]; + cliente_user_ids: string[]; + }>(` + SELECT + eg.supervisor_user_id, + COALESCE(( + SELECT array_agg(DISTINCT c.auxiliar_user_id) FILTER (WHERE c.auxiliar_user_id IS NOT NULL) + FROM cartera_entidades ce + JOIN carteras c ON c.id = ce.cartera_id + WHERE ce.entidad_id = eg.id + ), ARRAY[]::uuid[]) AS auxiliar_user_ids, + COALESCE(( + SELECT array_agg(DISTINCT user_id) FROM cliente_accesos WHERE entidad_id = eg.id + ), ARRAY[]::uuid[]) AS cliente_user_ids + FROM entidades_gestionadas eg + WHERE eg.id = $1::uuid + `, [safeId]); + + if (rows.length === 0) { + return { supervisor: null, auxiliares: [], clientes: [] }; + } + const r = rows[0]; + return { + supervisor: r.supervisor_user_id ?? null, + auxiliares: r.auxiliar_user_ids ?? [], + clientes: r.cliente_user_ids ?? [], + }; +} + +/** Owners activos del tenant (BD central). */ +async function getOwnerUserIds(tenantId: string): Promise { + const owners = await prisma.tenantMembership.findMany({ + where: { tenantId, isOwner: true, active: true }, + select: { userId: true }, + }); + return owners.map(o => o.userId); +} + +/** Resuelve emails para una lista de userIds; filtra inactivos. */ +async function getUserContacts(userIds: string[]): Promise { + if (userIds.length === 0) return []; + const users = await prisma.user.findMany({ + where: { id: { in: userIds }, active: true }, + select: { id: true, email: true, active: true }, + }); + return users.map(u => ({ userId: u.id, email: u.email, active: u.active })); +} + +/** + * Destinatarios de una alerta: supervisor + auxiliares + clientes del + * contribuyente. Si el owner del tenant es supervisor, ya queda incluido + * (no se duplica). + */ +async function recipientsForAlerta( + pool: Pool, + tenantId: string, + contribuyenteId: string, +): Promise { + const ids = await getUserIdsContribuyente(pool, contribuyenteId); + const userIds = new Set(); + if (ids.supervisor) userIds.add(ids.supervisor); + ids.auxiliares.forEach(id => userIds.add(id)); + ids.clientes.forEach(id => userIds.add(id)); + const contacts = await getUserContacts([...userIds]); + return [...new Set(contacts.map(c => c.email))]; +} + +/** + * Destinatarios de un recordatorio. Los recordatorios del despacho son + * tenant-level (no atados a contribuyente). Para públicos: clientes con + * algún acceso + auxiliares de cualquier cartera; si no hay auxiliares, + * supervisores; si owner aparece como supervisor, también recibe. + * + * Privados: solo el creador. + */ +async function recipientsForRecordatorio( + pool: Pool, + tenantId: string, + recordatorio: { creadoPor: string; privado: boolean }, +): Promise { + if (recordatorio.privado) { + const contacts = await getUserContacts([recordatorio.creadoPor]); + return [...new Set(contacts.map(c => c.email))]; + } + + // Recordatorio público: lee universos relevantes del tenant. + const { rows: [r] } = await pool.query<{ + auxiliar_user_ids: string[]; + supervisor_user_ids: string[]; + cliente_user_ids: string[]; + }>(` + SELECT + COALESCE(( + SELECT array_agg(DISTINCT auxiliar_user_id) + FROM carteras WHERE auxiliar_user_id IS NOT NULL + ), ARRAY[]::uuid[]) AS auxiliar_user_ids, + COALESCE(( + SELECT array_agg(DISTINCT supervisor_user_id) FROM ( + SELECT supervisor_user_id FROM entidades_gestionadas WHERE supervisor_user_id IS NOT NULL + UNION + SELECT supervisor_user_id FROM carteras WHERE supervisor_user_id IS NOT NULL + ) sup + ), ARRAY[]::uuid[]) AS supervisor_user_ids, + COALESCE(( + SELECT array_agg(DISTINCT user_id) FROM cliente_accesos + ), ARRAY[]::uuid[]) AS cliente_user_ids + `); + + const auxiliares = r?.auxiliar_user_ids ?? []; + const supervisores = r?.supervisor_user_ids ?? []; + const clientes = r?.cliente_user_ids ?? []; + const owners = await getOwnerUserIds(tenantId); + + // Regla del owner: clientes y auxiliares siempre. Si no hay auxiliares, + // agregar supervisores. Si owner es supervisor y no hay auxiliares, + // owner queda incluido vía la lista de supervisores. + const userIds = new Set(); + clientes.forEach(id => userIds.add(id)); + auxiliares.forEach(id => userIds.add(id)); + if (auxiliares.length === 0) { + supervisores.forEach(id => userIds.add(id)); + // Solo si owner aparece como supervisor (intersección): + for (const ownerId of owners) { + if (supervisores.includes(ownerId)) userIds.add(ownerId); + } + } + + const contacts = await getUserContacts([...userIds]); + return [...new Set(contacts.map(c => c.email))]; +} + +// ──────────────────────────────────────────────────────────────────────── +// Procesamiento de alertas +// ──────────────────────────────────────────────────────────────────────── + +interface ContribuyenteInfo { + entidadId: string; + rfc: string; + nombre: string; +} + +/** Lista contribuyentes activos del tenant. */ +async function listContribuyentes(pool: Pool): Promise { + const { rows } = await pool.query<{ entidad_id: string; rfc: string; nombre: string }>(` + SELECT eg.id AS entidad_id, c.rfc, eg.nombre + FROM entidades_gestionadas eg + JOIN contribuyentes c ON c.entidad_id = eg.id + WHERE eg.active = true AND eg.tipo = 'CONTRIBUYENTE' + `); + return rows.map(r => ({ entidadId: r.entidad_id, rfc: r.rfc, nombre: r.nombre })); +} + +function mapAlertaToItem(a: AlertaAuto): AlertaItem { + return { + alertaId: a.id, + nivel: a.prioridad === 'alta' ? 'high' : a.prioridad === 'media' ? 'medium' : 'low', + titulo: a.titulo, + mensaje: a.mensaje, + }; +} + +/** + * Para un (tenant, contribuyente): + * 1. Genera alertas activas vía `generarAlertasAutomaticas`. + * 2. Inserta filas nuevas en `alertas_notificadas` (ON CONFLICT DO NOTHING). + * 3. Marca como resueltas las alertas previamente notificadas que NO están + * activas hoy (UPDATE resuelta_at). + * 4. Si hay alertas nuevas, envía email batched a los responsables. + */ +async function processAlertasContribuyente( + pool: Pool, + tenantId: string, + tenant: { rfc: string; nombre: string }, + contribuyente: ContribuyenteInfo, +): Promise<{ nuevas: number; resueltas: number }> { + const alertasActivas = await generarAlertasAutomaticas(pool, tenantId, contribuyente.entidadId); + const activosIds = alertasActivas.map(a => a.id); + + // Re-notificación tras 30 días (D7, 2026-04-26): borra registros de + // alertas que estuvieron resueltas más de 30 días. Si la alerta vuelve + // a aparecer ahora, el INSERT siguiente la detecta como "nueva" y + // vuelve a notificar. Si nunca se resolvió (resuelta_at IS NULL) o se + // resolvió hace menos de 30 días, la fila se conserva y el INSERT no + // dispara email. + await pool.query(` + DELETE FROM alertas_notificadas + WHERE contribuyente_id = $1::uuid + AND resuelta_at IS NOT NULL + AND resuelta_at < NOW() - INTERVAL '30 days' + `, [contribuyente.entidadId]); + + // Detecta alertas nuevas: INSERT con ON CONFLICT DO NOTHING. RETURNING id + // solo trae las filas insertadas (no las que chocaron con el UNIQUE), + // así sabemos cuáles eran realmente nuevas. Tras la re-notificación de + // 30 días, una alerta puede volver a notificarse si reapareció después + // de >30 días resuelta. + const nuevas: AlertaAuto[] = []; + for (const a of alertasActivas) { + const { rows } = await pool.query<{ id: number }>(` + INSERT INTO alertas_notificadas (alerta_id, contribuyente_id) + VALUES ($1, $2::uuid) + ON CONFLICT (alerta_id, COALESCE(contribuyente_id::text, '')) DO NOTHING + RETURNING id + `, [a.id, contribuyente.entidadId]); + if (rows.length > 0) nuevas.push(a); + } + + // Marca como resueltas las alertas previamente notificadas que ya no + // aparecen activas hoy. Informativo (no genera email). + let resueltas = 0; + const updateQuery = activosIds.length > 0 + ? `UPDATE alertas_notificadas SET resuelta_at = NOW() + WHERE contribuyente_id = $1::uuid AND resuelta_at IS NULL + AND alerta_id <> ALL($2::text[])` + : `UPDATE alertas_notificadas SET resuelta_at = NOW() + WHERE contribuyente_id = $1::uuid AND resuelta_at IS NULL`; + const params: any[] = activosIds.length > 0 + ? [contribuyente.entidadId, activosIds] + : [contribuyente.entidadId]; + const { rowCount } = await pool.query(updateQuery, params); + resueltas = rowCount ?? 0; + + if (nuevas.length === 0) { + return { nuevas: 0, resueltas }; + } + + // Envía email batched a los responsables del contribuyente. + const recipients = await recipientsForAlerta(pool, tenantId, contribuyente.entidadId); + if (recipients.length === 0) { + console.warn(`[Notifications] Sin destinatarios para alertas de ${contribuyente.rfc} (tenant ${tenant.rfc})`); + return { nuevas: nuevas.length, resueltas }; + } + + await emailService.sendAlertasNuevas(recipients, { + contribuyenteRfc: contribuyente.rfc, + contribuyenteNombre: contribuyente.nombre, + despachoNombre: tenant.nombre, + alertas: nuevas.map(mapAlertaToItem), + link: `${FRONTEND_URL}/alertas`, + }); + + return { nuevas: nuevas.length, resueltas }; +} + +/** Procesa todas las alertas del tenant — itera contribuyentes activos. */ +export async function processNewAlertas( + pool: Pool, + tenantId: string, + tenant: { rfc: string; nombre: string }, +): Promise<{ contribuyentes: number; nuevasTotal: number }> { + const contribuyentes = await listContribuyentes(pool); + let nuevasTotal = 0; + for (const c of contribuyentes) { + try { + const { nuevas } = await processAlertasContribuyente(pool, tenantId, tenant, c); + nuevasTotal += nuevas; + } catch (err: any) { + console.error(`[Notifications] Error procesando alertas de ${c.rfc} (tenant ${tenant.rfc}):`, err.message || err); + } + } + return { contribuyentes: contribuyentes.length, nuevasTotal }; +} + +// ──────────────────────────────────────────────────────────────────────── +// Procesamiento de recordatorios próximos +// ──────────────────────────────────────────────────────────────────────── + +interface RecordatorioRow { + id: number; + titulo: string; + descripcion: string | null; + notas: string | null; + fecha_limite: string; + privado: boolean; + creado_por: string; + email_3d_at: Date | null; + email_1d_at: Date | null; + email_0d_at: Date | null; +} + +const VENTANA_DIAS: Record = { + '3d': 3, + '1d': 1, + '0d': 0, +}; + +/** + * Procesa recordatorios cuya `fecha_limite` cae en alguna ventana (3d/1d/0d) + * y que aún no tienen email enviado para esa ventana específica. Manda email + * y marca la columna correspondiente. + */ +export async function processProximosRecordatorios( + pool: Pool, + tenantId: string, + tenant: { rfc: string; nombre: string }, +): Promise<{ enviados: number }> { + let enviados = 0; + for (const ventana of (['3d', '1d', '0d'] as const)) { + const dias = VENTANA_DIAS[ventana]; + const col = `email_${ventana}_at`; + const { rows } = await pool.query(` + SELECT id, titulo, descripcion, notas, fecha_limite::text AS fecha_limite, + privado, creado_por, email_3d_at, email_1d_at, email_0d_at + FROM recordatorios + WHERE completado = false + AND fecha_limite = (CURRENT_DATE + ${dias})::date + AND ${col} IS NULL + `); + + for (const r of rows) { + try { + const recipients = await recipientsForRecordatorio(pool, tenantId, { + creadoPor: r.creado_por, + privado: r.privado, + }); + if (recipients.length === 0) { + console.warn(`[Notifications] Recordatorio ${r.id} (${tenant.rfc}) sin destinatarios — skip ${ventana}`); + continue; + } + await emailService.sendRecordatorioProximo(recipients, { + titulo: r.titulo, + descripcion: r.descripcion, + notas: r.notas, + fechaLimite: r.fecha_limite, + ventana, + despachoNombre: tenant.nombre, + link: `${FRONTEND_URL}/calendario`, + }); + // Marca columna de ventana enviada. + await pool.query(`UPDATE recordatorios SET ${col} = NOW() WHERE id = $1`, [r.id]); + enviados++; + } catch (err: any) { + console.error(`[Notifications] Error en recordatorio ${r.id} (${tenant.rfc}, ${ventana}):`, err.message || err); + } + } + } + return { enviados }; +} diff --git a/apps/api/src/services/notify-upload.service.ts b/apps/api/src/services/notify-upload.service.ts new file mode 100644 index 0000000..3e64805 --- /dev/null +++ b/apps/api/src/services/notify-upload.service.ts @@ -0,0 +1,90 @@ +import type { Pool } from 'pg'; +import { prisma } from '../config/database.js'; +import { emailService } from './email/email.service.js'; +import { getTenantOwnerEmails, getUserEmailById } from '../utils/memberships.js'; +import { env } from '../config/env.js'; +import { getContribuyenteEmailPreferences } from './notification-preferences.service.js'; +import type { DocumentoSubidoData } from './email/templates/documento-subido.js'; + +/** + * Notifica a los destinatarios relevantes cuando se sube una declaración + * o un documento extra. Destinatarios: + * - Owners activos del despacho (getTenantOwnerEmails) + * - Supervisor del contribuyente (entidades_gestionadas.supervisor_user_id), + * si existe y no coincide con un owner ya incluido + * + * El uploader mismo SE EXCLUYE (no tiene sentido notificarle su propia acción). + * + * Fire-and-forget: el caller hace `.catch()` y esta función no re-lanza. + * Fail-soft: si SMTP no está configurado, los envíos se loguean a consola + * vía el transport de @horux/core. + */ +export async function notifyDocumentoSubido(params: { + pool: Pool; + tenantId: string; + contribuyenteId: string | null; + subidoPor: string; + kind: DocumentoSubidoData['kind']; + declaracion?: DocumentoSubidoData['declaracion']; + extra?: DocumentoSubidoData['extra']; +}): Promise { + const { pool, tenantId, contribuyenteId, subidoPor } = params; + + // 1. Datos del contribuyente (desde BD tenant). Sin contribuyenteId no hay + // subject informativo ni supervisor — skip. + if (!contribuyenteId) return; + + // Respeta preferencias de notificación del contribuyente. Si el user + // desactivó `documento_subido` para este contribuyente, no enviar. + const prefs = await getContribuyenteEmailPreferences(pool, contribuyenteId); + if (!prefs.documento_subido) return; + + const { rows } = await pool.query<{ + rfc: string; + nombre: string; + supervisor_user_id: string | null; + }>( + `SELECT c.rfc, eg.nombre, eg.supervisor_user_id + FROM contribuyentes c + JOIN entidades_gestionadas eg ON eg.id = c.entidad_id + WHERE c.entidad_id = $1`, + [contribuyenteId.replace(/[^a-f0-9-]/gi, '')], + ); + if (rows.length === 0) return; + const contrib = rows[0]; + + // 2. Recipients. Owners primero; luego supervisor si aplica. + const owners = await getTenantOwnerEmails(tenantId); + const recipients = new Set(owners); + + if (contrib.supervisor_user_id) { + const supervisorEmail = await getUserEmailById(contrib.supervisor_user_id); + if (supervisorEmail) recipients.add(supervisorEmail); + } + + // Excluir al uploader: no notificarle su propia acción. + recipients.delete(subidoPor.toLowerCase()); + recipients.delete(subidoPor); + + if (recipients.size === 0) return; + + // 3. Datos del despacho (para mostrar en el body). + const tenant = await prisma.tenant.findUnique({ + where: { id: tenantId }, + select: { nombre: true }, + }); + + // 4. Link al sistema. Usa FRONTEND_URL del env. + const link = `${env.FRONTEND_URL}/documentos`; + + await emailService.sendDocumentoSubido(Array.from(recipients), { + kind: params.kind, + subidoPor, + contribuyenteRfc: contrib.rfc, + contribuyenteNombre: contrib.nombre, + despachoNombre: tenant?.nombre, + declaracion: params.declaracion, + extra: params.extra, + link, + }); +} diff --git a/apps/api/src/services/obligaciones.service.ts b/apps/api/src/services/obligaciones.service.ts new file mode 100644 index 0000000..1f9c36b --- /dev/null +++ b/apps/api/src/services/obligaciones.service.ts @@ -0,0 +1,492 @@ +import type { Pool } from 'pg'; +import { OBLIGACIONES_CATALOGO, getRecomendaciones, type ObligacionFiscal } from '../constants/obligaciones-fiscales.js'; + +/** + * Keyword-based matching: each catalog entry has discriminant keywords + * that must ALL appear in the SAT description (normalized, lowercase, no accents). + * Multiple keyword sets per entry allow for variant phrasings. + */ +const CATALOG_MATCH_RULES: Array<{ id: string; keywords: string[][] }> = [ + // ISR provisionales + { id: 'isr-provisional', keywords: [ + ['pago provisional', 'isr', 'actividades empresariales'], + ['pago provisional', 'isr personas morales', 'general'], + ['pago provisional mensual de isr personas morales'], + ]}, + { id: 'isr-resico-pm', keywords: [ + ['isr', 'simplificado de confianza', 'pago provisional'], + ['isr', 'simplificado de confianza', 'pago provisional mensual'], + ]}, + { id: 'isr-resico-pf', keywords: [ + ['isr', 'simplificado de confianza', 'ajuste anual'], + // Note: PF RESICO "pago provisional" is same id as PM; differentiate by RFC length at runtime + ]}, + + // IVA + { id: 'iva-mensual', keywords: [ + ['pago definitivo', 'iva', 'mensual'], + ['pago definitivo mensual de iva'], + ]}, + + // DIOT + { id: 'diot', keywords: [ + ['proveedores', 'iva'], + ['diot'], + ]}, + + // Anuales + { id: 'anual-isr-pm', keywords: [ + ['declaracion anual', 'isr', 'personas morales'], + ['anual de isr del regimen', 'simplificado', 'personas morales'], + ]}, + { id: 'anual-isr-pf', keywords: [ + ['declaracion anual', 'isr', 'personas fisicas'], + ['ajuste anual', 'isr', 'declaracion anual', 'simplificado'], + ]}, + + // Retenciones ISR + { id: 'ret-isr-sueldos', keywords: [ + ['retenciones', 'isr', 'sueldos y salarios'], + ['retenciones mensuales de isr por sueldos'], + ]}, + { id: 'ret-isr-asimilados', keywords: [ + ['retenciones', 'isr', 'asimilados a salarios'], + ['retenciones mensuales de isr por ingresos asimilados'], + ]}, + { id: 'ret-isr-honorarios', keywords: [ + ['retencion', 'isr', 'servicios profesionales'], + ['retenciones de isr por servicios profesionales'], // TPR variant (missing accent) + ]}, + + // Retenciones IVA + { id: 'ret-iva', keywords: [ + ['retenciones de iva'], + ['retenciones', 'iva', 'mensual'], + ]}, + + // IEPS + { id: 'ieps', keywords: [ + ['ieps'], + ]}, + + // RIF bimestral + { id: 'isr-provisional', keywords: [ + ['bimestral del rif'], + ['pago definitivo bimestral del rif'], + ]}, + + // Arrendamiento + { id: 'isr-provisional', keywords: [ + ['isr', 'arrendamiento de inmuebles', 'pago provisional'], + ['isr por arrendamiento de inmuebles pf'], + ]}, + + // Informativas (no tienen match directo en catálogo pero agrupar con DIM) + { id: 'dim', keywords: [ + ['declaracion informativa anual', 'pagos y retenciones'], + ['declaracion informativa anual de clientes y proveedores'], + ['declaracion informativa anual de retenciones'], + ['declaracion informativa de iva con la anual'], + ]}, +]; + +function normalizeForMatch(s: string): string { + return s + .normalize('NFD').replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[.,;:()]/g, '') + .replace(/\s+/g, ' ') + .trim(); +} + +function matchCsfToCatalog(descripcion: string, rfc: string): ObligacionFiscal | undefined { + const norm = normalizeForMatch(descripcion); + const esPM = rfc.length === 12; + + for (const rule of CATALOG_MATCH_RULES) { + for (const kwSet of rule.keywords) { + const allMatch = kwSet.every(kw => norm.includes(normalizeForMatch(kw))); + if (allMatch) { + // Special case: RESICO ISR provisional — PM vs PF + if (rule.id === 'isr-resico-pm' && !esPM) { + return OBLIGACIONES_CATALOGO.find(c => c.id === 'isr-resico-pf'); + } + if (rule.id === 'isr-resico-pf' && esPM) { + return OBLIGACIONES_CATALOGO.find(c => c.id === 'isr-resico-pm'); + } + return OBLIGACIONES_CATALOGO.find(c => c.id === rule.id); + } + } + } + return undefined; +} + +export interface ObligacionContribuyente { + id: string; + contribuyenteId: string; + catalogoId: string | null; + nombre: string; + fundamento: string | null; + frecuencia: string | null; + fechaLimite: string | null; + categoria: string | null; + activa: boolean; + esRecomendada: boolean; + esCustom: boolean; + completada: boolean; + completadaAt: string | null; + completadaPor: string | null; + periodoCompletado: string | null; + createdAt?: string; +} + +export function getCatalogo(): ObligacionFiscal[] { + return OBLIGACIONES_CATALOGO; +} + +export async function getObligaciones(pool: Pool, contribuyenteId: string): Promise { + const { rows } = await pool.query(` + SELECT id, contribuyente_id AS "contribuyenteId", catalogo_id AS "catalogoId", + nombre, fundamento, frecuencia, fecha_limite AS "fechaLimite", categoria, + activa, es_recomendada AS "esRecomendada", es_custom AS "esCustom", + completada, completada_at AS "completadaAt", completada_por AS "completadaPor", + periodo_completado AS "periodoCompletado", + created_at AS "createdAt" + FROM obligaciones_contribuyente + WHERE contribuyente_id = $1 + ORDER BY categoria, nombre + `, [contribuyenteId]); + return rows; +} + +/** + * Reads obligations from the latest Constancia de Situación Fiscal (CSF) + * and populates obligaciones_contribuyente. Falls back to catalog-based + * recommendations if no CSF exists. + */ +export async function initRecomendaciones( + pool: Pool, + contribuyenteId: string, + rfc: string, + regimenes: string[], + tieneNomina: boolean +): Promise { + // Clean up alerts and periodos for existing recommended obligations before replacing + await pool.query( + `DELETE FROM alertas WHERE tipo LIKE 'ob-%' AND SUBSTRING(tipo FROM 4 FOR 36) IN ( + SELECT id::text FROM obligaciones_contribuyente WHERE contribuyente_id = $1 AND es_recomendada = true + )`, + [contribuyenteId], + ); + await pool.query( + `DELETE FROM obligacion_periodos WHERE obligacion_id IN ( + SELECT id FROM obligaciones_contribuyente WHERE contribuyente_id = $1 AND es_recomendada = true + )`, + [contribuyenteId], + ); + // Clear previous recommended obligations (re-init replaces them) + await pool.query( + `DELETE FROM obligaciones_contribuyente WHERE contribuyente_id = $1 AND es_recomendada = true`, + [contribuyenteId], + ); + + // Try to get obligations from the latest CSF + const { rows: csfRows } = await pool.query(` + SELECT datos->'obligaciones' as obligaciones + FROM constancias_situacion_fiscal + WHERE rfc = $1 + ORDER BY created_at DESC LIMIT 1 + `, [rfc]); + + const csfObligaciones = csfRows[0]?.obligaciones as Array<{ + descripcion: string; + fechaInicio: string; + fechaFin?: string; + descripcionVencimiento: string; + }> | null; + + let count = 0; + + if (csfObligaciones && csfObligaciones.length > 0) { + // Use CSF obligations directly — these are the official SAT obligations + // Only import ACTIVE obligations (no fechaFin = still in effect) + const activeCsf = csfObligaciones.filter(ob => !ob.fechaFin); + for (const ob of activeCsf) { + // Keyword-based matching against catalog for enrichment (fundamento, categoria) + const catalogMatch = matchCsfToCatalog(ob.descripcion, rfc); + + const { rowCount } = await pool.query(` + INSERT INTO obligaciones_contribuyente ( + contribuyente_id, catalogo_id, nombre, fundamento, frecuencia, fecha_limite, categoria, es_recomendada + ) VALUES ($1, $2, $3, $4, $5, $6, $7, true) + ON CONFLICT DO NOTHING + `, [ + contribuyenteId, + catalogMatch?.id || null, + ob.descripcion, + catalogMatch?.fundamento || null, + catalogMatch?.frecuencia || inferirFrecuencia(ob.descripcionVencimiento), + ob.descripcionVencimiento, + catalogMatch?.categoria || 'SAT', + ]); + count += rowCount ?? 0; + } + } else { + // Fallback: use catalog-based recommendations + const recomendadas = getRecomendaciones(rfc, regimenes, tieneNomina); + for (const ob of recomendadas) { + const { rowCount } = await pool.query(` + INSERT INTO obligaciones_contribuyente (contribuyente_id, catalogo_id, nombre, fundamento, frecuencia, fecha_limite, categoria, es_recomendada) + VALUES ($1, $2, $3, $4, $5, $6, $7, true) + ON CONFLICT DO NOTHING + `, [contribuyenteId, ob.id, ob.nombre, ob.fundamento, ob.frecuencia, ob.fechaLimite, ob.categoria]); + count += rowCount ?? 0; + } + } + + return count; +} + +function inferirFrecuencia(vencimiento: string): string { + const lower = vencimiento.toLowerCase(); + if (lower.includes('mensual') || lower.includes('mes')) return 'mensual'; + if (lower.includes('bimest')) return 'bimestral'; + if (lower.includes('trimest')) return 'trimestral'; + if (lower.includes('anual') || lower.includes('ejercicio') || lower.includes('tres meses siguientes')) return 'anual'; + return 'mensual'; +} + +export async function completeObligacion(pool: Pool, obligacionId: string, userId: string, periodo: string): Promise { + const { rowCount } = await pool.query( + 'UPDATE obligaciones_contribuyente SET completada = true, completada_at = now(), completada_por = $2, periodo_completado = $3 WHERE id = $1', + [obligacionId, userId, periodo] + ); + return (rowCount ?? 0) > 0; +} + +export async function uncompleteObligacion(pool: Pool, obligacionId: string): Promise { + const { rowCount } = await pool.query( + 'UPDATE obligaciones_contribuyente SET completada = false, completada_at = null, completada_por = null, periodo_completado = null WHERE id = $1', + [obligacionId] + ); + return (rowCount ?? 0) > 0; +} + +export async function addObligacion(pool: Pool, contribuyenteId: string, data: { + catalogoId?: string; + nombre: string; + fundamento?: string; + frecuencia?: string; + fechaLimite?: string; + categoria?: string; +}): Promise { + const isFromCatalog = !!data.catalogoId; + const { rows: [row] } = await pool.query(` + INSERT INTO obligaciones_contribuyente (contribuyente_id, catalogo_id, nombre, fundamento, frecuencia, fecha_limite, categoria, es_custom) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id, contribuyente_id AS "contribuyenteId", catalogo_id AS "catalogoId", + nombre, fundamento, frecuencia, fecha_limite AS "fechaLimite", categoria, + activa, es_recomendada AS "esRecomendada", es_custom AS "esCustom" + `, [contribuyenteId, data.catalogoId || null, data.nombre, data.fundamento || null, + data.frecuencia || null, data.fechaLimite || null, data.categoria || 'Custom', + !isFromCatalog]); + return row; +} + +export async function removeObligacion(pool: Pool, obligacionId: string): Promise { + const { rowCount } = await pool.query( + 'UPDATE obligaciones_contribuyente SET activa = false WHERE id = $1', + [obligacionId] + ); + // Clean up alerts generated for this obligation (tipo format: 'ob-{obligacionId}-{periodo}') + await pool.query( + `DELETE FROM alertas WHERE tipo LIKE $1`, + [`ob-${obligacionId}-%`], + ); + // Clean up completion records + await pool.query( + 'DELETE FROM obligacion_periodos WHERE obligacion_id = $1', + [obligacionId], + ); + return (rowCount ?? 0) > 0; +} + +export async function restoreObligacion(pool: Pool, obligacionId: string): Promise { + const { rowCount } = await pool.query( + 'UPDATE obligaciones_contribuyente SET activa = true WHERE id = $1', + [obligacionId] + ); + return (rowCount ?? 0) > 0; +} + +/** + * Returns obligations for a specific period (YYYY-MM) for a contribuyente. + * Includes: + * - All active obligations that apply to this period (based on frequency) + * - Completion status from obligacion_periodos table + * - Past-due obligations from previous periods that were NOT completed + */ +export interface DeclaracionLink { + id: number; + año: number; + mes: number; + tipo: 'normal' | 'complementaria'; + pdfFilename: string | null; +} + +export async function getObligacionesPorPeriodo( + pool: Pool, + contribuyenteId: string, + periodo: string, // "2026-04" + incluirAtrasados: boolean = true +): Promise> { + // Get all active obligations for this contribuyente + const obligaciones = await getObligaciones(pool, contribuyenteId); + const activas = obligaciones.filter(o => o.activa); + + const [year, month] = periodo.split('-').map(Number); + const currentPeriodo = new Date().toISOString().substring(0, 7); + const results: Array = []; + + // Get all completion records + associated declaration info for this contribuyente + const { rows: completions } = await pool.query<{ + obligacion_id: string; + periodo: string; + completada: boolean; + declaracion_id: number | null; + decl_año: number | null; + decl_mes: number | null; + decl_tipo: 'normal' | 'complementaria' | null; + decl_pdf_filename: string | null; + }>(` + SELECT op.obligacion_id, op.periodo, op.completada, + op.declaracion_id, + dp.año AS decl_año, + dp.mes AS decl_mes, + dp.tipo AS decl_tipo, + dp.pdf_filename AS decl_pdf_filename + FROM obligacion_periodos op + JOIN obligaciones_contribuyente oc ON oc.id = op.obligacion_id + LEFT JOIN declaraciones_provisionales dp ON dp.id = op.declaracion_id + WHERE oc.contribuyente_id = $1 + `, [contribuyenteId]); + + const completionMap = new Map(); + const declaracionMap = new Map(); + for (const c of completions) { + const key = `${c.obligacion_id}:${c.periodo}`; + completionMap.set(key, c.completada); + if (c.declaracion_id && c.decl_año != null && c.decl_mes != null && c.decl_tipo) { + declaracionMap.set(key, { + id: c.declaracion_id, + año: c.decl_año, + mes: c.decl_mes, + tipo: c.decl_tipo, + pdfFilename: c.decl_pdf_filename, + }); + } + } + + for (const ob of activas) { + // Obligations only apply from the month they were created forward + const obStartPeriodo = ob.createdAt + ? new Date(ob.createdAt).toISOString().substring(0, 7) + : '2000-01'; + + // Check if this obligation applies to the requested period + if (periodo >= obStartPeriodo && appliesTo(ob.frecuencia, periodo)) { + const key = `${ob.id}:${periodo}`; + const isCompleted = completionMap.get(key) === true; + results.push({ + ...ob, + periodStatus: isCompleted ? 'completada' : 'pendiente', + periodoAplica: periodo, + declaracion: declaracionMap.get(key) ?? null, + }); + } + + // Check past-due (previous periods not completed) — only if requested + if (incluirAtrasados && periodo >= currentPeriodo) { + // Look back up to 12 months for overdue items + for (let i = 1; i <= 12; i++) { + let pm = month - i; + let py = year; + while (pm < 1) { pm += 12; py--; } + const pastPeriodo = `${py}-${String(pm).padStart(2, '0')}`; + + if (pastPeriodo >= currentPeriodo) continue; // only past periods + if (pastPeriodo < obStartPeriodo) continue; // don't go before obligation was created + if (!appliesTo(ob.frecuencia, pastPeriodo)) continue; + + const pastKey = `${ob.id}:${pastPeriodo}`; + const pastCompleted = completionMap.get(pastKey) === true; + + if (!pastCompleted) { + // Don't add duplicates + if (!results.find(r => r.id === ob.id && r.periodoAplica === pastPeriodo)) { + results.push({ + ...ob, + periodStatus: 'atrasada', + periodoAplica: pastPeriodo, + declaracion: null, + }); + } + } + } + } + } + + // Sort: atrasadas first, then by name + results.sort((a, b) => { + if (a.periodStatus === 'atrasada' && b.periodStatus !== 'atrasada') return -1; + if (b.periodStatus === 'atrasada' && a.periodStatus !== 'atrasada') return 1; + return a.nombre.localeCompare(b.nombre); + }); + + return results as Array; +} + +function appliesTo(frecuencia: string | null, periodo: string): boolean { + const [, month] = periodo.split('-').map(Number); + switch (frecuencia) { + case 'mensual': return true; + case 'bimestral': return month % 2 === 1; // Jan, Mar, May... + case 'trimestral': return [1, 4, 7, 10].includes(month); + case 'anual': return month === 3 || month === 4; // March (PM) or April (PF) — show in both + case 'eventual': return false; // Don't auto-show + default: return true; + } +} + +/** + * Mark an obligation as completed for a specific period + */ +export async function completePeriodo( + pool: Pool, + obligacionId: string, + periodo: string, + userId: string, + notas?: string +): Promise { + await pool.query(` + INSERT INTO obligacion_periodos (obligacion_id, periodo, completada, completada_at, completada_por, notas) + VALUES ($1, $2, true, now(), $3, $4) + ON CONFLICT (obligacion_id, periodo) + DO UPDATE SET completada = true, completada_at = now(), completada_por = $3, notas = COALESCE($4, obligacion_periodos.notas) + `, [obligacionId, periodo, userId, notas || null]); + return true; +} + +/** + * Unmark an obligation completion for a specific period + */ +export async function uncompletePeriodo( + pool: Pool, + obligacionId: string, + periodo: string +): Promise { + await pool.query(` + DELETE FROM obligacion_periodos WHERE obligacion_id = $1 AND periodo = $2 + `, [obligacionId, periodo]); + return true; +} diff --git a/apps/api/src/services/opinion-cumplimiento.service.ts b/apps/api/src/services/opinion-cumplimiento.service.ts new file mode 100644 index 0000000..ce6d37f --- /dev/null +++ b/apps/api/src/services/opinion-cumplimiento.service.ts @@ -0,0 +1,185 @@ +import { chromium } from 'playwright'; +import { writeFileSync, unlinkSync, mkdirSync, rmdirSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { randomUUID } from 'crypto'; +import type { Pool } from 'pg'; +import type { OpinionCumplimiento } from '@horux/shared'; +import { getDecryptedFiel } from './fiel.service.js'; +import { getDecryptedFielContribuyente } from './contribuyente-fiel.service.js'; +import { loginToSatOpinion } from './sat/sat-opinion-login.js'; +import { extractOpinionPdf } from './sat/sat-opinion-scraper.js'; +import { parseOpinionPdf } from './sat/sat-opinion-parser.js'; +import { prisma, tenantDb } from '../config/database.js'; + +const PROCESS_TIMEOUT = 180_000; // 3 minutes per tenant + +/** + * Downloads and stores the Opinión de Cumplimiento for a tenant. + */ +export async function consultarOpinion(tenantId: string): Promise { + const fiel = await getDecryptedFiel(tenantId); + if (!fiel) { + throw new Error('No hay FIEL configurada o está vencida'); + } + + const tempId = randomUUID(); + const tempDir = join(tmpdir(), `horux-fiel-${tempId}`); + mkdirSync(tempDir, { recursive: true, mode: 0o700 }); + + const cerPath = join(tempDir, 'cert.cer'); + const keyPath = join(tempDir, 'key.key'); + + try { + writeFileSync(cerPath, Buffer.from(fiel.cerContent, 'binary'), { mode: 0o600 }); + writeFileSync(keyPath, Buffer.from(fiel.keyContent, 'binary'), { mode: 0o600 }); + + const browser = await chromium.launch({ headless: true }); + + try { + const context = await browser.newContext({ + viewport: { width: 1280, height: 720 }, + }); + const page = await context.newPage(); + + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('Timeout: proceso de opinión excedió 3 minutos')), PROCESS_TIMEOUT) + ); + + const resultPromise = (async () => { + const reportPage = await loginToSatOpinion(page, cerPath, keyPath, fiel.password); + const pdfBuffer = await extractOpinionPdf(reportPage); + const parsed = await parseOpinionPdf(pdfBuffer); + + const tenant = await prisma.tenant.findUnique({ + where: { id: tenantId }, + select: { databaseName: true }, + }); + if (!tenant) throw new Error('Tenant no encontrado'); + + const pool = await tenantDb.getPool(tenantId, tenant.databaseName); + + const { rows } = await pool.query(` + INSERT INTO opiniones_cumplimiento (rfc, razon_social, estatus, folio, cadena_original, fecha_consulta, pdf) + VALUES ($1, $2, $3, $4, $5, NOW(), $6) + RETURNING id, rfc, razon_social as "razonSocial", estatus, folio, cadena_original as "cadenaOriginal", + fecha_consulta as "fechaConsulta", created_at as "createdAt" + `, [parsed.rfc, parsed.razonSocial, parsed.estatus, parsed.folio, parsed.cadenaOriginal, pdfBuffer]); + + return rows[0] as OpinionCumplimiento; + })(); + + return await Promise.race([resultPromise, timeoutPromise]); + } finally { + await browser.close(); + } + } finally { + try { unlinkSync(cerPath); } catch { /* ok */ } + try { unlinkSync(keyPath); } catch { /* ok */ } + try { rmdirSync(tempDir); } catch { /* ok */ } + } +} + +/** + * Get last N opinions for a tenant (metadata only, no PDF). + */ +export async function getOpiniones(pool: Pool, limit = 5, rfc?: string): Promise { + const params: unknown[] = [limit]; + let rfcFilter = ''; + if (rfc) { + rfcFilter = 'WHERE rfc = $2'; + params.push(rfc); + } + const { rows } = await pool.query(` + SELECT id, rfc, razon_social as "razonSocial", estatus, folio, + cadena_original as "cadenaOriginal", + fecha_consulta as "fechaConsulta", created_at as "createdAt" + FROM opiniones_cumplimiento + ${rfcFilter} + ORDER BY fecha_consulta DESC + LIMIT $1 + `, params); + return rows; +} + +/** + * Get PDF binary for a specific opinion. + */ +export async function getOpinionPdf(pool: Pool, id: number): Promise { + const { rows } = await pool.query( + `SELECT pdf FROM opiniones_cumplimiento WHERE id = $1`, + [id] + ); + return rows.length > 0 ? rows[0].pdf : null; +} + +/** + * Delete opinions older than 6 months. + */ +export async function limpiarOpinionesAntiguas(pool: Pool): Promise { + const { rowCount } = await pool.query( + `DELETE FROM opiniones_cumplimiento WHERE fecha_consulta < NOW() - interval '6 months'` + ); + return rowCount ?? 0; +} + +/** + * Downloads and stores the Opinión de Cumplimiento for a specific contribuyente + * (despacho mode). Uses FIEL stored in the tenant BD instead of the central BD. + */ +export async function consultarOpinionContribuyente( + pool: Pool, + contribuyenteId: string, +): Promise { + const safeId = contribuyenteId.replace(/[^a-f0-9-]/gi, ''); + const fiel = await getDecryptedFielContribuyente(pool, safeId); + if (!fiel) { + throw new Error('No hay FIEL configurada para este contribuyente o está vencida'); + } + + const tempId = randomUUID(); + const tempDir = join(tmpdir(), `horux-fiel-${tempId}`); + mkdirSync(tempDir, { recursive: true, mode: 0o700 }); + + const cerPath = join(tempDir, 'cert.cer'); + const keyPath = join(tempDir, 'key.key'); + + try { + writeFileSync(cerPath, Buffer.from(fiel.cerContent, 'binary'), { mode: 0o600 }); + writeFileSync(keyPath, Buffer.from(fiel.keyContent, 'binary'), { mode: 0o600 }); + + const browser = await chromium.launch({ headless: true }); + + try { + const context = await browser.newContext({ viewport: { width: 1280, height: 720 } }); + const page = await context.newPage(); + + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('Timeout: proceso de opinión excedió 3 minutos')), PROCESS_TIMEOUT) + ); + + const resultPromise = (async () => { + const reportPage = await loginToSatOpinion(page, cerPath, keyPath, fiel.password); + const pdfBuffer = await extractOpinionPdf(reportPage); + const parsed = await parseOpinionPdf(pdfBuffer); + + const { rows } = await pool.query(` + INSERT INTO opiniones_cumplimiento (rfc, razon_social, estatus, folio, cadena_original, fecha_consulta, pdf) + VALUES ($1, $2, $3, $4, $5, NOW(), $6) + RETURNING id, rfc, razon_social as "razonSocial", estatus, folio, cadena_original as "cadenaOriginal", + fecha_consulta as "fechaConsulta", created_at as "createdAt" + `, [parsed.rfc, parsed.razonSocial, parsed.estatus, parsed.folio, parsed.cadenaOriginal, pdfBuffer]); + + return rows[0] as OpinionCumplimiento; + })(); + + return await Promise.race([resultPromise, timeoutPromise]); + } finally { + await browser.close(); + } + } finally { + try { unlinkSync(cerPath); } catch { /* ok */ } + try { unlinkSync(keyPath); } catch { /* ok */ } + try { rmdirSync(tempDir); } catch { /* ok */ } + } +} diff --git a/apps/api/src/services/papeleria.service.ts b/apps/api/src/services/papeleria.service.ts new file mode 100644 index 0000000..fe01184 --- /dev/null +++ b/apps/api/src/services/papeleria.service.ts @@ -0,0 +1,211 @@ +import type { Pool } from 'pg'; + +export const ALLOWED_MIMES = new Set([ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // docx + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // xlsx +]); + +export const MAX_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB + +export type EstadoPapeleria = 'pendiente' | 'aprobado' | 'rechazado'; + +export interface PapeleriaItem { + id: number; + contribuyenteId: string; + nombre: string; + descripcion: string | null; + archivoFilename: string; + archivoMime: string; + archivoSize: number; + anio: number; + mes: number; + requiereAprobacion: boolean; + estado: EstadoPapeleria | null; + aprobadoPor: string | null; + aprobadoAt: Date | null; + comentarioRechazo: string | null; + subidoPor: string; + createdAt: Date; +} + +const SELECT = ` + id, contribuyente_id, nombre, descripcion, + archivo_filename, archivo_mime, archivo_size, + anio, mes, + requiere_aprobacion, estado, aprobado_por, aprobado_at, comentario_rechazo, + subido_por, created_at +`; + +const ROW = (r: any): PapeleriaItem => ({ + id: r.id, + contribuyenteId: r.contribuyente_id, + nombre: r.nombre, + descripcion: r.descripcion, + archivoFilename: r.archivo_filename, + archivoMime: r.archivo_mime, + archivoSize: r.archivo_size, + anio: r.anio, + mes: r.mes, + requiereAprobacion: r.requiere_aprobacion, + estado: r.estado, + aprobadoPor: r.aprobado_por, + aprobadoAt: r.aprobado_at, + comentarioRechazo: r.comentario_rechazo, + subidoPor: r.subido_por, + createdAt: r.created_at, +}); + +function sanitizeUuid(id: string): string { + return id.replace(/[^a-f0-9-]/gi, ''); +} + +export interface UploadInput { + contribuyenteId: string; + nombre: string; + descripcion: string | null; + anio: number; + mes: number; + requiereAprobacion: boolean; + archivo: Buffer; + archivoFilename: string; + archivoMime: string; + subidoPor: string; +} + +export async function uploadPapeleria( + pool: Pool, + input: UploadInput, +): Promise { + if (!ALLOWED_MIMES.has(input.archivoMime)) { + throw new Error(`Formato no permitido: ${input.archivoMime}. Solo PDF, Word y Excel.`); + } + if (input.archivo.length > MAX_SIZE_BYTES) { + throw new Error(`Archivo excede el máximo de 5 MB (recibido ${(input.archivo.length / 1024 / 1024).toFixed(1)} MB).`); + } + + const estadoInicial = input.requiereAprobacion ? 'pendiente' : null; + + const { rows: [r] } = await pool.query( + `INSERT INTO papeleria_trabajo + (contribuyente_id, nombre, descripcion, archivo, archivo_filename, archivo_mime, archivo_size, + anio, mes, requiere_aprobacion, estado, subido_por) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + RETURNING ${SELECT}`, + [ + sanitizeUuid(input.contribuyenteId), + input.nombre, + input.descripcion, + input.archivo, + input.archivoFilename, + input.archivoMime, + input.archivo.length, + input.anio, + input.mes, + input.requiereAprobacion, + estadoInicial, + input.subidoPor, + ], + ); + return ROW(r); +} + +export interface ListFilters { + contribuyenteId: string; + anio?: number; + mes?: number; + estado?: EstadoPapeleria | 'sin_aprobacion'; +} + +export async function listPapeleria(pool: Pool, f: ListFilters): Promise { + const conds: string[] = ['contribuyente_id = $1']; + const vals: unknown[] = [sanitizeUuid(f.contribuyenteId)]; + let i = 2; + if (f.anio) { conds.push(`anio = $${i++}`); vals.push(f.anio); } + if (f.mes) { conds.push(`mes = $${i++}`); vals.push(f.mes); } + if (f.estado === 'sin_aprobacion') { + conds.push('requiere_aprobacion = false'); + } else if (f.estado) { + conds.push(`estado = $${i++}`); vals.push(f.estado); + } + const { rows } = await pool.query( + `SELECT ${SELECT} FROM papeleria_trabajo + WHERE ${conds.join(' AND ')} + ORDER BY anio DESC, mes DESC, created_at DESC`, + vals, + ); + return rows.map(ROW); +} + +export async function getById(pool: Pool, id: number): Promise { + const { rows: [r] } = await pool.query( + `SELECT ${SELECT} FROM papeleria_trabajo WHERE id = $1`, + [id], + ); + return r ? ROW(r) : null; +} + +export async function downloadArchivo( + pool: Pool, + id: number, +): Promise<{ archivo: Buffer; filename: string; mime: string } | null> { + const { rows: [r] } = await pool.query( + `SELECT archivo, archivo_filename, archivo_mime FROM papeleria_trabajo WHERE id = $1`, + [id], + ); + if (!r) return null; + return { archivo: r.archivo, filename: r.archivo_filename, mime: r.archivo_mime }; +} + +const ROLES_APROBADOR = new Set(['owner', 'cfo', 'supervisor']); + +export async function aprobar( + pool: Pool, + id: number, + userId: string, + userRole: string, +): Promise { + if (!ROLES_APROBADOR.has(userRole)) { + throw new Error('Solo owner o supervisor pueden aprobar papelería'); + } + const { rows: [r] } = await pool.query( + `UPDATE papeleria_trabajo + SET estado = 'aprobado', aprobado_por = $2, aprobado_at = NOW(), + comentario_rechazo = NULL + WHERE id = $1 AND requiere_aprobacion = true + RETURNING ${SELECT}`, + [id, userId], + ); + return r ? ROW(r) : null; +} + +export async function rechazar( + pool: Pool, + id: number, + userId: string, + userRole: string, + comentario: string | null, +): Promise { + if (!ROLES_APROBADOR.has(userRole)) { + throw new Error('Solo owner o supervisor pueden rechazar papelería'); + } + const { rows: [r] } = await pool.query( + `UPDATE papeleria_trabajo + SET estado = 'rechazado', aprobado_por = $2, aprobado_at = NOW(), + comentario_rechazo = $3 + WHERE id = $1 AND requiere_aprobacion = true + RETURNING ${SELECT}`, + [id, userId, comentario], + ); + return r ? ROW(r) : null; +} + +export async function eliminar(pool: Pool, id: number): Promise { + const { rowCount } = await pool.query( + `DELETE FROM papeleria_trabajo WHERE id = $1`, + [id], + ); + return (rowCount ?? 0) > 0; +} diff --git a/apps/api/src/services/payment/addon.service.ts b/apps/api/src/services/payment/addon.service.ts new file mode 100644 index 0000000..10e184b --- /dev/null +++ b/apps/api/src/services/payment/addon.service.ts @@ -0,0 +1,422 @@ +import { prisma } from '../../config/database.js'; +import * as mpService from './mercadopago.service.js'; +import { getTenantOwnerEmail } from '../../utils/memberships.js'; +import { computeEffectiveLimits, type PlanLimits, type AddonDelta } from '../plan-catalogo.service.js'; +import { permiteOverage } from '@horux/shared'; +import { emailService } from '../email/email.service.js'; + +export async function listActiveAddons(tenantId: string, contribuyenteId?: string | null) { + const subscription = await prisma.subscription.findFirst({ + where: { tenantId, status: { in: ['authorized', 'pending', 'trial'] } }, + include: { + addons: { + include: { planAddonCatalogo: true }, + where: { + status: { in: ['authorized', 'pending'] }, + // Si se pide por contribuyente, solo trae los de ese contribuyente. + // Si no, trae todos (tenant-level + todos los contribuyentes). + ...(contribuyenteId !== undefined ? { contribuyenteId } : {}), + }, + }, + }, + }); + + if (!subscription) return { addons: [], subscription: null }; + + return { + subscription: { id: subscription.id, plan: subscription.plan, status: subscription.status }, + addons: subscription.addons.map(a => ({ + id: a.id, + codename: a.planAddonCatalogo.codename, + nombre: a.planAddonCatalogo.nombre, + precio: Number(a.amount), + quantity: a.quantity, + contribuyenteId: a.contribuyenteId, + status: a.status, + currentPeriodStart: a.currentPeriodStart?.toISOString() ?? null, + currentPeriodEnd: a.currentPeriodEnd?.toISOString() ?? null, + })), + }; +} + +export async function subscribeAddon(params: { + tenantId: string; + addonCodename: string; + quantity?: number; + payerEmail?: string; + /** + * UUID del contribuyente (entidad_id en BD tenant) cuando el add-on se ata a + * un RFC específico. Omitido para add-ons tenant-level. + */ + contribuyenteId?: string | null; +}): Promise<{ addon: any; paymentUrl: string }> { + const { tenantId, addonCodename, quantity = 1, contribuyenteId = null } = params; + + const addon = await prisma.planAddonCatalogo.findUnique({ where: { codename: addonCodename } }); + if (!addon || !addon.active) throw new Error('Addon no disponible'); + + const subscription = await prisma.subscription.findFirst({ + where: { tenantId, status: { in: ['authorized', 'trial'] } }, + }); + if (!subscription) throw new Error('No hay suscripción activa'); + + // Un solo addon activo por (subscription, addon, contribuyente?). Para + // tenant-level (contribuyenteId=null) cualquier activo bloquea; para + // per-contribuyente, solo bloquea si ya existe activo para ese mismo RFC. + const existing = await prisma.subscriptionAddon.findFirst({ + where: { + subscriptionId: subscription.id, + planAddonCatalogoId: addon.id, + contribuyenteId: contribuyenteId ?? null, + status: { in: ['authorized', 'pending'] }, + }, + }); + if (existing) throw new Error('Ya tienes este addon activo'); + + const ownerEmail = params.payerEmail || await getTenantOwnerEmail(tenantId); + if (!ownerEmail) throw new Error('No se pudo determinar un email para el cobro'); + + const tenant = await prisma.tenant.findUnique({ where: { id: tenantId }, select: { nombre: true } }); + const amount = Number(addon.precio) * quantity; + + // Create the SubscriptionAddon record first so we have an id for external_reference + const subscriptionAddon = await prisma.subscriptionAddon.create({ + data: { + subscriptionId: subscription.id, + planAddonCatalogoId: addon.id, + contribuyenteId: contribuyenteId ?? null, + status: 'pending', + quantity, + amount, + }, + }); + + let mp: { preapprovalId: string; initPoint: string; status: string }; + try { + mp = await mpService.createPreapproval({ + tenantId, + reason: `Horux Despachos - ${addon.nombre}${contribuyenteId ? ` (RFC ${contribuyenteId.slice(0, 8)})` : ''} x${quantity} - ${tenant?.nombre || tenantId}`, + amount, + payerEmail: ownerEmail, + frequency: addon.frecuencia === 'anual' ? 'annual' : 'monthly', + externalReference: `addon:${subscriptionAddon.id}`, + }); + } catch (err) { + // If MP creation fails, clean up the pending record so user can retry + await prisma.subscriptionAddon.delete({ where: { id: subscriptionAddon.id } }); + throw err; + } + + // Update record with the mp preapproval id and final status + const updated = await prisma.subscriptionAddon.update({ + where: { id: subscriptionAddon.id }, + data: { + mpPreapprovalId: mp.preapprovalId, + status: mp.status || 'pending', + }, + }); + + return { addon: updated, paymentUrl: mp.initPoint }; +} + +export async function cancelAddon(tenantId: string, addonId: string): Promise { + const addon = await prisma.subscriptionAddon.findUnique({ + where: { id: addonId }, + include: { subscription: { select: { tenantId: true } } }, + }); + + if (!addon || addon.subscription.tenantId !== tenantId) { + throw new Error('Addon no encontrado'); + } + + if (addon.mpPreapprovalId) { + try { + await mpService.cancelPreapproval(addon.mpPreapprovalId); + } catch (err) { + console.error('[Addon] Error cancelling MP preapproval:', err); + } + } + + await prisma.subscriptionAddon.update({ + where: { id: addonId }, + data: { status: 'cancelled' }, + }); +} + +export async function handleAddonPayment(addonId: string, mpPaymentId: string, status: string): Promise { + const addon = await prisma.subscriptionAddon.findUnique({ + where: { id: addonId }, + include: { subscription: { select: { tenantId: true } } }, + }); + if (!addon) { + console.error(`[Addon Webhook] SubscriptionAddon ${addonId} not found`); + return; + } + + const previousStatus = addon.status; + + if (status === 'authorized' || status === 'approved') { + const now = new Date(); + const periodEnd = new Date(now); + if (addon.amount) { + periodEnd.setMonth(periodEnd.getMonth() + 1); + } + + await prisma.subscriptionAddon.update({ + where: { id: addonId }, + data: { + status: 'authorized', + currentPeriodStart: now, + currentPeriodEnd: periodEnd, + }, + }); + } else if (status === 'cancelled' || status === 'paused' || status === 'rejected') { + await prisma.subscriptionAddon.update({ + where: { id: addonId }, + data: { status }, + }); + + // Aviso fail-soft al owner si el cobro de addon (overage de contribuyentes, + // típicamente $45/mes por extra >100) fue rechazado. Solo en transición + // real — si ya estaba en estado terminal, MP puede re-notificar y no + // queremos spam. + if ( + (status === 'rejected' || status === 'cancelled') && + previousStatus !== status && + addon.subscription + ) { + const tenantId = addon.subscription.tenantId; + const tenant = await prisma.tenant.findUnique({ where: { id: tenantId }, select: { nombre: true } }); + const ownerEmail = await getTenantOwnerEmail(tenantId); + if (tenant && ownerEmail) { + emailService.sendPaymentFailed(ownerEmail, { + nombre: tenant.nombre, + amount: Number(addon.amount ?? 0), + plan: 'Addon de contribuyentes adicionales', + }).catch(err => console.error('[EMAIL] addon failed notification:', err)); + } + } + } +} + +// ──────────────────────────────────────────────────────────────── +// Overage automático: Business Control y Enterprise (business_cloud) incluyen +// 100 contribuyentes; cada uno adicional cuesta $45/mes. Se modela como un +// único `SubscriptionAddon` con `codename = 'contribuyente_extra_business_cloud'` +// (codename heredado por compat con suscripciones existentes; nombre display ya +// es genérico), `contribuyenteId = null` (tenant-level) y +// `quantity = activeCount − 100`. El cobro MP usa un preapproval propio; cuando +// `quantity` cambia, se actualiza vía `updatePreapprovalAmount` (sin +// re-autorización del usuario). +// ──────────────────────────────────────────────────────────────── + +const DESPACHO_INCLUDED_RFCS = 100; +const OVERAGE_ADDON_CODENAME = 'contribuyente_extra_business_cloud'; + +export type OverageAction = 'none' | 'created' | 'updated' | 'cancelled' | 'skipped'; + +export interface OverageAdjustResult { + action: OverageAction; + /** Cantidad cobrada tras el ajuste (activeCount − 3, mínimo 0). */ + overageCount: number; + /** Sólo cuando action='created': URL de MercadoPago a presentar al usuario. */ + paymentUrl?: string; + /** Razón si action='skipped' o 'none' (útil para logs/UI). */ + reason?: string; +} + +/** + * Ajusta el add-on de overage para el tenant según el número actual de + * contribuyentes activos. Aplica a planes Business Control y Enterprise + * (business_cloud) — ambos incluyen 100 contribuyentes y cobran $45/mes por + * cada adicional. Idempotente: llamar varias veces con el mismo `activeCount` + * no tiene efecto. + * + * Casos: + * - Plan no permite overage (mi_empresa, mi_empresa_plus, trial, etc.) → 'skipped' + * - Sin suscripción activa → 'skipped' (addon requiere sub) + * - Catálogo no seeded → 'skipped' (error de setup) + * - overage=0 y no hay addon → 'none' + * - overage=0 y hay addon → 'cancelled' (revoca preapproval) + * - overage>0 y no hay addon → 'created' (crea addon + preapproval → paymentUrl) + * - overage>0 y addon.quantity == overage → 'none' (idempotente) + * - overage>0 y addon.quantity distinto → 'updated' (updatePreapprovalAmount) + */ +export async function adjustDespachoOverage( + tenantId: string, + activeContribuyenteCount: number, +): Promise { + const sub = await prisma.subscription.findFirst({ + where: { tenantId, status: { in: ['authorized', 'trial', 'pending'] } }, + orderBy: { createdAt: 'desc' }, + }); + if (!sub) return { action: 'skipped', overageCount: 0, reason: 'Sin suscripción activa' }; + if (!permiteOverage(sub.plan)) { + return { action: 'skipped', overageCount: 0, reason: `Plan ${sub.plan} no aplica overage` }; + } + + const overage = Math.max(0, activeContribuyenteCount - DESPACHO_INCLUDED_RFCS); + + const catalogo = await prisma.planAddonCatalogo.findUnique({ + where: { codename: OVERAGE_ADDON_CODENAME }, + }); + if (!catalogo) { + return { action: 'skipped', overageCount: overage, reason: 'Catálogo overage no seeded' }; + } + + const existing = await prisma.subscriptionAddon.findFirst({ + where: { + subscriptionId: sub.id, + planAddonCatalogoId: catalogo.id, + contribuyenteId: null, + status: { in: ['authorized', 'pending'] }, + }, + }); + + // Bajo o en el límite: cancela el addon si existe + if (overage === 0) { + if (!existing) return { action: 'none', overageCount: 0 }; + if (existing.mpPreapprovalId) { + try { + await mpService.cancelPreapproval(existing.mpPreapprovalId); + } catch (err) { + console.error('[Overage] Error cancelling MP preapproval:', err); + } + } + await prisma.subscriptionAddon.update({ + where: { id: existing.id }, + data: { status: 'cancelled' }, + }); + return { action: 'cancelled', overageCount: 0 }; + } + + // Hay overage → crear o actualizar addon + const price = Number(catalogo.precio); + const newAmount = price * overage; + + if (!existing) { + const ownerEmail = await getTenantOwnerEmail(tenantId); + if (!ownerEmail) { + return { action: 'skipped', overageCount: overage, reason: 'Sin email del owner para cobro' }; + } + const tenant = await prisma.tenant.findUnique({ where: { id: tenantId }, select: { nombre: true } }); + + const addon = await prisma.subscriptionAddon.create({ + data: { + subscriptionId: sub.id, + planAddonCatalogoId: catalogo.id, + contribuyenteId: null, + status: 'pending', + quantity: overage, + amount: newAmount, + }, + }); + + let mp: { preapprovalId: string; initPoint: string; status: string }; + try { + mp = await mpService.createPreapproval({ + tenantId, + reason: `Horux Despachos - ${catalogo.nombre} x${overage} - ${tenant?.nombre || tenantId}`, + amount: newAmount, + payerEmail: ownerEmail, + frequency: 'monthly', + externalReference: `addon:${addon.id}`, + }); + } catch (err) { + await prisma.subscriptionAddon.delete({ where: { id: addon.id } }); + throw err; + } + + await prisma.subscriptionAddon.update({ + where: { id: addon.id }, + data: { mpPreapprovalId: mp.preapprovalId, status: mp.status || 'pending' }, + }); + + return { action: 'created', overageCount: overage, paymentUrl: mp.initPoint }; + } + + // Idempotente: si quantity ya coincide, no hay nada que hacer + if (existing.quantity === overage) { + return { action: 'none', overageCount: overage }; + } + + // Actualizar quantity + amount + MP preapproval + await prisma.subscriptionAddon.update({ + where: { id: existing.id }, + data: { quantity: overage, amount: newAmount }, + }); + if (existing.mpPreapprovalId) { + try { + await mpService.updatePreapprovalAmount(existing.mpPreapprovalId, newAmount); + } catch (err) { + console.error('[Overage] Error updating MP amount:', err); + } + } + + return { action: 'updated', overageCount: overage }; +} + +/** + * Cuenta contribuyentes activos del tenant abriendo el pool tenant. Helper + * para callers que viven fuera de un endpoint con `req.tenantPool` (ej. + * `subscription.service.ts` cuando aplica un cambio de plan). + */ +export async function countActiveContribuyentesForTenant(tenantId: string): Promise { + const tenant = await prisma.tenant.findUnique({ + where: { id: tenantId }, + select: { databaseName: true }, + }); + if (!tenant?.databaseName) return 0; + const { tenantDb } = await import('../../config/database.js'); + const pool = await tenantDb.getPool(tenantId, tenant.databaseName); + const { rows: [{ cnt }] } = await pool.query<{ cnt: string }>( + `SELECT COUNT(*)::text AS cnt FROM entidades_gestionadas + WHERE active = true AND tipo = 'CONTRIBUYENTE'`, + ); + return Number(cnt) || 0; +} + +/** + * Cancela el add-on de overage del tenant (si existe) sin importar el status + * actual de la suscripción. Útil cuando una suscripción se cancela y queremos + * cerrar también el preapproval mensual del overage. Idempotente. + */ +export async function cancelOverageAddonForTenant(tenantId: string): Promise<{ cancelled: boolean }> { + const sub = await prisma.subscription.findFirst({ + where: { tenantId }, + orderBy: { createdAt: 'desc' }, + }); + if (!sub) return { cancelled: false }; + + const catalogo = await prisma.planAddonCatalogo.findUnique({ + where: { codename: OVERAGE_ADDON_CODENAME }, + }); + if (!catalogo) return { cancelled: false }; + + const existing = await prisma.subscriptionAddon.findFirst({ + where: { + subscriptionId: sub.id, + planAddonCatalogoId: catalogo.id, + contribuyenteId: null, + status: { in: ['authorized', 'pending'] }, + }, + }); + if (!existing) return { cancelled: false }; + + if (existing.mpPreapprovalId) { + try { + await mpService.cancelPreapproval(existing.mpPreapprovalId); + } catch (err) { + console.error('[Overage] Error cancelling MP preapproval on tenant cancel:', err); + } + } + await prisma.subscriptionAddon.update({ + where: { id: existing.id }, + data: { status: 'cancelled' }, + }); + return { cancelled: true }; +} + +// Re-export types used by callers that need to compose addon deltas with plan limits +export type { PlanLimits, AddonDelta }; +export { computeEffectiveLimits }; diff --git a/apps/api/src/services/payment/invoicing.service.ts b/apps/api/src/services/payment/invoicing.service.ts new file mode 100644 index 0000000..d0fb8bf --- /dev/null +++ b/apps/api/src/services/payment/invoicing.service.ts @@ -0,0 +1,351 @@ +/** + * Auto-facturación de pagos de suscripción. + * + * Cada vez que MercadoPago confirma un pago (webhook `payment.approved`), este + * servicio emite automáticamente un CFDI al público en general vía Facturapi, + * usando la organización de Horux 360 como emisor. + * + * Reglas: + * - El **primer pago** aprobado de cada tenant NO se factura automáticamente — + * el admin lo hace manualmente para verificar/capturar los datos fiscales del + * cliente. Los pagos subsecuentes sí van auto a público en general. + * - Trials (amount=0) no se facturan. + * - Idempotente: si `Payment.facturapiInvoiceId` ya existe, skip. + * - Si Facturapi falla (API down, CSD inválido), se logea el error pero NO se + * tira el webhook — `facturapiInvoiceId` queda null y el admin puede re-emitir + * manualmente después. Esto evita que MP reintente el webhook y que se + * dupliquen registros de Payment. + * + * Emisor: Horux 360 (RFC HTS240708LJA, RESICO PM, régimen 626, sin retenciones). + * Receptor: PUBLICO EN GENERAL (XAXX010101000, régimen 616). + * Concepto: clave prod/serv 81112502 (Servicios de alojamiento de aplicaciones). + */ +import { prisma } from '../../config/database.js'; +import * as facturapiService from '../facturapi.service.js'; +import { GLOBAL_ADMIN_RFC } from '@horux/shared'; +import { auditLog } from '../../utils/audit.js'; +import { getTenantOwnerEmail } from '../../utils/memberships.js'; + +// Constantes de facturación — ajustar aquí si cambia la convención +const CONCEPT_PRODUCT_KEY = '81112502'; // Servicios de alojamiento de aplicaciones +const CONCEPT_UNIT_KEY = 'E48'; // Unidad de servicio +const CONCEPT_UNIT_NAME = 'Servicio'; +// Fallback público en general — se usa cuando el tenant pagador no tiene +// suficientes datos fiscales (sin CSF cargada, sin domicilio, etc.). +const FALLBACK_TAX_ID = 'XAXX010101000'; +const FALLBACK_LEGAL_NAME = 'PUBLICO EN GENERAL'; +const FALLBACK_TAX_SYSTEM = '616'; // Sin obligaciones fiscales +const FALLBACK_USE_CFDI = 'S01'; // Sin efectos fiscales +// Default cuando facturamos con datos reales del cliente — gastos en general. +// Fase 2 hará esto configurable por tenant. +const DEFAULT_USE_CFDI = 'G03'; +const IVA_RATE = 0.16; + +// Mapeo MP payment_method → SAT forma_pago. Conservador: por default TEF (03). +const FORMA_PAGO_POR_METHOD: Record = { + credit_card: '04', // Tarjeta de crédito + debit_card: '28', // Tarjeta de débito + account_money: '03', // Transferencia (MP wallet) + bank_transfer: '03', +}; + +const PLAN_LABELS: Record = { + trial: 'Trial', + custom: 'Custom', + mi_empresa: 'Mi Empresa', + mi_empresa_plus: 'Mi Empresa Plus', + business_control: 'Business Control', + business_cloud: 'Enterprise', +}; + +/** + * Cuenta si este tenant ya tuvo un pago aprobado antes del actual. + * Si no hay ninguno, es el primer pago → devolvemos true (skip auto-emit). + */ +async function isFirstApprovedPayment( + tenantId: string, + excludePaymentId: string, +): Promise { + const count = await prisma.payment.count({ + where: { + tenantId, + status: 'approved', + id: { not: excludePaymentId }, + }, + }); + return count === 0; +} + +/** + * Busca el tenant emisor (Horux 360) con su organización Facturapi configurada. + * Si falta, lanza error — el admin global tiene que crear la organización primero. + */ +async function getEmitterTenant() { + const tenant = await prisma.tenant.findUnique({ + where: { rfc: GLOBAL_ADMIN_RFC }, + select: { + id: true, + nombre: true, + rfc: true, + codigoPostal: true, + facturapiOrgId: true, + }, + }); + if (!tenant) { + throw new Error(`Tenant emisor (RFC ${GLOBAL_ADMIN_RFC}) no existe — corre pnpm bootstrap:admin-global`); + } + if (!tenant.facturapiOrgId) { + throw new Error(`Tenant emisor no tiene organización Facturapi — configúrala en /configuracion`); + } + if (!tenant.codigoPostal) { + throw new Error(`Tenant emisor no tiene código postal — configúralo en /configuracion/domicilio-fiscal`); + } + return tenant; +} + +/** + * Datos fiscales del receptor para la factura. `null` si no hay datos suficientes + * (RFC + razón social + CP + régimen) — el caller cae a público en general. + */ +interface CustomerData { + legalName: string; + taxId: string; + taxSystem: string; + email: string; + zip: string; +} + +/** + * Resuelve los datos fiscales del receptor desde el tenant que paga. + * Requiere CSF sincronizada (régimen) + domicilio fiscal (CP). + * + * Heurística cuando hay múltiples regímenes activos: usa el más antiguo + * (primer regímen agregado al tenant). Fase 2 lo hará configurable. + * + * Retorna `null` si falta cualquier dato requerido — el caller debe caer + * a público en general en ese caso. + */ +async function getCustomerFromTenant(payerTenantId: string): Promise { + const tenant = await prisma.tenant.findUnique({ + where: { id: payerTenantId }, + select: { + nombre: true, + rfc: true, + codigoPostal: true, + factPreferencia: true, + factRegimenPreferido: true, + }, + }); + if (!tenant) return null; + // Si el cliente eligió "público en general" explícitamente, respetar. + if (tenant.factPreferencia === 'publico_general') return null; + if (!tenant.rfc || !tenant.nombre || !tenant.codigoPostal) return null; + + // Régimen fiscal: si el tenant configuró uno preferido, usar ese (validar + // que sigue activo). Si no, heurística "primer activo por createdAt". + let regimenClave: string | null = null; + if (tenant.factRegimenPreferido) { + const activo = await prisma.tenantRegimenActivo.findFirst({ + where: { + tenantId: payerTenantId, + regimen: { clave: tenant.factRegimenPreferido }, + }, + include: { regimen: true }, + }); + if (activo) regimenClave = activo.regimen.clave; + } + if (!regimenClave) { + const regimenActivo = await prisma.tenantRegimenActivo.findFirst({ + where: { tenantId: payerTenantId }, + include: { regimen: true }, + orderBy: { createdAt: 'asc' }, + }); + if (!regimenActivo) return null; + regimenClave = regimenActivo.regimen.clave; + } + + const email = await getTenantOwnerEmail(payerTenantId); + + return { + legalName: tenant.nombre.toUpperCase(), + taxId: tenant.rfc.toUpperCase(), + taxSystem: regimenClave, + email: email || '', + zip: tenant.codigoPostal, + }; +} + +/** + * Construye el payload para Facturapi. Acepta customer real (datos del cliente) + * o fallback a público en general si `customer` es null. + */ +function buildInvoicePayload(params: { + amount: number; + description: string; // Texto del concepto — varía por kind (subscription vs timbres) + emitterCp: string; + paymentMethod: string | null; + customer: CustomerData | null; + usoCfdi: string; // Resuelto por el caller según preferencia del tenant +}) { + const description = params.description; + + // Normaliza método de pago MP → código SAT. Default 03 (TEF) si no mapea. + const normalizedMethod = params.paymentMethod?.toLowerCase().replace(/^proration-/, '') || ''; + const formaPago = FORMA_PAGO_POR_METHOD[normalizedMethod] || '03'; + + const useCustomerData = params.customer !== null; + const customerPayload = useCustomerData + ? { + legalName: params.customer!.legalName, + taxId: params.customer!.taxId, + taxSystem: params.customer!.taxSystem, + email: params.customer!.email, + zip: params.customer!.zip, + } + : { + legalName: FALLBACK_LEGAL_NAME, + taxId: FALLBACK_TAX_ID, + taxSystem: FALLBACK_TAX_SYSTEM, + email: '', + zip: params.emitterCp, + }; + + return { + customer: customerPayload as any, + items: [ + { + description, + productKey: CONCEPT_PRODUCT_KEY, + unitKey: CONCEPT_UNIT_KEY, + unitName: CONCEPT_UNIT_NAME, + quantity: 1, + price: params.amount, // Ya incluye IVA + taxIncluded: true, // Facturapi desagrega subtotal + IVA 16% + taxes: [ + { type: 'IVA', rate: IVA_RATE, factor: 'Tasa' }, + // RESICO PM → sin retenciones + ], + }, + ], + use: params.usoCfdi, + paymentForm: formaPago, + paymentMethod: 'PUE', + currency: 'MXN', + } as facturapiService.FacturapiInvoiceData; +} + +/** + * Entry point. Se llama desde el webhook de MP cuando un pago se confirma. + * Todas las validaciones son fail-soft: loggear y retornar silenciosamente. + */ +export async function emitInvoiceIfApplicable(paymentId: string): Promise { + try { + const payment = await prisma.payment.findUnique({ + where: { id: paymentId }, + include: { subscription: true }, + }); + + if (!payment) { + console.warn(`[Invoicing] Payment ${paymentId} no existe`); + return; + } + + // Gate 1: ya facturado (idempotencia) + if (payment.facturapiInvoiceId) { + console.log(`[Invoicing] Payment ${paymentId} ya facturado (${payment.facturapiInvoiceId}), skip`); + return; + } + + // Gate 2: status + if (payment.status !== 'approved') { + console.log(`[Invoicing] Payment ${paymentId} status=${payment.status}, skip (sólo approved se factura)`); + return; + } + + // Gate 3: amount + const amount = Number(payment.amount); + if (!(amount > 0)) { + console.log(`[Invoicing] Payment ${paymentId} amount=${amount}, skip (trial o cero)`); + return; + } + + // Gate 4: primer pago del tenant → manual + if (await isFirstApprovedPayment(payment.tenantId, payment.id)) { + console.log(`[Invoicing] Payment ${paymentId} es el PRIMER pago aprobado del tenant ${payment.tenantId}, skip (factura manual)`); + return; + } + + // Gate 5: emisor configurado + const emitter = await getEmitterTenant(); + + // Construir payload. El concepto varía por tipo de pago: + // - subscription: "Suscripción {plan} {freq} a Horux 360" + // - timbres_pack: "{cantidad} timbres adicionales — Horux 360" + let description: string; + let auditMetadata: Record; + + if (payment.kind === 'timbres_pack') { + // Recupera cantidad del paquete — vinculado 1:1 con Payment + const paquete = await prisma.timbrePaquete.findUnique({ + where: { paymentId: payment.id }, + }); + const cantidad = paquete?.cantidad ?? 0; + description = `${cantidad.toLocaleString('es-MX')} timbres adicionales — Horux Despachos`; + auditMetadata = { cantidad, amount, kind: 'timbres_pack' }; + } else { + const plan = payment.subscription?.plan || 'trial'; + const frequency = payment.subscription?.frequency || 'monthly'; + const descFrecuencia = frequency === 'annual' ? 'anual' : 'mensual'; + description = `Suscripción ${PLAN_LABELS[plan] || plan} ${descFrecuencia} a Horux Despachos`; + auditMetadata = { amount, plan, frequency, kind: 'subscription' }; + } + + // Resuelve customer real si el tenant pagador tiene CSF + domicilio + + // preferencia 'mis_datos'. Si no, null → buildInvoicePayload cae a público + // en general como fallback seguro. + const customer = await getCustomerFromTenant(payment.tenantId); + if (!customer) { + console.log(`[Invoicing] Tenant ${payment.tenantId} sin datos fiscales completos o preferencia=publico_general. Facturando a Público en General.`); + } + + // Lee uso CFDI preferido del tenant (default G03 ya cargado en BD via default). + const tenantPref = await prisma.tenant.findUnique({ + where: { id: payment.tenantId }, + select: { factUsoCfdi: true }, + }); + const usoCfdi = customer ? (tenantPref?.factUsoCfdi || DEFAULT_USE_CFDI) : FALLBACK_USE_CFDI; + + const payload = buildInvoicePayload({ + amount, + description, + emitterCp: emitter.codigoPostal!, + paymentMethod: payment.paymentMethod, + customer, + usoCfdi, + }); + + console.log(`[Invoicing] Emitiendo factura para Payment ${paymentId} (tenant ${payment.tenantId}, $${amount}, receptor=${customer?.taxId || FALLBACK_TAX_ID})`); + const invoice = await facturapiService.createInvoice(emitter.id, payload); + + await prisma.payment.update({ + where: { id: payment.id }, + data: { facturapiInvoiceId: invoice.id }, + }); + + auditLog({ + tenantId: payment.tenantId, + action: 'invoice.emitted_auto', + entityType: 'Payment', + entityId: payment.id, + metadata: { + facturapiInvoiceId: invoice.id, + ...auditMetadata, + }, + }); + + console.log(`[Invoicing] Factura ${invoice.id} emitida y vinculada a Payment ${paymentId}`); + } catch (error: any) { + // Fail-soft: log y retorno silencioso. El admin puede re-emitir manualmente. + console.error(`[Invoicing] Error emitiendo factura para Payment ${paymentId}:`, error.message || error); + } +} diff --git a/apps/api/src/services/payment/mercadopago.service.ts b/apps/api/src/services/payment/mercadopago.service.ts new file mode 100644 index 0000000..d1dc9b7 --- /dev/null +++ b/apps/api/src/services/payment/mercadopago.service.ts @@ -0,0 +1,340 @@ +import { MercadoPagoConfig, PreApproval, Payment as MPPayment, Preference } from 'mercadopago'; +import { env } from '../../config/env.js'; +import { createHmac } from 'crypto'; + +// Selección del token según MP_USE_SANDBOX. Si se pide sandbox pero no hay +// MP_ACCESS_TOKEN_SANDBOX seteado, cae al de producción con warning — eso permite +// detectar config faltante sin romper el arranque. +const useSandbox = env.MP_USE_SANDBOX && !!env.MP_ACCESS_TOKEN_SANDBOX; +if (env.MP_USE_SANDBOX && !env.MP_ACCESS_TOKEN_SANDBOX) { + console.warn( + '[MP] MP_USE_SANDBOX=true pero MP_ACCESS_TOKEN_SANDBOX no está configurado. ' + + 'Cayendo al token de producción (MP_ACCESS_TOKEN). ' + + 'Configura el TEST-... token en .env para usar sandbox real.' + ); +} else if (useSandbox) { + console.log('[MP] Modo SANDBOX activo — usando MP_ACCESS_TOKEN_SANDBOX para todas las llamadas a MercadoPago.'); +} +const activeToken = useSandbox ? env.MP_ACCESS_TOKEN_SANDBOX! : (env.MP_ACCESS_TOKEN || ''); + +const config = new MercadoPagoConfig({ + accessToken: activeToken, +}); + +const preApprovalClient = new PreApproval(config); +const paymentClient = new MPPayment(config); +const preferenceClient = new Preference(config); + +/** + * Fallback público para `back_url` cuando `FRONTEND_URL` apunta a localhost. + * MercadoPago rechaza URLs `http://localhost...` o cualquier dominio no + * resoluble desde sus servidores con `400 Invalid value for back_url`. + * + * En dev (FRONTEND_URL=http://localhost:3000) sustituimos por la URL de + * producción para que el preapproval/preference se cree exitosamente y MP + * abra su flujo de pago. Después del pago, MP redirige al usuario a esa URL + * de prod (no al local) — para retorno limpio al local hace falta tunnel + * tipo ngrok. Pero el flujo de cobro funciona end-to-end en MP. + * + * En prod (FRONTEND_URL=https://horuxfin.com) es no-op. + */ +const PUBLIC_BACK_URL_FALLBACK = 'https://horuxfin.com'; + +let warnedLocalhost = false; +function backUrlBase(): string { + const fe = env.FRONTEND_URL; + if (!fe || /^https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0)/i.test(fe)) { + if (!warnedLocalhost) { + console.warn( + `[MP] FRONTEND_URL=${fe} no es válida para back_url de MercadoPago. ` + + `Usando fallback público ${PUBLIC_BACK_URL_FALLBACK}. Para retorno ` + + `limpio al local usa ngrok y override FRONTEND_URL.` + ); + warnedLocalhost = true; + } + return PUBLIC_BACK_URL_FALLBACK; + } + return fe; +} + +/** + * Override del payer_email para entornos donde el owner del tenant tiene el + * mismo correo vinculado al MP_ACCESS_TOKEN (vendedor) — MP rechaza con + * "Payer and collector cannot be the same user". En ese caso seteas + * `MP_TEST_PAYER_EMAIL` en `.env` y todas las llamadas a MP usan ese email + * como pagador. Production: dejar sin setear → no-op. + */ +let warnedTestPayer = false; +function resolvePayerEmail(callerEmail: string): string { + if (env.MP_TEST_PAYER_EMAIL) { + if (!warnedTestPayer) { + console.warn( + `[MP] Override de payer_email activo: usando ${env.MP_TEST_PAYER_EMAIL} ` + + `(MP_TEST_PAYER_EMAIL) en lugar del email del owner del tenant. ` + + `Quitar la variable en producción.` + ); + warnedTestPayer = true; + } + return env.MP_TEST_PAYER_EMAIL; + } + return callerEmail; +} + +/** + * Creates a recurring subscription (preapproval) in MercadoPago. + * Soporta cadencia mensual (cada 1 mes) o anual (cada 12 meses). + */ +export async function createPreapproval(params: { + tenantId: string; + reason: string; + amount: number; + payerEmail: string; + frequency?: 'monthly' | 'annual'; + /** + * Fecha del primer cobro. Si no se especifica, MP cobra al día siguiente. + * Útil para reactivaciones: el cliente ya pagó hasta `currentPeriodEnd`, + * queremos que MP empiece a cobrar desde ese momento, no mañana. + */ + startDate?: Date; + /** + * Referencia externa para el preapproval. Si no se especifica, se usa tenantId. + * Usar `addon:{subscriptionAddonId}` para preapprovals de addons. + */ + externalReference?: string; +}) { + if (!env.MP_ACCESS_TOKEN) { + throw new Error( + 'MercadoPago no está configurado (falta MP_ACCESS_TOKEN en .env). ' + + 'Pide al dueño de la cuenta que agregue el token de acceso para habilitar cobros.' + ); + } + + const freq = params.frequency === 'annual' + ? { frequency: 12, frequency_type: 'months' as const } + : { frequency: 1, frequency_type: 'months' as const }; + + // start_date sólo se envía si es en el futuro (MP rechaza fechas pasadas). + const now = new Date(); + const startDateIso = params.startDate && params.startDate.getTime() > now.getTime() + ? params.startDate.toISOString() + : undefined; + + const response = await preApprovalClient.create({ + body: { + reason: params.reason, + external_reference: params.externalReference || params.tenantId, + payer_email: resolvePayerEmail(params.payerEmail), + auto_recurring: { + ...freq, + transaction_amount: params.amount, + currency_id: 'MXN', + ...(startDateIso ? { start_date: startDateIso } : {}), + }, + back_url: `${backUrlBase()}/configuracion/suscripcion`, + }, + }); + + return { + preapprovalId: response.id!, + initPoint: response.init_point!, + status: response.status!, + }; +} + +/** + * Cancela un preapproval en MercadoPago. No falla si ya está cancelado o no existe. + */ +export async function cancelPreapproval(preapprovalId: string): Promise { + try { + await preApprovalClient.update({ + id: preapprovalId, + body: { status: 'cancelled' }, + }); + } catch (error: any) { + // No tiramos el error si el preapproval ya no existe o ya está cancelado + console.warn(`[MP] cancelPreapproval(${preapprovalId}):`, error.message || error); + } +} + +/** + * Actualiza el monto recurrente de un preapproval existente (usado en upgrades: + * después de cobrar el prorateo vía Preference, subimos el monto del preapproval + * para que el próximo cobro recurrente sea el del plan nuevo). + */ +export async function updatePreapprovalAmount( + preapprovalId: string, + newAmount: number, +): Promise { + if (!env.MP_ACCESS_TOKEN) { + throw new Error('MercadoPago no está configurado (falta MP_ACCESS_TOKEN)'); + } + await preApprovalClient.update({ + id: preapprovalId, + body: { + auto_recurring: { + transaction_amount: newAmount, + currency_id: 'MXN', + }, + }, + }); +} + +/** + * Crea una Preference (checkout de pago único) para cobrar el prorateo de un upgrade. + * `externalReference` se prefija con `proration:` para que el webhook distinga este + * pago del cobro recurrente del preapproval. + */ +export async function createProrationPreference(params: { + tenantId: string; + subscriptionId: string; + amount: number; + description: string; + payerEmail: string; +}): Promise<{ preferenceId: string; checkoutUrl: string }> { + if (!env.MP_ACCESS_TOKEN) { + throw new Error( + 'MercadoPago no está configurado (falta MP_ACCESS_TOKEN en .env). ' + + 'No es posible cobrar el prorateo del upgrade.' + ); + } + + const response = await preferenceClient.create({ + body: { + items: [ + { + id: `proration-${params.subscriptionId}`, + title: params.description, + quantity: 1, + unit_price: params.amount, + currency_id: 'MXN', + }, + ], + payer: { email: resolvePayerEmail(params.payerEmail) }, + // El prefijo proration: es el marcador que el webhook usa para ramificar + external_reference: `proration:${params.tenantId}:${params.subscriptionId}`, + back_urls: { + success: `${backUrlBase()}/configuracion/suscripcion?upgrade=success`, + failure: `${backUrlBase()}/configuracion/suscripcion?upgrade=failure`, + pending: `${backUrlBase()}/configuracion/suscripcion?upgrade=pending`, + }, + auto_return: 'approved', + }, + }); + + return { + preferenceId: response.id!, + checkoutUrl: response.init_point!, + }; +} + +/** + * Crea una Preference (checkout de pago único) para comprar un paquete de + * timbres adicionales. external_reference = `timbres-pack:${paymentId}` para + * que el webhook ramifique al handler correspondiente (crea TimbrePaquete + + * marca Payment approved + emite factura). + */ +export async function createTimbrePackPreference(params: { + paymentId: string; // Payment.id del record pre-creado con status=pending + tenantId: string; + cantidad: number; + amount: number; + payerEmail: string; +}): Promise<{ preferenceId: string; checkoutUrl: string }> { + if (!env.MP_ACCESS_TOKEN) { + throw new Error('MercadoPago no está configurado (MP_ACCESS_TOKEN faltante).'); + } + + const title = `${params.cantidad.toLocaleString('es-MX')} timbres adicionales — Horux 360`; + + const response = await preferenceClient.create({ + body: { + items: [ + { + id: `timbres-pack-${params.paymentId}`, + title, + quantity: 1, + unit_price: params.amount, + currency_id: 'MXN', + }, + ], + payer: { email: resolvePayerEmail(params.payerEmail) }, + external_reference: `timbres-pack:${params.paymentId}`, + back_urls: { + success: `${backUrlBase()}/facturacion?timbres=success`, + failure: `${backUrlBase()}/facturacion?timbres=failure`, + pending: `${backUrlBase()}/facturacion?timbres=pending`, + }, + auto_return: 'approved', + }, + }); + + return { + preferenceId: response.id!, + checkoutUrl: response.init_point!, + }; +} + +/** + * Gets subscription (preapproval) status from MercadoPago + */ +export async function getPreapproval(preapprovalId: string) { + const response = await preApprovalClient.get({ id: preapprovalId }); + return { + id: response.id, + status: response.status, + payerEmail: response.payer_email, + nextPaymentDate: response.next_payment_date, + autoRecurring: response.auto_recurring, + }; +} + +/** + * Gets payment details from MercadoPago + */ +export async function getPaymentDetails(paymentId: string) { + const response = await paymentClient.get({ id: paymentId }); + return { + id: response.id, + status: response.status, + statusDetail: response.status_detail, + transactionAmount: response.transaction_amount, + currencyId: response.currency_id, + payerEmail: response.payer?.email, + dateApproved: response.date_approved, + paymentMethodId: response.payment_method_id, + externalReference: response.external_reference, + }; +} + +/** + * Verifies MercadoPago webhook signature (HMAC-SHA256) + */ +export function verifyWebhookSignature( + xSignature: string, + xRequestId: string, + dataId: string +): boolean { + if (!env.MP_WEBHOOK_SECRET) { + console.error('[WEBHOOK] MP_WEBHOOK_SECRET not configured - rejecting webhook'); + return false; + } + + // Parse x-signature header: "ts=...,v1=..." + const parts: Record = {}; + for (const part of xSignature.split(',')) { + const [key, value] = part.split('='); + parts[key.trim()] = value.trim(); + } + + const ts = parts['ts']; + const v1 = parts['v1']; + if (!ts || !v1) return false; + + // Build the manifest string + const manifest = `id:${dataId};request-id:${xRequestId};ts:${ts};`; + const hmac = createHmac('sha256', env.MP_WEBHOOK_SECRET) + .update(manifest) + .digest('hex'); + + return hmac === v1; +} diff --git a/apps/api/src/services/payment/subscription.service.ts b/apps/api/src/services/payment/subscription.service.ts new file mode 100644 index 0000000..f3c1865 --- /dev/null +++ b/apps/api/src/services/payment/subscription.service.ts @@ -0,0 +1,1199 @@ +import { prisma } from '../../config/database.js'; +import * as mpService from './mercadopago.service.js'; +import { emailService } from '../email/email.service.js'; +import { auditLog } from '../../utils/audit.js'; +import { getTenantOwnerEmail } from '../../utils/memberships.js'; +import { isDespachoPaidPlan, permiteOverage, type DespachoPricePhase } from '@horux/shared'; +import { despachoPlanTieneDualidadDb, getPrecioDespachoDb } from '../plan-catalogo.service.js'; +import { + adjustDespachoOverage, + countActiveContribuyentesForTenant, + cancelOverageAddonForTenant, +} from './addon.service.js'; + +// Simple in-memory cache with TTL +const subscriptionCache = new Map(); + +export function invalidateSubscriptionCache(tenantId: string) { + subscriptionCache.delete(`sub:${tenantId}`); +} + +/** + * Creates a subscription record in DB and a MercadoPago preapproval + */ +export async function createSubscription(params: { + tenantId: string; + plan: string; + amount: number; + payerEmail: string; +}) { + const tenant = await prisma.tenant.findUnique({ + where: { id: params.tenantId }, + }); + if (!tenant) throw new Error('Tenant no encontrado'); + + // Create MercadoPago preapproval + const mp = await mpService.createPreapproval({ + tenantId: params.tenantId, + reason: `Horux360 - Plan ${params.plan} - ${tenant.nombre}`, + amount: params.amount, + payerEmail: params.payerEmail, + }); + + // Create DB record + const subscription = await prisma.subscription.create({ + data: { + tenantId: params.tenantId, + plan: params.plan as any, + status: mp.status || 'pending', + amount: params.amount, + frequency: 'monthly', + mpPreapprovalId: mp.preapprovalId, + }, + }); + + invalidateSubscriptionCache(params.tenantId); + + return { + subscription, + paymentUrl: mp.initPoint, + }; +} + +/** + * Gets active subscription for a tenant (cached 5 min) + */ +export async function getActiveSubscription(tenantId: string) { + const cached = subscriptionCache.get(`sub:${tenantId}`); + if (cached && cached.expires > Date.now()) return cached.data; + + const subscription = await prisma.subscription.findFirst({ + where: { tenantId }, + orderBy: { createdAt: 'desc' }, + }); + + subscriptionCache.set(`sub:${tenantId}`, { + data: subscription, + expires: Date.now() + 5 * 60 * 1000, + }); + + return subscription; +} + +/** + * Updates subscription status from webhook notification + */ +export async function updateSubscriptionStatus(mpPreapprovalId: string, status: string) { + const subscription = await prisma.subscription.findFirst({ + where: { mpPreapprovalId }, + }); + if (!subscription) return null; + + const updated = await prisma.subscription.update({ + where: { id: subscription.id }, + data: { status }, + }); + + invalidateSubscriptionCache(subscription.tenantId); + + // Handle cancellation + if (status === 'cancelled') { + const tenant = await prisma.tenant.findUnique({ + where: { id: subscription.tenantId }, + select: { nombre: true }, + }); + const ownerEmail = await getTenantOwnerEmail(subscription.tenantId); + if (tenant && ownerEmail) { + emailService.sendSubscriptionCancelled(ownerEmail, { + nombre: tenant.nombre, + plan: subscription.plan, + }).catch(err => console.error('[EMAIL] Subscription cancelled notification failed:', err)); + } + } + + return updated; +} + +/** + * Records a payment from MercadoPago webhook. Idempotente por `mpPaymentId`: + * MP puede mandar el mismo webhook múltiples veces y solo emitimos un email + * cuando hay transición de estado (no en cada notificación duplicada). + */ +export async function recordPayment(params: { + tenantId: string; + subscriptionId: string; + mpPaymentId: string; + amount: number; + status: string; + paymentMethod: string; +}) { + // Detectar duplicados antes de insertar — `mpPaymentId` no es UNIQUE en el + // schema (puede haber colisiones si MP reusa IDs entre flujos), pero combinado + // con `tenantId` sí es único en la práctica. + const existing = await prisma.payment.findFirst({ + where: { tenantId: params.tenantId, mpPaymentId: params.mpPaymentId }, + }); + + const previousStatus = existing?.status ?? null; + const statusChanged = previousStatus !== params.status; + + let payment; + if (existing) { + payment = await prisma.payment.update({ + where: { id: existing.id }, + data: { + amount: params.amount, + status: params.status, + paymentMethod: params.paymentMethod, + ...(params.status === 'approved' && !existing.paidAt ? { paidAt: new Date() } : {}), + }, + }); + } else { + payment = await prisma.payment.create({ + data: { + tenantId: params.tenantId, + subscriptionId: params.subscriptionId, + mpPaymentId: params.mpPaymentId, + amount: params.amount, + status: params.status, + paymentMethod: params.paymentMethod, + ...(params.status === 'approved' ? { paidAt: new Date() } : {}), + }, + }); + } + + // Solo notificar cuando hay transición real de estado. + if (!statusChanged) return payment; + + const tenant = await prisma.tenant.findUnique({ + where: { id: params.tenantId }, + select: { nombre: true }, + }); + const ownerEmail = await getTenantOwnerEmail(params.tenantId); + + if (tenant && ownerEmail) { + const subscription = await prisma.subscription.findUnique({ + where: { id: params.subscriptionId }, + }); + + if (params.status === 'approved') { + emailService.sendPaymentConfirmed(ownerEmail, { + nombre: tenant.nombre, + amount: params.amount, + plan: subscription?.plan || 'N/A', + date: new Date().toLocaleDateString('es-MX'), + }).catch(err => console.error('[EMAIL] Payment confirmed notification failed:', err)); + } else if (params.status === 'rejected' || params.status === 'cancelled') { + // Tanto `rejected` (banco/MP rechazó) como `cancelled` (user/sistema canceló + // antes de cobro) ameritan aviso al owner — el efecto operativo es el mismo: + // la suscripción no avanzó. + emailService.sendPaymentFailed(ownerEmail, { + nombre: tenant.nombre, + amount: params.amount, + plan: subscription?.plan || 'N/A', + }).catch(err => console.error('[EMAIL] Payment failed notification failed:', err)); + } + } + + return payment; +} + +/** + * Manually marks a subscription as paid (for bank transfers) + */ +export async function markAsPaidManually(tenantId: string, amount: number) { + const subscription = await getActiveSubscription(tenantId); + if (!subscription) throw new Error('No hay suscripción activa'); + + // Update subscription status + await prisma.subscription.update({ + where: { id: subscription.id }, + data: { status: 'authorized' }, + }); + + // Record the manual payment + const payment = await prisma.payment.create({ + data: { + tenantId, + subscriptionId: subscription.id, + mpPaymentId: `manual-${Date.now()}`, + amount, + status: 'approved', + paymentMethod: 'bank_transfer', + }, + }); + + invalidateSubscriptionCache(tenantId); + auditLog({ + tenantId, + action: 'payment.marked_paid_manually', + entityType: 'Payment', + entityId: payment.id, + metadata: { amount, subscriptionId: subscription.id }, + }); + return payment; +} + +/** + * Generates a payment link for a tenant + */ +export async function generatePaymentLink(tenantId: string) { + const tenant = await prisma.tenant.findUnique({ where: { id: tenantId } }); + if (!tenant) throw new Error('Tenant no encontrado'); + const ownerEmail = await getTenantOwnerEmail(tenantId); + if (!ownerEmail) throw new Error('No admin user found'); + + const subscription = await getActiveSubscription(tenantId); + const plan = subscription?.plan || tenant.plan; + const amount = subscription?.amount || 0; + + if (!amount) throw new Error('No se encontró monto de suscripción'); + + const mp = await mpService.createPreapproval({ + tenantId, + reason: `Horux360 - Plan ${plan} - ${tenant.nombre}`, + amount, + payerEmail: ownerEmail, + }); + + // Update subscription with new MP preapproval ID + if (subscription) { + await prisma.subscription.update({ + where: { id: subscription.id }, + data: { mpPreapprovalId: mp.preapprovalId }, + }); + } + + return { paymentUrl: mp.initPoint }; +} + +/** + * Gets payment history for a tenant + */ +export async function getPaymentHistory(tenantId: string) { + return prisma.payment.findMany({ + where: { tenantId }, + orderBy: { createdAt: 'desc' }, + take: 50, + }); +} + +// ============================================================================ +// Self-serve lifecycle (trial, subscribe, change, cancel) +// ============================================================================ + +type Plan = 'trial' | 'custom' | 'business_control' | 'business_cloud' + | 'mi_empresa' | 'mi_empresa_plus'; +type Frequency = 'monthly' | 'annual'; + +/** + * Precio vigente para un (plan, frequency, phase). Lee de BD vía + * `despacho_plan_prices` con cache 5min — admin global edita desde + * `/configuracion/precios-suscripcion` y los nuevos precios aplican + * inmediatamente (cache invalidation post-edit). + * + * - Mi Empresa / Mi Empresa+: aceptan `monthly` o `annual` (anual = 10 + * meses, descuento ~17%). + * - Business Control / Enterprise: solo `annual` (falla si monthly). + * - `custom`: no tiene precio en catálogo, el admin lo fija por tenant. + */ +export async function getPlanPrice( + plan: Plan, + frequency: Frequency, + phase: DespachoPricePhase = 'renewal', +): Promise { + if (plan === 'custom') { + throw new Error('El plan custom no tiene precio en plan_prices — usa createTenant con amount explícito'); + } + return getPrecioDespachoDb(plan, frequency, phase); +} + +/** + * Activa prueba gratuita de 30 días. Una sola vez **por RFC** y, si se pasa + * `ownerUserId`, también una sola vez **por humano** — un mismo dueño no puede + * obtener trials nuevos creando RFCs adicionales. + * + * Gates: + * 1. Plan no puede ser custom + * 2. Tenant no puede tener ya un `trialEndsAt` (su propia prueba en curso o consumida) + * 3. RFC normalizado NO debe existir en `trial_usages` + * 4. Si `ownerUserId`: ningún otro tenant donde el user es owner debe tener + * `trialEndsAt` (cada humano tiene derecho a 1 sola prueba) + * 5. No hay otra suscripción activa/pendiente/trial para este tenant + * + * Inserta el RFC en `trial_usages` dentro de la transacción — si algo falla, rollback + * deja el padrón sin la marca (consistente con la no-creación del trial). + */ +export async function startTrial(params: { + tenantId: string; + plan: Plan; + frequency: Frequency; + ownerUserId?: string; +}): Promise<{ subscription: any; trialEndsAt: Date }> { + if (params.plan === 'custom') { + throw new Error('No se puede iniciar trial en plan custom'); + } + + const tenant = await prisma.tenant.findUnique({ where: { id: params.tenantId } }); + if (!tenant) throw new Error('Tenant no encontrado'); + if (tenant.trialEndsAt) throw new Error('Este tenant ya usó su prueba gratuita'); + + // Gate persistente: RFC ya consumió trial en algún tenant (actual o previo) + const normalizedRfc = tenant.rfc.toUpperCase(); + const priorUsage = await prisma.trialUsage.findUnique({ + where: { rfc: normalizedRfc }, + }); + if (priorUsage) { + throw new Error( + `El RFC ${normalizedRfc} ya consumió su prueba gratuita. ` + + `Cada RFC tiene derecho a una sola prueba de 30 días. Contrata un plan para continuar.` + ); + } + + // Gate por owner: el mismo humano no puede usar trial dos veces creando RFCs + // distintos. Cubre el escenario "borro tenant, creo otro, pido trial otra vez" + // y "agrego segundo RFC bajo mi cuenta y pido trial". + if (params.ownerUserId) { + const ownedTenantWithTrial = await prisma.tenantMembership.findFirst({ + where: { + userId: params.ownerUserId, + isOwner: true, + active: true, + tenantId: { not: params.tenantId }, + tenant: { trialEndsAt: { not: null } }, + }, + select: { tenant: { select: { rfc: true } } }, + }); + if (ownedTenantWithTrial) { + throw new Error( + `Ya consumiste una prueba gratuita con otro RFC (${ownedTenantWithTrial.tenant.rfc}). ` + + `Cada dueño tiene derecho a una sola prueba de 30 días. Para esta empresa contrata un plan directamente.` + ); + } + } + + const existing = await prisma.subscription.findFirst({ + where: { tenantId: params.tenantId, status: { in: ['trial', 'pending', 'authorized', 'paused'] } }, + }); + if (existing) throw new Error('Ya existe una suscripción activa o pendiente'); + + const trialEndsAt = new Date(); + trialEndsAt.setDate(trialEndsAt.getDate() + 30); + + const now = new Date(); + const subscription = await prisma.$transaction(async (tx) => { + await tx.tenant.update({ + where: { id: params.tenantId }, + data: { trialEndsAt, plan: params.plan }, + }); + // Registra el RFC en el padrón — unique constraint previene race condition + await tx.trialUsage.create({ + data: { + rfc: normalizedRfc, + tenantId: params.tenantId, + }, + }); + return tx.subscription.create({ + data: { + tenantId: params.tenantId, + plan: params.plan, + status: 'trial', + amount: 0, + frequency: params.frequency, + currentPeriodStart: now, + currentPeriodEnd: trialEndsAt, + }, + }); + }); + + invalidateSubscriptionCache(params.tenantId); + auditLog({ + tenantId: params.tenantId, + action: 'trial.started', + entityType: 'Subscription', + entityId: subscription.id, + metadata: { plan: params.plan, frequency: params.frequency, rfc: normalizedRfc, trialEndsAt: trialEndsAt.toISOString() }, + }); + console.log(`[Trial] Iniciado para tenant ${params.tenantId} (RFC ${normalizedRfc}), vence ${trialEndsAt.toISOString()}`); + return { subscription, trialEndsAt }; +} + +/** + * Crea una suscripción self-serve (el usuario eligió plan + frecuencia). + * Lee precio de `plan_prices`, crea preapproval en MP, retorna paymentUrl. + * + * Falla si ya hay suscripción activa/pendiente o trial no vencido. Para cambiar + * de plan durante una suscripción activa, usa `scheduleChange`. + */ +export async function subscribe(params: { + tenantId: string; + plan: Plan; + frequency: Frequency; + payerEmail: string; +}): Promise<{ subscription: any; paymentUrl: string }> { + if (params.plan === 'custom') { + throw new Error('Plan custom no es self-serve — lo activa el admin global al crear tenant'); + } + + const tenant = await prisma.tenant.findUnique({ where: { id: params.tenantId } }); + if (!tenant) throw new Error('Tenant no encontrado'); + + const existing = await prisma.subscription.findFirst({ + where: { + tenantId: params.tenantId, + status: { in: ['authorized', 'pending', 'paused'] }, + }, + }); + if (existing) { + throw new Error('Ya existe una suscripción activa o pendiente — usa "Cambiar plan" para modificar'); + } + + // En planes despacho con dualidad (business_control: $21K primer año, $15K + // renovaciones) creamos el preapproval con `firstYear`. Tras el primer pago + // aprobado, el webhook llama `updatePreapprovalAmount` para bajar al monto + // de renewal en los siguientes cobros. El `reason` explica ambos montos al + // usuario en la pantalla de autorización MP. + const amount = await getPlanPrice(params.plan, params.frequency, 'firstYear'); + const hasDualidad = isDespachoPaidPlan(params.plan) && await despachoPlanTieneDualidadDb(params.plan); + const renewalAmount = hasDualidad + ? await getPlanPrice(params.plan, params.frequency, 'renewal') + : amount; + const reason = hasDualidad + ? `${tenant.nombre} - Plan ${params.plan} - $${amount.toLocaleString('es-MX')} primer año, $${renewalAmount.toLocaleString('es-MX')} renovaciones` + : `Horux360 - Plan ${params.plan} (${params.frequency}) - ${tenant.nombre}`; + + const mp = await mpService.createPreapproval({ + tenantId: params.tenantId, + reason, + amount, + payerEmail: params.payerEmail, + frequency: params.frequency, + }); + + // Si había un trial activo, lo marca como completed (no cancelled: el trial terminó exitosamente) + await prisma.subscription.updateMany({ + where: { tenantId: params.tenantId, status: 'trial' }, + data: { status: 'trial_converted' }, + }); + + const subscription = await prisma.subscription.create({ + data: { + tenantId: params.tenantId, + plan: params.plan, + status: mp.status || 'pending', + amount, + frequency: params.frequency, + mpPreapprovalId: mp.preapprovalId, + }, + }); + + await prisma.tenant.update({ + where: { id: params.tenantId }, + data: { plan: params.plan }, + }); + + invalidateSubscriptionCache(params.tenantId); + auditLog({ + tenantId: params.tenantId, + action: 'subscription.created', + entityType: 'Subscription', + entityId: subscription.id, + metadata: { plan: params.plan, frequency: params.frequency, amount }, + }); + return { subscription, paymentUrl: mp.initPoint }; +} + +/** + * Calcula el monto a cobrar por un upgrade prorateado. + * + * proration = (newAmount - currentAmount) * (daysRemaining / periodDays) + * + * Redondeado a 2 decimales. Si no hay días restantes (período vencido), retorna 0 + * — el caller debe caer en scheduleChange en vez de upgrade inmediato. + */ +export function calculateProration( + currentAmount: number, + newAmount: number, + periodStart: Date | null, + periodEnd: Date | null, +): { amount: number; daysRemaining: number; periodDays: number } { + if (!periodStart || !periodEnd) return { amount: 0, daysRemaining: 0, periodDays: 0 }; + const now = Date.now(); + const endMs = periodEnd.getTime(); + if (endMs <= now) return { amount: 0, daysRemaining: 0, periodDays: 0 }; + + const msPerDay = 1000 * 60 * 60 * 24; + const daysRemaining = Math.max(0, Math.ceil((endMs - now) / msPerDay)); + const periodDays = Math.max(1, Math.ceil((endMs - periodStart.getTime()) / msPerDay)); + const fraction = Math.min(1, daysRemaining / periodDays); + const diff = Math.max(0, newAmount - currentAmount); + const amount = Math.round(diff * fraction * 100) / 100; + return { amount, daysRemaining, periodDays }; +} + +/** + * Inicia un upgrade con cobro prorateado inmediato. + * + * Flujo: + * 1. Valida que sea estrictamente un upgrade (precio nuevo > precio actual, misma frecuencia) + * 2. Calcula el prorateo por días restantes del período actual + * 3. Crea una Preference de MercadoPago para ese monto one-time + * 4. Guarda en Subscription: upgradePreferenceId + upgradeTargetPlan + upgradeTargetAmount + * 5. Retorna checkoutUrl — el cliente lo abre en nueva pestaña, el usuario paga + * 6. El webhook detecta `external_reference: proration:*` y llama `applyApprovedUpgrade` + * + * Si falla antes de crear la preference, no hay estado que revertir. Si falla después + * del MP call pero antes del DB update, la preference queda huérfana en MP (expirará sola). + */ +export async function initiateUpgrade(params: { + tenantId: string; + newPlan: Plan; + payerEmail: string; +}): Promise<{ subscription: any; checkoutUrl: string; proratedAmount: number }> { + if (params.newPlan === 'custom') { + throw new Error('No se puede upgrade a plan custom — lo asigna el admin global'); + } + + const active = await prisma.subscription.findFirst({ + where: { + tenantId: params.tenantId, + status: { in: ['authorized', 'trial'] }, + }, + orderBy: { createdAt: 'desc' }, + }); + if (!active) throw new Error('No hay suscripción activa para upgrade'); + if (active.upgradePreferenceId) { + throw new Error('Ya hay un upgrade en curso — cancélalo antes de iniciar otro'); + } + + const currentFrequency = (active.frequency as Frequency) || 'monthly'; + const newAmount = await getPlanPrice(params.newPlan, currentFrequency); + const currentAmount = Number(active.amount); + + if (newAmount <= currentAmount) { + throw new Error('El plan seleccionado no es un upgrade (precio menor o igual). Usa scheduleChange para downgrades.'); + } + + const { amount: proratedAmount, daysRemaining } = calculateProration( + currentAmount, + newAmount, + active.currentPeriodStart, + active.currentPeriodEnd, + ); + if (proratedAmount <= 0) { + throw new Error('No hay días restantes del período actual para prorratear — espera a que termine y contrata el nuevo plan'); + } + + const tenant = await prisma.tenant.findUnique({ where: { id: params.tenantId } }); + if (!tenant) throw new Error('Tenant no encontrado'); + + const description = `Upgrade a ${params.newPlan} — prorateo ${daysRemaining} día${daysRemaining !== 1 ? 's' : ''} restante${daysRemaining !== 1 ? 's' : ''} del período actual`; + + const { preferenceId, checkoutUrl } = await mpService.createProrationPreference({ + tenantId: params.tenantId, + subscriptionId: active.id, + amount: proratedAmount, + description, + payerEmail: params.payerEmail, + }); + + const updated = await prisma.subscription.update({ + where: { id: active.id }, + data: { + upgradePreferenceId: preferenceId, + upgradeTargetPlan: params.newPlan, + upgradeTargetAmount: newAmount, + }, + }); + + invalidateSubscriptionCache(params.tenantId); + + console.log(`[Upgrade] Iniciado para tenant ${params.tenantId}: ${active.plan}→${params.newPlan} (${currentFrequency}). Prorateo: $${proratedAmount}. Preference: ${preferenceId}`); + + return { subscription: updated, checkoutUrl, proratedAmount }; +} + +/** + * Aplica un upgrade cuyo cobro prorateado fue aprobado por MercadoPago (llamado desde webhook). + * + * Acciones: + * 1. Actualiza el monto recurrente del preapproval existente al nuevo precio + * 2. Actualiza Subscription: plan, amount, status=authorized, limpia campos de upgrade + * 3. Actualiza Tenant.plan + * + * Si el paso 1 falla (MP API down), re-lanza para que el webhook no consuma el evento — + * MP reintentará. El paso 2 y 3 deben ser atómicos vía transacción. + */ +export async function applyApprovedUpgrade(subscriptionId: string): Promise { + const sub = await prisma.subscription.findUnique({ + where: { id: subscriptionId }, + }); + if (!sub) throw new Error(`Subscription ${subscriptionId} no encontrada`); + if (!sub.upgradeTargetPlan || !sub.upgradeTargetAmount) { + console.warn(`[Upgrade] Sub ${subscriptionId} sin campos upgradeTarget* — probable webhook duplicado o race, ignorando`); + return; + } + + const newPlan = sub.upgradeTargetPlan as Plan; + const newAmount = Number(sub.upgradeTargetAmount); + + // Actualiza el monto del preapproval en MP (si existe) + if (sub.mpPreapprovalId) { + try { + await mpService.updatePreapprovalAmount(sub.mpPreapprovalId, newAmount); + } catch (error: any) { + console.error(`[Upgrade] Error actualizando preapproval ${sub.mpPreapprovalId}:`, error.message); + throw error; // Re-lanza para que MP reintente el webhook + } + } + + await prisma.$transaction([ + prisma.subscription.update({ + where: { id: sub.id }, + data: { + plan: newPlan, + amount: newAmount, + status: 'authorized', + upgradePreferenceId: null, + upgradeTargetPlan: null, + upgradeTargetAmount: null, + }, + }), + prisma.tenant.update({ + where: { id: sub.tenantId }, + data: { plan: newPlan }, + }), + ]); + + invalidateSubscriptionCache(sub.tenantId); + auditLog({ + tenantId: sub.tenantId, + action: 'subscription.plan_changed', + entityType: 'Subscription', + entityId: sub.id, + metadata: { + kind: 'upgrade_immediate', + fromPlan: sub.plan, + toPlan: newPlan, + frequency: sub.frequency, + newAmount, + }, + }); + console.log(`[Upgrade] Aplicado exitosamente para tenant ${sub.tenantId}: ${sub.plan}→${newPlan} ($${newAmount}/${sub.frequency})`); + + // Reajusta el overage por contribuyente extra al nuevo plan: si pasa de + // un plan sin overage (mi_empresa, etc.) a uno con overage (business_*), + // crea el addon. Si pasa al revés, lo cancela. Fail-soft. + await reconcileOverageAfterPlanChange(sub.tenantId, sub.plan, newPlan); +} + +/** + * Después de cualquier cambio de plan (upgrade, scheduled change aplicado, + * cancelación), ajusta el add-on de overage según corresponda al nuevo plan. + * Fail-soft: cualquier error se logea sin propagar. + */ +async function reconcileOverageAfterPlanChange( + tenantId: string, + fromPlan: string, + toPlan: string, +): Promise { + try { + if (permiteOverage(toPlan)) { + const count = await countActiveContribuyentesForTenant(tenantId); + const result = await adjustDespachoOverage(tenantId, count); + if (result.action !== 'none' && result.action !== 'skipped') { + console.log(`[Overage] Reconcile ${fromPlan}→${toPlan} (tenant ${tenantId}): ${result.action} (count=${result.overageCount})`); + } + } else { + // Plan nuevo NO permite overage → cancelar addon si existía. + const r = await cancelOverageAddonForTenant(tenantId); + if (r.cancelled) { + console.log(`[Overage] Cancelado tras cambio a plan ${toPlan} sin overage (tenant ${tenantId})`); + } + } + } catch (err: any) { + console.error(`[Overage] Reconcile ${fromPlan}→${toPlan} (tenant ${tenantId}) fallo:`, err.message || err); + } +} + +/** + * Aborta un upgrade en curso (el usuario cambió de opinión antes de pagar la preference). + * Simplemente limpia los campos — MP dejará expirar la preference sola. + */ +export async function cancelPendingUpgrade(tenantId: string): Promise { + const sub = await prisma.subscription.findFirst({ + where: { tenantId, upgradePreferenceId: { not: null } }, + }); + if (!sub) throw new Error('No hay upgrade en curso para este tenant'); + + await prisma.subscription.update({ + where: { id: sub.id }, + data: { + upgradePreferenceId: null, + upgradeTargetPlan: null, + upgradeTargetAmount: null, + }, + }); + invalidateSubscriptionCache(tenantId); + console.log(`[Upgrade] Cancelado pendiente para tenant ${tenantId}`); +} + +/** + * Programa un cambio de plan o frecuencia al próximo período. + * Se aplica por el cron `applyPendingChanges` cuando `pendingEffectiveAt` llega. + * + * Usado para downgrades y cambios de frecuencia. Upgrades con misma frecuencia + * deben ir por `initiateUpgrade` (cobro prorateado inmediato). + */ +export async function scheduleChange(params: { + tenantId: string; + newPlan: Plan; + newFrequency: Frequency; +}): Promise<{ subscription: any; effectiveAt: Date }> { + if (params.newPlan === 'custom') { + throw new Error('No se puede cambiar a plan custom — lo asigna el admin global'); + } + + const active = await prisma.subscription.findFirst({ + where: { + tenantId: params.tenantId, + status: { in: ['authorized', 'trial'] }, + }, + orderBy: { createdAt: 'desc' }, + }); + if (!active) throw new Error('No hay suscripción activa para cambiar'); + + if (active.plan === params.newPlan && active.frequency === params.newFrequency) { + throw new Error('El plan y frecuencia son iguales a los actuales'); + } + + // Valida que el nuevo plan/frecuencia tenga precio + await getPlanPrice(params.newPlan, params.newFrequency); + + // Si no hay currentPeriodEnd (raro — trial en curso u otro caso), + // programa el cambio para mañana como salvaguarda + const effectiveAt = active.currentPeriodEnd || new Date(Date.now() + 24 * 60 * 60 * 1000); + + const updated = await prisma.subscription.update({ + where: { id: active.id }, + data: { + pendingPlan: params.newPlan, + pendingFrequency: params.newFrequency, + pendingEffectiveAt: effectiveAt, + }, + }); + + invalidateSubscriptionCache(params.tenantId); + auditLog({ + tenantId: params.tenantId, + action: 'subscription.plan_changed', + entityType: 'Subscription', + entityId: updated.id, + metadata: { + kind: 'scheduled', + fromPlan: active.plan, + toPlan: params.newPlan, + fromFrequency: active.frequency, + toFrequency: params.newFrequency, + effectiveAt: effectiveAt.toISOString(), + }, + }); + return { subscription: updated, effectiveAt }; +} + +/** + * Reactiva una suscripción cancelada que aún está dentro de su período pagado. + * + * MP preapproval cancelado es terminal — no se puede revivir. Esta función crea + * un preapproval nuevo con los mismos parámetros (plan/amount/frequency) y + * `start_date = currentPeriodEnd` para que el primer cobro caiga al final del + * período ya pagado (evita doble cobro). + * + * Resultado: subscription con status=pending + nuevo mpPreapprovalId. El usuario + * debe abrir paymentUrl y autorizar en MP. Al autorizar, webhook → authorized. + * + * Validaciones: + * - Debe existir una subscription status=cancelled con currentPeriodEnd en el futuro + * - Si el período ya venció, redirige al flujo normal de subscribe (picker) + */ +export async function reactivateSubscription(params: { + tenantId: string; + payerEmail: string; +}): Promise<{ subscription: any; paymentUrl: string }> { + const cancelled = await prisma.subscription.findFirst({ + where: { + tenantId: params.tenantId, + status: 'cancelled', + }, + orderBy: { createdAt: 'desc' }, + }); + if (!cancelled) { + throw new Error('No hay suscripción cancelada para reactivar'); + } + + const now = new Date(); + if (!cancelled.currentPeriodEnd || cancelled.currentPeriodEnd.getTime() <= now.getTime()) { + throw new Error('El período pagado ya venció — contrata un nuevo plan desde el selector'); + } + + if (cancelled.plan === 'custom') { + throw new Error('Reactivación de plan custom requiere coordinación con el admin global'); + } + + const tenant = await prisma.tenant.findUnique({ where: { id: params.tenantId } }); + if (!tenant) throw new Error('Tenant no encontrado'); + + const amount = Number(cancelled.amount); + const frequency = (cancelled.frequency as Frequency) || 'monthly'; + + const mp = await mpService.createPreapproval({ + tenantId: params.tenantId, + reason: `Horux360 - Reactivación Plan ${cancelled.plan} (${frequency}) - ${tenant.nombre}`, + amount, + payerEmail: params.payerEmail, + frequency, + startDate: cancelled.currentPeriodEnd, // Primer cobro al final del período actual + }); + + const updated = await prisma.subscription.update({ + where: { id: cancelled.id }, + data: { + status: 'pending', + mpPreapprovalId: mp.preapprovalId, + // Limpia cualquier upgrade/change pendiente que hubiera antes de cancelar + pendingPlan: null, + pendingFrequency: null, + pendingEffectiveAt: null, + upgradePreferenceId: null, + upgradeTargetPlan: null, + upgradeTargetAmount: null, + }, + }); + + // Asegura que tenant.plan refleje el plan reactivado + await prisma.tenant.update({ + where: { id: params.tenantId }, + data: { plan: cancelled.plan }, + }); + + invalidateSubscriptionCache(params.tenantId); + auditLog({ + tenantId: params.tenantId, + action: 'subscription.reactivated', + entityType: 'Subscription', + entityId: updated.id, + metadata: { plan: cancelled.plan, frequency, nextChargeAt: cancelled.currentPeriodEnd!.toISOString() }, + }); + + console.log(`[Reactivate] Tenant ${params.tenantId}: ${cancelled.plan} (${frequency}), próximo cobro: ${cancelled.currentPeriodEnd.toISOString()}`); + + return { subscription: updated, paymentUrl: mp.initPoint }; +} + +/** + * Cancela la suscripción activa. El acceso continúa hasta `currentPeriodEnd` + * (el middleware `plan-limits` sigue respetando `status in (authorized, cancelled)` + * con periodo vigente — no requiere cambio). + * + * También cancela el preapproval en MercadoPago para que no se siga cobrando. + */ +export async function cancelSubscription(tenantId: string): Promise<{ subscription: any }> { + const active = await prisma.subscription.findFirst({ + where: { + tenantId, + status: { in: ['authorized', 'trial', 'pending', 'paused'] }, + }, + orderBy: { createdAt: 'desc' }, + }); + if (!active) throw new Error('No hay suscripción activa para cancelar'); + + // Cancela el addon de overage primero (antes de marcar la sub como cancelled + // para que el lookup de la sub aún la encuentre activa). Fail-soft. + try { + const r = await cancelOverageAddonForTenant(tenantId); + if (r.cancelled) console.log(`[Overage] Cancelado por cancelación de suscripción (tenant ${tenantId})`); + } catch (err: any) { + console.error(`[Overage] Error cancelando addon en cancelSubscription (tenant ${tenantId}):`, err.message || err); + } + + if (active.mpPreapprovalId) { + await mpService.cancelPreapproval(active.mpPreapprovalId); + } + + const updated = await prisma.subscription.update({ + where: { id: active.id }, + data: { + status: 'cancelled', + pendingPlan: null, + pendingFrequency: null, + pendingEffectiveAt: null, + }, + }); + + invalidateSubscriptionCache(tenantId); + auditLog({ + tenantId, + action: 'subscription.cancelled', + entityType: 'Subscription', + entityId: updated.id, + metadata: { plan: active.plan, currentPeriodEnd: active.currentPeriodEnd?.toISOString() }, + }); + + // Email notificación (non-blocking) + const tenant = await prisma.tenant.findUnique({ + where: { id: tenantId }, + select: { nombre: true }, + }); + const ownerEmail = await getTenantOwnerEmail(tenantId); + if (tenant && ownerEmail) { + emailService.sendSubscriptionCancelled(ownerEmail, { + nombre: tenant.nombre, + plan: active.plan, + }).catch(err => console.error('[EMAIL] Cancellation email failed:', err)); + } + + return { subscription: updated }; +} + +// ============================================================================ +// Cron helpers: apply pending changes + expire trials +// ============================================================================ + +/** + * Aplica cambios de plan programados cuyo `pendingEffectiveAt` ya pasó. + * Llamado por cron diario. + * + * Para cada cambio pendiente: + * 1. Cancela el preapproval viejo en MP + * 2. Crea nuevo preapproval con el nuevo plan/frecuencia/monto + * 3. Actualiza la Subscription a `pending` (esperando que el usuario autorice el nuevo preapproval) + * + * Nota: si el usuario no autoriza el nuevo preapproval rápidamente, el middleware + * `plan-limits` lo trata como pending — aún con acceso según período previo. + */ +export async function applyPendingChanges(): Promise<{ applied: number; errors: number }> { + const now = new Date(); + const pending = await prisma.subscription.findMany({ + where: { + pendingEffectiveAt: { lte: now, not: null }, + pendingPlan: { not: null }, + status: { in: ['authorized', 'trial'] }, + }, + include: { tenant: true }, + }); + + let applied = 0; + let errors = 0; + + for (const sub of pending) { + try { + const newPlan = sub.pendingPlan as Plan; + const newFrequency = (sub.pendingFrequency || 'monthly') as Frequency; + const newAmount = await getPlanPrice(newPlan, newFrequency); + + const adminEmail = await getTenantOwnerEmail(sub.tenantId); + if (!adminEmail) { + console.error(`[Pending] Sub ${sub.id} sin admin user — omito`); + errors++; + continue; + } + + // Cancelar preapproval viejo + if (sub.mpPreapprovalId) { + await mpService.cancelPreapproval(sub.mpPreapprovalId); + } + + // Crear preapproval nuevo + const mp = await mpService.createPreapproval({ + tenantId: sub.tenantId, + reason: `Horux360 - Plan ${newPlan} (${newFrequency}) - ${sub.tenant.nombre}`, + amount: newAmount, + payerEmail: adminEmail, + frequency: newFrequency, + }); + + await prisma.$transaction([ + prisma.subscription.update({ + where: { id: sub.id }, + data: { + plan: newPlan, + frequency: newFrequency, + amount: newAmount, + status: 'pending', + mpPreapprovalId: mp.preapprovalId, + pendingPlan: null, + pendingFrequency: null, + pendingEffectiveAt: null, + currentPeriodStart: now, + // currentPeriodEnd se actualizará al recibir el webhook de authorization + }, + }), + prisma.tenant.update({ + where: { id: sub.tenantId }, + data: { plan: newPlan }, + }), + ]); + + invalidateSubscriptionCache(sub.tenantId); + + // Reajusta el overage según el nuevo plan (fail-soft). + await reconcileOverageAfterPlanChange(sub.tenantId, sub.plan, newPlan); + + applied++; + console.log(`[Pending] Aplicado cambio para tenant ${sub.tenantId}: ${sub.plan}→${newPlan} (${newFrequency})`); + } catch (error: any) { + console.error(`[Pending] Error en sub ${sub.id}:`, error.message); + errors++; + } + } + + return { applied, errors }; +} + +/** + * Cron diario de avisos pre-vencimiento. Itera suscripciones cuyo `currentPeriodEnd` + * está dentro de los próximos 7 días (o el día mismo del vencimiento). Por cada una + * envía un email al owner con el bucket apropiado (7d, 3d, 1d, 0d) y guarda el bucket + * en `lastReminderDay` para no duplicar. + * + * Idempotencia: + * - Si el bucket actual es menor o igual al guardado, ya se notificó este bucket → skip. + * - Si el bucket actual es MAYOR que el guardado, el período rolló (renovación) — se + * actualiza `lastReminderDay` al nuevo bucket pero NO se envía email (el período + * nuevo está lejos, no hay nada que avisar). Próximas corridas avisarán al bajar. + * + * Buckets (días restantes): 7 → 3 → 1 → 0 (post-vencimiento, último aviso de cortesía). + * Trial usa template `trialReminder`/`trialExpired`; suscripciones de pago usan + * `subscriptionExpiring`. `cancelled` dentro de período también recibe aviso (es la + * señal final antes de pagar de menos). + */ +export async function sendExpiryReminders(): Promise<{ sent: number; resetOnly: number; skipped: number; errors: number }> { + const now = new Date(); + const sevenDaysFromNow = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); + const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); + + // Solo avisamos a tenants que estén en alguno de estos estados — pending y + // paused no aplican (no han llegado al primer cobro o están suspendidas por MP). + // trial_expired y trial post-expiry caen en el bucket 0 si están dentro del + // último día — ahí mandamos el aviso final de "tu prueba terminó". + const candidates = await prisma.subscription.findMany({ + where: { + OR: [ + // Activos por vencer + { status: { in: ['authorized', 'trial', 'cancelled'] }, currentPeriodEnd: { lte: sevenDaysFromNow, gte: oneDayAgo } }, + // Trial recién vencido (último aviso 0d) + { status: 'trial_expired', currentPeriodEnd: { gte: oneDayAgo } }, + ], + }, + include: { tenant: { select: { nombre: true, rfc: true } } }, + }); + + let sent = 0; + let resetOnly = 0; + let skipped = 0; + let errors = 0; + + for (const sub of candidates) { + if (!sub.currentPeriodEnd) continue; + + // Calcula el bucket actual de días restantes (al cero más cercano hacia abajo). + const msUntil = sub.currentPeriodEnd.getTime() - now.getTime(); + const daysUntil = Math.ceil(msUntil / (24 * 60 * 60 * 1000)); + let bucket: number | null = null; + if (daysUntil <= 0) bucket = 0; + else if (daysUntil <= 1) bucket = 1; + else if (daysUntil <= 3) bucket = 3; + else if (daysUntil <= 7) bucket = 7; + if (bucket === null) { + skipped++; + continue; + } + + const lastBucket = sub.lastReminderDay; + + // Período renovado (ej. lastBucket=0 y bucket=7) — actualiza el tracker pero no envía. + if (lastBucket !== null && bucket > lastBucket) { + await prisma.subscription.update({ + where: { id: sub.id }, + data: { lastReminderDay: bucket, lastReminderSentAt: now }, + }); + resetOnly++; + continue; + } + + // Mismo o menor — ya se notificó este bucket o más cercano. + if (lastBucket !== null && bucket >= lastBucket) { + skipped++; + continue; + } + + // Hay algo que avisar. + try { + const ownerEmail = await getTenantOwnerEmail(sub.tenantId); + if (!ownerEmail) { + skipped++; + continue; + } + + const isTrialFlow = sub.status === 'trial' || sub.status === 'trial_expired'; + if (isTrialFlow) { + if (bucket === 0) { + await emailService.sendTrialExpired(ownerEmail, { + nombre: sub.tenant.nombre, + despachoNombre: sub.tenant.nombre, + }); + } else { + await emailService.sendTrialReminder(ownerEmail, { + nombre: sub.tenant.nombre, + despachoNombre: sub.tenant.nombre, + diasRestantes: Math.max(0, daysUntil), + wizardCompleto: true, + }); + } + } else { + await emailService.sendSubscriptionExpiring(ownerEmail, { + nombre: sub.tenant.nombre, + plan: sub.plan, + expiresAt: sub.currentPeriodEnd.toLocaleDateString('es-MX', { dateStyle: 'long' }), + }); + } + + await prisma.subscription.update({ + where: { id: sub.id }, + data: { lastReminderDay: bucket, lastReminderSentAt: now }, + }); + sent++; + } catch (err: any) { + console.error(`[ExpiryReminder] Error en sub ${sub.id}:`, err.message || err); + errors++; + } + } + + return { sent, resetOnly, skipped, errors }; +} + +/** + * Transiciona trials vencidos (`Tenant.trialEndsAt` ya pasó) a `pending`. + * El usuario debe agregar método de pago para continuar — el middleware `plan-limits` + * empezará a restringir features. + */ +export async function expireTrials(): Promise<{ expired: number }> { + const now = new Date(); + + const expiredTrials = await prisma.subscription.findMany({ + where: { status: 'trial', currentPeriodEnd: { lt: now } }, + }); + + let expired = 0; + for (const sub of expiredTrials) { + await prisma.subscription.update({ + where: { id: sub.id }, + data: { status: 'trial_expired' }, + }); + invalidateSubscriptionCache(sub.tenantId); + expired++; + console.log(`[Trial] Expiró trial de tenant ${sub.tenantId} (plan ${sub.plan})`); + } + + return { expired }; +} diff --git a/apps/api/src/services/plan-catalogo.service.ts b/apps/api/src/services/plan-catalogo.service.ts new file mode 100644 index 0000000..c927adb --- /dev/null +++ b/apps/api/src/services/plan-catalogo.service.ts @@ -0,0 +1,210 @@ +import { prisma } from '../config/database.js'; + +export interface PlanLimits { + maxRfcs: number; + maxUsers: number; + timbresIncluidosMes: number; + features: string[]; +} + +export interface AddonDelta { + maxRfcs?: number; + maxUsers?: number; + timbresIncluidosMes?: number; + features?: string[]; +} + +export interface DespachoPlanLimits { + plan: string; + nombre: string; + monthly: number | null; + firstYear: number | null; + renewal: number | null; + permiteMonthly: boolean; + maxRfcs: number; + maxUsers: number; + timbresIncluidosMes: number; + dbMode: 'BYO' | 'MANAGED'; + permiteServidorBackup: boolean; + permiteSatIncremental: boolean; +} + +/** Suma deltas de addons activos sobre los limits base de un plan. -1 = ilimitado se preserva. */ +export function computeEffectiveLimits(baseLimits: PlanLimits, addonDeltas: AddonDelta[]): PlanLimits { + const result: PlanLimits = { + maxRfcs: baseLimits.maxRfcs, + maxUsers: baseLimits.maxUsers, + timbresIncluidosMes: baseLimits.timbresIncluidosMes, + features: [...baseLimits.features], + }; + + for (const delta of addonDeltas) { + if (delta.maxRfcs) { + result.maxRfcs = result.maxRfcs === -1 ? -1 : result.maxRfcs + delta.maxRfcs; + } + if (delta.maxUsers) { + result.maxUsers = result.maxUsers === -1 ? -1 : result.maxUsers + delta.maxUsers; + } + if (delta.timbresIncluidosMes) { + result.timbresIncluidosMes += delta.timbresIncluidosMes; + } + if (delta.features) { + for (const f of delta.features) { + if (!result.features.includes(f)) result.features.push(f); + } + } + } + + return result; +} + +// ============================================================================ +// Catálogo despacho — lee de despacho_plan_prices con cache 5min +// ============================================================================ + +const CACHE_TTL_MS = 5 * 60 * 1000; +let cacheData: Map | null = null; +let cacheExpiresAt = 0; + +async function loadCache(): Promise> { + const rows = await prisma.despachoPlanPrice.findMany(); + const map = new Map(); + for (const r of rows) { + map.set(r.plan, { + plan: r.plan, + nombre: r.nombre, + monthly: r.monthly !== null ? Number(r.monthly) : null, + firstYear: r.firstYear !== null ? Number(r.firstYear) : null, + renewal: r.renewal !== null ? Number(r.renewal) : null, + permiteMonthly: r.permiteMonthly, + maxRfcs: r.maxRfcs, + maxUsers: r.maxUsers, + timbresIncluidosMes: r.timbresIncluidosMes, + dbMode: r.dbMode as 'BYO' | 'MANAGED', + permiteServidorBackup: r.permiteServidorBackup, + permiteSatIncremental: r.permiteSatIncremental, + }); + } + cacheData = map; + cacheExpiresAt = Date.now() + CACHE_TTL_MS; + return map; +} + +/** Invalida el cache. Llamar tras editar precios/limits desde admin. */ +export function invalidateDespachoPlanCache(): void { + cacheData = null; + cacheExpiresAt = 0; +} + +/** Lee limits + precios de un plan despacho. Cache 5min. */ +export async function getDespachoPlanLimits(plan: string): Promise { + if (!cacheData || Date.now() > cacheExpiresAt) await loadCache(); + return cacheData!.get(plan) ?? null; +} + +/** Lee todos los planes despacho. Cache 5min. */ +export async function getAllDespachoPlanLimits(): Promise { + if (!cacheData || Date.now() > cacheExpiresAt) await loadCache(); + return Array.from(cacheData!.values()); +} + +/** True si el plan acepta frecuencia mensual. Lee de BD via cache. */ +export async function permiteFrecuenciaMensualDb(plan: string): Promise { + const cfg = await getDespachoPlanLimits(plan); + return cfg?.permiteMonthly ?? false; +} + +/** True si el plan cobra distinto en el primer año vs renovaciones. Lee de BD. */ +export async function despachoPlanTieneDualidadDb(plan: string): Promise { + const cfg = await getDespachoPlanLimits(plan); + if (!cfg || cfg.firstYear === null || cfg.renewal === null) return false; + return cfg.firstYear !== cfg.renewal; +} + +/** + * Resuelve el precio MXN para un (plan, frequency, phase) leyendo de BD. + * Throws si el plan no existe en BD o no permite la frecuencia solicitada. + */ +export async function getPrecioDespachoDb( + plan: string, + frequency: 'monthly' | 'annual', + phase: 'firstYear' | 'renewal' = 'renewal', +): Promise { + const cfg = await getDespachoPlanLimits(plan); + if (!cfg) throw new Error(`Plan ${plan} no encontrado en catálogo BD`); + if (frequency === 'monthly') { + if (!cfg.permiteMonthly || cfg.monthly === null) { + throw new Error(`El plan ${plan} no permite frecuencia mensual`); + } + return cfg.monthly; + } + const price = phase === 'firstYear' ? cfg.firstYear : cfg.renewal; + if (price === null) throw new Error(`El plan ${plan} no tiene precio anual configurado`); + return price; +} + +// ============================================================================ +// Endpoints viejos — backward compat con /plan-catalogo/* routes +// (sin callers frontend conocidos; se mantienen por si algo externo consume) +// ============================================================================ + +export async function listPlans(_verticalProfile?: string) { + const all = await getAllDespachoPlanLimits(); + // Excluir trial y custom del catálogo público (admin-only) + return all + .filter(p => p.plan !== 'trial' && p.plan !== 'custom') + .map(p => ({ + codename: p.plan, + nombre: p.nombre, + verticalProfile: 'CONTABLE' as const, + precioBase: p.firstYear ?? 0, + frecuencia: p.permiteMonthly ? 'mensual' : 'anual', + limits: { + maxRfcs: p.maxRfcs, + maxUsers: p.maxUsers, + timbresIncluidosMes: p.timbresIncluidosMes, + features: [], // features viven en TS (DESPACHO_PLANS); este endpoint no las expone + } as PlanLimits, + })); +} + +export async function getPlanByCodename(codename: string) { + const p = await getDespachoPlanLimits(codename); + if (!p) return null; + return { + codename: p.plan, + nombre: p.nombre, + verticalProfile: 'CONTABLE' as const, + precioBase: p.firstYear ?? 0, + frecuencia: p.permiteMonthly ? 'mensual' : 'anual', + limits: { + maxRfcs: p.maxRfcs, + maxUsers: p.maxUsers, + timbresIncluidosMes: p.timbresIncluidosMes, + features: [], + } as PlanLimits, + }; +} + +export async function listAddons(verticalProfile?: string) { + const where: any = { active: true }; + if (verticalProfile) { + where.OR = [ + { verticalProfile }, + { verticalProfile: null }, + ]; + } + const addons = await prisma.planAddonCatalogo.findMany({ + where, + orderBy: { precio: 'asc' }, + }); + return addons.map(a => ({ + id: a.id, + codename: a.codename, + nombre: a.nombre, + verticalProfile: a.verticalProfile, + precio: Number(a.precio), + frecuencia: a.frecuencia, + delta: a.delta as AddonDelta, + })); +} diff --git a/apps/api/src/services/recordatorios.service.ts b/apps/api/src/services/recordatorios.service.ts new file mode 100644 index 0000000..2d4d80f --- /dev/null +++ b/apps/api/src/services/recordatorios.service.ts @@ -0,0 +1,143 @@ +import type { Pool } from 'pg'; +import type { EventoFiscal, EventoCreate, EventoUpdate } from '@horux/shared'; + +/** + * Obtiene recordatorios visibles para el usuario. + * - Públicos: todos los del tenant + * - Privados: solo los creados por el usuario + */ +export async function getRecordatorios( + pool: Pool, + userId: string, + año: number +): Promise { + const { rows } = await pool.query(` + SELECT id, titulo, descripcion, fecha_limite as "fechaLimite", + notas, completado, privado, creado_por as "creadoPor", + created_at as "createdAt" + FROM recordatorios + WHERE EXTRACT(YEAR FROM fecha_limite) = $1 + AND (privado = false OR creado_por = $2) + ORDER BY fecha_limite + `, [año, userId]); + + return rows.map(r => ({ + id: r.id, + titulo: r.titulo, + descripcion: r.descripcion || '', + tipo: 'custom' as const, + fechaLimite: r.fechaLimite instanceof Date + ? r.fechaLimite.toISOString().split('T')[0] + : String(r.fechaLimite).split('T')[0], + recurrencia: 'unica' as const, + completado: r.completado, + notas: r.notas, + privado: r.privado, + creadoPor: r.creadoPor, + createdAt: r.createdAt?.toISOString(), + })); +} + +export async function createRecordatorio( + pool: Pool, + userId: string, + data: EventoCreate & { privado?: boolean } +): Promise { + const { rows } = await pool.query(` + INSERT INTO recordatorios (titulo, descripcion, fecha_limite, notas, privado, creado_por) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, titulo, descripcion, fecha_limite as "fechaLimite", + notas, completado, privado, creado_por as "creadoPor", + created_at as "createdAt" + `, [ + data.titulo, + data.descripcion || null, + data.fechaLimite, + data.notas || null, + data.privado ?? false, + userId, + ]); + + const r = rows[0]; + return { + id: r.id, + titulo: r.titulo, + descripcion: r.descripcion || '', + tipo: 'custom', + fechaLimite: r.fechaLimite instanceof Date + ? r.fechaLimite.toISOString().split('T')[0] + : String(r.fechaLimite).split('T')[0], + recurrencia: 'unica', + completado: r.completado, + notas: r.notas, + createdAt: r.createdAt?.toISOString(), + }; +} + +export async function updateRecordatorio( + pool: Pool, + userId: string, + id: number, + data: EventoUpdate & { privado?: boolean } +): Promise { + // Verify ownership or public + const { rows: existing } = await pool.query( + `SELECT id, creado_por FROM recordatorios WHERE id = $1`, + [id] + ); + + if (existing.length === 0) return null; + + const sets: string[] = []; + const params: any[] = []; + let idx = 1; + + if (data.titulo !== undefined) { sets.push(`titulo = $${idx++}`); params.push(data.titulo); } + if (data.descripcion !== undefined) { sets.push(`descripcion = $${idx++}`); params.push(data.descripcion); } + if (data.fechaLimite !== undefined) { sets.push(`fecha_limite = $${idx++}`); params.push(data.fechaLimite); } + if (data.completado !== undefined) { sets.push(`completado = $${idx++}`); params.push(data.completado); } + if (data.notas !== undefined) { sets.push(`notas = $${idx++}`); params.push(data.notas); } + if (data.privado !== undefined) { sets.push(`privado = $${idx++}`); params.push(data.privado); } + + if (sets.length === 0) return null; + + sets.push(`updated_at = NOW()`); + params.push(id); + + const { rows } = await pool.query(` + UPDATE recordatorios SET ${sets.join(', ')} + WHERE id = $${idx} + RETURNING id, titulo, descripcion, fecha_limite as "fechaLimite", + notas, completado, privado, creado_por as "creadoPor", + created_at as "createdAt" + `, params); + + if (rows.length === 0) return null; + + const r = rows[0]; + return { + id: r.id, + titulo: r.titulo, + descripcion: r.descripcion || '', + tipo: 'custom', + fechaLimite: r.fechaLimite instanceof Date + ? r.fechaLimite.toISOString().split('T')[0] + : String(r.fechaLimite).split('T')[0], + recurrencia: 'unica', + completado: r.completado, + notas: r.notas, + createdAt: r.createdAt?.toISOString(), + }; +} + +export async function deleteRecordatorio( + pool: Pool, + userId: string, + id: number +): Promise { + const { rowCount } = await pool.query( + `DELETE FROM recordatorios WHERE id = $1`, + [id] + ); + return (rowCount ?? 0) > 0; +} diff --git a/apps/api/src/services/regimen.service.ts b/apps/api/src/services/regimen.service.ts new file mode 100644 index 0000000..3841401 --- /dev/null +++ b/apps/api/src/services/regimen.service.ts @@ -0,0 +1,92 @@ +import type { Pool } from 'pg'; +import { prisma } from '../config/database.js'; + +export async function getAllRegimenes() { + return prisma.regimen.findMany({ + where: { activo: true }, + orderBy: { clave: 'asc' }, + }); +} + +export async function getRegimenesIgnorados(tenantId: string) { + const rows = await prisma.tenantRegimenIgnorado.findMany({ + where: { tenantId }, + include: { regimen: true }, + orderBy: { regimen: { clave: 'asc' } }, + }); + return rows.map(r => r.regimen); +} + +export async function getRegimenesIgnoradosClaves(tenantId: string): Promise { + const rows = await prisma.tenantRegimenIgnorado.findMany({ + where: { tenantId }, + include: { regimen: { select: { clave: true } } }, + }); + return rows.map(r => r.regimen.clave); +} + +export async function getRegimenesActivos(tenantId: string) { + const rows = await prisma.tenantRegimenActivo.findMany({ + where: { tenantId }, + include: { regimen: true }, + orderBy: { regimen: { clave: 'asc' } }, + }); + return rows.map(r => r.regimen); +} + +export async function getRegimenesActivosClaves(tenantId: string): Promise { + const rows = await prisma.tenantRegimenActivo.findMany({ + where: { tenantId }, + include: { regimen: { select: { clave: true } } }, + }); + return rows.map(r => r.regimen.clave); +} + +/** + * Resuelve las claves de regímenes activos para la alerta de discrepancia. + * Si hay contribuyenteId, lee de contribuyentes.regimen_fiscal (comma-separated). + * Si no, fallback a TenantRegimenActivo (tabla central). + */ +export async function getRegimenesActivosClavesEfectivos( + tenantId: string, + pool: Pool, + contribuyenteId?: string | null, +): Promise { + if (contribuyenteId) { + const safeId = contribuyenteId.replace(/[^a-f0-9-]/gi, ''); + const { rows } = await pool.query( + `SELECT regimen_fiscal FROM contribuyentes WHERE entidad_id = $1`, + [safeId], + ); + if (rows.length > 0 && rows[0].regimen_fiscal) { + return rows[0].regimen_fiscal.split(',').map((c: string) => c.trim()).filter(Boolean); + } + return []; + } + return getRegimenesActivosClaves(tenantId); +} + +export async function setRegimenesActivos(tenantId: string, regimenIds: number[]) { + await prisma.tenantRegimenActivo.deleteMany({ where: { tenantId } }); + + if (regimenIds.length > 0) { + await prisma.tenantRegimenActivo.createMany({ + data: regimenIds.map(regimenId => ({ tenantId, regimenId })), + }); + } + + return getRegimenesActivos(tenantId); +} + +export async function setRegimenesIgnorados(tenantId: string, regimenIds: number[]) { + // Delete all existing and re-insert + await prisma.tenantRegimenIgnorado.deleteMany({ where: { tenantId } }); + + if (regimenIds.length > 0) { + await prisma.tenantRegimenIgnorado.createMany({ + data: regimenIds.map(regimenId => ({ tenantId, regimenId })), + }); + } + + return getRegimenesIgnorados(tenantId); +} diff --git a/apps/api/src/services/reportes.service.ts b/apps/api/src/services/reportes.service.ts new file mode 100644 index 0000000..cd45474 --- /dev/null +++ b/apps/api/src/services/reportes.service.ts @@ -0,0 +1,401 @@ +import type { Pool } from 'pg'; +import type { EstadoResultados, FlujoEfectivo, ComparativoPeriodos, ConcentradoRfc } from '@horux/shared'; +import { calcularIngresosPorRegimen, calcularEgresosPorRegimen } from './dashboard.service.js'; + +/** + * Resuelve condiciones `esEmisor` / `esReceptor` para un contribuyente + * usando su RFC. Reemplaza el par `type = 'X' AND contribuyente_id = Y`. + * Si no hay contribuyente, retorna fallback a `type`. + */ +async function resolveEmisorReceptor( + pool: Pool, + contribuyenteId?: string | null, +): Promise<{ esEmisor: string; esReceptor: string }> { + if (!contribuyenteId) { + return { esEmisor: `type = 'EMITIDO'`, esReceptor: `type = 'RECIBIDO'` }; + } + const safeId = contribuyenteId.replace(/[^a-f0-9-]/gi, ''); + if (!safeId) return { esEmisor: `type = 'EMITIDO'`, esReceptor: `type = 'RECIBIDO'` }; + const { rows } = await pool.query<{ rfc: string | null }>( + `SELECT rfc FROM contribuyentes WHERE entidad_id = $1`, + [safeId], + ); + const rfc = (rows[0]?.rfc || '').replace(/[^A-Z0-9]/gi, '').toUpperCase(); + if (!rfc) { + return { + esEmisor: `type = 'EMITIDO' AND contribuyente_id = '${safeId}'`, + esReceptor: `type = 'RECIBIDO' AND contribuyente_id = '${safeId}'`, + }; + } + return { + esEmisor: `UPPER(rfc_emisor) = '${rfc}'`, + esReceptor: `UPPER(rfc_receptor) = '${rfc}'`, + }; +} + +function sanitizeContribUuid(id?: string | null): string { + return id ? id.replace(/[^a-f0-9-]/gi, '') : ''; +} + +function toNumber(value: unknown): number { + if (value === null || value === undefined) return 0; + if (typeof value === 'number') return value; + if (typeof value === 'bigint') return Number(value); + if (typeof value === 'string') return parseFloat(value) || 0; + if (typeof value === 'object' && value !== null && 'toNumber' in value) { + return (value as { toNumber: () => number }).toNumber(); + } + return Number(value) || 0; +} + +export async function getEstadoResultados( + pool: Pool, + fechaInicio: string, + fechaFin: string, + tenantId: string, + contribuyenteId?: string | null, +): Promise { + // Totales usando la misma lógica del dashboard + const ingresosData = await calcularIngresosPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, undefined, contribuyenteId); + const egresosData = await calcularEgresosPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, undefined, contribuyenteId); + + const totalIngresos = ingresosData.total; + const totalEgresos = egresosData.total; + const utilidadBruta = totalIngresos - totalEgresos; + + // Desglose por régimen como conceptos + const ingresosConceptos = ingresosData.porRegimen.map(r => ({ + concepto: `${r.regimenClave} - ${r.regimenDescripcion}`, + monto: r.monto, + })); + + const egresosConceptos = egresosData.porRegimen.map(r => ({ + concepto: `${r.regimenClave} - ${r.regimenDescripcion}`, + monto: r.monto, + })); + + return { + periodo: { inicio: fechaInicio, fin: fechaFin }, + ingresos: ingresosConceptos, + egresos: egresosConceptos, + totalIngresos, + totalEgresos, + utilidadBruta, + impuestos: 0, + utilidadNeta: utilidadBruta, + }; +} + +export async function getFlujoEfectivo( + pool: Pool, + fechaInicio: string, + fechaFin: string, + contribuyenteId?: string | null, +): Promise { + const VIGENTE = `status NOT IN ('Cancelado', '0')`; + const RANGO = `fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')`; + const RANGO_PAGO = `fecha_pago_p >= $1::date AND fecha_pago_p < ($2::date + interval '1 day')`; + const { esEmisor, esReceptor } = await resolveEmisorReceptor(pool, contribuyenteId); + + const { rows: entradasPUE } = await pool.query(` + SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, COALESCE(SUM(total_mxn), 0) as total + FROM cfdis + WHERE ${esEmisor} AND tipo_comprobante = 'I' AND metodo_pago = 'PUE' + AND ${VIGENTE} AND ${RANGO} + GROUP BY mes ORDER BY mes + `, [fechaInicio, fechaFin]); + + const { rows: entradasPago } = await pool.query(` + SELECT TO_CHAR(fecha_pago_p, 'YYYY-MM') as mes, COALESCE(SUM(monto_pago_mxn), 0) as total + FROM cfdis + WHERE ${esEmisor} AND tipo_comprobante = 'P' + AND ${VIGENTE} AND ${RANGO_PAGO} + GROUP BY mes ORDER BY mes + `, [fechaInicio, fechaFin]); + + const { rows: entradasNC } = await pool.query(` + SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, COALESCE(SUM(total_mxn), 0) as total + FROM cfdis + WHERE ${esEmisor} AND tipo_comprobante = 'E' AND metodo_pago = 'PUE' + AND COALESCE(cfdi_tipo_relacion, '') <> '07' + AND ${VIGENTE} AND ${RANGO} + GROUP BY mes ORDER BY mes + `, [fechaInicio, fechaFin]); + + const { rows: salidasPUE } = await pool.query(` + SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, COALESCE(SUM(total_mxn), 0) as total + FROM cfdis + WHERE ${esReceptor} AND tipo_comprobante = 'I' AND metodo_pago = 'PUE' + AND ${VIGENTE} AND ${RANGO} + GROUP BY mes ORDER BY mes + `, [fechaInicio, fechaFin]); + + const { rows: salidasPago } = await pool.query(` + SELECT TO_CHAR(fecha_pago_p, 'YYYY-MM') as mes, COALESCE(SUM(monto_pago_mxn), 0) as total + FROM cfdis + WHERE ${esReceptor} AND tipo_comprobante = 'P' + AND ${VIGENTE} AND ${RANGO_PAGO} + GROUP BY mes ORDER BY mes + `, [fechaInicio, fechaFin]); + + const { rows: salidasNC } = await pool.query(` + SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, COALESCE(SUM(total_mxn), 0) as total + FROM cfdis + WHERE ${esReceptor} AND tipo_comprobante = 'E' AND metodo_pago = 'PUE' + AND COALESCE(cfdi_tipo_relacion, '') <> '07' + AND ${VIGENTE} AND ${RANGO} + GROUP BY mes ORDER BY mes + `, [fechaInicio, fechaFin]); + + // Combinar por mes + const mesesSet = new Set(); + [...entradasPUE, ...entradasPago, ...entradasNC, ...salidasPUE, ...salidasPago, ...salidasNC].forEach((r: any) => mesesSet.add(r.mes)); + const mesesOrdenados = Array.from(mesesSet).sort(); + + const get = (rows: any[], mes: string) => toNumber(rows.find((r: any) => r.mes === mes)?.total); + + const entradas = mesesOrdenados.map(mes => ({ + concepto: mes, + monto: get(entradasPUE, mes) + get(entradasPago, mes) - get(entradasNC, mes), + })); + + const salidas = mesesOrdenados.map(mes => ({ + concepto: mes, + monto: get(salidasPUE, mes) + get(salidasPago, mes) - get(salidasNC, mes), + })); + + const totalEntradas = entradas.reduce((s, e) => s + e.monto, 0); + const totalSalidas = salidas.reduce((s, e) => s + e.monto, 0); + + return { + periodo: { inicio: fechaInicio, fin: fechaFin }, + saldoInicial: 0, + entradas, + salidas, + totalEntradas, + totalSalidas, + flujoNeto: totalEntradas - totalSalidas, + saldoFinal: totalEntradas - totalSalidas, + }; +} + +/** + * Calcula entradas/salidas de un año completo mes a mes con la lógica de flujo de efectivo. + */ +async function calcularFlujoPorMes(pool: Pool, año: number, contribuyenteId?: string | null): Promise<{ entradas: number[]; salidas: number[] }> { + const VIGENTE = `status NOT IN ('Cancelado', '0')`; + const fi = `${año}-01-01`; + const ff = `${año}-12-31`; + const RANGO = `fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')`; + const RANGO_PAGO = `fecha_pago_p >= $1::date AND fecha_pago_p < ($2::date + interval '1 day')`; + const { esEmisor, esReceptor } = await resolveEmisorReceptor(pool, contribuyenteId); + + const q = async (lado: 'EMITIDO' | 'RECIBIDO', tc: string, campo: string, mp?: string) => { + const mpF = mp ? `AND metodo_pago = '${mp}'` : ''; + const fechaCol = tc === 'P' ? 'fecha_pago_p' : 'fecha_emision'; + const rango = tc === 'P' ? RANGO_PAGO : RANGO; + const noAnticipo = tc === 'E' ? `AND COALESCE(cfdi_tipo_relacion, '') <> '07'` : ''; + const ladoCond = lado === 'EMITIDO' ? esEmisor : esReceptor; + const { rows } = await pool.query(` + SELECT EXTRACT(MONTH FROM ${fechaCol})::int as mes, COALESCE(SUM(${campo}), 0) as total + FROM cfdis + WHERE ${ladoCond} AND tipo_comprobante = '${tc}' ${mpF} ${noAnticipo} AND ${VIGENTE} AND ${rango} + GROUP BY mes + `, [fi, ff]); + const map = new Map(); + for (const r of rows) map.set(r.mes, Number(r.total)); + return map; + }; + + const [ePUE, ePago, eNC, sPUE, sPago, sNC] = await Promise.all([ + q('EMITIDO', 'I', 'total_mxn', 'PUE'), + q('EMITIDO', 'P', 'monto_pago_mxn'), + q('EMITIDO', 'E', 'total_mxn', 'PUE'), + q('RECIBIDO', 'I', 'total_mxn', 'PUE'), + q('RECIBIDO', 'P', 'monto_pago_mxn'), + q('RECIBIDO', 'E', 'total_mxn', 'PUE'), + ]); + + const g = (map: Map, m: number) => map.get(m) || 0; + + const entradas: number[] = []; + const salidas: number[] = []; + + for (let m = 1; m <= 12; m++) { + entradas.push(g(ePUE, m) + g(ePago, m) - g(eNC, m)); + salidas.push(g(sPUE, m) + g(sPago, m) - g(sNC, m)); + } + + return { entradas, salidas }; +} + +export async function getComparativo( + pool: Pool, + año: number, + contribuyenteId?: string | null, +): Promise { + const [actual, anterior] = await Promise.all([ + calcularFlujoPorMes(pool, año, contribuyenteId), + calcularFlujoPorMes(pool, año - 1, contribuyenteId), + ]); + + const meses = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic']; + const utilidad = actual.entradas.map((e, i) => e - actual.salidas[i]); + + const totalActualIng = actual.entradas.reduce((a, b) => a + b, 0); + const totalAnteriorIng = anterior.entradas.reduce((a, b) => a + b, 0); + const totalActualEgr = actual.salidas.reduce((a, b) => a + b, 0); + const totalAnteriorEgr = anterior.salidas.reduce((a, b) => a + b, 0); + + return { + periodos: meses, + ingresos: actual.entradas, + egresos: actual.salidas, + utilidad, + variacionIngresos: totalAnteriorIng > 0 ? ((totalActualIng - totalAnteriorIng) / totalAnteriorIng) * 100 : 0, + variacionEgresos: totalAnteriorEgr > 0 ? ((totalActualEgr - totalAnteriorEgr) / totalAnteriorEgr) * 100 : 0, + variacionUtilidad: 0, + }; +} + +export async function getConcentradoRfc( + pool: Pool, + fechaInicio: string, + fechaFin: string, + tipo: 'cliente' | 'proveedor', + contribuyenteId?: string | null, +): Promise { + const { esEmisor, esReceptor } = await resolveEmisorReceptor(pool, contribuyenteId); + + if (tipo === 'cliente') { + const { rows: data } = await pool.query(` + SELECT rfc_receptor as rfc, nombre_receptor as nombre, + 'cliente' as tipo, + SUM(total_mxn) as "totalFacturado", + SUM(iva_traslado_mxn) as "totalIva", + COUNT(*)::int as "cantidadCfdis" + FROM cfdis + WHERE ${esEmisor} AND tipo_comprobante = 'I' AND status NOT IN ('Cancelado', '0') + AND fecha_emision BETWEEN $1::date AND $2::date + GROUP BY rfc_receptor, nombre_receptor + ORDER BY "totalFacturado" DESC + `, [fechaInicio, fechaFin]); + return data.map((d: any) => ({ + rfc: d.rfc, + nombre: d.nombre, + tipo: 'cliente' as const, + totalFacturado: toNumber(d.totalFacturado), + totalIva: toNumber(d.totalIva), + cantidadCfdis: d.cantidadCfdis + })); + } else { + const { rows: data } = await pool.query(` + SELECT rfc_emisor as rfc, nombre_emisor as nombre, + 'proveedor' as tipo, + SUM(total_mxn) as "totalFacturado", + SUM(iva_traslado_mxn) as "totalIva", + COUNT(*)::int as "cantidadCfdis" + FROM cfdis + WHERE ${esReceptor} AND tipo_comprobante = 'I' AND status NOT IN ('Cancelado', '0') + AND fecha_emision BETWEEN $1::date AND $2::date + GROUP BY rfc_emisor, nombre_emisor + ORDER BY "totalFacturado" DESC + `, [fechaInicio, fechaFin]); + return data.map((d: any) => ({ + rfc: d.rfc, + nombre: d.nombre, + tipo: 'proveedor' as const, + totalFacturado: toNumber(d.totalFacturado), + totalIva: toNumber(d.totalIva), + cantidadCfdis: d.cantidadCfdis + })); + } +} + +export interface CuentasPendientes { + cantidadCfdis: number; + saldoTotal: number; + detalle: { rfc: string; nombre: string; cantidad: number; saldo: number }[]; +} + +export async function getCuentasXPagar( + pool: Pool, + fechaInicio: string, + fechaFin: string, + regimen?: string, + contribuyenteId?: string | null, +): Promise { + const regimenFilter = regimen ? `AND regimen_fiscal_receptor = '${regimen}'` : ''; + const { esReceptor } = await resolveEmisorReceptor(pool, contribuyenteId); + + const { rows } = await pool.query(` + SELECT + rfc_emisor as rfc, + nombre_emisor as nombre, + COUNT(*)::int as cantidad, + COALESCE(SUM(COALESCE(saldo_pendiente_mxn, total_mxn)), 0) as saldo + FROM cfdis + WHERE ${esReceptor} AND tipo_comprobante = 'I' AND metodo_pago = 'PPD' + AND status NOT IN ('Cancelado', '0') + AND fecha_emision >= $1::date + AND fecha_emision < ($2::date + interval '1 day') + AND COALESCE(saldo_pendiente_mxn, total_mxn) > 0.01 + ${regimenFilter} + GROUP BY rfc_emisor, nombre_emisor + ORDER BY saldo DESC + `, [fechaInicio, fechaFin]); + + const detalle = rows.map((r: any) => ({ + rfc: r.rfc, + nombre: r.nombre, + cantidad: r.cantidad, + saldo: toNumber(r.saldo), + })); + + return { + cantidadCfdis: detalle.reduce((s, d) => s + d.cantidad, 0), + saldoTotal: detalle.reduce((s, d) => s + d.saldo, 0), + detalle, + }; +} + +export async function getCuentasXCobrar( + pool: Pool, + fechaInicio: string, + fechaFin: string, + regimen?: string, + contribuyenteId?: string | null, +): Promise { + const regimenFilter = regimen ? `AND regimen_fiscal_emisor = '${regimen}'` : ''; + const { esEmisor } = await resolveEmisorReceptor(pool, contribuyenteId); + + const { rows } = await pool.query(` + SELECT + rfc_receptor as rfc, + nombre_receptor as nombre, + COUNT(*)::int as cantidad, + COALESCE(SUM(COALESCE(saldo_pendiente_mxn, total_mxn)), 0) as saldo + FROM cfdis + WHERE ${esEmisor} AND tipo_comprobante = 'I' AND metodo_pago = 'PPD' + AND status NOT IN ('Cancelado', '0') + AND fecha_emision >= $1::date + AND fecha_emision < ($2::date + interval '1 day') + AND COALESCE(saldo_pendiente_mxn, total_mxn) > 0.01 + ${regimenFilter} + GROUP BY rfc_receptor, nombre_receptor + ORDER BY saldo DESC + `, [fechaInicio, fechaFin]); + + const detalle = rows.map((r: any) => ({ + rfc: r.rfc, + nombre: r.nombre, + cantidad: r.cantidad, + saldo: toNumber(r.saldo), + })); + + return { + cantidadCfdis: detalle.reduce((s, d) => s + d.cantidad, 0), + saldoTotal: detalle.reduce((s, d) => s + d.saldo, 0), + detalle, + }; +} diff --git a/apps/api/src/services/sat/sat-auth.service.ts b/apps/api/src/services/sat/sat-auth.service.ts new file mode 100644 index 0000000..b015bf7 --- /dev/null +++ b/apps/api/src/services/sat/sat-auth.service.ts @@ -0,0 +1,160 @@ +import { XMLParser, XMLBuilder } from 'fast-xml-parser'; +import { createHash, randomUUID } from 'crypto'; +import type { Credential } from '@nodecfdi/credentials/node'; + +const SAT_AUTH_URL = 'https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/Autenticacion/Autenticacion.svc'; + +interface SatToken { + token: string; + expiresAt: Date; +} + +/** + * Genera el timestamp para la solicitud SOAP + */ +function createTimestamp(): { created: string; expires: string } { + const now = new Date(); + const created = now.toISOString(); + const expires = new Date(now.getTime() + 5 * 60 * 1000).toISOString(); // 5 minutos + return { created, expires }; +} + +/** + * Construye el XML de solicitud de autenticación + */ +function buildAuthRequest(credential: Credential): string { + const timestamp = createTimestamp(); + const uuid = randomUUID(); + + const certificate = credential.certificate(); + // El PEM ya contiene el certificado en base64, solo quitamos headers y newlines + const cerB64 = certificate.pem().replace(/-----.*-----/g, '').replace(/\s/g, ''); + + // Canonicalizar y firmar + const toDigestXml = `` + + `${timestamp.created}` + + `${timestamp.expires}` + + ``; + + const digestValue = createHash('sha1').update(toDigestXml).digest('base64'); + + const signedInfoXml = `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `${digestValue}` + + `` + + ``; + + // Firmar con la llave privada (sign retorna binary string, convertir a base64) + const signatureBinary = credential.sign(signedInfoXml, 'sha1'); + const signatureValue = Buffer.from(signatureBinary, 'binary').toString('base64'); + + const soapEnvelope = ` + + + + + ${timestamp.created} + ${timestamp.expires} + + ${cerB64} + + ${signedInfoXml} + ${signatureValue} + + + + + + + + + + + +`; + + return soapEnvelope; +} + +/** + * Extrae el token de la respuesta SOAP + */ +function parseAuthResponse(responseXml: string): SatToken { + const parser = new XMLParser({ + ignoreAttributes: false, + removeNSPrefix: true, + }); + + const result = parser.parse(responseXml); + + // Navegar la estructura de respuesta SOAP + const envelope = result.Envelope || result['s:Envelope']; + if (!envelope) { + throw new Error('Respuesta SOAP inválida'); + } + + const body = envelope.Body || envelope['s:Body']; + if (!body) { + throw new Error('No se encontró el cuerpo de la respuesta'); + } + + const autenticaResponse = body.AutenticaResponse; + if (!autenticaResponse) { + throw new Error('No se encontró AutenticaResponse'); + } + + const autenticaResult = autenticaResponse.AutenticaResult; + if (!autenticaResult) { + throw new Error('No se obtuvo token de autenticación'); + } + + // El token es un SAML assertion en base64 + const token = autenticaResult; + + // El token expira en 5 minutos según documentación SAT + const expiresAt = new Date(Date.now() + 5 * 60 * 1000); + + return { token, expiresAt }; +} + +/** + * Autentica con el SAT usando la FIEL y obtiene un token + */ +export async function authenticate(credential: Credential): Promise { + const soapRequest = buildAuthRequest(credential); + + try { + const response = await fetch(SAT_AUTH_URL, { + method: 'POST', + headers: { + 'Content-Type': 'text/xml;charset=UTF-8', + 'SOAPAction': 'http://DescargaMasivaTerceros.gob.mx/IAutenticacion/Autentica', + }, + body: soapRequest, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Error HTTP ${response.status}: ${errorText}`); + } + + const responseXml = await response.text(); + return parseAuthResponse(responseXml); + } catch (error: any) { + console.error('[SAT Auth Error]', error); + throw new Error(`Error al autenticar con el SAT: ${error.message}`); + } +} + +/** + * Verifica si un token está vigente + */ +export function isTokenValid(token: SatToken): boolean { + return new Date() < token.expiresAt; +} diff --git a/apps/api/src/services/sat/sat-client.service.ts b/apps/api/src/services/sat/sat-client.service.ts new file mode 100644 index 0000000..01307b4 --- /dev/null +++ b/apps/api/src/services/sat/sat-client.service.ts @@ -0,0 +1,248 @@ +import { + Fiel, + HttpsWebClient, + FielRequestBuilder, + Service, + QueryParameters, + DateTimePeriod, + DownloadType, + RequestType, + DocumentStatus, + ServiceEndpoints, +} from '@nodecfdi/sat-ws-descarga-masiva'; + +export interface FielData { + cerContent: string; + keyContent: string; + password: string; +} + +/** + * Crea el servicio de descarga masiva del SAT usando los datos de la FIEL + */ +export function createSatService(fielData: FielData): Service { + // Crear FIEL usando el método estático create + const fiel = Fiel.create(fielData.cerContent, fielData.keyContent, fielData.password); + + // Verificar que la FIEL sea válida + if (!fiel.isValid()) { + throw new Error('La FIEL no es válida o está vencida'); + } + + // Crear cliente HTTP + const webClient = new HttpsWebClient(); + + // Crear request builder con la FIEL + const requestBuilder = new FielRequestBuilder(fiel); + + // Crear y retornar el servicio + return new Service(requestBuilder, webClient, undefined, ServiceEndpoints.cfdi()); +} + +export interface QueryResult { + success: boolean; + requestId?: string; + message: string; + statusCode?: string; +} + +export interface VerifyResult { + success: boolean; + status: 'pending' | 'processing' | 'ready' | 'failed' | 'rejected'; + packageIds: string[]; + totalCfdis: number; + message: string; + statusCode?: string; +} + +export interface DownloadResult { + success: boolean; + packageContent: string; // Base64 encoded ZIP + message: string; +} + +/** + * Realiza una consulta al SAT para solicitar CFDIs + */ +export async function querySat( + service: Service, + fechaInicio: Date, + fechaFin: Date, + tipo: 'emitidos' | 'recibidos', + requestType: 'metadata' | 'cfdi' = 'cfdi' +): Promise { + try { + const period = DateTimePeriod.createFromValues( + formatDateForSat(fechaInicio), + formatDateForSat(fechaFin) + ); + + const downloadType = new DownloadType(tipo === 'emitidos' ? 'issued' : 'received'); + const reqType = new RequestType(requestType === 'cfdi' ? 'xml' : 'metadata'); + + // XMLs: solo vigentes (active). Metadata: todos (undefined = sin filtro). + let parameters = QueryParameters.create(period, downloadType, reqType); + if (requestType === 'cfdi') { + parameters = parameters.withDocumentStatus(new DocumentStatus('active')); + } + const result = await service.query(parameters); + + if (!result.getStatus().isAccepted()) { + return { + success: false, + message: result.getStatus().getMessage(), + statusCode: result.getStatus().getCode().toString(), + }; + } + + return { + success: true, + requestId: result.getRequestId(), + message: 'Solicitud aceptada', + statusCode: result.getStatus().getCode().toString(), + }; + } catch (error: any) { + console.error('[SAT Query Error]', error); + return { + success: false, + message: error.message || 'Error al realizar consulta', + }; + } +} + +/** + * Verifica el estado de una solicitud + */ +export async function verifySatRequest( + service: Service, + requestId: string +): Promise { + try { + const result = await service.verify(requestId); + const statusRequest = result.getStatusRequest(); + + // `codeRequest` es el código SAT específico del estado de la solicitud + // (5000 Accepted, 5002 Exhausted, 5003 MaximumLimit, 5004 EmptyResult, + // 5005 Duplicated) y su mensaje explica POR QUÉ el SAT rechazó. Es la + // pieza clave para diagnosticar rejections — el `getStatus().getCode()` + // solo devuelve el wrapper HTTP (5000 genérico "Aceptada"). + // + // Fuente: docs phpcfdi + lib @nodecfdi/sat-ws-descarga-masiva (`CodeRequest`). + const codeReqObj = typeof (result as any).getCodeRequest === 'function' + ? (result as any).getCodeRequest() + : null; + const codeRequestValue = codeReqObj ? codeReqObj.getValue() : null; + const codeRequestMessage = codeReqObj ? codeReqObj.getMessage() : null; + const codeRequestEntry = codeReqObj ? codeReqObj.getEntryId() : null; + + // Debug logging + console.log('[SAT Verify Debug]', { + statusRequestValue: statusRequest.getValue(), + statusRequestEntryId: statusRequest.getEntryId(), + cfdis: result.getNumberCfdis(), + packages: result.getPackageIds(), + statusCode: result.getStatus().getCode(), + statusMsg: result.getStatus().getMessage(), + codeRequestValue, + codeRequestEntry, + codeRequestMessage, + }); + + // Usar isTypeOf para determinar el estado + let status: VerifyResult['status']; + if (statusRequest.isTypeOf('Finished')) { + status = 'ready'; + } else if (statusRequest.isTypeOf('InProgress')) { + status = 'processing'; + } else if (statusRequest.isTypeOf('Accepted')) { + status = 'pending'; + } else if (statusRequest.isTypeOf('Failure')) { + status = 'failed'; + } else if (statusRequest.isTypeOf('Rejected')) { + status = 'rejected'; + } else { + // Default: check by entryId + const entryId = statusRequest.getEntryId(); + if (entryId === 'Finished') status = 'ready'; + else if (entryId === 'InProgress') status = 'processing'; + else if (entryId === 'Accepted') status = 'pending'; + else status = 'pending'; + } + + // Para estados terminales no-felices, construir mensaje informativo. + // `codeRequest` (si está disponible) es la razón SAT real del rechazo. + const statusCode = result.getStatus().getCode().toString(); + const statusMsg = result.getStatus().getMessage(); + const reqValue = statusRequest.getValue(); + const reqEntry = statusRequest.getEntryId(); + let message = statusMsg; + if (status === 'rejected' || status === 'failed') { + const codeReqStr = codeRequestValue + ? ` codeRequest=${codeRequestEntry}(${codeRequestValue}) — ${codeRequestMessage}` + : ''; + message = `SAT request=${reqEntry}(${reqValue})${codeReqStr} wrapperCode=${statusCode} wrapperMsg="${statusMsg}"`; + } + + return { + success: result.getStatus().isAccepted(), + status, + packageIds: result.getPackageIds(), + totalCfdis: result.getNumberCfdis(), + message, + statusCode, + }; + } catch (error: any) { + console.error('[SAT Verify Error]', error.message || error); + // Errores de la librería (ej. webError.getResponse is not a function) + // no son fallos del SAT — devolver 'pending' para reintentar polling + return { + success: false, + status: 'pending', + packageIds: [], + totalCfdis: 0, + message: error.message || 'Error al verificar solicitud', + }; + } +} + +/** + * Descarga un paquete de CFDIs + */ +export async function downloadSatPackage( + service: Service, + packageId: string +): Promise { + try { + const result = await service.download(packageId); + + if (!result.getStatus().isAccepted()) { + return { + success: false, + packageContent: '', + message: result.getStatus().getMessage(), + }; + } + + return { + success: true, + packageContent: result.getPackageContent(), + message: 'Paquete descargado', + }; + } catch (error: any) { + console.error('[SAT Download Error]', error); + return { + success: false, + packageContent: '', + message: error.message || 'Error al descargar paquete', + }; + } +} + +/** + * Formatea una fecha para el SAT (YYYY-MM-DD HH:mm:ss) + */ +function formatDateForSat(date: Date): string { + const pad = (n: number) => n.toString().padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` + + `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; +} diff --git a/apps/api/src/services/sat/sat-crypto.service.ts b/apps/api/src/services/sat/sat-crypto.service.ts new file mode 100644 index 0000000..0a52ee1 --- /dev/null +++ b/apps/api/src/services/sat/sat-crypto.service.ts @@ -0,0 +1,94 @@ +import { encryptAesGcm, decryptAesGcm, deriveAesKey } from '@horux/core'; +import { env } from '../../config/env.js'; + +function getKey(): Buffer { + return deriveAesKey(env.FIEL_ENCRYPTION_KEY); +} + +/** + * Encripta datos usando AES-256-GCM con la clave derivada de FIEL_ENCRYPTION_KEY + */ +export function encrypt(data: Buffer): { encrypted: Buffer; iv: Buffer; tag: Buffer } { + return encryptAesGcm(data, getKey()); +} + +/** + * Desencripta datos usando AES-256-GCM con la clave derivada de FIEL_ENCRYPTION_KEY + */ +export function decrypt(encrypted: Buffer, iv: Buffer, tag: Buffer): Buffer { + return decryptAesGcm(encrypted, iv, tag, getKey()); +} + +/** + * Encripta un string y retorna los componentes + */ +export function encryptString(text: string): { encrypted: Buffer; iv: Buffer; tag: Buffer } { + return encrypt(Buffer.from(text, 'utf-8')); +} + +/** + * Desencripta a string + */ +export function decryptToString(encrypted: Buffer, iv: Buffer, tag: Buffer): string { + return decrypt(encrypted, iv, tag).toString('utf-8'); +} + +/** + * Encripta credenciales FIEL con IV/tag independiente por componente + */ +export function encryptFielCredentials( + cerData: Buffer, + keyData: Buffer, + password: string +): { + encryptedCer: Buffer; + encryptedKey: Buffer; + encryptedPassword: Buffer; + cerIv: Buffer; + cerTag: Buffer; + keyIv: Buffer; + keyTag: Buffer; + passwordIv: Buffer; + passwordTag: Buffer; +} { + const cer = encrypt(cerData); + const key = encrypt(keyData); + const pwd = encrypt(Buffer.from(password, 'utf-8')); + + return { + encryptedCer: cer.encrypted, + encryptedKey: key.encrypted, + encryptedPassword: pwd.encrypted, + cerIv: cer.iv, + cerTag: cer.tag, + keyIv: key.iv, + keyTag: key.tag, + passwordIv: pwd.iv, + passwordTag: pwd.tag, + }; +} + +/** + * Desencripta credenciales FIEL (per-component IV/tag) + */ +export function decryptFielCredentials( + encryptedCer: Buffer, + encryptedKey: Buffer, + encryptedPassword: Buffer, + cerIv: Buffer, + cerTag: Buffer, + keyIv: Buffer, + keyTag: Buffer, + passwordIv: Buffer, + passwordTag: Buffer +): { + cerData: Buffer; + keyData: Buffer; + password: string; +} { + const cerData = decrypt(encryptedCer, cerIv, cerTag); + const keyData = decrypt(encryptedKey, keyIv, keyTag); + const password = decrypt(encryptedPassword, passwordIv, passwordTag).toString('utf-8'); + + return { cerData, keyData, password }; +} diff --git a/apps/api/src/services/sat/sat-csf-login.ts b/apps/api/src/services/sat/sat-csf-login.ts new file mode 100644 index 0000000..a067877 --- /dev/null +++ b/apps/api/src/services/sat/sat-csf-login.ts @@ -0,0 +1,84 @@ +import type { Browser, BrowserContext, Page } from 'playwright'; + +const PUBLIC_URL = 'https://www.sat.gob.mx/portal/public/tramites/constancia-de-situacion-fiscal'; + +export interface CsfLoginSession { + context: BrowserContext; + appPage: Page; +} + +/** + * Navigates from the public CSF page → "SERVICIO" popup → FIEL login → + * returns the post-login app page (popup that became the SPA). + * Ver referencia_sat_portal_csf: el botón "Generar" vive en un iframe JSF + * dentro de esta appPage, por eso la retornamos tal cual. + */ +export async function loginSatCsf( + browser: Browser, + cerPath: string, + keyPath: string, + password: string, +): Promise { + const context = await browser.newContext({ acceptDownloads: true }); + const publicPage = await context.newPage(); + publicPage.setDefaultTimeout(60_000); + + await publicPage.goto(PUBLIC_URL, { waitUntil: 'networkidle' }); + await publicPage.waitForTimeout(2000); + + // Click acordeón "Obtén tu constancia" / "Obtener constancia" + const obtenerLocator = publicPage.locator( + 'text=/Obt[eé]n\\s+la\\s+constancia|Obt[eé]n\\s+tu\\s+constancia|Obtener\\s+constancia|Obtener\\s+la\\s+constancia/i', + ).first(); + await obtenerLocator.waitFor({ state: 'visible', timeout: 60_000 }); + await obtenerLocator.scrollIntoViewIfNeeded(); + await obtenerLocator.click(); + await publicPage.waitForTimeout(1500); + + // Click "SERVICIO" → popup + const popupPromise = context.waitForEvent('page', { timeout: 60_000 }); + await publicPage.locator('text=/^\\s*SERVICIO\\s*$/i').first().click(); + const loginPage = await popupPromise; + await loginPage.waitForLoadState('domcontentloaded'); + loginPage.setDefaultTimeout(60_000); + + // Click "e.firma" (NO "e.firma portable"). El SAT a veces aterriza en la + // pestaña de contraseña: el botón cambia a la vista FIEL. El click sintético + // de Playwright a veces no dispara el handler — afirmamos el efecto (aparece + // el file input) y reintentamos con dispatchEvent si hace falta. + const efirmaBtn = loginPage + .locator('button:has-text("e.firma"):not(:has-text("portable")), input[type="button"][value="e.firma" i], input[type="submit"][value="e.firma" i]') + .first(); + await efirmaBtn.waitFor({ state: 'visible', timeout: 30_000 }); + await efirmaBtn.scrollIntoViewIfNeeded(); + await efirmaBtn.click(); + + const fileInputs = loginPage.locator('input[type="file"]'); + try { + await fileInputs.first().waitFor({ state: 'attached', timeout: 10_000 }); + } catch { + // Retry: el click sintético no disparó el handler — forzamos dispatchEvent + await efirmaBtn.dispatchEvent('click'); + await fileInputs.first().waitFor({ state: 'attached', timeout: 30_000 }); + } + + // Upload .cer (primer input) y .key (segundo) + await fileInputs.nth(0).setInputFiles(cerPath); + await fileInputs.nth(1).setInputFiles(keyPath); + + // Password + Enviar + await loginPage.locator('input[type="password"]').first().fill(password); + await loginPage.locator('button:has-text("Enviar"), input[value="Enviar"]').first().click(); + + // Esperar a que salga del dominio de login + await loginPage.waitForURL(url => !url.toString().includes('loginda.siat.sat.gob.mx'), { timeout: 60_000 }); + await loginPage.waitForLoadState('networkidle').catch(() => undefined); + await loginPage.waitForTimeout(2000); + + const bodyText = await loginPage.locator('body').innerText().catch(() => ''); + if (/contrase[nñ]a\s+incorrecta|usuario.*no.*v[aá]lido|firma\s+inv[aá]lida/i.test(bodyText)) { + throw new Error('FIEL inválida o contraseña incorrecta'); + } + + return { context, appPage: loginPage }; +} diff --git a/apps/api/src/services/sat/sat-csf-parser.ts b/apps/api/src/services/sat/sat-csf-parser.ts new file mode 100644 index 0000000..03a6cc6 --- /dev/null +++ b/apps/api/src/services/sat/sat-csf-parser.ts @@ -0,0 +1,246 @@ +import { PDFParse } from 'pdf-parse'; + +export interface Domicilio { + codigoPostal?: string; + tipoVialidad?: string; + nombreVialidad?: string; + numeroExterior?: string; + numeroInterior?: string; + colonia?: string; + localidad?: string; + municipio?: string; + entidadFederativa?: string; + entreCalle?: string; + yCalle?: string; +} + +export interface ActividadEconomica { + orden: number; + descripcion: string; + porcentaje: number; + fechaInicio: string; + fechaFin?: string; +} + +export interface RegimenCsf { + nombre: string; + fechaInicio: string; + fechaFin?: string; +} + +export interface Obligacion { + descripcion: string; + descripcionVencimiento: string; + fechaInicio: string; + fechaFin?: string; +} + +export interface ConstanciaSituacionFiscal { + rfc: string; + curp?: string; + idCIF: string; + nombre?: string; + primerApellido?: string; + segundoApellido?: string; + razonSocial?: string; + nombreComercial?: string; + fechaInicioOperaciones: string; + estatusPadron: string; + fechaUltimoCambioEstado?: string; + lugarFechaEmision: string; + domicilio: Domicilio; + actividadesEconomicas: ActividadEconomica[]; + regimenes: RegimenCsf[]; + obligaciones: Obligacion[]; + cadenaOriginalSello: string; + selloDigital: string; +} + +async function extractPdfText(pdfBuffer: Buffer): Promise { + const parser = new PDFParse({ data: pdfBuffer }); + try { + const result = await parser.getText(); + return result.text; + } finally { + await parser.destroy(); + } +} + +const LABELS = [ + 'RFC', 'CURP', 'Nombre (s)', 'Primer Apellido', 'Segundo Apellido', + 'Denominación o Razón Social', 'Denominación/Razón Social', + 'Régimen Capital', 'Fecha inicio de operaciones', 'Estatus en el padrón', + 'Fecha de último cambio de estado', 'Nombre Comercial', + 'Código Postal', 'Tipo de Vialidad', 'Nombre de Vialidad', + 'Número Exterior', 'Número Interior', 'Nombre de la Colonia', + 'Nombre de la Localidad', 'Nombre del Municipio o Demarcación Territorial', + 'Nombre de la Entidad Federativa', 'Entre Calle', 'Y Calle', +] as const; + +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function extractLabels(text: string): Map { + const result = new Map(); + const labelAlternation = LABELS.map(escapeRegex).join('|'); + const re = new RegExp( + `(${labelAlternation})\\s*:\\s*([\\s\\S]*?)(?=\\s+(?:${labelAlternation})\\s*:|\\n?\\s*(?:Datos del domicilio registrado|Actividades Económicas|Regímenes|Obligaciones|Cadena Original|Sus datos personales)\\b|\\n\\s*--\\s*\\d+\\s+of\\s+\\d+|$)`, + 'g', + ); + for (const match of text.matchAll(re)) { + const label = match[1]; + const value = match[2].replace(/\s+/g, ' ').trim(); + if (!result.has(label)) result.set(label, value); + } + return result; +} + +function extractIdCIF(text: string): string { + const m = text.match(/idCIF\s*:?\s*(\d+)/i); + if (!m) throw new Error('idCIF no encontrado en PDF'); + return m[1]; +} + +function extractLugarFechaEmision(text: string): string { + const m = text.match(/Lugar y Fecha de Emisión\s*\n?\s*([^\n]+?)\s*(?=\n|TORC|HTS|[A-Z]{4}\d{6})/); + if (m) return m[1].replace(/\s+/g, ' ').trim(); + const m2 = text.match(/([A-ZÁÉÍÓÚÑ ]+,\s*[A-ZÁÉÍÓÚÑ ]+\s+A\s+\d{1,2}\s+DE\s+[A-ZÁÉÍÓÚÑ]+\s+DE\s+\d{4})/i); + if (m2) return m2[1].replace(/\s+/g, ' ').trim(); + throw new Error('Lugar y Fecha de Emisión no encontrado'); +} + +const PAGE_NOISE_RE = /^\s*(?:--\s*\d+\s+of\s+\d+\s*--|Página\s*\[\d+\]\s*de\s*\[\d+\])\s*$/; + +function sliceSection(text: string, header: string, nextHeaders: string[]): string { + const start = text.indexOf(header); + if (start === -1) return ''; + const after = start + header.length; + let end = text.length; + for (const h of nextHeaders) { + const idx = text.indexOf(h, after); + if (idx !== -1 && idx < end) end = idx; + } + return text.slice(after, end); +} + +function groupRowChunks(body: string, headerRowRegex: RegExp): string[] { + const lines = body.split(/\r?\n/).map(l => l.trim()).filter(l => l.length > 0 && !PAGE_NOISE_RE.test(l)); + if (lines.length > 0 && headerRowRegex.test(lines[0])) lines.shift(); + const chunks: string[] = []; + let current: string[] = []; + for (const line of lines) { + current.push(line); + if (/\d{2}\/\d{2}\/\d{4}\s*$/.test(line)) { + chunks.push(current.join(' ').replace(/\s+/g, ' ').trim()); + current = []; + } + } + return chunks; +} + +function extractActividades(text: string): ActividadEconomica[] { + const section = sliceSection(text, 'Actividades Económicas:', ['Regímenes:', 'Obligaciones:', 'Cadena Original']); + if (!section) return []; + const chunks = groupRowChunks(section, /^\s*Orden\s+Actividad\s+Económica\s+Porcentaje\s+Fecha\s+Inicio\s+Fecha\s+Fin\s*$/i); + const result: ActividadEconomica[] = []; + for (const chunk of chunks) { + const m = chunk.match(/^(\d+)\s+(.+?)\s+(\d+)\s+(\d{2}\/\d{2}\/\d{4})(?:\s+(\d{2}\/\d{2}\/\d{4}))?$/); + if (!m) continue; + result.push({ + orden: Number(m[1]), + descripcion: m[2].replace(/\s+/g, ' ').trim(), + porcentaje: Number(m[3]), + fechaInicio: m[4], + fechaFin: m[5], + }); + } + return result; +} + +function extractRegimenes(text: string): RegimenCsf[] { + const section = sliceSection(text, 'Regímenes:', ['Obligaciones:', 'Cadena Original']); + if (!section) return []; + const chunks = groupRowChunks(section, /^\s*Régimen\s+Fecha\s+Inicio\s+Fecha\s+Fin\s*$/i); + const result: RegimenCsf[] = []; + for (const chunk of chunks) { + const m = chunk.match(/^(.+?)\s+(\d{2}\/\d{2}\/\d{4})(?:\s+(\d{2}\/\d{2}\/\d{4}))?$/); + if (!m) continue; + result.push({ nombre: m[1].replace(/\s+/g, ' ').trim(), fechaInicio: m[2], fechaFin: m[3] }); + } + return result; +} + +function extractObligaciones(text: string): Obligacion[] { + const section = sliceSection(text, 'Obligaciones:', ['Sus datos personales', 'Cadena Original']); + if (!section) return []; + const chunks = groupRowChunks(section, /^\s*Descripción de la Obligación\s+Descripción Vencimiento\s+Fecha Inicio\s+Fecha Fin\s*$/i); + const result: Obligacion[] = []; + for (const chunk of chunks) { + const m = chunk.match(/^(.+?)\s+((?:A\s+m[aá]s\s+tardar|Dentro\s+de|Mensualmente|Bimestralmente|Trimestralmente|Anualmente|En\s+los|Cuando\s+)[\s\S]+?)\s+(\d{2}\/\d{2}\/\d{4})(?:\s+(\d{2}\/\d{2}\/\d{4}))?$/); + if (!m) continue; + result.push({ descripcion: m[1].trim(), descripcionVencimiento: m[2].trim(), fechaInicio: m[3], fechaFin: m[4] }); + } + return result; +} + +function extractCadenaOriginalSello(text: string): string { + const m = text.match(/Cadena Original Sello\s*:\s*(\|\|[\s\S]+?\|\|)\s*(?:Sello Digital|$)/); + if (!m) throw new Error('Cadena Original Sello no encontrada'); + return m[1].replace(/\s+/g, ''); +} + +function extractSelloDigital(text: string): string { + const m = text.match(/Sello Digital\s*:\s*([A-Za-z0-9+/=\s]+?)(?:\n\s*\n|Página|$)/); + if (!m) throw new Error('Sello Digital no encontrado'); + return m[1].replace(/\s+/g, ''); +} + +export async function parseCsfPdf(pdfBuffer: Buffer): Promise { + const text = await extractPdfText(pdfBuffer); + const labels = extractLabels(text); + const idCIF = extractIdCIF(text); + const lugarFechaEmision = extractLugarFechaEmision(text); + + const rfc = labels.get('RFC'); + if (!rfc) throw new Error('RFC no encontrado en PDF'); + + const fechaInicioOperaciones = labels.get('Fecha inicio de operaciones'); + if (!fechaInicioOperaciones) throw new Error('Fecha inicio de operaciones no encontrada'); + + const estatusPadron = labels.get('Estatus en el padrón'); + if (!estatusPadron) throw new Error('Estatus en el padrón no encontrado'); + + return { + rfc, + curp: labels.get('CURP'), + idCIF, + nombre: labels.get('Nombre (s)'), + primerApellido: labels.get('Primer Apellido'), + segundoApellido: labels.get('Segundo Apellido'), + razonSocial: labels.get('Denominación o Razón Social') ?? labels.get('Denominación/Razón Social'), + nombreComercial: labels.get('Nombre Comercial') || undefined, + fechaInicioOperaciones, + estatusPadron, + fechaUltimoCambioEstado: labels.get('Fecha de último cambio de estado'), + lugarFechaEmision, + domicilio: { + codigoPostal: labels.get('Código Postal'), + tipoVialidad: labels.get('Tipo de Vialidad'), + nombreVialidad: labels.get('Nombre de Vialidad'), + numeroExterior: labels.get('Número Exterior'), + numeroInterior: labels.get('Número Interior'), + colonia: labels.get('Nombre de la Colonia'), + localidad: labels.get('Nombre de la Localidad'), + municipio: labels.get('Nombre del Municipio o Demarcación Territorial'), + entidadFederativa: labels.get('Nombre de la Entidad Federativa'), + entreCalle: labels.get('Entre Calle'), + yCalle: labels.get('Y Calle'), + }, + actividadesEconomicas: extractActividades(text), + regimenes: extractRegimenes(text), + obligaciones: extractObligaciones(text), + cadenaOriginalSello: extractCadenaOriginalSello(text), + selloDigital: extractSelloDigital(text), + }; +} diff --git a/apps/api/src/services/sat/sat-csf-scraper.ts b/apps/api/src/services/sat/sat-csf-scraper.ts new file mode 100644 index 0000000..37e4b8e --- /dev/null +++ b/apps/api/src/services/sat/sat-csf-scraper.ts @@ -0,0 +1,121 @@ +import type { Page, Locator, Frame, Response } from 'playwright'; +import type { CsfLoginSession } from './sat-csf-login.js'; + +async function tryFetchPdfFromUrl(page: Page, url: string): Promise { + if (url.startsWith('blob:') || url.startsWith('data:')) { + const arr = await page.evaluate(async (u) => { + const r = await fetch(u); + const buf = await r.arrayBuffer(); + return Array.from(new Uint8Array(buf)); + }, url); + return Buffer.from(arr); + } + if (url.startsWith('http')) { + const response = await page.context().request.get(url); + if (!response.ok()) return null; + return Buffer.from(await response.body()); + } + return null; +} + +/** + * Busca "Generar Constancia" en cualquiera de los frames del appPage (vive + * típicamente en un iframe JSF legacy: rfcampc.siat.sat.gob.mx/PTSC/...). + * Intenta 3 rutas: download event, popup con viewer, response interception. + */ +export async function extractCsfPdf(session: CsfLoginSession): Promise { + const { context, appPage } = session; + + let interceptedPdf: Buffer | null = null; + const responseListener = async (response: Response) => { + const ct = response.headers()['content-type'] ?? ''; + if (ct.includes('application/pdf')) { + try { interceptedPdf = Buffer.from(await response.body()); } catch { /* ok */ } + } + }; + context.on('response', responseListener); + + const GENERAR_SELECTORS = [ + 'button:has-text("Generar Constancia")', + 'button:has-text("Generar constancia")', + 'input[type="button"][value*="Generar" i]', + 'input[type="submit"][value*="Generar" i]', + 'a:has-text("Generar Constancia")', + 'a:has-text("Generar constancia")', + ].join(', '); + + let generarLocator: Locator | null = null; + let foundFrame: Frame | null = null; + const deadline = Date.now() + 90_000; + while (Date.now() < deadline) { + for (const frame of appPage.frames()) { + const loc = frame.locator(GENERAR_SELECTORS).first(); + const count = await loc.count().catch(() => 0); + if (count > 0 && await loc.isVisible().catch(() => false)) { + generarLocator = loc; + foundFrame = frame; + break; + } + } + if (generarLocator) break; + await appPage.waitForTimeout(1000); + } + + if (!generarLocator || !foundFrame) { + context.off('response', responseListener); + throw new Error('Botón "Generar Constancia" no encontrado en ningún frame del portal SAT (tras 90s)'); + } + + await generarLocator.scrollIntoViewIfNeeded(); + await appPage.waitForTimeout(500); + + const popupPromise = context.waitForEvent('page', { timeout: 15_000 }).catch(() => null); + const downloadPromise = appPage.waitForEvent('download', { timeout: 15_000 }).catch(() => null); + await generarLocator.click(); + + const [popup, download] = await Promise.all([popupPromise, downloadPromise]); + + try { + // Path 1: download event + if (download) { + const stream = await download.createReadStream(); + const chunks: Buffer[] = []; + for await (const chunk of stream) chunks.push(chunk as Buffer); + const pdf = Buffer.concat(chunks); + if (!pdf.subarray(0, 5).toString().startsWith('%PDF-')) { + throw new Error('El archivo descargado no es un PDF válido'); + } + return pdf; + } + + // Path 2: viewer popup + if (popup) { + await popup.waitForLoadState('domcontentloaded').catch(() => undefined); + await popup.waitForTimeout(2000); + + let pdf = await tryFetchPdfFromUrl(popup, popup.url()).catch(() => null); + if (!pdf) { + const embedSrc = await popup.locator('embed[type="application/pdf"], iframe').first().getAttribute('src').catch(() => null); + if (embedSrc) { + const absolute = new URL(embedSrc, popup.url()).toString(); + pdf = await tryFetchPdfFromUrl(popup, absolute).catch(() => null); + } + } + if (!pdf && interceptedPdf) pdf = interceptedPdf; + if (!pdf || pdf.length === 0) throw new Error('El visor abrió pero no se pudo extraer el PDF'); + if (!pdf.subarray(0, 5).toString().startsWith('%PDF-')) throw new Error('Buffer extraído no es un PDF válido'); + return pdf; + } + + // Path 3: inline response (no popup, no download) + await appPage.waitForTimeout(3000); + if (interceptedPdf) { + const pdf = interceptedPdf as Buffer; + if (!pdf.subarray(0, 5).toString().startsWith('%PDF-')) throw new Error('Buffer interceptado no es un PDF válido'); + return pdf; + } + throw new Error('Click en "Generar Constancia" no produjo descarga, popup ni respuesta PDF'); + } finally { + context.off('response', responseListener); + } +} diff --git a/apps/api/src/services/sat/sat-download.service.ts b/apps/api/src/services/sat/sat-download.service.ts new file mode 100644 index 0000000..4489246 --- /dev/null +++ b/apps/api/src/services/sat/sat-download.service.ts @@ -0,0 +1,408 @@ +import { XMLParser } from 'fast-xml-parser'; +import { createHash, randomUUID } from 'crypto'; +import type { Credential } from '@nodecfdi/credentials/node'; +import type { + SatDownloadRequestResponse, + SatVerifyResponse, + SatPackageResponse, + CfdiSyncType +} from '@horux/shared'; + +const SAT_SOLICITUD_URL = 'https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/SolicitaDescargaService.svc'; +const SAT_VERIFICA_URL = 'https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/VerificaSolicitudDescargaService.svc'; +const SAT_DESCARGA_URL = 'https://cfdidescargamasaborrar.clouda.sat.gob.mx/DescargaMasivaService.svc'; + +type TipoSolicitud = 'CFDI' | 'Metadata'; + +interface RequestDownloadParams { + credential: Credential; + token: string; + rfc: string; + fechaInicio: Date; + fechaFin: Date; + tipoSolicitud: TipoSolicitud; + tipoCfdi: CfdiSyncType; +} + +/** + * Formatea fecha a formato SAT (YYYY-MM-DDTHH:MM:SS) + */ +function formatSatDate(date: Date): string { + return date.toISOString().slice(0, 19); +} + +/** + * Construye el XML de solicitud de descarga + */ +function buildDownloadRequest(params: RequestDownloadParams): string { + const { credential, token, rfc, fechaInicio, fechaFin, tipoSolicitud, tipoCfdi } = params; + const uuid = randomUUID(); + + const certificate = credential.certificate(); + const cerB64 = Buffer.from(certificate.pem().replace(/-----.*-----/g, '').replace(/\n/g, '')).toString('base64'); + + // Construir el elemento de solicitud + const rfcEmisor = tipoCfdi === 'emitidos' ? rfc : undefined; + const rfcReceptor = tipoCfdi === 'recibidos' ? rfc : undefined; + + const solicitudContent = `${rfc}` + + `${formatSatDate(fechaInicio)}` + + `${formatSatDate(fechaFin)}` + + `${tipoSolicitud}` + + (rfcEmisor ? `${rfcEmisor}` : '') + + (rfcReceptor ? `${rfcReceptor}` : ''); + + const solicitudToSign = `${solicitudContent}`; + const digestValue = createHash('sha1').update(solicitudToSign).digest('base64'); + + const signedInfoXml = `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `${digestValue}` + + `` + + ``; + + const signatureBinary = credential.sign(signedInfoXml, 'sha1'); + const signatureValue = Buffer.from(signatureBinary, 'binary').toString('base64'); + + return ` + + + + + + + ${signedInfoXml} + ${signatureValue} + + + + ${certificate.issuerAsRfc4514()} + ${certificate.serialNumber().bytes()} + + ${cerB64} + + + + + + +`; +} + +/** + * Solicita la descarga de CFDIs al SAT + */ +export async function requestDownload(params: RequestDownloadParams): Promise { + const soapRequest = buildDownloadRequest(params); + + try { + const response = await fetch(SAT_SOLICITUD_URL, { + method: 'POST', + headers: { + 'Content-Type': 'text/xml;charset=UTF-8', + 'SOAPAction': 'http://DescargaMasivaTerceros.sat.gob.mx/ISolicitaDescargaService/SolicitaDescarga', + 'Authorization': `WRAP access_token="${params.token}"`, + }, + body: soapRequest, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Error HTTP ${response.status}: ${errorText}`); + } + + const responseXml = await response.text(); + return parseDownloadRequestResponse(responseXml); + } catch (error: any) { + console.error('[SAT Download Request Error]', error); + throw new Error(`Error al solicitar descarga: ${error.message}`); + } +} + +/** + * Parsea la respuesta de solicitud de descarga + */ +function parseDownloadRequestResponse(responseXml: string): SatDownloadRequestResponse { + const parser = new XMLParser({ + ignoreAttributes: false, + removeNSPrefix: true, + attributeNamePrefix: '@_', + }); + + const result = parser.parse(responseXml); + const envelope = result.Envelope || result['s:Envelope']; + const body = envelope?.Body || envelope?.['s:Body']; + const respuesta = body?.SolicitaDescargaResponse?.SolicitaDescargaResult; + + if (!respuesta) { + throw new Error('Respuesta inválida del SAT'); + } + + return { + idSolicitud: respuesta['@_IdSolicitud'] || '', + codEstatus: respuesta['@_CodEstatus'] || '', + mensaje: respuesta['@_Mensaje'] || '', + }; +} + +/** + * Verifica el estado de una solicitud de descarga + */ +export async function verifyRequest( + credential: Credential, + token: string, + rfc: string, + idSolicitud: string +): Promise { + const certificate = credential.certificate(); + const cerB64 = Buffer.from(certificate.pem().replace(/-----.*-----/g, '').replace(/\n/g, '')).toString('base64'); + + const verificaContent = ``; + const digestValue = createHash('sha1').update(verificaContent).digest('base64'); + + const signedInfoXml = `` + + `` + + `` + + `` + + `` + + `` + + `${digestValue}` + + `` + + ``; + + const signatureBinary = credential.sign(signedInfoXml, 'sha1'); + const signatureValue = Buffer.from(signatureBinary, 'binary').toString('base64'); + + const soapRequest = ` + + + + + + + ${signedInfoXml} + ${signatureValue} + + + + ${certificate.issuerAsRfc4514()} + ${certificate.serialNumber().bytes()} + + ${cerB64} + + + + + + +`; + + try { + const response = await fetch(SAT_VERIFICA_URL, { + method: 'POST', + headers: { + 'Content-Type': 'text/xml;charset=UTF-8', + 'SOAPAction': 'http://DescargaMasivaTerceros.sat.gob.mx/IVerificaSolicitudDescargaService/VerificaSolicitudDescarga', + 'Authorization': `WRAP access_token="${token}"`, + }, + body: soapRequest, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Error HTTP ${response.status}: ${errorText}`); + } + + const responseXml = await response.text(); + return parseVerifyResponse(responseXml); + } catch (error: any) { + console.error('[SAT Verify Error]', error); + throw new Error(`Error al verificar solicitud: ${error.message}`); + } +} + +/** + * Parsea la respuesta de verificación + */ +function parseVerifyResponse(responseXml: string): SatVerifyResponse { + const parser = new XMLParser({ + ignoreAttributes: false, + removeNSPrefix: true, + attributeNamePrefix: '@_', + }); + + const result = parser.parse(responseXml); + const envelope = result.Envelope || result['s:Envelope']; + const body = envelope?.Body || envelope?.['s:Body']; + const respuesta = body?.VerificaSolicitudDescargaResponse?.VerificaSolicitudDescargaResult; + + if (!respuesta) { + throw new Error('Respuesta de verificación inválida'); + } + + // Extraer paquetes + let paquetes: string[] = []; + const paquetesNode = respuesta.IdsPaquetes; + if (paquetesNode) { + if (Array.isArray(paquetesNode)) { + paquetes = paquetesNode; + } else if (typeof paquetesNode === 'string') { + paquetes = [paquetesNode]; + } + } + + return { + codEstatus: respuesta['@_CodEstatus'] || '', + estadoSolicitud: parseInt(respuesta['@_EstadoSolicitud'] || '0', 10), + codigoEstadoSolicitud: respuesta['@_CodigoEstadoSolicitud'] || '', + numeroCfdis: parseInt(respuesta['@_NumeroCFDIs'] || '0', 10), + mensaje: respuesta['@_Mensaje'] || '', + paquetes, + }; +} + +/** + * Descarga un paquete de CFDIs + */ +export async function downloadPackage( + credential: Credential, + token: string, + rfc: string, + idPaquete: string +): Promise { + const certificate = credential.certificate(); + const cerB64 = Buffer.from(certificate.pem().replace(/-----.*-----/g, '').replace(/\n/g, '')).toString('base64'); + + const descargaContent = ``; + const digestValue = createHash('sha1').update(descargaContent).digest('base64'); + + const signedInfoXml = `` + + `` + + `` + + `` + + `` + + `` + + `${digestValue}` + + `` + + ``; + + const signatureBinary = credential.sign(signedInfoXml, 'sha1'); + const signatureValue = Buffer.from(signatureBinary, 'binary').toString('base64'); + + const soapRequest = ` + + + + + + + ${signedInfoXml} + ${signatureValue} + + + + ${certificate.issuerAsRfc4514()} + ${certificate.serialNumber().bytes()} + + ${cerB64} + + + + + + +`; + + try { + const response = await fetch(SAT_DESCARGA_URL, { + method: 'POST', + headers: { + 'Content-Type': 'text/xml;charset=UTF-8', + 'SOAPAction': 'http://DescargaMasivaTerceros.sat.gob.mx/IDescargaMasivaService/Descargar', + 'Authorization': `WRAP access_token="${token}"`, + }, + body: soapRequest, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Error HTTP ${response.status}: ${errorText}`); + } + + const responseXml = await response.text(); + return parseDownloadResponse(responseXml); + } catch (error: any) { + console.error('[SAT Download Package Error]', error); + throw new Error(`Error al descargar paquete: ${error.message}`); + } +} + +/** + * Parsea la respuesta de descarga de paquete + */ +function parseDownloadResponse(responseXml: string): SatPackageResponse { + const parser = new XMLParser({ + ignoreAttributes: false, + removeNSPrefix: true, + attributeNamePrefix: '@_', + }); + + const result = parser.parse(responseXml); + const envelope = result.Envelope || result['s:Envelope']; + const body = envelope?.Body || envelope?.['s:Body']; + const respuesta = body?.RespuestaDescargaMasivaTercerosSalida?.Paquete; + + if (!respuesta) { + throw new Error('No se pudo obtener el paquete'); + } + + return { + paquete: respuesta, + }; +} + +/** + * Estados de solicitud del SAT + */ +export const SAT_REQUEST_STATES = { + ACCEPTED: 1, + IN_PROGRESS: 2, + COMPLETED: 3, + ERROR: 4, + REJECTED: 5, + EXPIRED: 6, +} as const; + +/** + * Verifica si la solicitud está completa + */ +export function isRequestComplete(estadoSolicitud: number): boolean { + return estadoSolicitud === SAT_REQUEST_STATES.COMPLETED; +} + +/** + * Verifica si la solicitud falló + */ +export function isRequestFailed(estadoSolicitud: number): boolean { + return ( + estadoSolicitud === SAT_REQUEST_STATES.ERROR || + estadoSolicitud === SAT_REQUEST_STATES.REJECTED || + estadoSolicitud === SAT_REQUEST_STATES.EXPIRED + ); +} + +/** + * Verifica si la solicitud está en progreso + */ +export function isRequestInProgress(estadoSolicitud: number): boolean { + return ( + estadoSolicitud === SAT_REQUEST_STATES.ACCEPTED || + estadoSolicitud === SAT_REQUEST_STATES.IN_PROGRESS + ); +} diff --git a/apps/api/src/services/sat/sat-opinion-login.ts b/apps/api/src/services/sat/sat-opinion-login.ts new file mode 100644 index 0000000..4b23d97 --- /dev/null +++ b/apps/api/src/services/sat/sat-opinion-login.ts @@ -0,0 +1,92 @@ +import { type Page } from 'playwright'; + +const TIMEOUT = 60_000; + +export async function loginToSatOpinion( + page: Page, + cerPath: string, + keyPath: string, + password: string, +): Promise { + // Step 1: Navigate to SAT public page + const publicUrl = 'https://www.sat.gob.mx/portal/public/tramites/opinion-del-cumplimiento'; + console.log('[SAT Opinion] Navigating to SAT public page...'); + await page.goto(publicUrl, { waitUntil: 'domcontentloaded', timeout: TIMEOUT }); + await page.waitForTimeout(2000); + + // Step 2: Click "Obtén la Opinión del cumplimiento" tab + console.log('[SAT Opinion] Clicking "Obtén la Opinión del cumplimiento"...'); + const obtenerOpcion = page.locator('text=Obt').first(); + await obtenerOpcion.waitFor({ state: 'visible', timeout: TIMEOUT }); + await obtenerOpcion.click(); + await page.waitForTimeout(2000); + + // Step 3: Expand "De tu empresa" accordion + console.log('[SAT Opinion] Expanding "De tu empresa" section...'); + const empresaOption = page.locator('text=De tu empresa').first(); + await empresaOption.waitFor({ state: 'visible', timeout: TIMEOUT }); + await empresaOption.click(); + await page.waitForTimeout(2000); + + // Step 4: Click "Ingresa" link — opens new tab (target=_blank) + console.log('[SAT Opinion] Clicking "Ingresa" (opens new tab)...'); + const ingresaLink = page.locator('a:has-text("Ingresa")').first(); + await ingresaLink.waitFor({ state: 'visible', timeout: TIMEOUT }); + + const [loginPage] = await Promise.all([ + page.context().waitForEvent('page', { timeout: TIMEOUT }), + ingresaLink.click(), + ]); + await loginPage.waitForLoadState('domcontentloaded', { timeout: TIMEOUT }); + await loginPage.waitForTimeout(2000); + + // Step 5: Switch to e.firma login + console.log('[SAT Opinion] Clicking e.firma button...'); + const efirmaButton = loginPage.locator('button:has-text("e.firma"), input[value*="firma"], a:has-text("e.firma")').first(); + await efirmaButton.waitFor({ state: 'visible', timeout: TIMEOUT }); + await efirmaButton.click(); + await loginPage.waitForLoadState('domcontentloaded', { timeout: TIMEOUT }); + await loginPage.waitForTimeout(2000); + + // Step 6: Upload .cer + console.log('[SAT Opinion] Uploading .cer...'); + const cerInput = loginPage.locator('input[type="file"]').first(); + await cerInput.waitFor({ state: 'attached', timeout: TIMEOUT }); + await cerInput.setInputFiles(cerPath); + await loginPage.waitForTimeout(500); + + // Step 7: Upload .key + console.log('[SAT Opinion] Uploading .key...'); + const keyInput = loginPage.locator('input[type="file"]').nth(1); + await keyInput.waitFor({ state: 'attached', timeout: TIMEOUT }); + await keyInput.setInputFiles(keyPath); + await loginPage.waitForTimeout(500); + + // Step 8: Enter password + console.log('[SAT Opinion] Entering password...'); + const passwordInput = loginPage.locator('input[type="password"]').first(); + await passwordInput.waitFor({ state: 'visible', timeout: TIMEOUT }); + await passwordInput.fill(password); + + // Step 9: Submit + console.log('[SAT Opinion] Submitting login...'); + const submitButton = loginPage.locator('button:has-text("Enviar"), input[value="Enviar"], a:has-text("Enviar"), input[type="submit"], button[type="submit"]').first(); + await submitButton.waitFor({ state: 'visible', timeout: TIMEOUT }); + await submitButton.click(); + + // Step 10: Wait for auth + redirect to report + console.log('[SAT Opinion] Waiting for authentication...'); + await loginPage.waitForTimeout(8000); + + const currentUrl = loginPage.url(); + if (!currentUrl.includes('reporteOpinion32DContribuyente')) { + const baseUrl = currentUrl.replace(/#.*$/, '').replace(/\?.*$/, ''); + const reporteUrl = baseUrl + '#/reporteOpinion32DContribuyente'; + console.log(`[SAT Opinion] Navigating to report: ${reporteUrl}`); + await loginPage.goto(reporteUrl, { waitUntil: 'domcontentloaded', timeout: TIMEOUT }); + await loginPage.waitForTimeout(5000); + } + + console.log('[SAT Opinion] Login completed.'); + return loginPage; +} diff --git a/apps/api/src/services/sat/sat-opinion-parser.ts b/apps/api/src/services/sat/sat-opinion-parser.ts new file mode 100644 index 0000000..fd4b3cd --- /dev/null +++ b/apps/api/src/services/sat/sat-opinion-parser.ts @@ -0,0 +1,76 @@ +import { PDFParse } from 'pdf-parse'; + +export interface ParsedOpinion { + rfc: string; + razonSocial: string; + estatus: string; + folio: string; + cadenaOriginal: string; + fechaConsulta: string; +} + +export async function parseOpinionPdf(pdfBuffer: Buffer): Promise { + const pdfParse = new PDFParse({ data: new Uint8Array(pdfBuffer) }); + try { + const textResult = await pdfParse.getText(); + const text = textResult.text; + + return { + rfc: extractRfc(text), + razonSocial: extractRazonSocial(text), + estatus: extractEstatus(text), + folio: extractFolio(text), + cadenaOriginal: extractCadenaOriginal(text), + fechaConsulta: extractFecha(text), + }; + } finally { + await pdfParse.destroy(); + } +} + +function extractRfc(text: string): string { + const match = text.match(/RFC\s+Folio\s*\n\s*([A-ZÑ&]{3,4}\d{6}[A-Z\d]{3})/i); + if (match) return match[1].trim(); + const fallback = text.match(/\b([A-ZÑ&]{3,4}\d{6}[A-Z\d]{3})\b/); + return fallback ? fallback[1] : 'NO_ENCONTRADO'; +} + +function extractRazonSocial(text: string): string { + const match = text.match(/(?:Nombre|denominaci[oó]n|raz[oó]n social)\s+Sentido\s*\n\s*(.+)/i); + if (match) { + return match[1].trim().replace(/\s+(POSITIVO|NEGATIVO|EN SUSPENSI[OÓ]N|NO INSCRITO|INSCRITO SIN OBLIGACIONES)\s*$/i, '').trim(); + } + return 'NO_ENCONTRADO'; +} + +function extractEstatus(text: string): string { + const match = text.match(/Sentido\s*\n\s*.+\s+(POSITIVO|NEGATIVO|EN SUSPENSI[OÓ]N|NO INSCRITO|INSCRITO SIN OBLIGACIONES)\s*$/im); + if (match) { + const raw = match[1].trim().toUpperCase(); + if (raw === 'POSITIVO') return 'Positiva'; + if (raw === 'NEGATIVO') return 'Negativa'; + if (raw.includes('SUSPENSI')) return 'En suspensión'; + if (raw.includes('NO INSCRITO')) return 'No inscrito'; + if (raw.includes('SIN OBLIGACIONES')) return 'Inscrito sin obligaciones'; + } + if (/POSITIVO/i.test(text)) return 'Positiva'; + if (/NEGATIVO/i.test(text)) return 'Negativa'; + return 'NO_DETERMINADO'; +} + +function extractFolio(text: string): string { + const match = text.match(/RFC\s+Folio\s*\n\s*[A-ZÑ&]{3,4}\d{6}[A-Z\d]{3}\s+(\S+)/i); + return match ? match[1].trim() : 'NO_ENCONTRADO'; +} + +function extractCadenaOriginal(text: string): string { + const match = text.match(/Cadena Original\s*\n\s*(\|\|.+\|\|)/i); + return match ? match[1].trim() : 'NO_ENCONTRADO'; +} + +function extractFecha(text: string): string { + const match = text.match(/Fecha\s+y\s+hora\s+de\s+emisi[oó]n\s*\n\s*(.+)/i); + if (match) return match[1].trim(); + const fallback = text.match(/(\d{1,2}\s+de\s+\w+\s+de\s+\d{4}\s+a\s+las\s+[\d:]+\s+horas)/i); + return fallback ? fallback[1].trim() : 'NO_ENCONTRADO'; +} diff --git a/apps/api/src/services/sat/sat-opinion-scraper.ts b/apps/api/src/services/sat/sat-opinion-scraper.ts new file mode 100644 index 0000000..722229b --- /dev/null +++ b/apps/api/src/services/sat/sat-opinion-scraper.ts @@ -0,0 +1,84 @@ +import { type Page } from 'playwright'; + +export async function extractOpinionPdf(page: Page): Promise { + const TIMEOUT = 120_000; + const POLL_INTERVAL = 3_000; + + console.log('[SAT Opinion Scraper] Waiting for PDF to appear...'); + + let interceptedPdf: Buffer | null = null; + page.on('response', async (response) => { + try { + const contentType = response.headers()['content-type'] || ''; + if (contentType.includes('application/pdf') || response.url().endsWith('.pdf')) { + const body = await response.body(); + if (body.length > 100) { + interceptedPdf = body; + console.log(`[SAT Opinion Scraper] PDF intercepted via network: ${body.length} bytes`); + } + } + } catch { /* response body may not be available */ } + }); + + const startTime = Date.now(); + + while (Date.now() - startTime < TIMEOUT) { + if (interceptedPdf) return interceptedPdf; + + // Strategy 1: or with PDF data URI + const embedData = await page.evaluate(() => { + for (const el of document.querySelectorAll('embed, object')) { + const src = el.getAttribute('src') || el.getAttribute('data') || ''; + if (src.startsWith('data:application/pdf;base64,')) return src; + } + return null; + }).catch(() => null); + + if (embedData) { + console.log('[SAT Opinion Scraper] PDF found via /'); + return decodeDataUri(embedData); + } + + // Strategy 2: Scan full HTML for base64 PDF + const html = await page.content().catch(() => ''); + const match = html.match(/data:application\/pdf;base64,([A-Za-z0-9+/=\s]+)/); + if (match) { + console.log('[SAT Opinion Scraper] PDF found via page content scan'); + return decodeDataUri(`data:application/pdf;base64,${match[1]}`); + } + + // Strategy 3: Check iframes + for (const frame of page.frames()) { + try { + const frameUrl = frame.url(); + if (frameUrl.startsWith('data:application/pdf;base64,')) { + console.log('[SAT Opinion Scraper] PDF found via iframe URL'); + return decodeDataUri(frameUrl); + } + const frameHtml = await frame.content(); + const frameMatch = frameHtml.match(/data:application\/pdf;base64,([A-Za-z0-9+/=\s]+)/); + if (frameMatch) { + console.log('[SAT Opinion Scraper] PDF found via iframe content'); + return decodeDataUri(`data:application/pdf;base64,${frameMatch[1]}`); + } + } catch { /* cross-origin frame */ } + } + + // Strategy 4: Page URL itself + if (page.url().startsWith('data:application/pdf;base64,')) { + console.log('[SAT Opinion Scraper] PDF found via page URL'); + return decodeDataUri(page.url()); + } + + console.log(`[SAT Opinion Scraper] PDF not found, retrying in ${POLL_INTERVAL / 1000}s...`); + await page.waitForTimeout(POLL_INTERVAL); + } + + throw new Error(`PDF not found after ${TIMEOUT / 1000}s`); +} + +function decodeDataUri(dataUri: string): Buffer { + const prefix = 'data:application/pdf;base64,'; + const base64 = dataUri.substring(prefix.length).replace(/\s/g, ''); + return Buffer.from(base64, 'base64'); +} diff --git a/apps/api/src/services/sat/sat-parser.service.ts b/apps/api/src/services/sat/sat-parser.service.ts new file mode 100644 index 0000000..b7d1056 --- /dev/null +++ b/apps/api/src/services/sat/sat-parser.service.ts @@ -0,0 +1,735 @@ +import AdmZip from 'adm-zip'; +import { XMLParser } from 'fast-xml-parser'; +import type { TipoCfdi, EstadoCfdi } from '@horux/shared'; + +interface CfdiParsed { + uuid: string; + type: TipoCfdi; + tipoComprobante: string; + serie: string | null; + folio: string | null; + status: EstadoCfdi; + fechaEmision: Date; + fechaCertSat: Date | null; + rfcEmisor: string; + nombreEmisor: string; + rfcReceptor: string; + nombreReceptor: string; + subtotal: number; + descuento: number; + total: number; + moneda: string; + tipoCambio: number; + metodoPago: string | null; + formaPago: string | null; + usoCfdi: string | null; + pac: string | null; + // Impuestos del comprobante + ivaTraslado: number; + isrRetencion: number; + ivaRetencion: number; + iepsTraslado: number; + iepsRetencion: number; + // Impuestos locales + impuestosLocalesTrasladado: number; + impuestosLocalesRetenidos: number; + // Complemento de pagos + montoPago: number; + fechaPagoP: string | null; + numParcialidad: string | null; + uuidRelacionado: string | null; + saldoInsoluto: string | null; + isrRetencionPago: number; + ivaTrasladoPago: number; + ivaRetencionPago: number; + iepsTrasladoPago: number; + iepsRetencionPago: number; + // Nómina + fechaPago: string | null; + fechaInicialPago: string | null; + fechaFinalPago: string | null; + numDiasPagados: number; + numSeguroSocial: string | null; + puesto: string | null; + salarioBaseCotApor: number; + salarioDiarioIntegrado: number; + totalPercepciones: number; + totalDeducciones: number; + impRetenidosNomina: number; + otrasDeduccionesNomina: number; + subsidioCausado: number; + + regimenFiscalEmisor: string | null; + regimenFiscalReceptor: string | null; + // CfdiRelacionados a nivel raíz del comprobante (CFDI 4.0). + // `cfdiTipoRelacion` — clave SAT (01..07). NULL si no hay relación. + // `cfdisRelacionados` — UUIDs pipe-separated. + cfdiTipoRelacion: string | null; + cfdisRelacionados: string | null; + conceptos: ConceptoParsed[]; + xmlOriginal: string; +} + +interface ConceptoParsed { + claveProdServ: string | null; + noIdentificacion: string | null; + descripcion: string; + cantidad: number; + claveUnidad: string | null; + unidad: string | null; + valorUnitario: number; + importe: number; + descuento: number; + // Impuestos por concepto + isrRetencion: number; + ivaTraslado: number; + ivaRetencion: number; + iepsTraslado: number; + iepsRetencion: number; +} + +interface ExtractedXml { + filename: string; + content: string; +} + +const xmlParser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@_', + removeNSPrefix: true, +}); + +/** + * Extrae archivos XML de un paquete ZIP en base64 + */ +export function extractXmlsFromZip(zipBase64: string): ExtractedXml[] { + const zipBuffer = Buffer.from(zipBase64, 'base64'); + const zip = new AdmZip(zipBuffer); + const entries = zip.getEntries(); + + const xmlFiles: ExtractedXml[] = []; + + for (const entry of entries) { + if (entry.entryName.toLowerCase().endsWith('.xml')) { + const content = entry.getData().toString('utf-8'); + xmlFiles.push({ + filename: entry.entryName, + content, + }); + } + } + + return xmlFiles; +} + +/** + * Parsea una fecha del XML/CSV del SAT preservando la hora **literal**. + * + * Problema: el CFDI 4.0 define `Fecha` y `FechaTimbrado` como ISO-8601 sin + * zona horaria (hora local del contribuyente = México). Si se pasa tal cual + * a `new Date(str)`, Node lo interpreta según la timezone de la máquina: + * en CDMX (UTC-6), "2025-12-31T18:37:51" se convierte a UTC + * "2026-01-01T00:37:51Z", cambiando la fecha efectiva y desalineando el + * mes/año del CFDI. Postgres guarda ese valor UTC, y los filtros por rango + * lo sacan del mes correcto. + * + * Solución: forzar 'Z' si el string no trae TZ indicator. Esto hace que + * Node interprete el texto como UTC literal y preserve la hora tal cual. + * El valor queda naive pero consistente: todo el sistema filtra con + * fechas naive (sin TZ), así que el resultado es correcto. + */ +function parseCfdiDate(str: string | null | undefined): Date { + if (!str) return new Date(0); + const s = String(str).trim(); + if (!s) return new Date(0); + const hasTz = /[Zz]|[+-]\d{2}:?\d{2}$/.test(s); + return new Date(hasTz ? s : s + 'Z'); +} + +function toArray(val: any): any[] { + if (!val) return []; + return Array.isArray(val) ? val : [val]; +} + +function pf(val: any): number { + return parseFloat(val || '0') || 0; +} + +/** + * Extrae el UUID del TimbreFiscalDigital + */ +function extractUuid(comprobante: any): string { + return comprobante.Complemento?.TimbreFiscalDigital?.['@_UUID'] || ''; +} + +/** + * Extrae datos del timbre: fecha cert SAT y PAC + */ +function extractTimbreData(comprobante: any): { fechaCertSat: Date | null; pac: string | null } { + const timbre = comprobante.Complemento?.TimbreFiscalDigital; + if (!timbre) return { fechaCertSat: null, pac: null }; + + return { + fechaCertSat: timbre['@_FechaTimbrado'] ? parseCfdiDate(timbre['@_FechaTimbrado']) : null, + pac: timbre['@_RfcProvCertif'] || null, + }; +} + +/** + * Extrae impuestos trasladados (IVA 002, IEPS 003) + */ +function extractTraslados(comprobante: any): { iva: number; ieps: number } { + const traslados = toArray(comprobante.Impuestos?.Traslados?.Traslado); + let iva = 0, ieps = 0; + + for (const t of traslados) { + const importe = pf(t['@_Importe']); + if (t['@_Impuesto'] === '002') iva += importe; + else if (t['@_Impuesto'] === '003') ieps += importe; + } + + return { iva, ieps }; +} + +/** + * Extrae impuestos retenidos (ISR 001, IVA 002, IEPS 003) + */ +function extractRetenciones(comprobante: any): { isr: number; iva: number; ieps: number } { + const retenciones = toArray(comprobante.Impuestos?.Retenciones?.Retencion); + let isr = 0, iva = 0, ieps = 0; + + for (const r of retenciones) { + const importe = pf(r['@_Importe']); + if (r['@_Impuesto'] === '001') isr += importe; + else if (r['@_Impuesto'] === '002') iva += importe; + else if (r['@_Impuesto'] === '003') ieps += importe; + } + + return { isr, iva, ieps }; +} + +/** + * Extrae impuestos locales + */ +function extractImpuestosLocales(comprobante: any): { trasladado: number; retenido: number } { + const complemento = comprobante.Complemento; + if (!complemento) return { trasladado: 0, retenido: 0 }; + + const impLocales = complemento.ImpuestosLocales; + if (!impLocales) return { trasladado: 0, retenido: 0 }; + + return { + trasladado: pf(impLocales['@_TotaldeTraslados']), + retenido: pf(impLocales['@_TotaldeRetenciones']), + }; +} + +/** + * Extrae CfdiRelacionados a nivel raíz del Comprobante. Puede haber 1+ + * nodos `cfdi:CfdiRelacionados` (cada uno con un `TipoRelacion`), y dentro + * de cada uno 1+ `cfdi:CfdiRelacionado` con UUID. Retorna el primer + * TipoRelacion encontrado (lo más común) y todos los UUIDs pipe-separated. + */ +function extractCfdiRelacionados(comprobante: any): { + tipoRelacion: string | null; + uuids: string | null; +} { + const nodes = toArray(comprobante.CfdiRelacionados); + if (nodes.length === 0) return { tipoRelacion: null, uuids: null }; + + let tipoRelacion: string | null = null; + const allUuids: string[] = []; + + for (const node of nodes) { + if (!tipoRelacion && node['@_TipoRelacion']) { + tipoRelacion = String(node['@_TipoRelacion']); + } + const rels = toArray(node.CfdiRelacionado); + for (const r of rels) { + if (r['@_UUID']) allUuids.push(String(r['@_UUID'])); + } + } + + return { + tipoRelacion, + uuids: allUuids.length > 0 ? allUuids.join('|') : null, + }; +} + +/** + * Extrae datos del complemento de pagos (pago20) + */ +function extractPagos(comprobante: any): { + montoPago: number; + fechaPagoP: string | null; + numParcialidad: string | null; + uuidRelacionado: string | null; + saldoInsoluto: string | null; + isrRetencion: number; + ivaTraslado: number; + ivaRetencion: number; + iepsTraslado: number; + iepsRetencion: number; +} { + const result = { + montoPago: 0, fechaPagoP: null as string | null, + numParcialidad: null as string | null, + uuidRelacionado: null as string | null, + saldoInsoluto: null as string | null, + isrRetencion: 0, ivaTraslado: 0, ivaRetencion: 0, + iepsTraslado: 0, iepsRetencion: 0, + }; + + const complemento = comprobante.Complemento; + if (!complemento) return result; + + // Try pago20:Pagos or just Pagos + const pagosNode = complemento.Pagos; + if (!pagosNode) return result; + + const pagos = toArray(pagosNode.Pago); + const fechas: string[] = []; + const parcialidades: string[] = []; + const uuids: string[] = []; + const saldos: string[] = []; + + for (const pago of pagos) { + result.montoPago += pf(pago['@_Monto']); + if (pago['@_FechaPago']) fechas.push(pago['@_FechaPago']); + + // Impuestos del pago + const retPago = toArray(pago.ImpuestosP?.RetencionesPP?.RetencionP || pago.ImpuestosP?.RetencionesPP); + for (const r of retPago) { + const importe = pf(r['@_ImporteP']); + if (r['@_ImpuestoP'] === '001') result.isrRetencion += importe; + else if (r['@_ImpuestoP'] === '002') result.ivaRetencion += importe; + else if (r['@_ImpuestoP'] === '003') result.iepsRetencion += importe; + } + + const trasPago = toArray(pago.ImpuestosP?.TrasladosP?.TrasladoP || pago.ImpuestosP?.TrasladosP); + for (const t of trasPago) { + const importe = pf(t['@_ImporteP']); + if (t['@_ImpuestoP'] === '002') result.ivaTraslado += importe; + else if (t['@_ImpuestoP'] === '003') result.iepsTraslado += importe; + } + + // Documentos relacionados + const doctos = toArray(pago.DoctoRelacionado); + for (const d of doctos) { + if (d['@_IdDocumento']) uuids.push(d['@_IdDocumento']); + if (d['@_NumParcialidad']) parcialidades.push(d['@_NumParcialidad']); + if (d['@_ImpSaldoInsoluto'] !== undefined) saldos.push(d['@_ImpSaldoInsoluto']); + } + } + + result.fechaPagoP = fechas.length > 0 ? fechas.join('|') : null; + result.numParcialidad = parcialidades.length > 0 ? parcialidades.join('|') : null; + result.uuidRelacionado = uuids.length > 0 ? uuids.join('|') : null; + result.saldoInsoluto = saldos.length > 0 ? saldos.join('|') : null; + + return result; +} + +/** + * Extrae datos del complemento de nómina (nomina12) + */ +function extractNomina(comprobante: any): { + fechaPago: string | null; + fechaInicialPago: string | null; + fechaFinalPago: string | null; + numDiasPagados: number; + numSeguroSocial: string | null; + puesto: string | null; + salarioBaseCotApor: number; + salarioDiarioIntegrado: number; + totalPercepciones: number; + totalDeducciones: number; + impRetenidosNomina: number; + otrasDeduccionesNomina: number; + subsidioCausado: number; +} { + const result = { + fechaPago: null as string | null, + fechaInicialPago: null as string | null, + fechaFinalPago: null as string | null, + numDiasPagados: 0, + numSeguroSocial: null as string | null, + puesto: null as string | null, + salarioBaseCotApor: 0, + salarioDiarioIntegrado: 0, + totalPercepciones: 0, + totalDeducciones: 0, + impRetenidosNomina: 0, + otrasDeduccionesNomina: 0, + subsidioCausado: 0, + }; + + const complemento = comprobante.Complemento; + if (!complemento) return result; + + const nomina = complemento.Nomina; + if (!nomina) return result; + + result.fechaPago = nomina['@_FechaPago'] || null; + result.fechaInicialPago = nomina['@_FechaInicialPago'] || null; + result.fechaFinalPago = nomina['@_FechaFinalPago'] || null; + result.numDiasPagados = pf(nomina['@_NumDiasPagados']); + result.totalPercepciones = pf(nomina['@_TotalPercepciones']); + result.totalDeducciones = pf(nomina['@_TotalDeducciones']); + + // Receptor de nómina + const receptor = nomina.Receptor; + if (receptor) { + result.numSeguroSocial = receptor['@_NumSeguridadSocial'] || null; + result.puesto = receptor['@_Puesto'] || null; + result.salarioBaseCotApor = pf(receptor['@_SalarioBaseCotApor']); + result.salarioDiarioIntegrado = pf(receptor['@_SalarioDiarioIntegrado']); + } + + // Deducciones + const deducciones = nomina.Deducciones; + if (deducciones) { + result.impRetenidosNomina = pf(deducciones['@_TotalImpuestosRetenidos']); + result.otrasDeduccionesNomina = pf(deducciones['@_TotalOtrasDeducciones']); + } + + // Subsidio causado (OtrosPagos/OtroPago[@TipoOtroPago='002']) + const otrosPagos = toArray(nomina.OtrosPagos?.OtroPago); + for (const op of otrosPagos) { + if (op['@_TipoOtroPago'] === '002') { + result.subsidioCausado = pf(op.SubsidioAlEmpleo?.['@_SubsidioCausado']); + } + } + + return result; +} + +/** + * Extrae los conceptos del comprobante + */ +function extractConceptos(comprobante: any): ConceptoParsed[] { + const conceptosNode = comprobante.Conceptos?.Concepto; + if (!conceptosNode) return []; + + const conceptos = toArray(conceptosNode); + return conceptos.map((c: any) => { + // Impuestos por concepto + const trasladosC = toArray(c.Impuestos?.Traslados?.Traslado); + const retencionesC = toArray(c.Impuestos?.Retenciones?.Retencion); + + let ivaTraslado = 0, iepsTraslado = 0; + for (const t of trasladosC) { + const importe = pf(t['@_Importe']); + if (t['@_Impuesto'] === '002') ivaTraslado += importe; + else if (t['@_Impuesto'] === '003') iepsTraslado += importe; + } + + let isrRetencion = 0, ivaRetencion = 0, iepsRetencion = 0; + for (const r of retencionesC) { + const importe = pf(r['@_Importe']); + if (r['@_Impuesto'] === '001') isrRetencion += importe; + else if (r['@_Impuesto'] === '002') ivaRetencion += importe; + else if (r['@_Impuesto'] === '003') iepsRetencion += importe; + } + + return { + claveProdServ: c['@_ClaveProdServ'] || null, + noIdentificacion: c['@_NoIdentificacion'] || null, + descripcion: c['@_Descripcion'] || '', + cantidad: pf(c['@_Cantidad']) || 1, + claveUnidad: c['@_ClaveUnidad'] || null, + unidad: c['@_Unidad'] || null, + valorUnitario: pf(c['@_ValorUnitario']), + importe: pf(c['@_Importe']), + descuento: pf(c['@_Descuento']), + isrRetencion, + ivaTraslado, + ivaRetencion, + iepsTraslado, + iepsRetencion, + }; + }); +} + +/** + * Parsea un XML de CFDI y extrae los datos relevantes + * @param downloadType - 'emitidos' o 'recibidos' para determinar el type (EMITIDO/RECIBIDO) + */ +export function parseXml(xmlContent: string, downloadType: 'emitidos' | 'recibidos' = 'emitidos'): CfdiParsed | null { + try { + const result = xmlParser.parse(xmlContent); + const comprobante = result.Comprobante; + + if (!comprobante) { + console.error('[Parser] No se encontró el nodo Comprobante'); + return null; + } + + const emisor = comprobante.Emisor || {}; + const receptor = comprobante.Receptor || {}; + const retenciones = extractRetenciones(comprobante); + const traslados = extractTraslados(comprobante); + const timbreData = extractTimbreData(comprobante); + const impLocales = extractImpuestosLocales(comprobante); + const tipoComprobante = comprobante['@_TipoDeComprobante'] || 'I'; + + // Complemento de pagos (solo tipo P) + const pagosData = tipoComprobante === 'P' ? extractPagos(comprobante) : { + montoPago: 0, fechaPagoP: null, numParcialidad: null, + uuidRelacionado: null, saldoInsoluto: null, + isrRetencion: 0, ivaTraslado: 0, ivaRetencion: 0, + iepsTraslado: 0, iepsRetencion: 0, + }; + + // CfdiRelacionados a nivel raíz. CFDI 4.0 permite 1+ nodos + // `cfdi:CfdiRelacionados` cada uno con un TipoRelacion y múltiples UUIDs. + // Aquí capturamos el PRIMER TipoRelacion (lo más común es que haya uno + // solo, especialmente en NC tipo E). Los UUIDs de todos los bloques se + // concatenan con `|`. + const relacionesData = extractCfdiRelacionados(comprobante); + + // Complemento de nómina (solo tipo N) + const nominaData = tipoComprobante === 'N' ? extractNomina(comprobante) : { + fechaPago: null, fechaInicialPago: null, fechaFinalPago: null, + numDiasPagados: 0, numSeguroSocial: null, puesto: null, + salarioBaseCotApor: 0, salarioDiarioIntegrado: 0, + totalPercepciones: 0, totalDeducciones: 0, + impRetenidosNomina: 0, otrasDeduccionesNomina: 0, subsidioCausado: 0, + }; + + const cfdi: CfdiParsed = { + uuid: extractUuid(comprobante), + type: downloadType === 'emitidos' ? 'EMITIDO' : 'RECIBIDO', + tipoComprobante, + serie: comprobante['@_Serie'] || null, + folio: comprobante['@_Folio'] || null, + status: 'Vigente', + fechaEmision: parseCfdiDate(comprobante['@_Fecha']), + fechaCertSat: timbreData.fechaCertSat, + rfcEmisor: emisor['@_Rfc'] || '', + nombreEmisor: emisor['@_Nombre'] || '', + rfcReceptor: receptor['@_Rfc'] || '', + nombreReceptor: receptor['@_Nombre'] || '', + subtotal: pf(comprobante['@_SubTotal']), + descuento: pf(comprobante['@_Descuento']), + total: pf(comprobante['@_Total']), + moneda: comprobante['@_Moneda'] || 'MXN', + tipoCambio: pf(comprobante['@_TipoCambio']) || 1, + metodoPago: comprobante['@_MetodoPago'] || null, + formaPago: comprobante['@_FormaPago'] || null, + usoCfdi: receptor['@_UsoCFDI'] || null, + pac: timbreData.pac, + regimenFiscalEmisor: emisor['@_RegimenFiscal'] || null, + regimenFiscalReceptor: receptor['@_RegimenFiscalReceptor'] || receptor['@_RegimenFiscal'] || null, + cfdiTipoRelacion: relacionesData.tipoRelacion, + cfdisRelacionados: relacionesData.uuids, + // Impuestos comprobante + ivaTraslado: traslados.iva, + isrRetencion: retenciones.isr, + ivaRetencion: retenciones.iva, + iepsTraslado: traslados.ieps, + iepsRetencion: retenciones.ieps, + // Impuestos locales + impuestosLocalesTrasladado: impLocales.trasladado, + impuestosLocalesRetenidos: impLocales.retenido, + // Complemento de pagos + montoPago: pagosData.montoPago, + fechaPagoP: pagosData.fechaPagoP, + numParcialidad: pagosData.numParcialidad, + uuidRelacionado: pagosData.uuidRelacionado, + saldoInsoluto: pagosData.saldoInsoluto, + isrRetencionPago: pagosData.isrRetencion, + ivaTrasladoPago: pagosData.ivaTraslado, + ivaRetencionPago: pagosData.ivaRetencion, + iepsTrasladoPago: pagosData.iepsTraslado, + iepsRetencionPago: pagosData.iepsRetencion, + // Nómina + ...nominaData, + conceptos: extractConceptos(comprobante), + xmlOriginal: xmlContent, + }; + + if (!cfdi.uuid) { + console.error('[Parser] CFDI sin UUID'); + return null; + } + + return cfdi; + } catch (error) { + console.error('[Parser Error]', error); + return null; + } +} + +/** + * Procesa un paquete ZIP completo y retorna los CFDIs parseados + * @param downloadType - 'emitidos' o 'recibidos' + */ +export function processPackage(zipBase64: string, downloadType: 'emitidos' | 'recibidos' = 'emitidos'): CfdiParsed[] { + const xmlFiles = extractXmlsFromZip(zipBase64); + const cfdis: CfdiParsed[] = []; + + for (const { content } of xmlFiles) { + const cfdi = parseXml(content, downloadType); + if (cfdi) { + cfdis.push(cfdi); + } + } + + return cfdis; +} + +/** + * Datos parseados de un registro de metadata del SAT + */ +interface CfdiMetadata { + uuid: string; + rfcEmisor: string; + nombreEmisor: string; + rfcReceptor: string; + nombreReceptor: string; + rfcPac: string | null; + fechaEmision: Date; + fechaCertSat: Date | null; + fechaCancelacion: Date | null; + monto: number; + tipoComprobante: string; + status: string; // 'Vigente' | 'Cancelado' + type: 'EMITIDO' | 'RECIBIDO'; +} + +/** + * Extrae archivos CSV de un paquete ZIP de metadata en base64. + * Usa AdmZip directamente para evitar problemas de archivos temporales en Windows. + */ +function extractCsvsFromZip(zipBase64: string): string[] { + const zipBuffer = Buffer.from(zipBase64, 'base64'); + const zip = new AdmZip(zipBuffer); + const entries = zip.getEntries(); + const csvContents: string[] = []; + + for (const entry of entries) { + const name = entry.entryName.toLowerCase(); + if (name.endsWith('.csv') || name.endsWith('.txt')) { + csvContents.push(entry.getData().toString('utf-8')); + } + } + + return csvContents; +} + +/** + * Parsea una línea CSV respetando campos entrecomillados + */ +function parseCsvLine(line: string): string[] { + const fields: string[] = []; + let current = ''; + let inQuotes = false; + + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (ch === '"') { + inQuotes = !inQuotes; + } else if (ch === '~' && !inQuotes) { + fields.push(current.trim()); + current = ''; + } else { + current += ch; + } + } + fields.push(current.trim()); + return fields; +} + +/** + * Procesa un paquete de metadata del SAT (ZIP con CSV) y retorna los registros. + * Usa AdmZip directo en vez de MetadataPackageReader para compatibilidad Windows. + */ +export function processMetadataPackage( + zipBase64: string, + downloadType: 'emitidos' | 'recibidos' = 'emitidos' +): CfdiMetadata[] { + const csvContents = extractCsvsFromZip(zipBase64); + const results: CfdiMetadata[] = []; + + const tipoMap: Record = { + 'Ingreso': 'I', 'Egreso': 'E', 'Traslado': 'T', 'Nómina': 'N', 'Nomina': 'N', 'Pago': 'P', + }; + + for (const csv of csvContents) { + const lines = csv.split(/\r?\n/).filter(l => l.trim()); + if (lines.length < 2) continue; + + // Header line — SAT uses ~ as delimiter + const headers = parseCsvLine(lines[0]); + + // Find column indices (case-insensitive) + const idx = (name: string) => headers.findIndex(h => h.toLowerCase() === name.toLowerCase()); + const iUuid = idx('Uuid'); + const iRfcEmisor = idx('RfcEmisor'); + const iNombreEmisor = idx('NombreEmisor'); + const iRfcReceptor = idx('RfcReceptor'); + const iNombreReceptor = idx('NombreReceptor'); + const iRfcPac = idx('RfcPac'); + const iFechaEmision = idx('FechaEmision'); + const iFechaCert = idx('FechaCertificacionSat'); + const iFechaCancel = idx('FechaCancelacion'); + const iMonto = idx('Monto'); + const iEfecto = idx('EfectoComprobante'); + const iEstatus = idx('Estatus'); + // Fallback column names + const iEstado = iEstatus >= 0 ? iEstatus : idx('Estado'); + + if (iUuid < 0) continue; // No UUID column = invalid CSV + + for (let i = 1; i < lines.length; i++) { + const fields = parseCsvLine(lines[i]); + const uuid = (fields[iUuid] || '').trim(); + if (!uuid) continue; + + const estatus = (fields[iEstado] || 'Vigente').trim(); + const fechaCancelStr = iFechaCancel >= 0 ? (fields[iFechaCancel] || '').trim() : ''; + const fechaEmisionStr = iFechaEmision >= 0 ? (fields[iFechaEmision] || '').trim() : ''; + const fechaCertStr = iFechaCert >= 0 ? (fields[iFechaCert] || '').trim() : ''; + const efecto = iEfecto >= 0 ? (fields[iEfecto] || 'Ingreso').trim() : 'Ingreso'; + + results.push({ + uuid: uuid.toUpperCase(), + rfcEmisor: iRfcEmisor >= 0 ? (fields[iRfcEmisor] || '').trim() : '', + nombreEmisor: iNombreEmisor >= 0 ? (fields[iNombreEmisor] || '').trim() : '', + rfcReceptor: iRfcReceptor >= 0 ? (fields[iRfcReceptor] || '').trim() : '', + nombreReceptor: iNombreReceptor >= 0 ? (fields[iNombreReceptor] || '').trim() : '', + rfcPac: iRfcPac >= 0 ? (fields[iRfcPac] || '').trim() || null : null, + fechaEmision: fechaEmisionStr ? parseCfdiDate(fechaEmisionStr) : new Date(), + fechaCertSat: fechaCertStr ? parseCfdiDate(fechaCertStr) : null, + fechaCancelacion: fechaCancelStr ? parseCfdiDate(fechaCancelStr) : null, + monto: parseFloat(iMonto >= 0 ? fields[iMonto] || '0' : '0') || 0, + tipoComprobante: tipoMap[efecto] || efecto.charAt(0) || 'I', + status: estatus === '0' || estatus.toLowerCase().includes('cancel') ? 'Cancelado' : 'Vigente', + type: downloadType === 'emitidos' ? 'EMITIDO' : 'RECIBIDO', + }); + } + } + + return results; +} + +/** + * Valida que un XML sea un CFDI válido + */ +export function isValidCfdi(xmlContent: string): boolean { + try { + const result = xmlParser.parse(xmlContent); + const comprobante = result.Comprobante; + + if (!comprobante) return false; + if (!comprobante.Complemento?.TimbreFiscalDigital) return false; + if (!extractUuid(comprobante)) return false; + + return true; + } catch { + return false; + } +} + +export type { CfdiParsed, CfdiMetadata, ConceptoParsed, ExtractedXml }; diff --git a/apps/api/src/services/sat/sat.service.ts b/apps/api/src/services/sat/sat.service.ts new file mode 100644 index 0000000..be371f3 --- /dev/null +++ b/apps/api/src/services/sat/sat.service.ts @@ -0,0 +1,1463 @@ +import { prisma, tenantDb } from '../../config/database.js'; +import { getDecryptedFiel } from '../fiel.service.js'; +import { getDecryptedFielContribuyente } from '../contribuyente-fiel.service.js'; +import { markForInvalidation } from '../metricas.service.js'; +import { + createSatService, + querySat, + verifySatRequest, + downloadSatPackage, + type FielData, +} from './sat-client.service.js'; +import { processPackage, processMetadataPackage, extractXmlsFromZip, type CfdiParsed, type CfdiMetadata } from './sat-parser.service.js'; +import { recomputarSaldoPendiente, uuidsAfectadosPorCfdi } from '../../utils/saldo.js'; +import type { SatSyncJob, CfdiSyncType, SatSyncType } from '@horux/shared'; +import type { Service } from '@nodecfdi/sat-ws-descarga-masiva'; +import type { Pool } from 'pg'; +import * as fs from 'fs'; +import * as path from 'path'; + +const POLL_INTERVAL_MS = 60000; // 60 segundos +const MAX_POLL_ATTEMPTS = 45; // 45 minutos máximo (45 × 60s) +const YEARS_TO_SYNC = 6; // SAT solo permite descargar últimos 6 años + +/** + * Política de retry por tipo de sync. + * - `retryAtHours[i]` = horas DESDE startedAt para el retry i+1. + * Los tiempos son ABSOLUTOS desde el inicio, no acumulativos desde el + * intento anterior — así el retry 1 cae exactamente a startedAt+6h sin + * importar cuánto tardó el intento original en hacer timeout. + * - `maxRetries` = nº máximo de reintentos (después → status='failed'). + * + * Justificación de las políticas: + * - daily/custom: el contador puede esperar 12h. 2 retries cubre fallas + * transitorias del SAT sin atascar pendientes infinitamente. + * - initial bootstrap: la primera sync de un tenant puede ser de 6 años + * de datos — vale la pena más paciencia (24h del start es el último + * intento). Si después de 24h sigue fallando, hay un problema estructural. + * - incremental: corre cada 4h por el cron. Si una falla, la siguiente + * ejecución cubrirá el gap (la ventana de 8h se solapa). Reintentar + * duplicaría carga sin beneficio. + */ +const RETRY_POLICIES: Record<'daily' | 'custom' | 'initial' | 'incremental', { + maxRetries: number; + retryAtHours: number[]; +}> = { + daily: { maxRetries: 2, retryAtHours: [6, 12] }, + custom: { maxRetries: 2, retryAtHours: [6, 12] }, + initial: { maxRetries: 3, retryAtHours: [6, 12, 24] }, + incremental: { maxRetries: 0, retryAtHours: [] }, +}; + +function getRetryPolicy(job: { type: SatSyncType; isCustomRange: boolean }) { + if (job.type === 'initial' && job.isCustomRange) return RETRY_POLICIES.custom; + return RETRY_POLICIES[job.type]; +} + +/** + * Calcula `nextRetryAt` para el retry número `nextRetryNumber` (1-based). + * Devuelve null si ya no hay retries disponibles según la política. + */ +function computeNextRetryAt( + startedAt: Date, + nextRetryNumber: number, + policy: { retryAtHours: number[] }, +): Date | null { + const idx = nextRetryNumber - 1; + if (idx < 0 || idx >= policy.retryAtHours.length) return null; + return new Date(startedAt.getTime() + policy.retryAtHours[idx] * 60 * 60 * 1000); +} + +interface SyncContext { + fielData: FielData; + service: Service; + rfc: string; + tenantId: string; + databaseName: string; + contribuyenteId: string | null; + getPool: () => Promise; +} + +/** + * Actualiza el progreso de un job + */ +async function updateJobProgress( + jobId: string, + updates: Partial<{ + status: 'pending' | 'running' | 'completed' | 'failed'; + satRequestId: string; + satPackageIds: string[]; + cfdisFound: number; + cfdisDownloaded: number; + cfdisInserted: number; + cfdisUpdated: number; + progressPercent: number; + errorMessage: string; + startedAt: Date; + completedAt: Date; + retryCount: number; + nextRetryAt: Date; + }> +): Promise { + await prisma.satSyncJob.update({ + where: { id: jobId }, + data: updates, + }); +} + +/** + * Obtiene o crea un RFC en la tabla rfcs del tenant + */ +async function getOrCreateRfc(pool: Pool, rfc: string, razonSocial: string | null, regimenFiscal?: string | null): Promise { + const { rows } = 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`, + [rfc, razonSocial || null, regimenFiscal || null] + ); + return rows[0].id; +} + +/** + * Guarda los XMLs extraídos del ZIP en disco para respaldo + */ +function saveXmlsToDisk( + zipBase64: string, + tenantRfc: string, + tipoCfdi: CfdiSyncType, + packageId: string +): string { + const baseDir = path.join(process.cwd(), 'data', 'xmls', tenantRfc.toLowerCase(), tipoCfdi); + fs.mkdirSync(baseDir, { recursive: true }); + + const xmlFiles = extractXmlsFromZip(zipBase64); + const packageDir = path.join(baseDir, packageId); + fs.mkdirSync(packageDir, { recursive: true }); + + for (const { filename, content } of xmlFiles) { + fs.writeFileSync(path.join(packageDir, filename), content, 'utf-8'); + } + + console.log(`[SAT] ${xmlFiles.length} XMLs guardados en ${packageDir}`); + return packageDir; +} + +/** + * Guarda los CFDIs en la base de datos del tenant + */ +async function saveCfdis( + pool: Pool, + cfdis: CfdiParsed[], + jobId: string, + contribuyenteId: string | null = null +): Promise<{ inserted: number; updated: number }> { + let inserted = 0; + let updated = 0; + + for (const cfdi of cfdis) { + try { + const tc = cfdi.tipoCambio || 1; + const m = (v: number) => v * tc; // compute MXN + const fechaEmision = cfdi.fechaEmision; + const year = String(fechaEmision.getFullYear()); + const month = String(fechaEmision.getMonth() + 1).padStart(2, '0'); + + // Upsert RFCs y obtener IDs + const rfcEmisorId = await getOrCreateRfc(pool, cfdi.rfcEmisor, cfdi.nombreEmisor, cfdi.regimenFiscalEmisor); + const rfcReceptorId = await getOrCreateRfc(pool, cfdi.rfcReceptor, cfdi.nombreReceptor, cfdi.regimenFiscalReceptor); + + // Normaliza UUID a lowercase (RFC 4122 canonical) para evitar duplicados + // entre el XML parser y el CSV metadata parser del SAT. + const uuidNorm = cfdi.uuid ? cfdi.uuid.toLowerCase() : cfdi.uuid; + + // All values for the full column set + const vals = [ + year, month, cfdi.type, uuidNorm, cfdi.serie, cfdi.folio, + cfdi.status, fechaEmision, + rfcEmisorId, cfdi.rfcEmisor, cfdi.nombreEmisor, + rfcReceptorId, cfdi.rfcReceptor, cfdi.nombreReceptor, + cfdi.subtotal, m(cfdi.subtotal), + cfdi.descuento, m(cfdi.descuento), + cfdi.total, m(cfdi.total), + cfdi.saldoInsoluto, + cfdi.moneda, tc, cfdi.tipoComprobante, + cfdi.metodoPago, cfdi.formaPago, cfdi.usoCfdi, + cfdi.pac, cfdi.fechaCertSat, + cfdi.uuidRelacionado, + cfdi.isrRetencion, m(cfdi.isrRetencion), + cfdi.ivaTraslado, m(cfdi.ivaTraslado), + cfdi.ivaRetencion, m(cfdi.ivaRetencion), + cfdi.iepsTraslado, m(cfdi.iepsTraslado), + cfdi.iepsRetencion, m(cfdi.iepsRetencion), + cfdi.impuestosLocalesTrasladado, m(cfdi.impuestosLocalesTrasladado), + cfdi.impuestosLocalesRetenidos, m(cfdi.impuestosLocalesRetenidos), + cfdi.montoPago, m(cfdi.montoPago), + cfdi.fechaPagoP, cfdi.numParcialidad, + cfdi.isrRetencionPago, m(cfdi.isrRetencionPago), + cfdi.ivaTrasladoPago, m(cfdi.ivaTrasladoPago), + cfdi.ivaRetencionPago, m(cfdi.ivaRetencionPago), + cfdi.iepsTrasladoPago, m(cfdi.iepsTrasladoPago), + cfdi.iepsRetencionPago, m(cfdi.iepsRetencionPago), + cfdi.fechaPago, cfdi.fechaInicialPago, cfdi.fechaFinalPago, + cfdi.numDiasPagados, cfdi.numSeguroSocial, cfdi.puesto, + cfdi.salarioBaseCotApor, m(cfdi.salarioBaseCotApor), + cfdi.salarioDiarioIntegrado, m(cfdi.salarioDiarioIntegrado), + cfdi.totalPercepciones, m(cfdi.totalPercepciones), + cfdi.totalDeducciones, m(cfdi.totalDeducciones), + cfdi.impRetenidosNomina, m(cfdi.impRetenidosNomina), + cfdi.otrasDeduccionesNomina, m(cfdi.otrasDeduccionesNomina), + cfdi.subsidioCausado, m(cfdi.subsidioCausado), + cfdi.regimenFiscalEmisor, cfdi.regimenFiscalReceptor, + cfdi.xmlOriginal, + cfdi.cfdiTipoRelacion, cfdi.cfdisRelacionados, + jobId, + ]; + + const { rows: existing } = await pool.query( + `SELECT id FROM cfdis WHERE LOWER(uuid) = $1`, + [uuidNorm] + ); + + if (existing.length > 0) { + // $1=uuid(WHERE), $2-$85=all vals (includes rfc_emisor_id, rfc_receptor_id, + // cfdi_tipo_relacion, cfdis_relacionados) + await pool.query( + `UPDATE cfdis SET + year=$2, month=$3, type=$4, uuid=$5, serie=$6, folio=$7, + status=$8, fecha_emision=$9, + rfc_emisor_id=$10, rfc_emisor=$11, nombre_emisor=$12, + rfc_receptor_id=$13, rfc_receptor=$14, nombre_receptor=$15, + subtotal=$16, subtotal_mxn=$17, descuento=$18, descuento_mxn=$19, + total=$20, total_mxn=$21, saldo_insoluto=$22, + moneda=$23, tipo_cambio=$24, tipo_comprobante=$25, + metodo_pago=$26, forma_pago=$27, uso_cfdi=$28, + pac=$29, fecha_cert_sat=$30, uuid_relacionado=$31, + isr_retencion=$32, isr_retencion_mxn=$33, + iva_traslado=$34, iva_traslado_mxn=$35, + iva_retencion=$36, iva_retencion_mxn=$37, + ieps_traslado=$38, ieps_traslado_mxn=$39, + ieps_retencion=$40, ieps_retencion_mxn=$41, + impuestos_locales_trasladado=$42, impuestos_locales_trasladado_mxn=$43, + impuestos_locales_retenidos=$44, impuestos_locales_retenidos_mxn=$45, + monto_pago=$46, monto_pago_mxn=$47, + fecha_pago_p=$48, num_parcialidad=$49, + isr_retencion_pago=$50, isr_retencion_pago_mxn=$51, + iva_traslado_pago=$52, iva_traslado_pago_mxn=$53, + iva_retencion_pago=$54, iva_retencion_pago_mxn=$55, + ieps_traslado_pago=$56, ieps_traslado_pago_mxn=$57, + ieps_retencion_pago=$58, ieps_retencion_pago_mxn=$59, + fecha_pago=$60, fecha_inicial_pago=$61, fecha_final_pago=$62, + num_dias_pagados=$63, num_seguro_social=$64, puesto=$65, + salario_base_cot_apor=$66, salario_base_cot_apor_mxn=$67, + salario_diario_integrado=$68, salario_diario_integrado_mxn=$69, + total_percepciones=$70, total_percepciones_mxn=$71, + total_deducciones=$72, total_deducciones_mxn=$73, + imp_retenidos_nomina=$74, imp_retenidos_nomina_mxn=$75, + otras_deducciones_nomina=$76, otras_deducciones_nomina_mxn=$77, + subsidio_causado=$78, subsidio_causado_mxn=$79, + regimen_fiscal_emisor=$80, regimen_fiscal_receptor=$81, + xml_original=$82, + cfdi_tipo_relacion=$83, cfdis_relacionados=$84, + last_sat_sync=NOW(), sat_sync_job_id=$85::uuid, + actualizado_en=NOW() + WHERE uuid = $1`, + [cfdi.uuid, ...vals] + ); + // Re-insert conceptos for updated CFDI + await pool.query(`DELETE FROM cfdi_conceptos WHERE cfdi_id = $1`, [existing[0].id]); + await saveConceptos(pool, existing[0].id, cfdi); + updated++; + } else { + // $1-$83 = data fields (year..cfdis_relacionados), $84 = jobId, $85 = contribuyente_id + const dataPlaceholders = vals.slice(0, -1).map((_, i) => `$${i + 1}`).join(','); + await pool.query( + `INSERT INTO cfdis ( + year, month, type, uuid, serie, folio, status, fecha_emision, + rfc_emisor_id, rfc_emisor, nombre_emisor, + rfc_receptor_id, rfc_receptor, nombre_receptor, + subtotal, subtotal_mxn, descuento, descuento_mxn, + total, total_mxn, saldo_insoluto, + moneda, tipo_cambio, tipo_comprobante, + metodo_pago, forma_pago, uso_cfdi, + pac, fecha_cert_sat, uuid_relacionado, + isr_retencion, isr_retencion_mxn, + iva_traslado, iva_traslado_mxn, + iva_retencion, iva_retencion_mxn, + ieps_traslado, ieps_traslado_mxn, + ieps_retencion, ieps_retencion_mxn, + impuestos_locales_trasladado, impuestos_locales_trasladado_mxn, + impuestos_locales_retenidos, impuestos_locales_retenidos_mxn, + monto_pago, monto_pago_mxn, + fecha_pago_p, num_parcialidad, + isr_retencion_pago, isr_retencion_pago_mxn, + iva_traslado_pago, iva_traslado_pago_mxn, + iva_retencion_pago, iva_retencion_pago_mxn, + ieps_traslado_pago, ieps_traslado_pago_mxn, + ieps_retencion_pago, ieps_retencion_pago_mxn, + fecha_pago, fecha_inicial_pago, fecha_final_pago, + num_dias_pagados, num_seguro_social, puesto, + salario_base_cot_apor, salario_base_cot_apor_mxn, + salario_diario_integrado, salario_diario_integrado_mxn, + total_percepciones, total_percepciones_mxn, + total_deducciones, total_deducciones_mxn, + imp_retenidos_nomina, imp_retenidos_nomina_mxn, + otras_deducciones_nomina, otras_deducciones_nomina_mxn, + subsidio_causado, subsidio_causado_mxn, + regimen_fiscal_emisor, regimen_fiscal_receptor, + xml_original, + cfdi_tipo_relacion, cfdis_relacionados, + source, sat_sync_job_id, last_sat_sync, contribuyente_id + ) VALUES ( + ${dataPlaceholders}, + 'sat', $${vals.length}::uuid, NOW(), $${vals.length + 1} + )`, + [...vals, contribuyenteId] + ); + // Get the inserted cfdi id and save conceptos + const { rows: [newRow] } = await pool.query(`SELECT id FROM cfdis WHERE uuid = $1`, [cfdi.uuid]); + if (newRow) await saveConceptos(pool, newRow.id, cfdi); + inserted++; + } + // Marcar el mes para recompute de métricas pre-calculadas. Para tipo P + // el mes contable es el de fecha_pago_p (coherente con los cálculos + // fiscales). Solo tiene sentido en tenants con contribuyentes. + if (contribuyenteId) { + const fechaContableRaw = cfdi.tipoComprobante === 'P' && cfdi.fechaPagoP + ? cfdi.fechaPagoP + : cfdi.fechaEmision; + const fechaContable = fechaContableRaw instanceof Date ? fechaContableRaw : new Date(fechaContableRaw); + const anio = fechaContable.getFullYear(); + const mes = fechaContable.getMonth() + 1; + await markForInvalidation(pool, contribuyenteId, anio, mes, 'SAT_SYNC_CFDI').catch( + err => console.warn('[SAT] markForInvalidation falló:', err?.message || err), + ); + } + } catch (error) { + console.error(`[SAT] Error guardando CFDI ${cfdi.uuid}:`, error); + } + } + + // Recompute saldo_pendiente_mxn para todos los CFDIs afectados por este + // batch: los I PPD recién insertados, más los I PPD referenciados por los + // P y E no-07 que entraron. Un solo UPDATE agregado al final del loop es + // más eficiente que uno por CFDI (la subquery del SALDO es costosa). + const afectados = new Set(); + for (const cfdi of cfdis) { + for (const u of uuidsAfectadosPorCfdi(cfdi)) afectados.add(u); + } + if (afectados.size > 0) { + try { + await recomputarSaldoPendiente(pool, Array.from(afectados)); + } catch (err: any) { + console.warn(`[SAT] recomputarSaldoPendiente falló: ${err?.message || err}`); + } + } + + return { inserted, updated }; +} + +/** + * Guarda los conceptos de un CFDI en cfdi_conceptos + */ +async function saveConceptos(pool: Pool, cfdiId: number, cfdi: CfdiParsed): Promise { + if (!cfdi.conceptos || cfdi.conceptos.length === 0) return; + + const tc = cfdi.tipoCambio || 1; + const m = (v: number) => v * tc; + + for (const c of cfdi.conceptos) { + await pool.query(` + INSERT INTO cfdi_conceptos ( + cfdi_id, + clave_prod_serv, no_identificacion, descripcion, cantidad, + clave_unidad, unidad, + valor_unitario, valor_unitario_mxn, importe, importe_mxn, + descuento, descuento_mxn, + isr_retencion, isr_retencion_mxn, + iva_traslado, iva_traslado_mxn, + iva_retencion, iva_retencion_mxn, + ieps_traslado, ieps_traslado_mxn, + ieps_retencion, ieps_retencion_mxn + ) VALUES ( + $1,$2,$3,$4,$5,$6,$7,$8,$9,$10, + $11,$12,$13,$14,$15,$16,$17,$18,$19,$20, + $21,$22,$23 + ) + `, [ + cfdiId, + c.claveProdServ, c.noIdentificacion, c.descripcion, c.cantidad, + c.claveUnidad, c.unidad, + c.valorUnitario, m(c.valorUnitario), c.importe, m(c.importe), + c.descuento, m(c.descuento), + c.isrRetencion, m(c.isrRetencion), + c.ivaTraslado, m(c.ivaTraslado), + c.ivaRetencion, m(c.ivaRetencion), + c.iepsTraslado, m(c.iepsTraslado), + c.iepsRetencion, m(c.iepsRetencion), + ]); + } +} + +/** + * Guarda/actualiza CFDIs desde metadata del SAT. + * - Si el CFDI no existe: inserta con datos básicos de metadata (sin XML). + * - Si el CFDI ya existe y la metadata dice Cancelado: actualiza status + fecha_cancelacion. + * - Si el CFDI ya existe y sigue Vigente: no toca nada (los datos del XML son más completos). + */ +async function saveMetadata( + pool: Pool, + items: CfdiMetadata[], + jobId: string, + contribuyenteId: string | null = null +): Promise<{ inserted: number; updated: number }> { + let inserted = 0; + let updated = 0; + + for (const m of items) { + try { + // Normaliza UUID a lowercase para evitar duplicados case-sensitive con + // el XML parser (que también normaliza). CSV del SAT lo devuelve UPPERCASE. + const uuidNorm = m.uuid ? m.uuid.toLowerCase() : m.uuid; + + const { rows: existing } = await pool.query( + `SELECT id, status FROM cfdis WHERE LOWER(uuid) = $1`, + [uuidNorm] + ); + + if (existing.length > 0) { + // Solo actualizar si cambió a Cancelado + if (m.status === 'Cancelado' && existing[0].status !== 'Cancelado') { + await pool.query( + `UPDATE cfdis SET status = 'Cancelado', fecha_cancelacion = $2, actualizado_en = NOW() + WHERE id = $1`, + [existing[0].id, m.fechaCancelacion] + ); + updated++; + } + } else { + // Insertar CFDI con datos básicos de metadata (sin XML) + const year = String(m.fechaEmision.getFullYear()); + const month = String(m.fechaEmision.getMonth() + 1).padStart(2, '0'); + + // Upsert RFCs + const rfcEmisorId = await getOrCreateRfc(pool, m.rfcEmisor, m.nombreEmisor); + const rfcReceptorId = await getOrCreateRfc(pool, m.rfcReceptor, m.nombreReceptor); + + await pool.query( + `INSERT INTO cfdis ( + year, month, type, uuid, status, fecha_emision, fecha_cancelacion, + rfc_emisor_id, rfc_emisor, nombre_emisor, + rfc_receptor_id, rfc_receptor, nombre_receptor, + total, total_mxn, moneda, tipo_comprobante, + pac, fecha_cert_sat, + source, sat_sync_job_id, last_sat_sync, contribuyente_id + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, + $8, $9, $10, + $11, $12, $13, + $14, $14, 'MXN', $15, + $16, $17, + 'sat-metadata', $18::uuid, NOW(), $19 + )`, + [ + year, month, m.type, uuidNorm, m.status, m.fechaEmision, m.fechaCancelacion, + rfcEmisorId, m.rfcEmisor, m.nombreEmisor, + rfcReceptorId, m.rfcReceptor, m.nombreReceptor, + m.monto, m.tipoComprobante, + m.rfcPac, m.fechaCertSat, + jobId, contribuyenteId, + ] + ); + inserted++; + } + // Invalidar metricas: tanto insert como status→Cancelado afectan el mes. + if (contribuyenteId) { + const anio = m.fechaEmision.getFullYear(); + const mes = m.fechaEmision.getMonth() + 1; + await markForInvalidation(pool, contribuyenteId, anio, mes, 'SAT_METADATA').catch( + err => console.warn('[SAT] markForInvalidation falló:', err?.message || err), + ); + } + } catch (error: any) { + console.error(`[SAT] Error guardando metadata CFDI ${m.uuid}:`, error.message); + } + } + + return { inserted, updated }; +} + +/** + * Construye el identificador único de un request dentro de un job. + * Hay 2-N requests por job (daily=4, initial=N×4) — necesitamos distinguirlos + * para reusar el correcto en retries. + */ +function makeRequestKindKey( + fechaInicio: Date, + fechaFin: Date, + tipoCfdi: CfdiSyncType, + requestType: 'cfdi' | 'metadata', +): string { + return `${requestType}-${tipoCfdi}-${fechaInicio.toISOString().slice(0, 10)}-${fechaFin.toISOString().slice(0, 10)}`; +} + +/** + * Persiste un (kindKey, requestId) en el mapa `sat_request_ids` con merge + * atómico SQL. Evita race conditions vs read-modify-write desde JS. + * Sigue actualizando `satRequestId` (singular) para backward compat. + */ +async function persistSatRequestId(jobId: string, kindKey: string, requestId: string): Promise { + await prisma.$executeRawUnsafe( + `UPDATE sat_sync_jobs + SET sat_request_ids = COALESCE(sat_request_ids, '{}'::jsonb) || $1::jsonb, + sat_request_id = $2 + WHERE id = $3`, + JSON.stringify({ [kindKey]: requestId }), + requestId, + jobId, + ); +} + +/** + * Solicita, espera y descarga paquetes del SAT para un rango+tipo+requestType. + * Retorna los contenidos base64 de los paquetes descargados. + * + * Reuso de requestIds en retries: + * - Antes de crear una nueva solicitud al SAT, busca si el job ya tiene un + * requestId guardado para esta misma kindKey (mismo rango+tipo+requestType). + * - Si existe: hace `verifySatRequest` directo. Si está listo → descarga. + * Si está pending/processing → entra al polling con ese mismo id. + * Si está failed/rejected o el verify lanza excepción → fallback a crear nuevo. + * - Esto evita quemar cuota del SAT en cada reintento (límite ~5 solicitudes + * activas por RFC). + */ +async function requestAndDownload( + ctx: SyncContext, + jobId: string, + fechaInicio: Date, + fechaFin: Date, + tipoCfdi: CfdiSyncType, + requestType: 'cfdi' | 'metadata', +): Promise<{ packageContents: string[]; totalCfdis: number }> { + const label = `${tipoCfdi}/${requestType}`; + const kindKey = makeRequestKindKey(fechaInicio, fechaFin, tipoCfdi, requestType); + + // Intentar reusar requestId previo del mismo job/kindKey (caso retry) + const jobRow = await prisma.satSyncJob.findUnique({ + where: { id: jobId }, + select: { satRequestIds: true }, + }); + const existingMap = (jobRow?.satRequestIds as Record | null) || {}; + let requestId: string | null = existingMap[kindKey] || null; + let verifyResult: Awaited> | undefined; + + if (requestId) { + console.log(`[SAT] Reusando requestId previo (${label}): ${requestId}`); + try { + verifyResult = await verifySatRequest(ctx.service, requestId); + console.log(`[SAT] Estado del request reusado (${label}): ${verifyResult.status}`); + + // Estados terminales inválidos → descartar y crear nuevo + if (verifyResult.status === 'failed' || verifyResult.status === 'rejected') { + console.log(`[SAT] Request reusado en estado ${verifyResult.status}, creando nuevo`); + requestId = null; + verifyResult = undefined; + } + } catch (err: any) { + // El SAT a veces devuelve errores raros para requestIds expirados (>72h). + // Defensivo: descartar y crear nuevo. + console.warn(`[SAT] Verify del request reusado falló (${err.message}), creando nuevo`); + requestId = null; + verifyResult = undefined; + } + } + + // Si no hay requestId válido reusable, crear nuevo + if (!requestId) { + console.log(`[SAT] Solicitando ${label} desde ${fechaInicio.toISOString()} hasta ${fechaFin.toISOString()}`); + + const queryResult = await querySat(ctx.service, fechaInicio, fechaFin, tipoCfdi, requestType); + + if (!queryResult.success) { + if (queryResult.statusCode === '5004') { + console.log(`[SAT] No se encontraron CFDIs (${label})`); + return { packageContents: [], totalCfdis: 0 }; + } + throw new Error(`Error SAT (${label}): ${queryResult.message}`); + } + + requestId = queryResult.requestId!; + console.log(`[SAT] Nueva solicitud creada (${label}): ${requestId}`); + + await persistSatRequestId(jobId, kindKey, requestId); + } + + // Polling — si el reuse ya devolvió `ready`, salta el loop directamente. + if (!verifyResult || verifyResult.status !== 'ready') { + let attempts = 0; + while (attempts < MAX_POLL_ATTEMPTS) { + await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)); + attempts++; + + verifyResult = await verifySatRequest(ctx.service, requestId); + console.log(`[SAT] Estado ${label}: ${verifyResult.status} (intento ${attempts})`); + + if (verifyResult.status === 'ready') break; + if (verifyResult.status === 'failed' || verifyResult.status === 'rejected') { + throw new Error(`Solicitud fallida (${label}): ${verifyResult.message}`); + } + } + } + + if (!verifyResult || verifyResult.status !== 'ready') { + throw new Error(`Timeout esperando respuesta del SAT (${label})`); + } + + const packageContents: string[] = []; + for (let i = 0; i < verifyResult.packageIds.length; i++) { + const packageId = verifyResult.packageIds[i]; + console.log(`[SAT] Descargando paquete ${label} ${i + 1}/${verifyResult.packageIds.length}: ${packageId}`); + + const downloadResult = await downloadSatPackage(ctx.service, packageId); + if (!downloadResult.success) { + console.error(`[SAT] Error descargando paquete ${packageId}: ${downloadResult.message}`); + continue; + } + + // Guardar en disco como respaldo + if (requestType === 'cfdi') { + try { + saveXmlsToDisk(downloadResult.packageContent, ctx.rfc, tipoCfdi, packageId); + } catch (err: any) { + console.error(`[SAT] Error guardando XMLs en disco: ${err.message}`); + } + } + + packageContents.push(downloadResult.packageContent); + } + + return { packageContents, totalCfdis: verifyResult.totalCfdis }; +} + +/** + * Procesa una solicitud de descarga para un rango de fechas. + * 1) Descarga XMLs de CFDIs vigentes → INSERT/UPDATE completo + * 2) Descarga metadata de todos (vigentes+cancelados) → INSERT básico o UPDATE status + */ +async function processDateRange( + ctx: SyncContext, + jobId: string, + fechaInicio: Date, + fechaFin: Date, + tipoCfdi: CfdiSyncType +): Promise<{ found: number; downloaded: number; inserted: number; updated: number }> { + let totalFound = 0; + let totalDownloaded = 0; + let totalInserted = 0; + let totalUpdated = 0; + + // Solo XMLs de vigentes (datos completos) + try { + const { packageContents, totalCfdis } = await requestAndDownload( + ctx, jobId, fechaInicio, fechaFin, tipoCfdi, 'cfdi' + ); + totalFound += totalCfdis; + + for (const content of packageContents) { + const cfdis = processPackage(content, tipoCfdi); + totalDownloaded += cfdis.length; + console.log(`[SAT] Procesando ${cfdis.length} CFDIs XML del paquete`); + + const { inserted, updated } = await saveCfdis(await ctx.getPool(), cfdis, jobId, ctx.contribuyenteId); + totalInserted += inserted; + totalUpdated += updated; + } + } catch (error: any) { + console.error(`[SAT] Error en XMLs ${tipoCfdi}: ${error.message}`); + } + + await updateJobProgress(jobId, { + cfdisFound: totalFound, + cfdisDownloaded: totalDownloaded, + cfdisInserted: totalInserted, + cfdisUpdated: totalUpdated, + }); + + return { + found: totalFound, + downloaded: totalDownloaded, + inserted: totalInserted, + updated: totalUpdated, + }; +} + +/** + * Descarga y procesa metadata de un rango de fechas para un tipo de CFDI. + * Metadata incluye vigentes + cancelados. + */ +async function processMetadataRange( + ctx: SyncContext, + jobId: string, + fechaInicio: Date, + fechaFin: Date, + tipoCfdi: CfdiSyncType +): Promise<{ inserted: number; updated: number }> { + let totalInserted = 0; + let totalUpdated = 0; + + try { + const { packageContents } = await requestAndDownload( + ctx, jobId, fechaInicio, fechaFin, tipoCfdi, 'metadata' + ); + + for (const content of packageContents) { + const items = processMetadataPackage(content, tipoCfdi); + console.log(`[SAT] Procesando ${items.length} registros de metadata ${tipoCfdi}`); + + const { inserted, updated } = await saveMetadata(await ctx.getPool(), items, jobId, ctx.contribuyenteId); + totalInserted += inserted; + totalUpdated += updated; + } + } catch (error: any) { + console.error(`[SAT] Error en metadata ${tipoCfdi}: ${error.message}`); + } + + return { inserted: totalInserted, updated: totalUpdated }; +} + +/** + * Determina el tamaño de bloque óptimo consultando metadata del rango completo. + * <= 15,000 CFDIs → bloques de 6 meses + * > 15,000 CFDIs → bloques de 2 meses + */ +async function determineChunkMonths( + ctx: SyncContext, + jobId: string, + fechaInicio: Date, + fechaFin: Date, +): Promise { + const THRESHOLD = 15_000; + let totalCfdis = 0; + + for (const tipo of ['emitidos', 'recibidos'] as const) { + try { + const { totalCfdis: count } = await requestAndDownload( + ctx, jobId, fechaInicio, fechaFin, tipo, 'metadata' + ); + totalCfdis += count; + console.log(`[SAT] Sondeo metadata ${tipo}: ${count} CFDIs en rango completo`); + } catch (error: any) { + console.log(`[SAT] No se pudo sondear metadata ${tipo}: ${error.message}`); + } + } + + const chunkMonths = totalCfdis > THRESHOLD ? 3 : 6; + console.log(`[SAT] Total estimado: ${totalCfdis} CFDIs → bloques de ${chunkMonths} meses`); + return chunkMonths; +} + +/** + * Genera bloques de fechas segmentados por N meses. + */ +function generateChunks(fechaInicio: Date, fechaFin: Date, chunkMonths: number): { start: Date; end: Date }[] { + const chunks: { start: Date; end: Date }[] = []; + let current = new Date(fechaInicio); + + while (current < fechaFin) { + const chunkEnd = new Date(current.getFullYear(), current.getMonth() + chunkMonths, 0, 23, 59, 59); + const end = chunkEnd > fechaFin ? fechaFin : chunkEnd; + chunks.push({ start: new Date(current), end }); + current = new Date(current.getFullYear(), current.getMonth() + chunkMonths, 1); + } + + return chunks; +} + +/** + * Ejecuta sincronización inicial o por rango personalizado. + * - XMLs: bloques de 3 o 6 meses (según volumen) + * - Metadata: bloques anuales (siempre) + */ +async function processInitialSync( + ctx: SyncContext, + jobId: string, + customDateFrom?: Date, + customDateTo?: Date +): Promise { + const ahora = new Date(); + const inicioHistorico = customDateFrom || new Date(ahora.getFullYear() - YEARS_TO_SYNC, ahora.getMonth(), 1); + const fechaFin = customDateTo || ahora; + + // Paso 1: Sondeo — determinar tamaño de bloque para XMLs + const chunkMonths = await determineChunkMonths(ctx, jobId, inicioHistorico, fechaFin); + const xmlChunks = generateChunks(inicioHistorico, fechaFin, chunkMonths); + const metaChunks = generateChunks(inicioHistorico, fechaFin, 36); // bloques de 3 años + + console.log(`[SAT] Sincronización: ${xmlChunks.length} bloques XML (${chunkMonths}m) + ${metaChunks.length} bloques metadata (36m)`); + + let totalFound = 0; + let totalDownloaded = 0; + let totalInserted = 0; + let totalUpdated = 0; + + // Paso 2: Descargar XMLs de vigentes (bloques de 3/6 meses) + for (let i = 0; i < xmlChunks.length; i++) { + const { start, end } = xmlChunks[i]; + console.log(`[SAT] XML bloque ${i + 1}/${xmlChunks.length}: ${start.toISOString().slice(0, 10)} → ${end.toISOString().slice(0, 10)}`); + + try { + const emitidos = await processDateRange(ctx, jobId, start, end, 'emitidos'); + totalFound += emitidos.found; + totalDownloaded += emitidos.downloaded; + totalInserted += emitidos.inserted; + totalUpdated += emitidos.updated; + } catch (error: any) { + console.error(`[SAT] Error emitidos XML bloque ${i + 1}:`, error.message); + } + + try { + const recibidos = await processDateRange(ctx, jobId, start, end, 'recibidos'); + totalFound += recibidos.found; + totalDownloaded += recibidos.downloaded; + totalInserted += recibidos.inserted; + totalUpdated += recibidos.updated; + } catch (error: any) { + console.error(`[SAT] Error recibidos XML bloque ${i + 1}:`, error.message); + } + + await new Promise(resolve => setTimeout(resolve, 5000)); + } + + // Paso 3: Descargar metadata (bloques anuales) + for (let i = 0; i < metaChunks.length; i++) { + const { start, end } = metaChunks[i]; + console.log(`[SAT] Metadata bloque ${i + 1}/${metaChunks.length}: ${start.toISOString().slice(0, 10)} → ${end.toISOString().slice(0, 10)}`); + + try { + const { inserted, updated } = await processMetadataRange(ctx, jobId, start, end, 'emitidos'); + totalInserted += inserted; + totalUpdated += updated; + } catch (error: any) { + console.error(`[SAT] Error metadata emitidos bloque ${i + 1}:`, error.message); + } + + try { + const { inserted, updated } = await processMetadataRange(ctx, jobId, start, end, 'recibidos'); + totalInserted += inserted; + totalUpdated += updated; + } catch (error: any) { + console.error(`[SAT] Error metadata recibidos bloque ${i + 1}:`, error.message); + } + + await new Promise(resolve => setTimeout(resolve, 5000)); + } + + await updateJobProgress(jobId, { + cfdisFound: totalFound, + cfdisDownloaded: totalDownloaded, + cfdisInserted: totalInserted, + cfdisUpdated: totalUpdated, + }); +} + +/** + * Ejecuta sincronización diaria (mes actual) + */ +/** + * Procesa un rango personalizado de fechas. + * Si el rango supera 6 meses, divide en bloques de 6 meses. + * Si no, usa el rango directamente. + */ +async function processCustomRangeSync( + ctx: SyncContext, + jobId: string, + dateFrom: Date, + dateTo: Date +): Promise { + const diffMs = dateTo.getTime() - dateFrom.getTime(); + const diffMonths = diffMs / (1000 * 60 * 60 * 24 * 30); + const MAX_MONTHS_DIRECT = 6; + + let totalFound = 0; + let totalDownloaded = 0; + let totalInserted = 0; + let totalUpdated = 0; + + if (diffMonths <= MAX_MONTHS_DIRECT) { + // Rango <= 6 meses: solicitud directa sin dividir + console.log(`[SAT] Rango personalizado (${diffMonths.toFixed(1)} meses) — solicitud directa`); + + for (const tipo of ['emitidos', 'recibidos'] as const) { + try { + const result = await processDateRange(ctx, jobId, dateFrom, dateTo, tipo); + totalFound += result.found; + totalDownloaded += result.downloaded; + totalInserted += result.inserted; + totalUpdated += result.updated; + } catch (error: any) { + console.error(`[SAT] Error ${tipo} rango personalizado:`, error.message); + } + } + } else { + // Rango > 6 meses: dividir en bloques de 6 meses + const chunks = generateChunks(dateFrom, dateTo, 6); + console.log(`[SAT] Rango personalizado (${diffMonths.toFixed(1)} meses) — ${chunks.length} bloques de 6 meses`); + + for (let i = 0; i < chunks.length; i++) { + const { start, end } = chunks[i]; + console.log(`[SAT] Bloque ${i + 1}/${chunks.length}: ${start.toISOString().slice(0, 10)} → ${end.toISOString().slice(0, 10)}`); + + for (const tipo of ['emitidos', 'recibidos'] as const) { + try { + const result = await processDateRange(ctx, jobId, start, end, tipo); + totalFound += result.found; + totalDownloaded += result.downloaded; + totalInserted += result.inserted; + totalUpdated += result.updated; + } catch (error: any) { + console.error(`[SAT] Error ${tipo} bloque ${i + 1}:`, error.message); + } + } + + // Pausa entre bloques para no saturar el SAT + if (i < chunks.length - 1) { + await new Promise(resolve => setTimeout(resolve, 5000)); + } + } + } + + // Metadata: siempre el rango completo (incluye cancelados) + for (const tipo of ['emitidos', 'recibidos'] as const) { + try { + const { inserted, updated } = await processMetadataRange(ctx, jobId, dateFrom, dateTo, tipo); + totalInserted += inserted; + totalUpdated += updated; + } catch (error: any) { + console.error(`[SAT] Error metadata ${tipo} rango personalizado:`, error.message); + } + } + + await updateJobProgress(jobId, { + cfdisFound: totalFound, + cfdisDownloaded: totalDownloaded, + cfdisInserted: totalInserted, + cfdisUpdated: totalUpdated, + }); +} + +/** + * Sincronización incremental: ventana fija de las últimas 8 horas. + * Diseñada para correr 3 veces al día (11:00, 15:00, 19:00) en clientes Enterprise. + * La ventana de 8h cubre el gap máximo (03:00 → 11:00) entre el daily y el primer + * incremental; los disparos siguientes solapan, pero la unicidad del UUID deduplica. + * Fuera de ese rango, el daily de 03:00 se encarga. + */ +const INCREMENTAL_WINDOW_HOURS = 8; + +async function processIncrementalSync(ctx: SyncContext, jobId: string): Promise { + const ahora = new Date(); + const desde = new Date(ahora.getTime() - INCREMENTAL_WINDOW_HOURS * 60 * 60 * 1000); + + let totalFound = 0; + let totalDownloaded = 0; + let totalInserted = 0; + let totalUpdated = 0; + + console.log(`[SAT] Incremental: ${desde.toISOString()} → ${ahora.toISOString()} (${INCREMENTAL_WINDOW_HOURS}h)`); + + for (const tipo of ['emitidos', 'recibidos'] as const) { + try { + const result = await processDateRange(ctx, jobId, desde, ahora, tipo); + totalFound += result.found; + totalDownloaded += result.downloaded; + totalInserted += result.inserted; + totalUpdated += result.updated; + } catch (error: any) { + console.error(`[SAT] Error incremental XMLs ${tipo}:`, error.message); + } + } + + for (const tipo of ['emitidos', 'recibidos'] as const) { + try { + const { inserted, updated } = await processMetadataRange(ctx, jobId, desde, ahora, tipo); + totalInserted += inserted; + totalUpdated += updated; + } catch (error: any) { + console.error(`[SAT] Error incremental metadata ${tipo}:`, error.message); + } + } + + await updateJobProgress(jobId, { + cfdisFound: totalFound, + cfdisDownloaded: totalDownloaded, + cfdisInserted: totalInserted, + cfdisUpdated: totalUpdated, + }); +} + +async function processDailySync(ctx: SyncContext, jobId: string): Promise { + const ahora = new Date(); + const inicioAño = new Date(ahora.getFullYear(), 0, 1); + const hace7Dias = new Date(ahora.getTime() - 7 * 24 * 60 * 60 * 1000); + + let totalFound = 0; + let totalDownloaded = 0; + let totalInserted = 0; + let totalUpdated = 0; + + // Paso 1: XMLs de los últimos 7 días (CFDIs nuevos) + console.log(`[SAT] Daily: XMLs desde ${hace7Dias.toISOString().slice(0, 10)} → ${ahora.toISOString().slice(0, 10)}`); + + for (const tipo of ['emitidos', 'recibidos'] as const) { + try { + const result = await processDateRange(ctx, jobId, hace7Dias, ahora, tipo); + totalFound += result.found; + totalDownloaded += result.downloaded; + totalInserted += result.inserted; + totalUpdated += result.updated; + } catch (error: any) { + console.error(`[SAT] Error XMLs ${tipo} (7 días):`, error.message); + } + } + + // Paso 2: Metadata del ciclo fiscal actual (enero → hoy) + // Captura cancelaciones y cambios de status del año completo + console.log(`[SAT] Daily: Metadata desde ${inicioAño.toISOString().slice(0, 10)} → ${ahora.toISOString().slice(0, 10)}`); + + for (const tipo of ['emitidos', 'recibidos'] as const) { + try { + const { inserted, updated } = await processMetadataRange(ctx, jobId, inicioAño, ahora, tipo); + totalInserted += inserted; + totalUpdated += updated; + } catch (error: any) { + console.error(`[SAT] Error metadata ${tipo} (ciclo fiscal):`, error.message); + } + } + + await updateJobProgress(jobId, { + cfdisFound: totalFound, + cfdisDownloaded: totalDownloaded, + cfdisInserted: totalInserted, + cfdisUpdated: totalUpdated, + }); +} + +/** + * Inicia la sincronización con el SAT + */ +export async function startSync( + tenantId: string, + type: SatSyncType = 'daily', + dateFrom?: Date, + dateTo?: Date, + contribuyenteId?: string +): Promise { + // Try per-contribuyente FIEL first (despachos), then legacy (Horux360) + let decryptedFiel = null; + if (contribuyenteId) { + const tenant = await prisma.tenant.findUnique({ where: { id: tenantId }, select: { databaseName: true } }); + if (tenant) { + const pool = await tenantDb.getPool(tenantId, tenant.databaseName); + decryptedFiel = await getDecryptedFielContribuyente(pool, contribuyenteId); + } + } + if (!decryptedFiel) { + decryptedFiel = await getDecryptedFiel(tenantId); + } + if (!decryptedFiel) { + throw new Error('No hay FIEL configurada o está vencida'); + } + + const fielData: FielData = { + cerContent: decryptedFiel.cerContent, + keyContent: decryptedFiel.keyContent, + password: decryptedFiel.password, + }; + + const service = createSatService(fielData); + + const tenant = await prisma.tenant.findUnique({ + where: { id: tenantId }, + select: { databaseName: true }, + }); + + if (!tenant) { + throw new Error('Tenant no encontrado'); + } + + // Lock a nivel (tenantId, contribuyenteId). Contribuyentes distintos dentro + // del mismo tenant (despacho) pueden sincronizarse en paralelo — cada uno + // usa su propio FIEL y su propio conjunto de CFDIs. El null de Horux 360 + // (tenant-wide) solo se bloquea contra sí mismo. + const activeSync = await prisma.satSyncJob.findFirst({ + where: { + tenantId, + contribuyenteId: contribuyenteId ?? null, + status: { in: ['pending', 'running'] }, + }, + }); + + if (activeSync) { + throw new Error('Ya hay una sincronización en curso'); + } + + const now = new Date(); + // `isCustomRange` solo aplica a 'initial' con fechas explícitas del UI. + // Bootstrap puro = initial sin fechas → usa default de 6 años atrás. + const isCustomRange = type === 'initial' && (!!dateFrom || !!dateTo); + const job = await prisma.satSyncJob.create({ + data: { + tenantId, + contribuyenteId: contribuyenteId || null, + type, + status: 'running', + dateFrom: dateFrom || new Date(now.getFullYear() - YEARS_TO_SYNC, 0, 1), + dateTo: dateTo || now, + startedAt: now, + isCustomRange, + }, + }); + + const ctx: SyncContext = { + fielData, + service, + rfc: decryptedFiel.rfc, + tenantId, + databaseName: tenant.databaseName, + contribuyenteId: contribuyenteId || null, + getPool: () => tenantDb.getPool(tenantId, tenant.databaseName), + }; + + // Ejecutar sincronización en background + (async () => { + try { + if (type === 'initial') { + await processInitialSync(ctx, job.id, dateFrom, dateTo); + } else if (type === 'incremental') { + await processIncrementalSync(ctx, job.id); + } else if (dateFrom && dateTo) { + await processCustomRangeSync(ctx, job.id, dateFrom, dateTo); + } else { + await processDailySync(ctx, job.id); + } + + await updateJobProgress(job.id, { + status: 'completed', + completedAt: new Date(), + progressPercent: 100, + }); + + console.log(`[SAT] Sincronización ${job.id} completada`); + } catch (error: any) { + console.error(`[SAT] Error en sincronización ${job.id}:`, error); + + const isTimeout = error.message?.includes('Timeout'); + const currentRetries = job.retryCount || 0; + const policy = getRetryPolicy(job); + const nextRetryNumber = currentRetries + 1; + const nextRetry = isTimeout && nextRetryNumber <= policy.maxRetries + ? computeNextRetryAt(job.startedAt!, nextRetryNumber, policy) + : null; + + if (nextRetry) { + await updateJobProgress(job.id, { + status: 'pending', + errorMessage: `Timeout (intento ${nextRetryNumber}/${policy.maxRetries}). Reintento programado para ${nextRetry.toLocaleString('es-MX')}.`, + retryCount: nextRetryNumber, + nextRetryAt: nextRetry, + }); + console.log(`[SAT] Job ${job.id} programado para reintento ${nextRetryNumber}/${policy.maxRetries} a las ${nextRetry.toLocaleString('es-MX')}`); + } else { + // Sin reintentos restantes, error no-timeout, o policy con maxRetries=0 (incremental) + const finalMsg = isTimeout + ? policy.maxRetries === 0 + ? 'Timeout en sync incremental — sin reintentos por política. Próximo cron incremental cubrirá el gap.' + : 'Fallo conexión SAT, vuelve a intentar con un rango de fechas menor.' + : error.message; + await updateJobProgress(job.id, { + status: 'failed', + errorMessage: finalMsg, + completedAt: new Date(), + }); + } + } + })(); + + return job.id; +} + +/** + * Reintenta jobs de SAT que tienen nextRetryAt pasado. + * Llamado por el cron cada hora. + */ +export async function retryTimedOutJobs(): Promise { + // No filtramos por retryCount aquí porque el max es per-policy (varía por + // type + isCustomRange). El catch del retry ya valida y marca failed si + // se excede. Los jobs con maxRetries=0 nunca llegan a status='pending', + // van directo a 'failed', así que no aparecen acá. + const pendingJobs = await prisma.satSyncJob.findMany({ + where: { + status: 'pending', + nextRetryAt: { lte: new Date() }, + }, + include: { tenant: { select: { id: true, databaseName: true, rfc: true } } }, + }); + + if (pendingJobs.length === 0) return; + + console.log(`[SAT Retry] ${pendingJobs.length} job(s) pendientes de reintento`); + + for (const job of pendingJobs) { + try { + // Verificar que no haya otro sync activo para el MISMO (tenant, contribuyente). + // Contribuyentes distintos pueden correr en paralelo. + const activeSync = await prisma.satSyncJob.findFirst({ + where: { + tenantId: job.tenantId, + contribuyenteId: job.contribuyenteId ?? null, + status: 'running', + }, + }); + + if (activeSync) { + console.log(`[SAT Retry] (${job.tenant.rfc}, contrib=${job.contribuyenteId || 'tenant-wide'}) tiene sync activo, posponiendo`); + await updateJobProgress(job.id, { + nextRetryAt: new Date(Date.now() + 60 * 60 * 1000), // +1 hora + }); + continue; + } + + console.log(`[SAT Retry] Reintentando job ${job.id} (${job.tenant.rfc}), intento ${job.retryCount}/${getRetryPolicy(job).maxRetries}`); + + // Try per-contribuyente FIEL first, then legacy + let decryptedFiel = null; + if (job.contribuyenteId) { + const pool = await tenantDb.getPool(job.tenantId, job.tenant.databaseName); + decryptedFiel = await getDecryptedFielContribuyente(pool, job.contribuyenteId); + } + if (!decryptedFiel) { + decryptedFiel = await getDecryptedFiel(job.tenantId); + } + if (!decryptedFiel) { + await updateJobProgress(job.id, { + status: 'failed', + errorMessage: 'FIEL no disponible para reintento', + completedAt: new Date(), + }); + continue; + } + + const service = createSatService({ + cerContent: decryptedFiel.cerContent, + keyContent: decryptedFiel.keyContent, + password: decryptedFiel.password, + }); + + const ctx: SyncContext = { + fielData: { + cerContent: decryptedFiel.cerContent, + keyContent: decryptedFiel.keyContent, + password: decryptedFiel.password, + }, + service, + rfc: decryptedFiel.rfc, + tenantId: job.tenantId, + databaseName: job.tenant.databaseName, + contribuyenteId: job.contribuyenteId ?? null, + getPool: () => tenantDb.getPool(job.tenantId, job.tenant.databaseName), + }; + + await updateJobProgress(job.id, { status: 'running', errorMessage: null as any }); + + // Re-ejecutar según tipo original + try { + if (job.type === 'initial') { + await processInitialSync(ctx, job.id, job.dateFrom, job.dateTo); + } else if (job.type === 'incremental') { + await processIncrementalSync(ctx, job.id); + } else { + await processDailySync(ctx, job.id); + } + + await updateJobProgress(job.id, { + status: 'completed', + completedAt: new Date(), + progressPercent: 100, + errorMessage: null as any, + }); + console.log(`[SAT Retry] Job ${job.id} completado en reintento ${job.retryCount}`); + } catch (retryError: any) { + console.error(`[SAT Retry] Job ${job.id} falló de nuevo:`, retryError.message); + + const isTimeout = retryError.message?.includes('Timeout'); + const policy = getRetryPolicy(job); + const nextRetryNumber = job.retryCount + 1; + const nextRetry = isTimeout && nextRetryNumber <= policy.maxRetries + ? computeNextRetryAt(job.startedAt!, nextRetryNumber, policy) + : null; + + if (nextRetry) { + await updateJobProgress(job.id, { + status: 'pending', + errorMessage: `Timeout (intento ${nextRetryNumber}/${policy.maxRetries}). Reintento programado para ${nextRetry.toLocaleString('es-MX')}.`, + retryCount: nextRetryNumber, + nextRetryAt: nextRetry, + }); + } else { + await updateJobProgress(job.id, { + status: 'failed', + errorMessage: isTimeout + ? 'Fallo conexión SAT, vuelve a intentar con un rango de fechas menor.' + : retryError.message, + completedAt: new Date(), + }); + } + } + } catch (error: any) { + console.error(`[SAT Retry] Error procesando job ${job.id}:`, error.message); + await updateJobProgress(job.id, { + status: 'failed', + errorMessage: error.message, + completedAt: new Date(), + }); + } + } +} + +/** + * Obtiene el estado actual de sincronización de un tenant + */ +export async function getSyncStatus(tenantId: string, contribuyenteId?: string): Promise<{ + hasActiveSync: boolean; + currentJob?: SatSyncJob; + lastCompletedJob?: SatSyncJob; + totalCfdisSynced: number; +}> { + const contribuyenteFilter = contribuyenteId ? { contribuyenteId } : {}; + + const activeJob = await prisma.satSyncJob.findFirst({ + where: { + tenantId, + ...contribuyenteFilter, + status: { in: ['pending', 'running'] }, + }, + orderBy: { createdAt: 'desc' }, + }); + + const lastCompleted = await prisma.satSyncJob.findFirst({ + where: { + tenantId, + ...contribuyenteFilter, + status: 'completed', + }, + orderBy: { completedAt: 'desc' }, + }); + + const totals = await prisma.satSyncJob.aggregate({ + where: { + tenantId, + ...contribuyenteFilter, + status: 'completed', + }, + _sum: { + cfdisInserted: true, + }, + }); + + const mapJob = (job: any): SatSyncJob => ({ + id: job.id, + tenantId: job.tenantId, + type: job.type, + status: job.status, + dateFrom: job.dateFrom.toISOString(), + dateTo: job.dateTo.toISOString(), + cfdiType: job.cfdiType ?? undefined, + satRequestId: job.satRequestId ?? undefined, + satPackageIds: job.satPackageIds, + cfdisFound: job.cfdisFound, + cfdisDownloaded: job.cfdisDownloaded, + cfdisInserted: job.cfdisInserted, + cfdisUpdated: job.cfdisUpdated, + progressPercent: job.progressPercent, + errorMessage: job.errorMessage ?? undefined, + startedAt: job.startedAt?.toISOString(), + completedAt: job.completedAt?.toISOString(), + createdAt: job.createdAt.toISOString(), + retryCount: job.retryCount, + }); + + return { + hasActiveSync: !!activeJob, + currentJob: activeJob ? mapJob(activeJob) : undefined, + lastCompletedJob: lastCompleted ? mapJob(lastCompleted) : undefined, + totalCfdisSynced: totals._sum.cfdisInserted || 0, + }; +} + +/** + * Obtiene el historial de sincronizaciones + */ +export async function getSyncHistory( + tenantId: string, + page: number = 1, + limit: number = 10, + contribuyenteId?: string +): Promise<{ jobs: SatSyncJob[]; total: number }> { + const contribuyenteFilter = contribuyenteId ? { contribuyenteId } : {}; + + const [jobs, total] = await Promise.all([ + prisma.satSyncJob.findMany({ + where: { tenantId, ...contribuyenteFilter }, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * limit, + take: limit, + }), + prisma.satSyncJob.count({ where: { tenantId, ...contribuyenteFilter } }), + ]); + + return { + jobs: jobs.map(job => ({ + id: job.id, + tenantId: job.tenantId, + type: job.type, + status: job.status, + dateFrom: job.dateFrom.toISOString(), + dateTo: job.dateTo.toISOString(), + cfdiType: job.cfdiType ?? undefined, + satRequestId: job.satRequestId ?? undefined, + satPackageIds: job.satPackageIds, + cfdisFound: job.cfdisFound, + cfdisDownloaded: job.cfdisDownloaded, + cfdisInserted: job.cfdisInserted, + cfdisUpdated: job.cfdisUpdated, + progressPercent: job.progressPercent, + errorMessage: job.errorMessage ?? undefined, + startedAt: job.startedAt?.toISOString(), + completedAt: job.completedAt?.toISOString(), + createdAt: job.createdAt.toISOString(), + retryCount: job.retryCount, + })), + total, + }; +} + +/** + * Reintenta un job fallido + */ +export async function retryJob(jobId: string): Promise { + const job = await prisma.satSyncJob.findUnique({ + where: { id: jobId }, + }); + + if (!job) { + throw new Error('Job no encontrado'); + } + + if (job.status !== 'failed') { + throw new Error('Solo se pueden reintentar jobs fallidos'); + } + + return startSync(job.tenantId, job.type, job.dateFrom, job.dateTo); +} diff --git a/apps/api/src/services/sat/sweep-stale-jobs.service.ts b/apps/api/src/services/sat/sweep-stale-jobs.service.ts new file mode 100644 index 0000000..d0af285 --- /dev/null +++ b/apps/api/src/services/sat/sweep-stale-jobs.service.ts @@ -0,0 +1,98 @@ +import { prisma } from '../../config/database.js'; + +export interface SweepResult { + pendingFound: number; + runningFound: number; + pendingMarked: number; + runningMarked: number; + entries: Array<{ + id: string; + tenantId: string; + kind: 'pending-stale' | 'running-stale'; + ageHours: number; + }>; +} + +/** + * Watchdog para jobs `sat_sync_jobs` stale. + * + * Categorías: + * 1. `pending` con `nextRetryAt` > pendingHours atrás. El cron horario + * `retryTimedOutJobs` normalmente los retoma, pero si no arranca + * (dev, caída, reinicio largo) el job queda colgado y bloquea el + * lock para nuevos syncs del mismo (tenant, contribuyente). + * + * 2. `running` con `startedAt` > runningHours atrás. Un sync inicial + * típico termina en <2h; si lleva >runningHours es casi seguro + * huérfano de un proceso que murió. La solicitud SAT ya expiró. + * + * Marca ambos como `failed` con `errorMessage` descriptivo. Idempotente + * (volver a correrlo no reabre los ya-marcados-failed). + * + * - `apply=false` (default): dry-run, no toca BD. + * - `pendingHours`/`runningHours`: thresholds (default 12h / 4h). + */ +export async function sweepStaleSatJobs(params: { + apply: boolean; + pendingHours?: number; + runningHours?: number; +} = { apply: false }): Promise { + const pendingHours = params.pendingHours ?? 12; + const runningHours = params.runningHours ?? 4; + const now = new Date(); + const pendingCutoff = new Date(now.getTime() - pendingHours * 3600 * 1000); + const runningCutoff = new Date(now.getTime() - runningHours * 3600 * 1000); + + const stalePending = await prisma.satSyncJob.findMany({ + where: { status: 'pending', nextRetryAt: { lt: pendingCutoff } }, + orderBy: { createdAt: 'asc' }, + }); + const staleRunning = await prisma.satSyncJob.findMany({ + where: { status: 'running', startedAt: { lt: runningCutoff } }, + orderBy: { createdAt: 'asc' }, + }); + + const result: SweepResult = { + pendingFound: stalePending.length, + runningFound: staleRunning.length, + pendingMarked: 0, + runningMarked: 0, + entries: [], + }; + + for (const j of stalePending) { + const ageHours = Math.round((now.getTime() - (j.nextRetryAt ?? j.createdAt).getTime()) / 3_600_000); + result.entries.push({ id: j.id, tenantId: j.tenantId, kind: 'pending-stale', ageHours }); + } + for (const j of staleRunning) { + const ageHours = Math.round((now.getTime() - (j.startedAt ?? j.createdAt).getTime()) / 3_600_000); + result.entries.push({ id: j.id, tenantId: j.tenantId, kind: 'running-stale', ageHours }); + } + + if (!params.apply) return result; + + for (const j of stalePending) { + await prisma.satSyncJob.update({ + where: { id: j.id }, + data: { + status: 'failed', + completedAt: now, + errorMessage: `Abandoned by watchdog: pending with nextRetryAt ${j.nextRetryAt?.toISOString()} > ${pendingHours}h in the past. Retry cron didn't pick it up.`, + }, + }); + result.pendingMarked++; + } + for (const j of staleRunning) { + await prisma.satSyncJob.update({ + where: { id: j.id }, + data: { + status: 'failed', + completedAt: now, + errorMessage: `Abandoned by watchdog: running with startedAt ${j.startedAt?.toISOString()} > ${runningHours}h (process crash / orphan). SAT request is lost; re-launch manually.`, + }, + }); + result.runningMarked++; + } + + return result; +} diff --git a/apps/api/src/services/tareas.service.ts b/apps/api/src/services/tareas.service.ts new file mode 100644 index 0000000..1be1b80 --- /dev/null +++ b/apps/api/src/services/tareas.service.ts @@ -0,0 +1,467 @@ +import type { Pool } from 'pg'; + +export type Recurrencia = + | 'semanal' | 'quincenal' | 'mensual' + | 'bimestral' | 'trimestral' | 'semestral' | 'anual'; + +export interface TareaCatalogo { + id: string; + contribuyenteId: string; + nombre: string; + descripcion: string | null; + recurrencia: Recurrencia; + diaSemana: number | null; + diaMes: number | null; + soloSupervisorCompleta: boolean; + esDefault: boolean; + active: boolean; + orden: number; + createdAt: Date; +} + +export interface TareaPeriodo { + id: string; + tareaId: string; + periodo: string; + fechaLimite: Date; + completada: boolean; + completadaAt: Date | null; + completadaPor: string | null; + notas: string | null; +} + +export interface TareaConPeriodo extends TareaCatalogo { + periodoActual: TareaPeriodo | null; +} + +const ROW_TO_TAREA = (r: any): TareaCatalogo => ({ + id: r.id, + contribuyenteId: r.contribuyente_id, + nombre: r.nombre, + descripcion: r.descripcion, + recurrencia: r.recurrencia, + diaSemana: r.dia_semana, + diaMes: r.dia_mes, + soloSupervisorCompleta: r.solo_supervisor_completa, + esDefault: r.es_default, + active: r.active, + orden: r.orden, + createdAt: r.created_at, +}); + +const ROW_TO_PERIODO = (r: any): TareaPeriodo => ({ + id: r.id, + tareaId: r.tarea_id, + periodo: r.periodo, + fechaLimite: r.fecha_limite, + completada: r.completada, + completadaAt: r.completada_at, + completadaPor: r.completada_por, + notas: r.notas, +}); + +function sanitizeUuid(id: string): string { + return id.replace(/[^a-f0-9-]/gi, ''); +} + +// ─── Catálogo CRUD ─── + +export async function listTareas(pool: Pool, contribuyenteId: string): Promise { + const { rows } = await pool.query( + `SELECT * FROM tareas_catalogo + WHERE contribuyente_id = $1 AND active = true + ORDER BY orden, nombre`, + [sanitizeUuid(contribuyenteId)], + ); + return rows.map(ROW_TO_TAREA); +} + +export interface TareaInput { + nombre: string; + descripcion?: string | null; + recurrencia: Recurrencia; + diaSemana?: number | null; + diaMes?: number | null; + soloSupervisorCompleta?: boolean; + orden?: number; +} + +export async function createTarea( + pool: Pool, + contribuyenteId: string, + data: TareaInput, +): Promise { + const { rows: [r] } = await pool.query( + `INSERT INTO tareas_catalogo + (contribuyente_id, nombre, descripcion, recurrencia, + dia_semana, dia_mes, solo_supervisor_completa, orden) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING *`, + [ + sanitizeUuid(contribuyenteId), + data.nombre, + data.descripcion ?? null, + data.recurrencia, + data.diaSemana ?? null, + data.diaMes ?? null, + data.soloSupervisorCompleta ?? false, + data.orden ?? 0, + ], + ); + return ROW_TO_TAREA(r); +} + +export async function updateTarea( + pool: Pool, + tareaId: string, + data: Partial, +): Promise { + const fields: string[] = []; + const values: unknown[] = []; + let i = 1; + if (data.nombre !== undefined) { fields.push(`nombre = $${i++}`); values.push(data.nombre); } + if (data.descripcion !== undefined) { fields.push(`descripcion = $${i++}`); values.push(data.descripcion); } + if (data.recurrencia !== undefined) { fields.push(`recurrencia = $${i++}`); values.push(data.recurrencia); } + if (data.diaSemana !== undefined) { fields.push(`dia_semana = $${i++}`); values.push(data.diaSemana); } + if (data.diaMes !== undefined) { fields.push(`dia_mes = $${i++}`); values.push(data.diaMes); } + if (data.soloSupervisorCompleta !== undefined) { fields.push(`solo_supervisor_completa = $${i++}`); values.push(data.soloSupervisorCompleta); } + if (data.orden !== undefined) { fields.push(`orden = $${i++}`); values.push(data.orden); } + if (fields.length === 0) return null; + values.push(sanitizeUuid(tareaId)); + const { rows: [r] } = await pool.query( + `UPDATE tareas_catalogo SET ${fields.join(', ')} WHERE id = $${i} RETURNING *`, + values, + ); + return r ? ROW_TO_TAREA(r) : null; +} + +export async function deleteTarea(pool: Pool, tareaId: string): Promise { + const { rowCount } = await pool.query( + `UPDATE tareas_catalogo SET active = false WHERE id = $1`, + [sanitizeUuid(tareaId)], + ); + return (rowCount ?? 0) > 0; +} + +// ─── Materialización de periodos ─── + +/** Convierte una fecha a su `periodo` según recurrencia. */ +function periodoForDate(date: Date, recurrencia: Recurrencia): string { + const año = date.getFullYear(); + const mes = date.getMonth() + 1; + if (recurrencia === 'semanal' || recurrencia === 'quincenal') { + return `${año}-W${String(isoWeek(date)).padStart(2, '0')}`; + } + if (recurrencia === 'mensual') return `${año}-${String(mes).padStart(2, '0')}`; + if (recurrencia === 'bimestral') return `${año}-B${Math.ceil(mes / 2)}`; + if (recurrencia === 'trimestral') return `${año}-Q${Math.ceil(mes / 3)}`; + if (recurrencia === 'semestral') return `${año}-S${Math.ceil(mes / 6)}`; + return `${año}`; +} + +function isoWeek(date: Date): number { + const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); + const dayNum = d.getUTCDay() || 7; + d.setUTCDate(d.getUTCDate() + 4 - dayNum); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + return Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7); +} + +/** + * Calcula la fecha límite del próximo periodo de una tarea, basado en + * `recurrencia`, `dia_semana`/`dia_mes` y un punto de referencia. + * + * Para semanal/quincenal: primera ocurrencia del dia_semana en/después de + * `fromDate`. (Quincenal alterna con el mismo dia_semana cada 14 días.) + * Para mensual+: dia_mes del periodo en curso de `fromDate`. Si el dia_mes + * excede el último día del mes, se clampa al último día. + */ +function computeFechaLimite( + recurrencia: Recurrencia, + diaSemana: number | null, + diaMes: number | null, + fromDate: Date, +): Date { + if (recurrencia === 'semanal' || recurrencia === 'quincenal') { + const target = (diaSemana ?? 5); + const d = new Date(fromDate); + const current = d.getDay() === 0 ? 7 : d.getDay(); + const diff = (target - current + 7) % 7; + d.setDate(d.getDate() + diff); + return d; + } + // mensual a anual: usar el mes "ancla" del periodo + const año = fromDate.getFullYear(); + let mesAncla = fromDate.getMonth() + 1; + if (recurrencia === 'bimestral') mesAncla = Math.ceil(mesAncla / 2) * 2; + else if (recurrencia === 'trimestral') mesAncla = Math.ceil(mesAncla / 3) * 3; + else if (recurrencia === 'semestral') mesAncla = Math.ceil(mesAncla / 6) * 6; + else if (recurrencia === 'anual') mesAncla = 12; + const lastDay = new Date(año, mesAncla, 0).getDate(); + const dia = Math.min(diaMes ?? lastDay, lastDay); + return new Date(año, mesAncla - 1, dia); +} + +/** + * Asegura que existan los periodos vigentes (presente + futuros próximos) + * para todas las tareas activas del contribuyente. Solo crea periodos + * cuya `fecha_limite >= today` (no retroactivos, igual que obligaciones). + * + * Para cada tarea genera el periodo CURRENT (donde cae hoy) si su fecha + * límite aún no ha pasado, o el NEXT si ya pasó. + */ +export async function materializarPeriodos( + pool: Pool, + contribuyenteId: string, +): Promise { + const tareas = await listTareas(pool, contribuyenteId); + const today = new Date(); + today.setHours(0, 0, 0, 0); + + for (const t of tareas) { + let fechaLimite = computeFechaLimite(t.recurrencia, t.diaSemana, t.diaMes, today); + // Si la fecha límite calculada para "hoy" ya pasó, salta al siguiente periodo + while (fechaLimite < today) { + const next = new Date(fechaLimite); + const recurrenciaIncrement: Record void> = { + semanal: () => next.setDate(next.getDate() + 7), + quincenal: () => next.setDate(next.getDate() + 14), + mensual: () => next.setMonth(next.getMonth() + 1), + bimestral: () => next.setMonth(next.getMonth() + 2), + trimestral: () => next.setMonth(next.getMonth() + 3), + semestral: () => next.setMonth(next.getMonth() + 6), + anual: () => next.setFullYear(next.getFullYear() + 1), + }; + recurrenciaIncrement[t.recurrencia](); + fechaLimite = computeFechaLimite(t.recurrencia, t.diaSemana, t.diaMes, next); + } + + const periodo = periodoForDate(fechaLimite, t.recurrencia); + await pool.query( + `INSERT INTO tarea_periodos (tarea_id, periodo, fecha_limite) + VALUES ($1, $2, $3) + ON CONFLICT (tarea_id, periodo) DO NOTHING`, + [t.id, periodo, fechaLimite.toISOString().split('T')[0]], + ); + } +} + +/** + * Lee tareas activas del contribuyente con su periodo más cercano (vigente + * o pendiente). Materializa los faltantes antes de leer. + */ +export async function listTareasConPeriodoActual( + pool: Pool, + contribuyenteId: string, +): Promise { + await materializarPeriodos(pool, contribuyenteId); + const tareas = await listTareas(pool, contribuyenteId); + if (tareas.length === 0) return []; + + const ids = tareas.map(t => t.id); + const today = new Date().toISOString().split('T')[0]; + const { rows } = await pool.query( + `SELECT DISTINCT ON (tarea_id) * + FROM tarea_periodos + WHERE tarea_id = ANY($1::uuid[]) + AND (completada = false OR fecha_limite >= $2::date) + ORDER BY tarea_id, fecha_limite ASC`, + [ids, today], + ); + const periodos = new Map(rows.map(r => [r.tarea_id, ROW_TO_PERIODO(r)])); + return tareas.map(t => ({ ...t, periodoActual: periodos.get(t.id) ?? null })); +} + +// ─── Completar / descompletar periodo ─── + +const ROLES_SUPERVISOR = new Set(['owner', 'cfo', 'supervisor']); + +/** + * Marca un periodo como completado. Si la tarea tiene + * `solo_supervisor_completa=true`, valida que el rol del usuario sea + * owner/cfo/supervisor. Devuelve el periodo actualizado y la tarea + * (para que el caller pueda disparar notificaciones). + */ +export async function completarPeriodo( + pool: Pool, + periodoId: string, + userId: string, + userRole: string, + notas: string | null = null, +): Promise<{ periodo: TareaPeriodo; tarea: TareaCatalogo } | null> { + const { rows: [pRow] } = await pool.query( + `SELECT tp.*, tc.solo_supervisor_completa + FROM tarea_periodos tp + JOIN tareas_catalogo tc ON tc.id = tp.tarea_id + WHERE tp.id = $1`, + [sanitizeUuid(periodoId)], + ); + if (!pRow) return null; + if (pRow.solo_supervisor_completa && !ROLES_SUPERVISOR.has(userRole)) { + throw new Error('Solo supervisor o owner pueden marcar esta tarea como completada'); + } + const { rows: [updated] } = await pool.query( + `UPDATE tarea_periodos + SET completada = true, completada_at = NOW(), completada_por = $2, notas = $3 + WHERE id = $1 + RETURNING *`, + [sanitizeUuid(periodoId), userId, notas], + ); + const { rows: [tareaRow] } = await pool.query( + `SELECT * FROM tareas_catalogo WHERE id = $1`, + [pRow.tarea_id], + ); + return { periodo: ROW_TO_PERIODO(updated), tarea: ROW_TO_TAREA(tareaRow) }; +} + +export async function descompletarPeriodo( + pool: Pool, + periodoId: string, +): Promise { + const { rowCount } = await pool.query( + `UPDATE tarea_periodos + SET completada = false, completada_at = NULL, completada_por = NULL + WHERE id = $1`, + [sanitizeUuid(periodoId)], + ); + return (rowCount ?? 0) > 0; +} + +// ─── Calendario / alertas ─── + +export interface TareaEventoCalendario { + titulo: string; + descripcion: string; + tipo: 'tarea'; + fechaLimite: string; + recurrencia: string; + completado: boolean; + notas: string | null; + contribuyenteId: string; + tareaId: string; + periodoId: string; +} + +/** + * Lee tareas + sus periodos del año para mostrar en /calendario. Materializa + * los faltantes antes de leer (mismo patrón que listTareasConPeriodoActual). + * Si no hay contribuyenteId, no retorna nada (las tareas son siempre por + * contribuyente). + */ +export async function getEventosTareasParaCalendario( + pool: Pool, + contribuyenteId: string, + año: number, +): Promise { + await materializarPeriodos(pool, contribuyenteId); + const { rows } = await pool.query( + `SELECT tp.id AS periodo_id, tp.tarea_id, tp.fecha_limite, tp.completada, tp.notas, + tc.nombre, tc.descripcion, tc.recurrencia, tc.contribuyente_id + FROM tarea_periodos tp + JOIN tareas_catalogo tc ON tc.id = tp.tarea_id + WHERE tc.contribuyente_id = $1 + AND tc.active = true + AND EXTRACT(YEAR FROM tp.fecha_limite) = $2`, + [sanitizeUuid(contribuyenteId), año], + ); + return rows.map(r => ({ + titulo: r.nombre, + descripcion: r.descripcion ?? '', + tipo: 'tarea' as const, + fechaLimite: r.fecha_limite instanceof Date ? r.fecha_limite.toISOString().split('T')[0] : String(r.fecha_limite), + recurrencia: r.recurrencia, + completado: r.completada, + notas: r.notas, + contribuyenteId: r.contribuyente_id, + tareaId: r.tarea_id, + periodoId: r.periodo_id, + })); +} + +/** + * Cuenta tareas próximas a vencer (≤3 días) para alertas auto. Solo cuenta + * los periodos pendientes del contribuyente filtrado o de todo el tenant. + */ +export async function contarTareasProximasVencer( + pool: Pool, + contribuyenteId: string | null | undefined, +): Promise<{ total: number; monto?: number }> { + const safeId = contribuyenteId ? sanitizeUuid(contribuyenteId) : null; + if (safeId) await materializarPeriodos(pool, safeId); + const cf = safeId ? `AND tc.contribuyente_id = '${safeId}'` : ''; + const { rows: [r] } = await pool.query( + `SELECT COUNT(*)::int AS total + FROM tarea_periodos tp + JOIN tareas_catalogo tc ON tc.id = tp.tarea_id + WHERE tc.active = true + AND tp.completada = false + AND tp.fecha_limite >= CURRENT_DATE + AND tp.fecha_limite <= CURRENT_DATE + INTERVAL '3 days' + ${cf}`, + ); + return { total: r?.total || 0 }; +} + +/** + * Resuelve el auxiliar asignado a la cartera del contribuyente (si existe). + * Busca primero en subcarteras (más específico) y luego en la top-level. + * Retorna null si el contribuyente no está en ninguna cartera o ninguna + * tiene auxiliar asignado. + */ +export async function getAuxiliarUserIdDeContribuyente( + pool: Pool, + contribuyenteId: string, +): Promise { + const { rows } = await pool.query<{ auxiliar_user_id: string | null; parent_id: string | null }>( + `SELECT c.auxiliar_user_id, c.parent_id + FROM cartera_entidades ce + JOIN carteras c ON c.id = ce.cartera_id + WHERE ce.entidad_id = $1 + ORDER BY (c.parent_id IS NOT NULL) DESC, c.created_at DESC`, + [sanitizeUuid(contribuyenteId)], + ); + for (const row of rows) { + if (row.auxiliar_user_id) return row.auxiliar_user_id; + } + return null; +} + +// ─── Seed de tareas default ─── + +const TAREAS_DEFAULT: TareaInput[] = [ + { nombre: 'Solicitar estados de cuenta', descripcion: 'Pedir al cliente los estados de cuenta bancarios del mes', recurrencia: 'mensual', diaMes: 5, orden: 1 }, + { nombre: 'Realizar conciliación bancaria', descripcion: 'Conciliar los movimientos bancarios contra los CFDIs', recurrencia: 'mensual', diaMes: 10, orden: 2 }, + { nombre: 'Realizar contabilización', descripcion: 'Registrar los CFDIs y movimientos en la contabilidad', recurrencia: 'mensual', diaMes: 14, orden: 3 }, + { nombre: 'Revisión fiscal preliminar', descripcion: 'Revisar declaración antes de presentación (solo supervisor/owner)', recurrencia: 'mensual', diaMes: 15, soloSupervisorCompleta: true, orden: 4 }, +]; + +export async function seedTareasDefault( + pool: Pool, + contribuyenteId: string, +): Promise { + const existentes = await pool.query( + `SELECT 1 FROM tareas_catalogo WHERE contribuyente_id = $1 AND es_default = true LIMIT 1`, + [sanitizeUuid(contribuyenteId)], + ); + if (existentes.rowCount && existentes.rowCount > 0) return 0; + + let count = 0; + for (const t of TAREAS_DEFAULT) { + await pool.query( + `INSERT INTO tareas_catalogo + (contribuyente_id, nombre, descripcion, recurrencia, dia_mes, solo_supervisor_completa, es_default, orden) + VALUES ($1, $2, $3, $4, $5, $6, true, $7)`, + [ + sanitizeUuid(contribuyenteId), + t.nombre, + t.descripcion ?? null, + t.recurrencia, + t.diaMes ?? null, + t.soloSupervisorCompleta ?? false, + t.orden ?? 0, + ], + ); + count++; + } + return count; +} diff --git a/apps/api/src/services/tenants.service.ts b/apps/api/src/services/tenants.service.ts new file mode 100644 index 0000000..3180e11 --- /dev/null +++ b/apps/api/src/services/tenants.service.ts @@ -0,0 +1,399 @@ +import { prisma, tenantDb } from '../config/database.js'; +import { DESPACHO_PLANS, type DespachoPlan } from '@horux/shared'; +import { emailService } from './email/email.service.js'; +import * as metabaseService from './metabase.service.js'; +import { randomBytes } from 'crypto'; +import bcrypt from 'bcryptjs'; + +export async function getAllTenants() { + return prisma.tenant.findMany({ + where: { active: true }, + select: { + id: true, + nombre: true, + rfc: true, + plan: true, + databaseName: true, + createdAt: true, + _count: { + select: { memberships: { where: { active: true } } as any } + } + }, + orderBy: { nombre: 'asc' } + }); +} + +export async function getTenantById(id: string) { + return prisma.tenant.findUnique({ + where: { id }, + select: { + id: true, + nombre: true, + rfc: true, + plan: true, + databaseName: true, + createdAt: true, + } + }); +} + +export async function createTenant(data: { + nombre: string; + rfc: string; + plan?: DespachoPlan; + adminEmail: string; + adminNombre: string; + amount: number; + /** Solo plan custom: primera fecha de pago (deadline para que el cliente + * complete su primer cobro). Se persiste como Subscription.currentPeriodEnd. */ + firstPaymentDueAt?: string | null; +}) { + const plan = data.plan || 'trial'; + + // 1. Provision a dedicated database for this tenant + const databaseName = await tenantDb.provisionDatabase(data.rfc); + + // 1b. Register tenant database in Metabase (non-blocking, logs errors only) + metabaseService.registerDatabase({ + nombre: data.nombre, + dbName: databaseName, + }).catch(err => console.error('[METABASE] Register failed:', err)); + + // 2. Create tenant record + const tenant = await prisma.tenant.create({ + data: { + nombre: data.nombre, + rfc: data.rfc.toUpperCase(), + plan, + databaseName, + } + }); + + // 3. Create admin user with temp password + const tempPassword = randomBytes(4).toString('hex'); // 8-char random + const hashedPassword = await bcrypt.hash(tempPassword, 10); + + // Get owner role ID from roles table (rol que asignamos al dueño del tenant al crearlo) + const ownerRol = await prisma.rol.findUnique({ where: { nombre: 'owner' } }); + if (!ownerRol) throw new Error('Rol owner no encontrado en la base de datos'); + + const user = await prisma.user.create({ + data: { + email: data.adminEmail, + passwordHash: hashedPassword, + nombre: data.adminNombre, + lastTenantId: tenant.id, + }, + }); + + // Crea membership owner del nuevo user en su tenant (fase 4 multi-tenant) + await prisma.tenantMembership.create({ + data: { + userId: user.id, + tenantId: tenant.id, + rolId: ownerRol.id, + isOwner: true, + active: true, + }, + }); + + // 4. Create initial subscription. Para plan custom, si admin proveyó + // firstPaymentDueAt, lo guardamos como currentPeriodEnd — sirve como + // deadline visible al cliente para realizar su primer pago. + const firstPayment = plan === 'custom' && data.firstPaymentDueAt + ? new Date(data.firstPaymentDueAt) + : null; + await prisma.subscription.create({ + data: { + tenantId: tenant.id, + plan, + status: 'pending', + amount: data.amount, + frequency: 'monthly', + ...(firstPayment ? { currentPeriodStart: new Date(), currentPeriodEnd: firstPayment } : {}), + }, + }); + + // 5. Send welcome email to client (non-blocking) + emailService.sendWelcome(data.adminEmail, { + nombre: data.adminNombre, + email: data.adminEmail, + tempPassword, + }).catch(err => console.error('[EMAIL] Welcome email failed:', err)); + + // 6. Send new client notification to admin with DB credentials + emailService.sendNewClientAdmin({ + clienteNombre: data.nombre, + clienteRfc: data.rfc.toUpperCase(), + adminEmail: data.adminEmail, + adminNombre: data.adminNombre, + tempPassword, + databaseName, + plan, + }).catch(err => console.error('[EMAIL] New client admin email failed:', err)); + + return { tenant, user, tempPassword }; +} + +/** + * Flow "Agregar empresa" — un user existente (típicamente owner) agrega un + * segundo RFC bajo su cuenta. A diferencia de `createTenant()`, NO crea un user + * nuevo: el caller se vuelve owner de la empresa nueva vía TenantMembership. + * + * Sin trial por default — el check de trial por owner (fase 5) bloquearía + * cualquier intento de re-activarlo. El owner contrata el plan desde la página + * de suscripción del nuevo tenant tras esta llamada. + */ +export async function addTenantToOwner(data: { + userId: string; + nombre: string; + rfc: string; + plan?: DespachoPlan; +}) { + const plan = data.plan || 'trial'; + const rfcUpper = data.rfc.toUpperCase(); + + // Valida que el RFC no exista en el sistema + const existingTenant = await prisma.tenant.findUnique({ where: { rfc: rfcUpper } }); + if (existingTenant) { + throw new Error('Ya existe una empresa con ese RFC en el sistema'); + } + + // Valida que el user exista y esté activo + const user = await prisma.user.findUnique({ where: { id: data.userId } }); + if (!user || !user.active) throw new Error('Usuario no encontrado'); + + // 1. Provision BD dedicada + const databaseName = await tenantDb.provisionDatabase(rfcUpper); + + // 1b. Register tenant database in Metabase (non-blocking, logs errors only) + metabaseService.registerDatabase({ + nombre: data.nombre, + dbName: databaseName, + }).catch(err => console.error('[METABASE] Register failed:', err)); + + // 2. Crea el tenant + const tenant = await prisma.tenant.create({ + data: { + nombre: data.nombre, + rfc: rfcUpper, + plan, + databaseName, + }, + }); + + // 3. Crea membership del caller como owner + const ownerRol = await prisma.rol.findUnique({ where: { nombre: 'owner' } }); + if (!ownerRol) throw new Error('Rol owner no encontrado'); + + await prisma.tenantMembership.create({ + data: { + userId: user.id, + tenantId: tenant.id, + rolId: ownerRol.id, + isOwner: true, + active: true, + }, + }); + + // 4. Subscripción pending (se activa al contratar un plan) + await prisma.subscription.create({ + data: { + tenantId: tenant.id, + plan, + status: 'pending', + amount: 0, // el precio real se setea al contratar + frequency: 'monthly', + }, + }); + + return { tenant }; +} + +/** + * Lista detallada de tenants del user actual con estado de suscripción. Usado + * por la página `/mis-empresas` — incluye plan, status, currentPeriodEnd, + * pendingPlan si aplica. + * + * @param onlyOwner filtra a solo memberships donde isOwner=true. Default true: + * un user contador que trabaja en empresa ajena NO la ve aquí, pero sí sus + * propias empresas donde es owner. El header switcher usa un endpoint distinto. + */ +export async function getMyTenantsDetailed(userId: string, onlyOwner = true) { + const memberships = await prisma.tenantMembership.findMany({ + where: { userId, active: true, ...(onlyOwner ? { isOwner: true } : {}) }, + include: { + tenant: { + include: { + subscriptions: { + orderBy: { createdAt: 'desc' }, + take: 1, + }, + }, + }, + rol: { select: { nombre: true } }, + }, + orderBy: { joinedAt: 'asc' }, + }); + + return memberships + .filter(m => m.tenant.active) + .map(m => { + const sub = m.tenant.subscriptions[0] || null; + return { + tenantId: m.tenant.id, + nombre: m.tenant.nombre, + rfc: m.tenant.rfc, + plan: m.tenant.plan, + role: m.rol.nombre, + isOwner: m.isOwner, + trialEndsAt: m.tenant.trialEndsAt, + subscription: sub ? { + status: sub.status, + plan: sub.plan, + amount: sub.amount ? Number(sub.amount) : 0, + frequency: sub.frequency, + currentPeriodEnd: sub.currentPeriodEnd, + pendingPlan: sub.pendingPlan, + pendingEffectiveAt: sub.pendingEffectiveAt, + } : null, + }; + }); +} + +export async function updateTenant(id: string, data: { + nombre?: string; + rfc?: string; + plan?: DespachoPlan; + active?: boolean; +}) { + return prisma.tenant.update({ + where: { id }, + data: { + ...(data.nombre && { nombre: data.nombre }), + ...(data.rfc && { rfc: data.rfc.toUpperCase() }), + ...(data.plan && { plan: data.plan }), + ...(data.active !== undefined && { active: data.active }), + }, + select: { + id: true, + nombre: true, + rfc: true, + plan: true, + databaseName: true, + active: true, + createdAt: true, + } + }); +} + +export async function getDatosFiscales(id: string) { + return prisma.tenant.findUnique({ + where: { id }, + select: { + codigoPostal: true, + calle: true, + numExterior: true, + numInterior: true, + colonia: true, + ciudad: true, + municipio: true, + estado: true, + telefono: true, + }, + }); +} + +export async function updateDatosFiscales(id: string, data: { + codigoPostal?: string; + calle?: string; + numExterior?: string; + numInterior?: string; + colonia?: string; + ciudad?: string; + municipio?: string; + estado?: string; + telefono?: string; +}) { + return prisma.tenant.update({ + where: { id }, + data, + select: { + codigoPostal: true, + calle: true, + numExterior: true, + numInterior: true, + colonia: true, + ciudad: true, + municipio: true, + estado: true, + telefono: true, + }, + }); +} + +/** + * Preferencias de auto-facturación de pagos de suscripción. + * Lee también los regímenes activos del tenant — útil para que la UI muestre + * las opciones del dropdown "Régimen preferido" sin queries adicionales. + */ +export async function getPreferenciasFacturacion(id: string) { + const tenant = await prisma.tenant.findUnique({ + where: { id }, + select: { + factPreferencia: true, + factUsoCfdi: true, + factRegimenPreferido: true, + regimenesActivos: { + select: { regimen: { select: { clave: true, descripcion: true } } }, + orderBy: { createdAt: 'asc' }, + }, + }, + }); + if (!tenant) return null; + return { + factPreferencia: tenant.factPreferencia, + factUsoCfdi: tenant.factUsoCfdi, + factRegimenPreferido: tenant.factRegimenPreferido, + regimenesActivos: tenant.regimenesActivos.map(ra => ra.regimen), + }; +} + +export async function updatePreferenciasFacturacion(id: string, data: { + factPreferencia?: 'publico_general' | 'mis_datos'; + factUsoCfdi?: string; + factRegimenPreferido?: string | null; +}) { + return prisma.tenant.update({ + where: { id }, + data, + select: { + factPreferencia: true, + factUsoCfdi: true, + factRegimenPreferido: true, + }, + }); +} + +export async function deleteTenant(id: string) { + const tenant = await prisma.tenant.findUnique({ + where: { id }, + select: { databaseName: true }, + }); + + // Soft-delete the tenant record + await prisma.tenant.update({ + where: { id }, + data: { active: false } + }); + + // Soft-delete the database (rename with _deleted_ suffix) + if (tenant) { + await tenantDb.deprovisionDatabase(tenant.databaseName); + tenantDb.invalidatePool(id); + // Remove from Metabase (non-blocking) + metabaseService.deleteDatabase(tenant.databaseName).catch(err => + console.error('[METABASE] Delete failed:', err) + ); + } +} diff --git a/apps/api/src/services/usuarios.service.ts b/apps/api/src/services/usuarios.service.ts new file mode 100644 index 0000000..03a96e4 --- /dev/null +++ b/apps/api/src/services/usuarios.service.ts @@ -0,0 +1,251 @@ +import { prisma } from '../config/database.js'; +import bcrypt from 'bcryptjs'; +import { randomBytes } from 'crypto'; +import { getDespachoPlanLimits } from './plan-catalogo.service.js'; +import type { UserListItem, UserInvite, UserUpdate, Role } from '@horux/shared'; + +/** + * Refactor F6.2 (multi-tenant): los users se listan/invitan/borran vía + * `tenant_memberships`. `User.tenantId` y `User.rolId` se siguen poblando al + * crear (legacy, F6.4 los borra) pero NO se leen en este servicio. + * + * - getUsuarios(tenantId) → memberships activos en ese tenant + * - inviteUsuario → crea User + Membership (siempre isOwner=false aquí) + * - updateUsuario → cambia role en la membership de ese tenant; active es global + * - deleteUsuario → desactiva membership (soft delete por tenant). El user + * sigue existiendo si tiene otras memberships. + */ + +async function getRolId(nombre: string): Promise { + const rol = await prisma.rol.findUnique({ where: { nombre } }); + if (!rol) throw new Error(`Rol '${nombre}' no encontrado`); + return rol.id; +} + +/** Mapea una row de membership con user joineado al shape UserListItem. */ +function mapMembershipRow(m: any, includeTenant = false): UserListItem { + return { + id: m.user.id, + email: m.user.email, + nombre: m.user.nombre, + role: m.rol.nombre as Role, + active: m.user.active && m.active, // user activo Y membership activa + lastLogin: m.user.lastLogin?.toISOString() || null, + createdAt: m.user.createdAt.toISOString(), + ...(includeTenant && { tenantId: m.tenantId, tenantName: m.tenant.nombre }), + }; +} + +const MEMBERSHIP_INCLUDE = { + user: { + select: { + id: true, + email: true, + nombre: true, + active: true, + lastLogin: true, + createdAt: true, + }, + }, + rol: { select: { nombre: true } }, +}; + +export async function getUsuarios(tenantId: string): Promise { + const memberships = await prisma.tenantMembership.findMany({ + where: { tenantId, active: true }, + include: MEMBERSHIP_INCLUDE, + orderBy: { joinedAt: 'desc' }, + }); + + return memberships.map(m => mapMembershipRow(m)); +} + +export async function inviteUsuario(tenantId: string, data: UserInvite): Promise { + const tenant = await prisma.tenant.findUnique({ + where: { id: tenantId }, + select: { plan: true }, + }); + + // Límite del catálogo despacho desde BD (con cache). -1 = ilimitado. + // Si el plan no existe en BD (shouldn't happen post-seed) cae a 1 como + // mínimo defensivo para no permitir invitaciones masivas accidentales. + const planLimits = tenant ? await getDespachoPlanLimits(tenant.plan) : null; + const maxUsers = planLimits?.maxUsers ?? 1; + + // Cuenta memberships activos del tenant (no users globales) — esto es el + // límite real ahora que los users pueden estar en varios tenants. + const currentCount = await prisma.tenantMembership.count({ + where: { tenantId, active: true }, + }); + + if (maxUsers !== -1 && currentCount >= maxUsers) { + throw new Error('Límite de usuarios alcanzado para este plan'); + } + + // Si el email ya existe como user global, agregamos membership en este + // tenant en vez de crear un user nuevo. Cubre el caso "el contador X ya + // trabaja en otra empresa, ahora lo invitan a esta también". + let user = await prisma.user.findUnique({ where: { email: data.email } }); + let tempPassword: string | null = null; + + if (!user) { + tempPassword = randomBytes(4).toString('hex'); + const passwordHash = await bcrypt.hash(tempPassword, 12); + user = await prisma.user.create({ + data: { + email: data.email, + passwordHash, + nombre: data.nombre, + lastTenantId: tenantId, + }, + }); + } + + const rolId = await getRolId(data.role); + + // Membership en este tenant. Si ya existía (caso edge: re-invitación tras + // delete), reactivar. isOwner siempre false (owners se crean por register + // o addTenantToOwner). + await prisma.tenantMembership.upsert({ + where: { userId_tenantId: { userId: user.id, tenantId } }, + update: { rolId, isOwner: false, active: true }, + create: { + userId: user.id, + tenantId, + rolId, + isOwner: false, + active: true, + }, + }); + + // Volvemos a leer la membership para devolver el shape correcto + const membership = await prisma.tenantMembership.findUnique({ + where: { userId_tenantId: { userId: user.id, tenantId } }, + include: MEMBERSHIP_INCLUDE, + }); + + return mapMembershipRow(membership!); +} + +export async function updateUsuario( + tenantId: string, + userId: string, + data: UserUpdate +): Promise { + // Verifica que la membership existe en este tenant antes de actualizar. + const membership = await prisma.tenantMembership.findUnique({ + where: { userId_tenantId: { userId, tenantId } }, + }); + if (!membership) { + throw new Error('El usuario no es miembro de este tenant'); + } + + // El cambio de role es por-tenant (afecta solo la membership) + if (data.role) { + const rolId = await getRolId(data.role); + await prisma.tenantMembership.update({ + where: { userId_tenantId: { userId, tenantId } }, + data: { rolId }, + }); + } + + // active y nombre son globales del user + const userUpdate: any = {}; + if (data.nombre) userUpdate.nombre = data.nombre; + if (data.active !== undefined) userUpdate.active = data.active; + if (Object.keys(userUpdate).length > 0) { + await prisma.user.update({ where: { id: userId }, data: userUpdate }); + } + + const refreshed = await prisma.tenantMembership.findUnique({ + where: { userId_tenantId: { userId, tenantId } }, + include: MEMBERSHIP_INCLUDE, + }); + + return mapMembershipRow(refreshed!); +} + +export async function deleteUsuario(tenantId: string, userId: string): Promise { + // Soft-delete: desactiva la membership. El user sigue existiendo (puede + // tener acceso a otros tenants). Si era su única membership activa, queda + // sin acceso pero no se borra el record. + const result = await prisma.tenantMembership.deleteMany({ + where: { userId, tenantId }, + }); + if (result.count === 0) { + throw new Error('El usuario no es miembro de este tenant'); + } +} + +// ============================================================================ +// Admin global (cross-tenant) +// ============================================================================ + +/** + * Lista todos los memberships del sistema. Cada row es una combinación + * (user, tenant) — un mismo user con N memberships aparece N veces. La UI + * admin global lo presenta así para ser explícita sobre quién tiene acceso + * dónde. + */ +export async function getAllUsuarios(): Promise { + const memberships = await prisma.tenantMembership.findMany({ + where: { active: true }, + include: { + ...MEMBERSHIP_INCLUDE, + tenant: { select: { id: true, nombre: true } }, + }, + orderBy: [{ tenant: { nombre: 'asc' } }, { joinedAt: 'desc' }], + }); + + return memberships.map(m => mapMembershipRow(m, true)); +} + +export async function updateUsuarioGlobal( + userId: string, + data: UserUpdate & { tenantId?: string } +): Promise { + // Si data.tenantId está presente: actualiza el role en esa membership. + // Si data.active está: actualiza User.active globalmente. + const targetTenantId = data.tenantId; + + if (data.role && targetTenantId) { + const rolId = await getRolId(data.role); + await prisma.tenantMembership.update({ + where: { userId_tenantId: { userId, tenantId: targetTenantId } }, + data: { rolId }, + }); + } + + const userUpdate: any = {}; + if (data.nombre) userUpdate.nombre = data.nombre; + if (data.active !== undefined) userUpdate.active = data.active; + if (Object.keys(userUpdate).length > 0) { + await prisma.user.update({ where: { id: userId }, data: userUpdate }); + } + + // Devuelve la primera membership activa del user (o la del tenant target si + // se especificó) para mantener el shape esperado por el caller. + const where = targetTenantId + ? { userId_tenantId: { userId, tenantId: targetTenantId } } + : undefined; + + const m = where + ? await prisma.tenantMembership.findUnique({ + where, + include: { ...MEMBERSHIP_INCLUDE, tenant: { select: { id: true, nombre: true } } }, + }) + : await prisma.tenantMembership.findFirst({ + where: { userId, active: true }, + include: { ...MEMBERSHIP_INCLUDE, tenant: { select: { id: true, nombre: true } } }, + orderBy: { joinedAt: 'asc' }, + }); + + if (!m) throw new Error('Usuario sin memberships activas'); + return mapMembershipRow(m, true); +} + +export async function deleteUsuarioGlobal(userId: string): Promise { + // Hard delete del user — cascade borra todas las memberships, refresh + // tokens, password reset tokens, platform roles, etc. + await prisma.user.delete({ where: { id: userId } }); +} diff --git a/apps/api/src/utils/audit.ts b/apps/api/src/utils/audit.ts new file mode 100644 index 0000000..e1efa11 --- /dev/null +++ b/apps/api/src/utils/audit.ts @@ -0,0 +1,72 @@ +/** + * Audit log — registro de acciones críticas. + * + * Convención fire-and-forget: auditar NUNCA debe romper la acción principal. + * Si falla el INSERT, se logea en consola y la ejecución continúa. + * + * Acciones nombradas con convención `domain.verb`: + * - user.login, user.logout, user.password_changed + * - tenant.created, tenant.deleted + * - subscription.created, subscription.cancelled, subscription.reactivated, + * subscription.plan_changed + * - trial.started + * - price.updated + * - invoice.emitted_auto, invoice.emitted_manual, invoice.cancelled + * - fiel.uploaded, fiel.deleted + * - payment.recorded, payment.marked_paid_manually + * + * No logear GETs normales (dashboard, listar CFDIs) — demasiado volumen y sin + * valor auditable. Log solo write operations con implicación fiscal/financiera. + */ +import type { Request } from 'express'; +import { prisma } from '../config/database.js'; + +export interface AuditLogParams { + userId?: string; + tenantId?: string; + action: string; + entityType?: string; + entityId?: string; + metadata?: Record; +} + +export async function auditLog(params: AuditLogParams): Promise { + try { + await prisma.auditLog.create({ + data: { + userId: params.userId, + tenantId: params.tenantId, + action: params.action, + entityType: params.entityType, + entityId: params.entityId, + metadata: (params.metadata ?? undefined) as any, + }, + }); + } catch (error: any) { + console.error('[Audit] Falló registrar evento:', params.action, error.message || error); + } +} + +/** + * Helper para usar desde controllers — extrae user + tenant del `req` y enriquece + * metadata con IP y user-agent automáticamente. + */ +export async function auditFromReq( + req: Request, + action: string, + extra?: Omit & { metadata?: Record }, +): Promise { + const baseMetadata = { + ip: req.ip, + userAgent: req.get('user-agent'), + }; + + await auditLog({ + userId: req.user?.userId, + tenantId: extra?.tenantId ?? req.user?.tenantId, + entityType: extra?.entityType, + entityId: extra?.entityId, + metadata: { ...baseMetadata, ...(extra?.metadata ?? {}) }, + action, + }); +} diff --git a/apps/api/src/utils/contribuyente-context.ts b/apps/api/src/utils/contribuyente-context.ts new file mode 100644 index 0000000..49be032 --- /dev/null +++ b/apps/api/src/utils/contribuyente-context.ts @@ -0,0 +1,91 @@ +import type { Pool } from 'pg'; +import { prisma } from '../config/database.js'; + +export interface ContribuyenteContext { + contribuyenteId: string | null; + rfc: string; + rfcLength: number; + /** + * DEPRECATED — filtro genérico `AND (contribuyente_id = X OR rfc_emisor=X_RFC OR rfc_receptor=X_RFC)`. + * Útil solo cuando el query no distingue lado (emisor vs receptor). + * Para queries con semantic por lado, usa `esEmisor` / `esReceptor`. + */ + contribFilter: string; + /** + * Fragmento SQL que identifica al contribuyente como **emisor** del CFDI. + * Reemplaza al par `type = 'EMITIDO' AND contribuyente_id = X`. Usa RFC + * como fuente de verdad (el `type` y `contribuyente_id` en BD pueden ser + * inconsistentes cuando dos contribuyentes del mismo tenant se facturan + * entre sí — el RFC en la posición emisor/receptor no tiene ambigüedad). + * Sin el `AND` prefijo — úsalo dentro de expresión compuesta. + */ + esEmisor: string; + /** Similar a `esEmisor`, identifica al contribuyente como **receptor**. */ + esReceptor: string; +} + +/** + * Resolves the RFC and builds a CFDI contribuyente filter fragment. + * - If contribuyenteId is provided: RFC comes from contribuyentes table (tenant BD), + * filter restricts CFDIs to that contribuyente_id. + * - If not: RFC comes from Tenant (central BD), no contribuyente filter (Horux360 compat). + */ +export async function resolveContribuyenteContext( + pool: Pool, + tenantId: string, + contribuyenteId?: string | null, +): Promise { + if (contribuyenteId) { + // Sanitize: UUIDs are only hex + hyphens + const safeId = contribuyenteId.replace(/[^a-f0-9-]/gi, ''); + const { rows } = await pool.query( + 'SELECT rfc FROM contribuyentes WHERE entidad_id = $1', + [safeId], + ); + const rfc: string = rows[0]?.rfc || ''; + const safeRfc = rfc.replace(/[^A-Z0-9]/gi, '').toUpperCase(); + + // Filtro genérico (deprecated): contribuyente_id directo O RFC en + // cualquier lado. Sigue disponible para queries legacy que no distinguen + // lado. Los callers modernos deberían usar esEmisor/esReceptor. + let contribFilter = ''; + if (safeId && safeRfc) { + contribFilter = `AND (contribuyente_id = '${safeId}' OR UPPER(rfc_emisor) = '${safeRfc}' OR UPPER(rfc_receptor) = '${safeRfc}')`; + } else if (safeId) { + contribFilter = `AND contribuyente_id = '${safeId}'`; + } + + // esEmisor / esReceptor: fuente de verdad por posición del RFC. + // Reemplazan al par `type = 'EMITIDO/RECIBIDO' AND contribuyente_id = X`. + const esEmisor = safeRfc ? `UPPER(rfc_emisor) = '${safeRfc}'` : `1=0`; + const esReceptor = safeRfc ? `UPPER(rfc_receptor) = '${safeRfc}'` : `1=0`; + + return { + contribuyenteId: safeId, + rfc, + rfcLength: rfc.length, + contribFilter, + esEmisor, + esReceptor, + }; + } + + // Horux360 mode: no contribuyente → RFC from central BD + const tenant = await prisma.tenant.findUnique({ + where: { id: tenantId }, + select: { rfc: true }, + }); + const rfc = tenant?.rfc || ''; + const safeRfc = rfc.replace(/[^A-Z0-9]/gi, '').toUpperCase(); + // Para Horux 360 single-tenant, el RFC del tenant actúa como el contrib. + const esEmisor = safeRfc ? `UPPER(rfc_emisor) = '${safeRfc}'` : `type = 'EMITIDO'`; + const esReceptor = safeRfc ? `UPPER(rfc_receptor) = '${safeRfc}'` : `type = 'RECIBIDO'`; + return { + contribuyenteId: null, + rfc, + rfcLength: rfc.length, + contribFilter: '', + esEmisor, + esReceptor, + }; +} diff --git a/apps/api/src/utils/entidades-visibles.ts b/apps/api/src/utils/entidades-visibles.ts new file mode 100644 index 0000000..d191846 --- /dev/null +++ b/apps/api/src/utils/entidades-visibles.ts @@ -0,0 +1,68 @@ +import type { Pool } from 'pg'; +import type { Role } from '@horux/shared'; + +export async function getEntidadesVisibles(pool: Pool, userId: string, role: Role): Promise { + let query: string; + let params: unknown[]; + + switch (role) { + case 'owner': + case 'cfo': + case 'contador': + case 'visor': + // Full access to all active entities + query = 'SELECT id FROM entidades_gestionadas WHERE active = true'; + params = []; + break; + + case 'supervisor': + // Entities assigned to supervisor: via entidades_gestionadas.supervisor_user_id + // OR via cartera_entidades in carteras assigned to this supervisor + query = ` + SELECT DISTINCT id FROM ( + SELECT id FROM entidades_gestionadas WHERE supervisor_user_id = $1 AND active = true + UNION + SELECT ce.entidad_id AS id + FROM cartera_entidades ce + JOIN carteras c ON c.id = ce.cartera_id AND c.parent_id IS NULL + JOIN entidades_gestionadas e ON e.id = ce.entidad_id + WHERE c.supervisor_user_id = $1 AND e.active = true + ) sub + `; + params = [userId]; + break; + + case 'auxiliar': + // Entities from subcarteras assigned to this auxiliar + // OR from legacy cartera_auxiliares (backward compat) + query = ` + SELECT DISTINCT id FROM ( + SELECT ce.entidad_id AS id + FROM cartera_entidades ce + JOIN carteras c ON c.id = ce.cartera_id + JOIN entidades_gestionadas e ON e.id = ce.entidad_id + WHERE c.auxiliar_user_id = $1 AND e.active = true + UNION + SELECT ce.entidad_id AS id + FROM cartera_entidades ce + JOIN cartera_auxiliares ca ON ca.cartera_id = ce.cartera_id + JOIN entidades_gestionadas e ON e.id = ce.entidad_id + WHERE ca.auxiliar_user_id = $1 AND e.active = true + ) sub + `; + params = [userId]; + break; + + case 'cliente': + // Only entities explicitly granted via cliente_accesos + query = 'SELECT entidad_id AS id FROM cliente_accesos WHERE user_id = $1'; + params = [userId]; + break; + + default: + return []; + } + + const { rows } = await pool.query(query, params); + return rows.map(r => r.id); +} diff --git a/apps/api/src/utils/errors.ts b/apps/api/src/utils/errors.ts new file mode 100644 index 0000000..373dafd --- /dev/null +++ b/apps/api/src/utils/errors.ts @@ -0,0 +1,10 @@ +export class AppError extends Error { + constructor( + public statusCode: number, + public message: string, + public isOperational = true + ) { + super(message); + Object.setPrototypeOf(this, AppError.prototype); + } +} diff --git a/apps/api/src/utils/global-admin.ts b/apps/api/src/utils/global-admin.ts new file mode 100644 index 0000000..8102e86 --- /dev/null +++ b/apps/api/src/utils/global-admin.ts @@ -0,0 +1,14 @@ +/** + * DEPRECATED — shim de compatibilidad. La lógica vive ahora en `platform-admin.ts`. + * + * `isGlobalAdmin` ahora resuelve vía tabla `user_platform_roles` (rol + * `platform_admin`) en lugar del hardcode `GLOBAL_ADMIN_RFC`. El check por RFC + * queda como fallback por si la tabla aún no está poblada (escenario pre-seed). + * + * Código nuevo debería usar: + * - `hasPlatformRole(userId, 'platform_admin')` para el equivalente directo + * - `canManageTenants(userId)`, `canEditPrices(userId)`, `canEmitInvoicesManual(userId)` + * para controles granulares + * - `isPlatformStaff(userId)` para "cualquier staff interno" + */ +export { isGlobalAdmin } from './platform-admin.js'; diff --git a/apps/api/src/utils/memberships.ts b/apps/api/src/utils/memberships.ts new file mode 100644 index 0000000..36324e7 --- /dev/null +++ b/apps/api/src/utils/memberships.ts @@ -0,0 +1,103 @@ +import { prisma } from '../config/database.js'; +import type { Role, TenantMembership } from '@horux/shared'; + +/** + * Lista los tenants a los que el user tiene acceso vía la tabla + * `tenant_memberships`. Usado por /auth/login, /auth/me, /auth/switch-tenant + * para poblar el array `tenants[]` del UserInfo. + * + * Durante la transición (fase 2), si el user todavía no tiene memberships + * (caso borde post-deploy antes del backfill), devuelve un array con su + * tenant "default" (User.tenantId) para no romper UX. + */ +export async function getUserTenants(userId: string): Promise { + const memberships = await prisma.tenantMembership.findMany({ + where: { userId, active: true }, + include: { + tenant: { select: { id: true, nombre: true, rfc: true, plan: true, active: true } }, + rol: { select: { nombre: true } }, + }, + orderBy: { joinedAt: 'asc' }, + }); + + return memberships + .filter(m => m.tenant.active) // Esconde memberships de tenants desactivados + .map(m => ({ + id: m.tenant.id, + nombre: m.tenant.nombre, + rfc: m.tenant.rfc, + plan: m.tenant.plan, + role: m.rol.nombre as Role, + isOwner: m.isOwner, + })); +} + +/** + * Verifica que el user tiene membership activo en el tenant dado. Usado por + * /auth/switch-tenant antes de emitir un JWT con ese tenantId. + */ +/** + * ¿El user es owner en al menos un tenant activo? Usado como gate en + * POST /tenants/mine (agregar RFC nuevo) — solo users que ya son owner en + * alguna empresa pueden registrar otra más. + */ +export async function isOwnerSomewhere(userId: string): Promise { + const row = await prisma.tenantMembership.findFirst({ + where: { userId, isOwner: true, active: true, tenant: { active: true } }, + select: { id: true }, + }); + return !!row; +} + +/** + * Devuelve el email del owner del tenant (primero por joinedAt). Usado por + * subscription.service para enviar notificaciones de pago/cancelación al + * dueño. Si el tenant no tiene owner activo, retorna null. + */ +export async function getTenantOwnerEmail(tenantId: string): Promise { + const m = await prisma.tenantMembership.findFirst({ + where: { tenantId, isOwner: true, active: true }, + include: { user: { select: { email: true } } }, + orderBy: { joinedAt: 'asc' }, + }); + return m?.user.email ?? null; +} + +/** + * Devuelve los emails de TODOS los owners activos del tenant (puede haber + * más de uno). Usado para notificaciones broadcast (upload de documentos, + * etc.) donde queremos avisar a todos los dueños. + */ +export async function getTenantOwnerEmails(tenantId: string): Promise { + const ms = await prisma.tenantMembership.findMany({ + where: { tenantId, isOwner: true, active: true }, + include: { user: { select: { email: true, active: true } } }, + orderBy: { joinedAt: 'asc' }, + }); + return ms + .filter(m => m.user.active) + .map(m => m.user.email); +} + +/** + * Dado un userId, retorna su email. Null si el user no existe o está inactivo. + */ +export async function getUserEmailById(userId: string): Promise { + const u = await prisma.user.findUnique({ + where: { id: userId }, + select: { email: true, active: true }, + }); + return u && u.active ? u.email : null; +} + +export async function verifyMembership(userId: string, tenantId: string): Promise<{ + rolNombre: Role; + isOwner: boolean; +} | null> { + const m = await prisma.tenantMembership.findFirst({ + where: { userId, tenantId, active: true }, + include: { rol: { select: { nombre: true } }, tenant: { select: { active: true } } }, + }); + if (!m || !m.tenant.active) return null; + return { rolNombre: m.rol.nombre as Role, isOwner: m.isOwner }; +} diff --git a/apps/api/src/utils/metricas-cache.ts b/apps/api/src/utils/metricas-cache.ts new file mode 100644 index 0000000..ab9d83b --- /dev/null +++ b/apps/api/src/utils/metricas-cache.ts @@ -0,0 +1,40 @@ +export interface CacheRange { + contribuyenteId: string; + startDate: string; // YYYY-MM-01 (día 1 del mes inicial) + endDate: string; // YYYY-MM-01 (día 1 del mes final, para BETWEEN contra make_date) +} + +/** + * Decide si un rango de fechas es elegible para leer de `metricas_mensuales` + * (read-through cache de Tanda B). Requisitos: + * - `contribuyenteId` presente (la tabla solo tiene datos por contribuyente) + * - `conciliacion` desactivada (la tabla guarda flujo normal, no id_conciliacion) + * - `fechaFin` antes del primer día del año actual (años cerrados) + * - `fechaInicio` es día 1 del mes; `fechaFin` es último día del mes + * + * Retorna `null` si no califica — el caller debe caer al path on-the-fly. + */ +export function planCache( + fechaInicio: string, + fechaFin: string, + conciliacion: boolean | undefined, + contribuyenteId: string | null | undefined, +): CacheRange | null { + // Escape hatch para validación: forzar el path on-the-fly aunque el rango + // califique. Útil en `validate-metricas.ts` para comparar cache vs raw. + if (process.env.METRICAS_BYPASS_CACHE === '1') return null; + if (!contribuyenteId || conciliacion) return null; + const fi = new Date(fechaInicio + 'T00:00:00Z'); + const ff = new Date(fechaFin + 'T00:00:00Z'); + if (isNaN(fi.getTime()) || isNaN(ff.getTime())) return null; + const currentYearStart = new Date(Date.UTC(new Date().getUTCFullYear(), 0, 1)); + if (ff >= currentYearStart) return null; + if (fi.getUTCDate() !== 1) return null; + const lastDay = new Date(Date.UTC(ff.getUTCFullYear(), ff.getUTCMonth() + 1, 0)).getUTCDate(); + if (ff.getUTCDate() !== lastDay) return null; + const safe = contribuyenteId.replace(/[^a-f0-9-]/gi, ''); + if (!safe) return null; + const startDate = `${fi.getUTCFullYear()}-${String(fi.getUTCMonth() + 1).padStart(2, '0')}-01`; + const endDate = `${ff.getUTCFullYear()}-${String(ff.getUTCMonth() + 1).padStart(2, '0')}-01`; + return { contribuyenteId: safe, startDate, endDate }; +} diff --git a/apps/api/src/utils/platform-admin.ts b/apps/api/src/utils/platform-admin.ts new file mode 100644 index 0000000..dfa531a --- /dev/null +++ b/apps/api/src/utils/platform-admin.ts @@ -0,0 +1,133 @@ +/** + * Helpers de autorización basados en **roles de plataforma** (staff interno de + * Horux 360). Ortogonal al rol per-tenant (owner, cfo, contador, auxiliar, visor). + * + * Migración desde el modelo anterior: + * - Antes: `isGlobalAdmin(tenantId, role)` checaba si el user era owner del + * tenant con RFC HTS240708LJA. Una sola persona admin global. + * - Ahora: `isGlobalAdmin()` sigue existiendo como alias de `hasPlatformRole('platform_admin')`, + * pero múltiples users pueden tener ese rol. + * - Compatibilidad: si la tabla `user_platform_roles` está vacía (pre-migración), + * cae al check por RFC como fallback. Post-seed, los owners del tenant dueño + * ya están registrados como `platform_admin` y el check funciona sin fallback. + * + * Cache in-memory 5min por (userId, role) para evitar hit a BD en cada request. + */ +import { prisma } from '../config/database.js'; +import { GLOBAL_ADMIN_RFC } from '@horux/shared'; + +export type PlatformRole = 'platform_admin' | 'platform_ti' | 'platform_support' | 'platform_sales' | 'platform_finance'; + +/** Roles superset — implican todos los demás. */ +const SUPERSET_ROLES: PlatformRole[] = ['platform_admin', 'platform_ti']; + +function hasSuperset(roles: Set): boolean { + return SUPERSET_ROLES.some(r => roles.has(r)); +} + +interface CacheEntry { + roles: Set; + expires: number; +} + +const cache = new Map(); +const CACHE_TTL = 5 * 60 * 1000; // 5 minutos + +export function invalidatePlatformRolesCache(userId: string) { + cache.delete(userId); +} + +/** + * Carga los roles de plataforma de un user. Hit a BD con cache de 5 min. + * Si el user no tiene rows en `user_platform_roles`, devuelve Set vacío. + */ +async function loadPlatformRoles(userId: string): Promise> { + const cached = cache.get(userId); + if (cached && cached.expires > Date.now()) return cached.roles; + + const rows = await prisma.userPlatformRole.findMany({ + where: { userId }, + select: { role: true }, + }); + const roles = new Set(rows.map(r => r.role as PlatformRole)); + cache.set(userId, { roles, expires: Date.now() + CACHE_TTL }); + return roles; +} + +/** + * ¿El user tiene el rol de plataforma indicado? + * `platform_admin` automáticamente implica TODOS los otros roles (superrol). + */ +export async function hasPlatformRole(userId: string | undefined, role: PlatformRole): Promise { + if (!userId) return false; + const roles = await loadPlatformRoles(userId); + if (hasSuperset(roles)) return true; + return roles.has(role); +} + +/** + * ¿El user tiene alguno de los roles indicados? + */ +export async function hasAnyPlatformRole(userId: string | undefined, ...allowedRoles: PlatformRole[]): Promise { + if (!userId) return false; + const roles = await loadPlatformRoles(userId); + if (hasSuperset(roles)) return true; + return allowedRoles.some(r => roles.has(r)); +} + +/** Atajos granulares — usar en middlewares o guards de endpoints */ +export const canManageTenants = (userId?: string) => + hasAnyPlatformRole(userId, 'platform_admin', 'platform_sales', 'platform_support'); + +export const canEditPrices = (userId?: string) => + hasAnyPlatformRole(userId, 'platform_admin', 'platform_finance'); + +export const canEmitInvoicesManual = (userId?: string) => + hasAnyPlatformRole(userId, 'platform_admin', 'platform_finance'); + +export const isPlatformStaff = async (userId?: string) => { + if (!userId) return false; + const roles = await loadPlatformRoles(userId); + return roles.size > 0; +}; + +/** + * Obtiene los roles actuales del user (para incluir en JWT al login). + */ +export async function getPlatformRoles(userId: string): Promise { + const roles = await loadPlatformRoles(userId); + return Array.from(roles); +} + +/** + * Compatibilidad con el código legacy que usa `isGlobalAdmin(tenantId, role)`. + * Ahora resuelve vía tabla `user_platform_roles` → rol `platform_admin`. + * + * Fallback: si no hay rows en la tabla para el user que está intentando, pero es + * owner del tenant HTS240708LJA, se considera platform_admin (cubre el escenario + * post-deploy pre-seed). + */ +export async function isGlobalAdmin(tenantId: string, role: string): Promise { + // Las firmas viejas no tienen userId disponible. Lo resolvemos buscando el user + // que matchea tenantId + rol 'owner'. Para evitar ese hit extra, la preferencia + // es usar `hasPlatformRole(req.user.userId, 'platform_admin')` en código nuevo. + if (role !== 'owner') return false; + + // Primer intento: ¿hay users con rol superset (admin o TI) en este tenant? + // Vía memberships: el user tiene un superset role Y es miembro del tenant. + const adminRow = await prisma.userPlatformRole.findFirst({ + where: { + role: { in: SUPERSET_ROLES }, + user: { memberships: { some: { tenantId, active: true } } }, + }, + select: { userId: true }, + }); + if (adminRow) return true; + + // Fallback: tenant con RFC admin global (compat antes del backfill) + const tenant = await prisma.tenant.findUnique({ + where: { id: tenantId }, + select: { rfc: true }, + }); + return tenant?.rfc === GLOBAL_ADMIN_RFC; +} diff --git a/apps/api/src/utils/saldo.ts b/apps/api/src/utils/saldo.ts new file mode 100644 index 0000000..b8b3241 --- /dev/null +++ b/apps/api/src/utils/saldo.ts @@ -0,0 +1,136 @@ +import type { Pool } from 'pg'; + +/** + * SQL que computa el saldo pendiente de un CFDI I PPD. + * + * Fórmula (misma que §13 del doc Horux_despacho): + * saldo = total_mxn + * − Σ P.monto_pago_mxn (complementos P que referencian el UUID) + * − Σ E.total_mxn (NC donde cfdi_tipo_relacion <> '07') + * − anticipo_aplicado (si el CFDI es I/07, suma de totales de + * los UUIDs en cfdis_relacionados) + * + * NO se clamp a 0: un saldo negativo indica over-pago o over-aplicación + * (señal útil). Los filtros downstream pueden excluir saldos < 0.01 si + * solo quieren ver pendientes. + * + * `alias` = alias de la tabla `cfdis` en la query (ej. 'c', 'cfdis'). + */ +export function saldoComputadoExpr(alias: string): string { + const c = alias; + return `( + 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 + )`; +} + +/** + * Recomputa `saldo_pendiente_mxn` para un conjunto de UUIDs de CFDIs. + * Solo aplica a I PPD vigentes (otros tipos no tienen saldo pendiente + * en el sentido de cuentas por cobrar/pagar). Idempotente. + * + * UUIDs se normalizan a lowercase para matchear independiente del case + * en que vienen (XML suele traer uppercase, parser local normaliza). + */ +export async function recomputarSaldoPendiente( + pool: Pool, + uuids: string[], +): Promise { + if (uuids.length === 0) return 0; + const expr = saldoComputadoExpr('c'); + const lowered = uuids.map(u => u.toLowerCase()); + const { rowCount } = await pool.query( + `UPDATE cfdis c + SET saldo_pendiente_mxn = ${expr} + WHERE LOWER(c.uuid) = ANY($1::text[]) + AND c.tipo_comprobante = 'I' + AND c.metodo_pago = 'PPD' + AND c.status NOT IN ('Cancelado', '0')`, + [lowered], + ); + return rowCount ?? 0; +} + +/** + * Recomputa saldo pendiente para TODAS las I PPD vigentes del tenant. + * Usado por el backfill one-shot y por validación/debug. + */ +export async function recomputarSaldoTodos(pool: Pool): Promise { + const expr = saldoComputadoExpr('c'); + const { rowCount } = await pool.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')`, + ); + return rowCount ?? 0; +} + +/** + * Dado un CFDI recién insertado/actualizado, devuelve el set de UUIDs + * cuya saldo_pendiente_mxn debe recomputarse. Centraliza la lógica de + * "qué filas tocar" para cada tipo: + * + * - I PPD: su propio UUID (saldo inicial, considera anticipo si es I/07) + * - P: los UUIDs referenciados en uuid_relacionado (pueden ser pipe-separated) + * - E no-07: los UUIDs en cfdis_relacionados + * - Otros tipos: no afectan saldos + */ +export function uuidsAfectadosPorCfdi(cfdi: { + uuid: string; + tipoComprobante: string | null; + metodoPago?: string | null; + cfdiTipoRelacion?: string | null; + uuidRelacionado?: string | null; + cfdisRelacionados?: string | null; +}): string[] { + const result: string[] = []; + + if (cfdi.tipoComprobante === 'I' && cfdi.metodoPago === 'PPD' && cfdi.uuid) { + result.push(cfdi.uuid.toLowerCase()); + } + + if (cfdi.tipoComprobante === 'P' && cfdi.uuidRelacionado) { + for (const ref of cfdi.uuidRelacionado.split('|')) { + const clean = ref.trim().toLowerCase(); + if (clean) result.push(clean); + } + } + + if ( + cfdi.tipoComprobante === 'E' && + cfdi.cfdiTipoRelacion !== '07' && + cfdi.cfdisRelacionados + ) { + for (const ref of cfdi.cfdisRelacionados.split('|')) { + const clean = ref.trim().toLowerCase(); + if (clean) result.push(clean); + } + } + + return result; +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..1170081 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 0000000..0ce6b13 --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1 @@ +NEXT_PUBLIC_API_URL=http://localhost:4000/api diff --git a/apps/web/app/(auth)/forgot-password/page.tsx b/apps/web/app/(auth)/forgot-password/page.tsx new file mode 100644 index 0000000..5cd1653 --- /dev/null +++ b/apps/web/app/(auth)/forgot-password/page.tsx @@ -0,0 +1,118 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; +import Image from 'next/image'; +import { Button, Input, Label, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@horux/shared-ui'; +import { requestPasswordReset } from '@/lib/api/auth'; +import { Mail, CheckCircle2 } from 'lucide-react'; + +export default function ForgotPasswordPage() { + const [isLoading, setIsLoading] = useState(false); + const [submitted, setSubmitted] = useState(false); + const [error, setError] = useState(''); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setIsLoading(true); + setError(''); + + const formData = new FormData(e.currentTarget); + const email = formData.get('email') as string; + + try { + await requestPasswordReset(email); + setSubmitted(true); + } catch (err: any) { + // Rate limit u otro error explícito + setError(err.response?.data?.message || 'Error al enviar el enlace. Intenta más tarde.'); + } finally { + setIsLoading(false); + } + } + + if (submitted) { + return ( +
+ + +
+
+ +
+
+ Revisa tu correo + + Si el email que ingresaste está registrado, recibirás un enlace para restablecer tu contraseña. + +
+ +
+

¿No recibiste el correo?

+
    +
  • Revisa tu carpeta de spam o promociones
  • +
  • El enlace expira en 1 hora — si pasó más tiempo, vuelve a solicitar
  • +
  • Asegúrate de haber escrito bien tu email
  • +
+
+
+ + + + Volver al login + + +
+
+ ); + } + + return ( +
+ + +
+ Horux360 +
+ + + Recuperar contraseña + + + Ingresa tu email y te enviaremos un enlace para crear una nueva contraseña. + +
+
+ + {error && ( +
+ {error} +
+ )} +
+ + +
+
+ + + + Volver al login + + +
+
+
+ ); +} diff --git a/apps/web/app/(auth)/layout.tsx b/apps/web/app/(auth)/layout.tsx new file mode 100644 index 0000000..9e3abba --- /dev/null +++ b/apps/web/app/(auth)/layout.tsx @@ -0,0 +1,11 @@ +export default function AuthLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ {children} +
+ ); +} diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx new file mode 100644 index 0000000..1bbb85d --- /dev/null +++ b/apps/web/app/(auth)/login/page.tsx @@ -0,0 +1,122 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import Image from 'next/image'; +import { Button, Input, Label, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@horux/shared-ui'; +import { login } from '@/lib/api/auth'; +import { useAuthStore } from '@/stores/auth-store'; +import { isGlobalAdminRfc } from '@horux/shared'; +import { shouldShowOnboarding } from '@/lib/onboarding'; + +export default function LoginPage() { + const router = useRouter(); + const { setUser, setTokens } = useAuthStore(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(''); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setIsLoading(true); + setError(''); + + const formData = new FormData(e.currentTarget); + const email = formData.get('email') as string; + const password = formData.get('password') as string; + + try { + const response = await login({ email, password }); + setTokens(response.accessToken, response.refreshToken); + setUser(response.user); + + const userRole = response.user?.role; + // Admin global aterriza directo en `/clientes` — su home natural es la + // gestión de tenants, no el dashboard operativo del despacho. + const platformRoles = (response.user as { platformRoles?: string[] }).platformRoles; + const isGlobalAdmin = isGlobalAdminRfc(response.user?.tenantRfc, userRole, platformRoles); + if (isGlobalAdmin) { + router.push('/clientes'); + } else if (userRole === 'cliente' || userRole === 'auxiliar' || userRole === 'supervisor') { + // Clients and roles without onboarding go straight to dashboard + router.push('/dashboard'); + } else { + // Owner/CFO/Contador: onboarding hasta 4 logins o hasta que el user + // complete los pasos requeridos (lo que pase primero). + router.push(shouldShowOnboarding(response.user) ? '/onboarding' : '/dashboard'); + } + } catch (err: any) { + setError(err.response?.data?.message || 'Error al iniciar sesión'); + } finally { + setIsLoading(false); + } + } + + return ( +
+ + +
+ Horux360 +
+ Iniciar Sesión + + Ingresa tus credenciales para acceder a tu cuenta + +
+
+ + {error && ( +
+ {error} +
+ )} +
+ + +
+
+
+ + + ¿Olvidaste tu contraseña? + +
+ +
+
+ + +

+ ¿No tienes cuenta?{' '} + + Regístrate + +

+
+
+
+
+ ); +} diff --git a/apps/web/app/(auth)/register-despacho/page.tsx b/apps/web/app/(auth)/register-despacho/page.tsx new file mode 100644 index 0000000..8297f24 --- /dev/null +++ b/apps/web/app/(auth)/register-despacho/page.tsx @@ -0,0 +1,415 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { Button, Input, Label, Card, CardContent, CardHeader, CardTitle, cn } from '@horux/shared-ui'; +import { useAuthStore } from '@/stores/auth-store'; +import { apiClient } from '@/lib/api/client'; +import { CheckCircle2, Server, Cloud, ArrowLeft, Clock, Building, Sparkles } from 'lucide-react'; + +type VerticalProfile = 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA'; +type PlanType = 'trial' | 'mi_empresa' | 'mi_empresa_plus' | 'business_control' | 'business_cloud'; + +export default function RegisterDespachoPage() { + const router = useRouter(); + const { setUser, setTokens } = useAuthStore(); + const [step, setStep] = useState(1); + const [verticalProfile, setVerticalProfile] = useState(null); + const [selectedPlan, setSelectedPlan] = useState(null); + // Default 'annual' — sesgo intencional al cash-flow del negocio (10 meses + // = 17% descuento para el cliente, año completo cobrado upfront para nosotros). + const [billingFrequency, setBillingFrequency] = useState<'monthly' | 'annual'>('annual'); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [form, setForm] = useState({ + despachoNombre: '', + ownerNombre: '', + ownerEmail: '', + ownerPassword: '', + acceptedTerms: false, + }); + + const handleChange = (field: string) => (e: React.ChangeEvent) => { + setForm((prev) => ({ ...prev, [field]: e.target.value })); + setError(''); + }; + + const handleSubmit = async () => { + if (!form.acceptedTerms) { setError('Debes aceptar los términos y condiciones'); return; } + if (!verticalProfile || !selectedPlan) { setError('Completa todos los pasos'); return; } + setLoading(true); + setError(''); + try { + const { data } = await apiClient.post('/despachos/signup', { + despacho: { + nombre: form.despachoNombre, + verticalProfile, + plan: selectedPlan, + // Solo mi_empresa(+) acepta monthly; el backend ignora frequency + // para los demás planes. Mandamos siempre el state para coherencia. + frequency: billingFrequency, + }, + owner: { + nombre: form.ownerNombre, + email: form.ownerEmail, + password: form.ownerPassword, + }, + }); + setTokens(data.accessToken, data.refreshToken); + setUser(data.user); + + // If paid plan with payment URL, redirect to MercadoPago + if (data.paymentUrl) { + window.location.href = data.paymentUrl; + } else { + router.push('/onboarding'); + } + } catch (err: any) { + setError(err.response?.data?.message || 'Error al registrar el despacho'); + setStep(1); + } finally { + setLoading(false); + } + }; + + // =================== STEP 1: Registration Form =================== + if (step === 1) { + const canProceed = form.despachoNombre && form.ownerNombre && form.ownerEmail && form.ownerPassword.length >= 10 && form.acceptedTerms; + + return ( +
+ + +
+ 1 + + 2 + + 3 +
+ Crea tu cuenta +

Plataforma para despachos profesionales

+
+ +
+
+
+
+
+
+
+
+
+ setForm((p) => ({ ...p, acceptedTerms: e.target.checked }))} className="mt-1" /> + +
+ {error &&

{error}

} + +

+ ¿Ya tienes cuenta? Inicia sesión +

+
+
+
+ ); + } + + // =================== STEP 2: Vertical Selection =================== + if (step === 2) { + return ( +
+
+
+
+ + + 2 + + 3 +
+

¿Qué tipo de despacho eres?

+

Selecciona tu área profesional

+
+
+ +
+
⚖️
+

Jurídico

+

Próximamente

+
+
+
🏗️
+

Arquitectura

+

Próximamente

+
+
+ +
+
+ ); + } + + // =================== STEP 3: Subscription Selection =================== + return ( +
+
+
+
+ + + + + 3 +
+

Elige tu plan

+

Empieza con el trial gratuito de 30 días o contrata un plan directo.

+
+ + {/* Toggle facturación mensual / anual (afecta solo Mi Empresa y Mi Empresa+) */} +
+
+ + +
+
+ +
+ {/* Trial Gratuito */} + setSelectedPlan('trial')} + > + +
+ +
+ Trial Gratuito +

Prueba sin compromiso

+
+ +
+
$0
+

30 días

+

Sin tarjeta

+
+
+
Hasta 3 RFCs
+
1 usuario
+
Todas las funcionalidades
+
BD en la nube
+
+
+
+ + {/* Mi Empresa */} + setSelectedPlan('mi_empresa')} + > + +
+ +
+ Mi Empresa +

Para 1 contribuyente

+
+ +
+ {billingFrequency === 'annual' ? ( + <> +
$5,800
+

por año

+

Equivale a 10 meses

+ + ) : ( + <> +
$580
+

mensual

+

o $5,800/año (10 meses)

+ + )} +
+
+
1 RFC
+
3 usuarios
+
50 timbres/mes
+
BD en la nube
+
+
+
+ + {/* Mi Empresa + */} + setSelectedPlan('mi_empresa_plus')} + > + +
+ +
+ Mi Empresa + +

Con IA + API

+
+ +
+ {billingFrequency === 'annual' ? ( + <> +
$9,000
+

por año

+

Equivale a 10 meses

+ + ) : ( + <> +
$900
+

mensual

+

o $9,000/año (10 meses)

+ + )} +
+
+
Todo Mi Empresa
+
Lolita IA Fiscal
+
API de integración
+
SAT incremental
+
+
+
+ + {/* Business Control */} + setSelectedPlan('business_control')} + > +
+ Más popular +
+ +
+ +
+ Business Control +

Despachos contables

+
+ +
+
$25,850
+

por año (IVA inc.)

+

+ $45/mes por RFC extra

+
+
+
100 RFCs incluidos
+
Usuarios ilimitados
+
BD en tu servidor
+
Servidor backup
+
API de integración
+
SAT incremental
+
+
+
+ + {/* Enterprise (business_cloud) */} + setSelectedPlan('business_cloud')} + > + +
+ +
+ Enterprise +

Despachos de alto volumen

+
+ +
+
$43,000
+

por año (IVA inc.)

+

+ $45/mes por RFC extra

+
+
+
100 RFCs incluidos
+
3M CFDIs por contribuyente
+
Usuarios ilimitados
+
BD en tu servidor
+
Servidor backup
+
SAT incremental + API
+
+
+
+
+ + {error &&

{error}

} + +
+ + +
+
+
+ ); +} diff --git a/apps/web/app/(auth)/register/page.tsx b/apps/web/app/(auth)/register/page.tsx new file mode 100644 index 0000000..03cb861 --- /dev/null +++ b/apps/web/app/(auth)/register/page.tsx @@ -0,0 +1,145 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { Button, Input, Label, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@horux/shared-ui'; +import { register } from '@/lib/api/auth'; +import { useAuthStore } from '@/stores/auth-store'; + +export default function RegisterPage() { + const router = useRouter(); + const { setUser, setTokens } = useAuthStore(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(''); + const [acceptedTerms, setAcceptedTerms] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!acceptedTerms) { + setError('Debes aceptar los términos y condiciones para continuar.'); + return; + } + setIsLoading(true); + setError(''); + + const formData = new FormData(e.currentTarget); + + try { + const response = await register({ + empresa: { + nombre: formData.get('empresaNombre') as string, + rfc: formData.get('empresaRfc') as string, + }, + usuario: { + nombre: formData.get('nombre') as string, + email: formData.get('email') as string, + password: formData.get('password') as string, + }, + }); + setTokens(response.accessToken, response.refreshToken); + setUser(response.user); + router.push('/dashboard'); + } catch (err: any) { + setError(err.response?.data?.message || 'Error al registrarse'); + } finally { + setIsLoading(false); + } + } + + return ( +
+ + + Crear Cuenta + + Registra tu empresa y comienza tu prueba gratuita + + +
+ + {error && ( +
+ {error} +
+ )} + +
+ + + +
+ +
+ + + + +
+ + +
+ + +

+ ¿Ya tienes cuenta?{' '} + + Inicia sesión + +

+
+
+
+
+ ); +} diff --git a/apps/web/app/(auth)/reset-password/page.tsx b/apps/web/app/(auth)/reset-password/page.tsx new file mode 100644 index 0000000..b3b783c --- /dev/null +++ b/apps/web/app/(auth)/reset-password/page.tsx @@ -0,0 +1,166 @@ +'use client'; + +import { Suspense, useState } from 'react'; +import Link from 'next/link'; +import Image from 'next/image'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { Button, Input, Label, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@horux/shared-ui'; +import { confirmPasswordReset } from '@/lib/api/auth'; +import { KeyRound, CheckCircle2, AlertTriangle } from 'lucide-react'; + +function ResetPasswordContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const token = searchParams.get('token') || ''; + + const [isLoading, setIsLoading] = useState(false); + const [success, setSuccess] = useState(false); + const [error, setError] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + + if (!token) { + return ( + + +
+
+ +
+
+ Enlace inválido + + El enlace no incluye un token de recuperación válido. Solicita uno nuevo desde la pantalla de login. + +
+ + + + + +
+ ); + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(''); + + if (newPassword.length < 8) { + setError('La contraseña debe tener al menos 8 caracteres'); + return; + } + if (newPassword !== confirmPassword) { + setError('Las contraseñas no coinciden'); + return; + } + + setIsLoading(true); + try { + await confirmPasswordReset(token, newPassword); + setSuccess(true); + setTimeout(() => router.push('/login'), 3000); + } catch (err: any) { + setError(err.response?.data?.message || 'Error al actualizar contraseña'); + } finally { + setIsLoading(false); + } + } + + if (success) { + return ( + + +
+
+ +
+
+ Contraseña actualizada + + Tu contraseña fue cambiada exitosamente. Redireccionando al login... + +
+ + + + + +
+ ); + } + + return ( + + +
+ Horux360 +
+ + + Nueva contraseña + + + Ingresa tu nueva contraseña. Mínimo 8 caracteres. + +
+
+ + {error && ( +
+ {error} +
+ )} +
+ + setNewPassword(e.target.value)} + placeholder="••••••••" + required + minLength={8} + autoFocus + /> +
+
+ + setConfirmPassword(e.target.value)} + placeholder="••••••••" + required + minLength={8} + /> +
+

+ Al actualizar, se cerrarán todas tus sesiones activas por seguridad. Tendrás que volver a iniciar sesión. +

+
+ + + + Volver al login + + +
+
+ ); +} + +export default function ResetPasswordPage() { + return ( +
+
+ Cargando...}> + + +
+
+ ); +} diff --git a/apps/web/app/(dashboard)/admin/audit-log/page.tsx b/apps/web/app/(dashboard)/admin/audit-log/page.tsx new file mode 100644 index 0000000..43911a3 --- /dev/null +++ b/apps/web/app/(dashboard)/admin/audit-log/page.tsx @@ -0,0 +1,229 @@ +'use client'; + +import { useState } from 'react'; +import { Header } from '@/components/layouts/header'; +import { Card, CardContent, CardHeader, CardTitle, Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@horux/shared-ui'; +import { useAuthStore } from '@/stores/auth-store'; +import { isGlobalAdminRfc } from '@horux/shared'; +import { useAuditLog } from '@/lib/hooks/use-audit-log'; +import { ChevronLeft, ChevronRight, Search, X, FileWarning, ShieldAlert } from 'lucide-react'; + +const ACTION_GROUPS = [ + { value: '', label: 'Todas las acciones' }, + { value: 'user.', label: 'Usuarios (login, logout, password)' }, + { value: 'subscription.', label: 'Suscripciones (crear, cancelar, cambiar, reactivar)' }, + { value: 'trial.', label: 'Trials' }, + { value: 'price.', label: 'Precios' }, + { value: 'invoice.', label: 'Facturación' }, + { value: 'payment.', label: 'Pagos' }, + { value: 'tenant.', label: 'Tenants' }, + { value: 'fiel.', label: 'FIEL' }, +]; + +const ACTION_LABELS: Record = { + 'user.login': { label: 'Login', color: 'bg-blue-50 text-blue-700' }, + 'user.logout': { label: 'Logout', color: 'bg-slate-50 text-slate-700' }, + 'user.password_changed': { label: 'Cambio password', color: 'bg-amber-50 text-amber-700' }, + 'trial.started': { label: 'Trial iniciado', color: 'bg-sky-50 text-sky-700' }, + 'subscription.created': { label: 'Suscripción creada',color: 'bg-green-50 text-green-700' }, + 'subscription.cancelled': { label: 'Suscripción cancelada', color: 'bg-orange-50 text-orange-700' }, + 'subscription.reactivated': { label: 'Reactivada', color: 'bg-teal-50 text-teal-700' }, + 'subscription.plan_changed': { label: 'Cambio de plan', color: 'bg-indigo-50 text-indigo-700' }, + 'price.updated': { label: 'Precio editado', color: 'bg-purple-50 text-purple-700' }, + 'invoice.emitted_auto': { label: 'Factura auto', color: 'bg-emerald-50 text-emerald-700' }, + 'invoice.emitted_manual': { label: 'Factura manual', color: 'bg-emerald-50 text-emerald-700' }, + 'payment.marked_paid_manually': { label: 'Pago marcado manual', color: 'bg-lime-50 text-lime-700' }, +}; + +function formatDateTime(iso: string): string { + const d = new Date(iso); + return d.toLocaleString('es-MX', { dateStyle: 'short', timeStyle: 'medium' }); +} + +function ActionBadge({ action }: { action: string }) { + const cfg = ACTION_LABELS[action] || { label: action, color: 'bg-muted text-muted-foreground' }; + return {cfg.label}; +} + +export default function AuditLogPage() { + const { user } = useAuthStore(); + const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles); + + const [filters, setFilters] = useState({ + action: '', + tenantId: '', + userId: '', + from: '', + to: '', + page: 1, + limit: 50, + }); + const [expandedId, setExpandedId] = useState(null); + + const { data, isLoading } = useAuditLog(filters); + + if (!isGlobalAdmin) { + return ( + <> +
+
+ + + +

Acceso restringido

+

+ Solo el administrador global puede consultar el audit log. +

+
+
+
+ + ); + } + + const clearFilters = () => setFilters({ action: '', tenantId: '', userId: '', from: '', to: '', page: 1, limit: 50 }); + + return ( + <> +
+
+ + + + + Filtros + + + +
+
+ + +
+
+ + setFilters(f => ({ ...f, tenantId: e.target.value, page: 1 }))} placeholder="UUID del tenant" /> +
+
+ + setFilters(f => ({ ...f, userId: e.target.value, page: 1 }))} placeholder="UUID del usuario" /> +
+
+ + setFilters(f => ({ ...f, from: e.target.value, page: 1 }))} /> +
+
+ + setFilters(f => ({ ...f, to: e.target.value, page: 1 }))} /> +
+
+
+ +
+
+
+ + + + + Eventos {data?.total !== undefined && ({data.total.toLocaleString('es-MX')} totales)} + + + + {isLoading ? ( +

Cargando...

+ ) : !data || data.data.length === 0 ? ( +

No hay eventos con estos filtros.

+ ) : ( + <> +
+ + + + + + + + + + + + + {data.data.map(row => ( + + + + + + + + + ))} + +
FechaAcciónUsuarioTenantEntidad
{formatDateTime(row.createdAt)} + {row.user ? ( +
+
{row.user.nombre}
+
{row.user.email}
+
+ ) : Sistema} +
+ {row.tenant ? ( +
+
{row.tenant.nombre}
+
{row.tenant.rfc}
+
+ ) : } +
+ {row.entityType ? `${row.entityType}${row.entityId ? ` ${row.entityId.slice(0, 8)}` : ''}` : '—'} + + +
+
+ + {expandedId && (() => { + const row = data.data.find(r => r.id === expandedId); + if (!row) return null; + return ( + + +

Metadata del evento {row.id}

+
{JSON.stringify(row.metadata, null, 2)}
+
+
+ ); + })()} + + {data.totalPages > 1 && ( +
+

Página {data.page} de {data.totalPages}

+
+ + +
+
+ )} + + )} +
+
+
+ + ); +} diff --git a/apps/web/app/(dashboard)/admin/staff/page.tsx b/apps/web/app/(dashboard)/admin/staff/page.tsx new file mode 100644 index 0000000..3e5e3f1 --- /dev/null +++ b/apps/web/app/(dashboard)/admin/staff/page.tsx @@ -0,0 +1,264 @@ +'use client'; + +import { useState } from 'react'; +import { Header } from '@/components/layouts/header'; +import { Card, CardContent, CardHeader, CardTitle, Button, Input, Label, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@horux/shared-ui'; +import { useAuthStore } from '@/stores/auth-store'; +import { isGlobalAdminRfc, type PlatformRole } from '@horux/shared'; +import { useStaff, useSearchUsers, useGrantRole, useRevokeRole } from '@/lib/hooks/use-platform-staff'; +import { ShieldAlert, Shield, ShieldCheck, HeadphonesIcon, TrendingUp, DollarSign, UserPlus, X, Loader2, Search, Cpu } from 'lucide-react'; + +const ROLE_META: Record = { + platform_admin: { label: 'Admin', desc: 'Todo: gestión staff, precios, clientes, facturas', icon: ShieldCheck, color: 'bg-red-100 text-red-700 border-red-200' }, + platform_ti: { label: 'TI', desc: 'Equipo de TI. Mismos permisos que Admin (diferencia solo en trazabilidad)', icon: Cpu, color: 'bg-slate-100 text-slate-700 border-slate-200' }, + platform_support: { label: 'Support', desc: 'Ver tenants, resolver tickets', icon: HeadphonesIcon, color: 'bg-blue-100 text-blue-700 border-blue-200' }, + platform_sales: { label: 'Sales', desc: 'Crear/editar clientes, ver suscripciones', icon: TrendingUp, color: 'bg-green-100 text-green-700 border-green-200' }, + platform_finance: { label: 'Finance', desc: 'Pagos, facturas manuales, editar precios', icon: DollarSign, color: 'bg-amber-100 text-amber-700 border-amber-200' }, +}; + +const ALL_ROLES: PlatformRole[] = ['platform_admin', 'platform_ti', 'platform_support', 'platform_sales', 'platform_finance']; + +export default function StaffPage() { + const { user } = useAuthStore(); + const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles); + + const { data: staff = [], isLoading } = useStaff(); + const grantRole = useGrantRole(); + const revokeRole = useRevokeRole(); + + const [addOpen, setAddOpen] = useState(false); + const [searchQ, setSearchQ] = useState(''); + const { data: candidates = [] } = useSearchUsers(searchQ); + const [pickedUserId, setPickedUserId] = useState(null); + const [pickedRole, setPickedRole] = useState('platform_support'); + + if (!isGlobalAdmin) { + return ( + <> +
+
+ + + +

Acceso restringido

+

Solo platform_admin puede gestionar staff.

+
+
+
+ + ); + } + + const handleGrant = async () => { + if (!pickedUserId) return; + try { + await grantRole.mutateAsync({ userId: pickedUserId, role: pickedRole }); + setAddOpen(false); + setSearchQ(''); + setPickedUserId(null); + } catch (err: any) { + alert(err?.response?.data?.message || err?.message || 'Error al asignar rol'); + } + }; + + const handleRevoke = async (userId: string, role: PlatformRole, userEmail: string) => { + if (!confirm(`¿Quitar el rol "${ROLE_META[role].label}" a ${userEmail}?`)) return; + try { + await revokeRole.mutateAsync({ userId, role }); + } catch (err: any) { + alert(err?.response?.data?.message || err?.message || 'Error al quitar rol'); + } + }; + + return ( + <> +
+
+
+
+

+ Staff interno de Horux 360 con poderes transversales. platform_admin implica todos los otros roles. +

+
+ +
+ + + + Equipo ({staff.length}) + + + {isLoading ? ( +

Cargando...

+ ) : staff.length === 0 ? ( +

+ Todavía no hay staff. Agrega al primer miembro con el botón arriba. +

+ ) : ( +
+ + + + + + + + + + + {staff.map(s => ( + + + + + + + ))} + +
UsuarioTenant origenRolesAcciones
+
{s.nombre}
+
{s.email}
+
+ {s.tenant ? ( +
+
{s.tenant.nombre}
+
{s.tenant.rfc}
+
+ ) : } +
+
+ {s.roles.map(r => { + const meta = ROLE_META[r]; + const Icon = meta.icon; + return ( + + + {meta.label} + + + ); + })} +
+
+ +
+
+ )} +
+
+ + + + + + Descripción de roles + + + +
+ {ALL_ROLES.map(r => { + const m = ROLE_META[r]; + const Icon = m.icon; + return ( +
+
+ + {m.label} +
+

{m.desc}

+
+ ); + })} +
+
+
+ + {/* Add staff dialog */} + + + + Agregar rol de staff + + Busca al usuario por email o nombre y asígnale un rol de plataforma. + + + +
+
+ +
+ + { setSearchQ(e.target.value); setPickedUserId(null); }} + placeholder="email o nombre (min 2 caracteres)" + className="pl-8" + /> +
+ {searchQ.length >= 2 && candidates.length > 0 && !pickedUserId && ( +
+ {candidates.map(c => ( + + ))} +
+ )} +
+ +
+ +
+ {ALL_ROLES.map(r => { + const m = ROLE_META[r]; + const Icon = m.icon; + return ( + + ); + })} +
+
+
+ + + + + +
+
+
+ + ); +} diff --git a/apps/web/app/(dashboard)/admin/usuarios/page.tsx b/apps/web/app/(dashboard)/admin/usuarios/page.tsx new file mode 100644 index 0000000..ddcba35 --- /dev/null +++ b/apps/web/app/(dashboard)/admin/usuarios/page.tsx @@ -0,0 +1,311 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { DashboardShell } from '@/components/layouts/dashboard-shell'; +import { Card, CardContent, CardHeader, CardTitle, Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@horux/shared-ui'; +import { useAllUsuarios, useUpdateUsuarioGlobal, useDeleteUsuarioGlobal } from '@/lib/hooks/use-usuarios'; +import { getTenants, type Tenant } from '@/lib/api/tenants'; +import { useAuthStore } from '@/stores/auth-store'; +import { Users, Pencil, Trash2, Shield, Eye, Calculator, Building2, X, Check, UserCog, UserCheck, User, Briefcase } from 'lucide-react'; +import { cn } from '@horux/shared-ui'; + +// Mapa de roles + fallback defensivo. El fork despacho introduce roles +// adicionales (cfo, supervisor, auxiliar, cliente) que no estaban en +// Horux 360 single-tenant; si llega un rol no mapeado (ej. uno nuevo +// agregado en BD sin tocar este archivo), `defaultRoleInfo` previene +// runtime errors al hacer `roleInfo.icon`. +const roleLabels: Record = { + owner: { label: 'Dueño', icon: Shield, color: 'text-primary' }, + cfo: { label: 'CFO', icon: Briefcase, color: 'text-indigo-600' }, + contador: { label: 'Contador', icon: Calculator, color: 'text-green-600' }, + supervisor: { label: 'Supervisor', icon: UserCheck, color: 'text-blue-600' }, + auxiliar: { label: 'Auxiliar', icon: UserCog, color: 'text-cyan-600' }, + cliente: { label: 'Cliente', icon: User, color: 'text-amber-600' }, + visor: { label: 'Visor', icon: Eye, color: 'text-muted-foreground' }, +}; +const defaultRoleInfo = { label: 'Sin rol', icon: User, color: 'text-muted-foreground' }; + +interface EditingUser { + id: string; + nombre: string; + role: 'owner' | 'contador' | 'visor'; + tenantId: string; +} + +export default function AdminUsuariosPage() { + const { user: currentUser } = useAuthStore(); + const { data: usuarios, isLoading, error } = useAllUsuarios(); + const updateUsuario = useUpdateUsuarioGlobal(); + const deleteUsuario = useDeleteUsuarioGlobal(); + + const [tenants, setTenants] = useState([]); + const [editingUser, setEditingUser] = useState(null); + const [filterTenant, setFilterTenant] = useState('all'); + const [searchTerm, setSearchTerm] = useState(''); + + useEffect(() => { + getTenants().then(setTenants).catch(console.error); + }, []); + + const handleEdit = (usuario: any) => { + setEditingUser({ + id: usuario.id, + nombre: usuario.nombre, + role: usuario.role, + tenantId: usuario.tenantId, + }); + }; + + const handleSave = async () => { + if (!editingUser) return; + try { + await updateUsuario.mutateAsync({ + id: editingUser.id, + data: { + nombre: editingUser.nombre, + role: editingUser.role, + tenantId: editingUser.tenantId, + }, + }); + setEditingUser(null); + } catch (err: any) { + alert(err.response?.data?.error || 'Error al actualizar usuario'); + } + }; + + const handleDelete = async (id: string) => { + if (!confirm('Estas seguro de eliminar este usuario?')) return; + try { + await deleteUsuario.mutateAsync(id); + } catch (err: any) { + alert(err.response?.data?.error || 'Error al eliminar usuario'); + } + }; + + const filteredUsuarios = usuarios?.filter(u => { + const matchesTenant = filterTenant === 'all' || u.tenantId === filterTenant; + const matchesSearch = !searchTerm || + u.nombre.toLowerCase().includes(searchTerm.toLowerCase()) || + u.email.toLowerCase().includes(searchTerm.toLowerCase()); + return matchesTenant && matchesSearch; + }); + + // Agrupar por empresa + const groupedByTenant = filteredUsuarios?.reduce((acc, u) => { + const key = u.tenantId || 'sin-empresa'; + if (!acc[key]) { + acc[key] = { + tenantName: u.tenantName || 'Sin empresa', + users: [], + }; + } + acc[key].users.push(u); + return acc; + }, {} as Record); + + if (error) { + return ( + + + +

+ No tienes permisos para ver esta pagina o ocurrio un error. +

+
+
+
+ ); + } + + return ( + +
+ {/* Filtros */} + + +
+
+ setSearchTerm(e.target.value)} + /> +
+
+ +
+
+
+
+ + {/* Stats */} +
+
+ + {filteredUsuarios?.length || 0} usuarios +
+
+ + {Object.keys(groupedByTenant || {}).length} empresas +
+
+ + {/* Users by tenant */} + {isLoading ? ( + + + Cargando usuarios... + + + ) : ( + Object.entries(groupedByTenant || {}).map(([tenantId, { tenantName, users }]) => ( + + + + + {tenantName} + + ({users?.length} usuarios) + + + + +
+ {users?.map(usuario => { + const roleInfo = roleLabels[usuario.role] || defaultRoleInfo; + const RoleIcon = roleInfo.icon; + const isCurrentUser = usuario.id === currentUser?.id; + const isEditing = editingUser?.id === usuario.id; + + return ( +
+
+
+ {usuario.nombre.charAt(0).toUpperCase()} +
+
+ {isEditing ? ( +
+ setEditingUser({ ...editingUser, nombre: e.target.value })} + className="h-8" + /> +
+ + +
+
+ ) : ( + <> +
+ {usuario.nombre} + {isCurrentUser && ( + Tu + )} + {!usuario.active && ( + Inactivo + )} +
+
{usuario.email}
+ + )} +
+
+
+ {!isEditing && ( +
+ + {roleInfo.label} +
+ )} + {!isCurrentUser && ( +
+ {isEditing ? ( + <> + + + + ) : ( + <> + + + + )} +
+ )} +
+
+ ); + })} +
+
+
+ )) + )} +
+
+ ); +} diff --git a/apps/web/app/(dashboard)/alertas/cancelaciones-periodo-anterior/page.tsx b/apps/web/app/(dashboard)/alertas/cancelaciones-periodo-anterior/page.tsx new file mode 100644 index 0000000..41200ca --- /dev/null +++ b/apps/web/app/(dashboard)/alertas/cancelaciones-periodo-anterior/page.tsx @@ -0,0 +1,126 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { DashboardShell } from '@/components/layouts/dashboard-shell'; +import { Card, CardContent, Button, SortableHeader } from '@horux/shared-ui'; +import { apiClient } from '@/lib/api/client'; +import { formatCurrency } from '@/lib/utils'; +import { exportToExcel } from '@/lib/export-excel'; +import { useTableSort } from '@horux/shared-ui'; +import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal'; +import { Eye, Download } from 'lucide-react'; +import type { Cfdi } from '@horux/shared'; +import { useContribuyenteStore } from '@/stores/contribuyente-store'; + +const EXCEL_COLUMNS = [ + { header: 'UUID', key: 'uuid', width: 40 }, + { header: 'Fecha Emision', key: '_fechaEmision', width: 15 }, + { header: 'Fecha Cancelacion', key: '_fechaCancelacion', width: 18 }, + { header: 'RFC Emisor', key: 'rfcEmisor', width: 15 }, + { header: 'Nombre Emisor', key: 'nombreEmisor', width: 30 }, + { header: 'RFC Receptor', key: 'rfcReceptor', width: 15 }, + { header: 'Nombre Receptor', key: 'nombreReceptor', width: 30 }, + { header: 'Total MXN', key: '_totalMxn', width: 15 }, +]; + +function prepareRows(data: any[]) { + return data.map((c) => ({ + ...c, + _fechaEmision: new Date(c.fechaEmision).toLocaleDateString('es-MX'), + _fechaCancelacion: c.fechaCancelacion ? new Date(c.fechaCancelacion).toLocaleDateString('es-MX') : '', + _totalMxn: Number(c.totalMxn || 0), + })); +} + +export default function CancelacionesPeriodoAnteriorPage() { + const [selectedCfdi, setSelectedCfdi] = useState(null); + const { selectedContribuyenteId } = useContribuyenteStore(); + + const { data, isLoading } = useQuery({ + queryKey: ['drilldown-cancelaciones-periodo-anterior', selectedContribuyenteId], + queryFn: async () => { + const params = new URLSearchParams(); + if (selectedContribuyenteId) params.set('contribuyenteId', selectedContribuyenteId); + const res = await apiClient.get(`/alertas/drilldown/cancelaciones-periodo-anterior?${params}`); + return res.data; + }, + }); + + const { sortedData, toggleSort, getSortIndicator } = useTableSort( + data, + { + fecha: (c) => new Date(c.fechaEmision).getTime(), + cancelacion: (c: any) => c.fechaCancelacion ? new Date(c.fechaCancelacion).getTime() : 0, + total: (c) => Number(c.totalMxn || 0), + }, + 'cancelacion', + ); + + const handleExport = () => { + if (!sortedData || sortedData.length === 0) return; + exportToExcel(prepareRows(sortedData), EXCEL_COLUMNS, 'cancelaciones-periodo-anterior'); + }; + + return ( + + + + {isLoading ? ( +
Cargando...
+ ) : !data || data.length === 0 ? ( +
No hay CFDIs cancelados de periodos anteriores
+ ) : ( +
+
+

+ {data.length} CFDIs emitidos en meses anteriores y cancelados este mes +

+ +
+ + + + + toggleSort('fecha')} /> + toggleSort('cancelacion')} /> + + + toggleSort('total')} /> + + + + + {(sortedData || []).map((cfdi: any) => ( + + + + + + + + + + ))} + +
UUIDRFC EmisorRFC Receptor
{cfdi.uuid?.substring(0, 8)}{new Date(cfdi.fechaEmision).toLocaleDateString('es-MX')}{cfdi.fechaCancelacion ? new Date(cfdi.fechaCancelacion).toLocaleDateString('es-MX') : '-'}{cfdi.rfcEmisor}{cfdi.rfcReceptor}{formatCurrency(Number(cfdi.totalMxn))} + +
+
+ )} +
+
+ + setSelectedCfdi(null)} + /> +
+ ); +} diff --git a/apps/web/app/(dashboard)/alertas/cancelaciones/page.tsx b/apps/web/app/(dashboard)/alertas/cancelaciones/page.tsx new file mode 100644 index 0000000..49afd7c --- /dev/null +++ b/apps/web/app/(dashboard)/alertas/cancelaciones/page.tsx @@ -0,0 +1,120 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { DashboardShell } from '@/components/layouts/dashboard-shell'; +import { Card, CardContent, Button, SortableHeader } from '@horux/shared-ui'; +import { apiClient } from '@/lib/api/client'; +import { formatCurrency } from '@/lib/utils'; +import { exportToExcel } from '@/lib/export-excel'; +import { useTableSort } from '@horux/shared-ui'; +import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal'; +import { Eye, Download } from 'lucide-react'; +import type { Cfdi } from '@horux/shared'; + +const EXCEL_COLUMNS = [ + { header: 'UUID', key: 'uuid', width: 40 }, + { header: 'Fecha Emision', key: '_fechaEmision', width: 15 }, + { header: 'Fecha Cancelacion', key: '_fechaCancelacion', width: 18 }, + { header: 'RFC Emisor', key: 'rfcEmisor', width: 15 }, + { header: 'Nombre Emisor', key: 'nombreEmisor', width: 30 }, + { header: 'RFC Receptor', key: 'rfcReceptor', width: 15 }, + { header: 'Nombre Receptor', key: 'nombreReceptor', width: 30 }, + { header: 'Total MXN', key: '_totalMxn', width: 15 }, +]; + +function prepareRows(data: any[]) { + return data.map((c) => ({ + ...c, + _fechaEmision: new Date(c.fechaEmision).toLocaleDateString('es-MX'), + _fechaCancelacion: c.fechaCancelacion ? new Date(c.fechaCancelacion).toLocaleDateString('es-MX') : '', + _totalMxn: Number(c.totalMxn || 0), + })); +} + +export default function CancelacionesPage() { + const [selectedCfdi, setSelectedCfdi] = useState(null); + + const { data, isLoading } = useQuery({ + queryKey: ['drilldown-cancelaciones'], + queryFn: async () => { + const res = await apiClient.get('/alertas/drilldown/cancelaciones'); + return res.data; + }, + }); + + const { sortedData, toggleSort, getSortIndicator } = useTableSort( + data, + { + fecha: (c) => new Date(c.fechaEmision).getTime(), + cancelacion: (c: any) => c.fechaCancelacion ? new Date(c.fechaCancelacion).getTime() : 0, + total: (c) => Number(c.totalMxn || 0), + }, + 'cancelacion', + ); + + const handleExport = () => { + if (!sortedData || sortedData.length === 0) return; + exportToExcel(prepareRows(sortedData), EXCEL_COLUMNS, 'cfdis-cancelados'); + }; + + return ( + + + + {isLoading ? ( +
Cargando...
+ ) : !data || data.length === 0 ? ( +
No hay CFDIs cancelados
+ ) : ( +
+
+

{data.length} CFDIs cancelados

+ +
+ + + + + toggleSort('fecha')} /> + toggleSort('cancelacion')} /> + + + toggleSort('total')} /> + + + + + {(sortedData || []).map((cfdi: any) => ( + + + + + + + + + + ))} + +
UUIDRFC EmisorRFC Receptor
{cfdi.uuid?.substring(0, 8)}{new Date(cfdi.fechaEmision).toLocaleDateString('es-MX')}{cfdi.fechaCancelacion ? new Date(cfdi.fechaCancelacion).toLocaleDateString('es-MX') : '-'}{cfdi.rfcEmisor}{cfdi.rfcReceptor}{formatCurrency(Number(cfdi.totalMxn))} + +
+
+ )} +
+
+ + setSelectedCfdi(null)} + /> +
+ ); +} diff --git a/apps/web/app/(dashboard)/alertas/concentracion-clientes/page.tsx b/apps/web/app/(dashboard)/alertas/concentracion-clientes/page.tsx new file mode 100644 index 0000000..339a01e --- /dev/null +++ b/apps/web/app/(dashboard)/alertas/concentracion-clientes/page.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import { DashboardShell } from '@/components/layouts/dashboard-shell'; +import { Card, CardContent, CardHeader, CardTitle, SortableHeader } from '@horux/shared-ui'; +import { apiClient } from '@/lib/api/client'; +import { formatCurrency } from '@/lib/utils'; +import { useTableSort } from '@horux/shared-ui'; + +export default function ConcentracionClientesPage() { + const { data, isLoading } = useQuery({ + queryKey: ['drilldown-concentracion-clientes'], + queryFn: async () => { + const res = await apiClient.get('/alertas/drilldown/concentracion-clientes'); + return res.data; + }, + }); + + const { sortedData, toggleSort, getSortIndicator } = useTableSort( + data, + { + cfdis: (d) => Number(d.cantidad || 0), + total: (d) => Number(d.total || 0), + }, + 'total', + ); + + return ( + + + + Participacion por Cliente (Facturas Emitidas) + + + {isLoading ? ( +
Cargando...
+ ) : !data || data.length === 0 ? ( +
No hay datos
+ ) : ( +
+ + + + + + toggleSort('cfdis')} /> + toggleSort('total')} /> + + + + + {(sortedData || []).map((d: any) => ( + + + + + + + + ))} + +
RFCNombreParticipacion
{d.rfc}{d.nombre}{d.cantidad}{formatCurrency(d.total)} +
+
+
+
+ {d.participacion}% +
+
+
+ )} +
+
+
+ ); +} diff --git a/apps/web/app/(dashboard)/alertas/concentracion-proveedores/page.tsx b/apps/web/app/(dashboard)/alertas/concentracion-proveedores/page.tsx new file mode 100644 index 0000000..ba96a65 --- /dev/null +++ b/apps/web/app/(dashboard)/alertas/concentracion-proveedores/page.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import { DashboardShell } from '@/components/layouts/dashboard-shell'; +import { Card, CardContent, CardHeader, CardTitle, SortableHeader } from '@horux/shared-ui'; +import { apiClient } from '@/lib/api/client'; +import { formatCurrency } from '@/lib/utils'; +import { useTableSort } from '@horux/shared-ui'; + +export default function ConcentracionProveedoresPage() { + const { data, isLoading } = useQuery({ + queryKey: ['drilldown-concentracion-proveedores'], + queryFn: async () => { + const res = await apiClient.get('/alertas/drilldown/concentracion-proveedores'); + return res.data; + }, + }); + + const { sortedData, toggleSort, getSortIndicator } = useTableSort( + data, + { + cfdis: (d) => Number(d.cantidad || 0), + total: (d) => Number(d.total || 0), + }, + 'total', + ); + + return ( + + + + Participacion por Proveedor (Facturas Recibidas) + + + {isLoading ? ( +
Cargando...
+ ) : !data || data.length === 0 ? ( +
No hay datos
+ ) : ( +
+ + + + + + toggleSort('cfdis')} /> + toggleSort('total')} /> + + + + + {(sortedData || []).map((d: any) => ( + + + + + + + + ))} + +
RFCNombreParticipacion
{d.rfc}{d.nombre}{d.cantidad}{formatCurrency(d.total)} +
+
+
+
+ {d.participacion}% +
+
+
+ )} +
+
+
+ ); +} diff --git a/apps/web/app/(dashboard)/alertas/discrepancia-regimen/page.tsx b/apps/web/app/(dashboard)/alertas/discrepancia-regimen/page.tsx new file mode 100644 index 0000000..61d76e5 --- /dev/null +++ b/apps/web/app/(dashboard)/alertas/discrepancia-regimen/page.tsx @@ -0,0 +1,344 @@ +'use client'; + +import { useState, useMemo } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { DashboardShell } from '@/components/layouts/dashboard-shell'; +import { Card, CardContent, CardHeader, CardTitle, Button, SortableHeader, Input } from '@horux/shared-ui'; +import { apiClient } from '@/lib/api/client'; +import { formatCurrency } from '@/lib/utils'; +import { exportToExcel } from '@/lib/export-excel'; +import { useTableSort } from '@horux/shared-ui'; +import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal'; +import { Eye, Download, CheckSquare, Square, EyeOff, Filter, RotateCcw } from 'lucide-react'; +import type { Cfdi } from '@horux/shared'; +import { useContribuyenteStore } from '@/stores/contribuyente-store'; + +const TIPO_ALERTA = 'discrepancia-regimen'; + +const EXCEL_COLUMNS = [ + { header: 'UUID', key: 'uuid', width: 40 }, + { header: 'Fecha', key: '_fecha', width: 15 }, + { header: 'RFC Emisor', key: 'rfcEmisor', width: 15 }, + { header: 'Nombre Emisor', key: 'nombreEmisor', width: 30 }, + { header: 'RFC Receptor', key: 'rfcReceptor', width: 15 }, + { header: 'Nombre Receptor', key: 'nombreReceptor', width: 30 }, + { header: 'Regimen Receptor', key: 'regimenReceptor', width: 18 }, + { header: 'Total MXN', key: '_totalMxn', width: 15 }, +]; + +function prepareRows(data: any[]) { + return data.map((c) => ({ + ...c, + _fecha: new Date(c.fechaEmision).toLocaleDateString('es-MX'), + _totalMxn: Number(c.totalMxn || 0), + regimenReceptor: c.regimenReceptor || c.regimenFiscalReceptor || '', + })); +} + +export default function DiscrepanciaRegimenPage() { + const [selectedCfdi, setSelectedCfdi] = useState(null); + const [checked, setChecked] = useState>(new Set()); + const [view, setView] = useState<'activos' | 'descartados'>('activos'); + const { selectedContribuyenteId } = useContribuyenteStore(); + const queryClient = useQueryClient(); + + // Filters + const [fechaDesde, setFechaDesde] = useState(''); + const [fechaHasta, setFechaHasta] = useState(''); + const [regimenFilter, setRegimenFilter] = useState(''); + + // Activos (lo que aparece en la alerta) + const activosQ = useQuery({ + queryKey: ['drilldown-discrepancia', selectedContribuyenteId], + queryFn: async () => { + const params = new URLSearchParams(); + if (selectedContribuyenteId) params.set('contribuyenteId', selectedContribuyenteId); + const res = await apiClient.get(`/alertas/drilldown/discrepancia-regimen?${params}`); + return res.data; + }, + enabled: view === 'activos', + }); + + // Descartados (lo que ya se marcó para ignorar) + const descartadosQ = useQuery({ + queryKey: ['descartados-discrepancia', selectedContribuyenteId], + queryFn: async () => { + const params = new URLSearchParams({ tipoAlerta: TIPO_ALERTA }); + if (selectedContribuyenteId) params.set('contribuyenteId', selectedContribuyenteId); + const res = await apiClient.get<{ data: Cfdi[] }>(`/alertas/descartados?${params}`); + return res.data.data; + }, + enabled: view === 'descartados', + }); + + const data = view === 'activos' ? activosQ.data : descartadosQ.data; + const isLoading = view === 'activos' ? activosQ.isLoading : descartadosQ.isLoading; + + // Extract unique regímenes for the filter dropdown + const regimenesUnicos = useMemo(() => { + if (!data) return []; + const set = new Set(); + data.forEach((c: any) => { + const reg = c.regimenReceptor || c.regimenFiscalReceptor; + if (reg) set.add(reg); + }); + return [...set].sort(); + }, [data]); + + // Apply filters: fecha + regimen (descartados already excluded by backend) + const visibleData = useMemo(() => { + if (!data) return []; + let filtered = data; + + if (fechaDesde) { + filtered = filtered.filter(c => c.fechaEmision >= fechaDesde); + } + if (fechaHasta) { + filtered = filtered.filter(c => c.fechaEmision <= fechaHasta + 'T23:59:59'); + } + if (regimenFilter) { + filtered = filtered.filter((c: any) => (c.regimenReceptor || c.regimenFiscalReceptor) === regimenFilter); + } + + return filtered; + }, [data, fechaDesde, fechaHasta, regimenFilter]); + + const { sortedData, toggleSort, getSortIndicator } = useTableSort( + visibleData, + { + fecha: (c) => new Date(c.fechaEmision).getTime(), + total: (c) => Number(c.totalMxn || 0), + }, + 'fecha', + ); + + const handleExport = () => { + if (!sortedData || sortedData.length === 0) return; + exportToExcel(prepareRows(sortedData), EXCEL_COLUMNS, 'cfdis-discrepancia-regimen'); + }; + + const toggleCheck = (id: string) => { + setChecked(prev => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); else next.add(id); + return next; + }); + }; + + const toggleSelectAll = () => { + if (!sortedData) return; + if (checked.size === sortedData.length) { + setChecked(new Set()); + } else { + setChecked(new Set(sortedData.map(c => String(c.id)))); + } + }; + + const invalidateAll = () => { + queryClient.invalidateQueries({ queryKey: ['drilldown-discrepancia'] }); + queryClient.invalidateQueries({ queryKey: ['descartados-discrepancia'] }); + queryClient.invalidateQueries({ queryKey: ['alertas-automaticas'] }); + queryClient.invalidateQueries({ queryKey: ['alertas'] }); + }; + + const handleDescartar = async () => { + const cfdiIds = [...checked].map(id => Number(id)); + try { + await apiClient.post('/alertas/descartar', { cfdiIds, tipoAlerta: TIPO_ALERTA }); + setChecked(new Set()); + invalidateAll(); + } catch { + alert('Error al descartar'); + } + }; + + const handleRestaurar = async () => { + const cfdiIds = [...checked].map(id => Number(id)); + try { + await apiClient.delete('/alertas/descartar', { data: { cfdiIds, tipoAlerta: TIPO_ALERTA } }); + setChecked(new Set()); + invalidateAll(); + } catch { + alert('Error al restaurar'); + } + }; + + const handleChangeView = (next: 'activos' | 'descartados') => { + setView(next); + setChecked(new Set()); + }; + + const handleClearFilters = () => { + setFechaDesde(''); + setFechaHasta(''); + setRegimenFilter(''); + }; + + const hasActiveFilters = fechaDesde || fechaHasta || regimenFilter; + + const allChecked = sortedData && sortedData.length > 0 && + checked.size === sortedData.length; + + return ( + + + +
+ + {view === 'activos' + ? 'Facturas recibidas con régimen fiscal que no coincide con los regímenes activos' + : 'CFDIs descartados manualmente — ignorados en la alerta'} + +
+ {/* Toggle Activos / Descartados */} +
+ + +
+ {checked.size > 0 && view === 'activos' && ( + + )} + {checked.size > 0 && view === 'descartados' && ( + + )} + {data && data.length > 0 && ( + + )} +
+
+ + {/* Filters */} +
+
+ + Filtros: +
+
+ + setFechaDesde(e.target.value)} + className="h-8 w-[150px] text-sm" + /> +
+
+ + setFechaHasta(e.target.value)} + className="h-8 w-[150px] text-sm" + /> +
+
+ + +
+ {hasActiveFilters && ( + + )} +
+
+ + {isLoading ? ( +
Cargando...
+ ) : !sortedData || sortedData.length === 0 ? ( +
+ {hasActiveFilters + ? 'No hay resultados con los filtros seleccionados' + : view === 'activos' + ? 'No hay discrepancias nuevas' + : 'No hay CFDIs descartados'} +
+ ) : ( +
+ + + + + + toggleSort('fecha')} /> + + + + toggleSort('total')} /> + + + + + {sortedData.map((cfdi: any) => ( + + + + + + + + + + + ))} + +
+ + UUIDRFC EmisorNombre EmisorRégimen Receptor
+ + {cfdi.uuid?.substring(0, 8)}{new Date(cfdi.fechaEmision).toLocaleDateString('es-MX')}{cfdi.rfcEmisor}{cfdi.nombreEmisor}{cfdi.regimenReceptor}{formatCurrency(Number(cfdi.totalMxn))} + +
+

+ {sortedData.length} CFDI{sortedData.length !== 1 ? 's' : ''} {view === 'activos' ? 'con discrepancia' : 'descartados'} + {hasActiveFilters && data && ` (de ${data.length} total)`} +

+
+ )} +
+
+ + setSelectedCfdi(null)} + /> +
+ ); +} diff --git a/apps/web/app/(dashboard)/alertas/efectivo/page.tsx b/apps/web/app/(dashboard)/alertas/efectivo/page.tsx new file mode 100644 index 0000000..14fea33 --- /dev/null +++ b/apps/web/app/(dashboard)/alertas/efectivo/page.tsx @@ -0,0 +1,118 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { DashboardShell } from '@/components/layouts/dashboard-shell'; +import { Card, CardContent, Button, SortableHeader } from '@horux/shared-ui'; +import { apiClient } from '@/lib/api/client'; +import { formatCurrency } from '@/lib/utils'; +import { exportToExcel } from '@/lib/export-excel'; +import { useTableSort } from '@horux/shared-ui'; +import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal'; +import { Eye, Download } from 'lucide-react'; +import type { Cfdi } from '@horux/shared'; + +const EXCEL_COLUMNS = [ + { header: 'UUID', key: 'uuid', width: 40 }, + { header: 'Fecha', key: '_fecha', width: 15 }, + { header: 'RFC Emisor', key: 'rfcEmisor', width: 15 }, + { header: 'Nombre Emisor', key: 'nombreEmisor', width: 30 }, + { header: 'RFC Receptor', key: 'rfcReceptor', width: 15 }, + { header: 'Nombre Receptor', key: 'nombreReceptor', width: 30 }, + { header: 'Total MXN', key: '_totalMxn', width: 15 }, + { header: 'Forma Pago', key: 'formaPago', width: 12 }, +]; + +function prepareRows(data: any[]) { + return data.map((c) => ({ + ...c, + _fecha: new Date(c.fechaEmision).toLocaleDateString('es-MX'), + _totalMxn: Number(c.totalMxn || 0), + })); +} + +export default function EfectivoPage() { + const [selectedCfdi, setSelectedCfdi] = useState(null); + + const { data, isLoading } = useQuery({ + queryKey: ['drilldown-efectivo'], + queryFn: async () => { + const res = await apiClient.get('/alertas/drilldown/efectivo'); + return res.data; + }, + }); + + const { sortedData, toggleSort, getSortIndicator } = useTableSort( + data, + { + fecha: (c) => new Date(c.fechaEmision).getTime(), + total: (c) => Number(c.totalMxn || 0), + }, + 'fecha', + ); + + const handleExport = () => { + if (!sortedData || sortedData.length === 0) return; + exportToExcel(prepareRows(sortedData), EXCEL_COLUMNS, 'cfdis-pago-efectivo'); + }; + + return ( + + + + {isLoading ? ( +
Cargando...
+ ) : !data || data.length === 0 ? ( +
No hay CFDIs con pago en efectivo
+ ) : ( +
+
+

{data.length} CFDIs con pago en efectivo

+ +
+ + + + + toggleSort('fecha')} /> + + + + toggleSort('total')} /> + + + + + {(sortedData || []).map((cfdi: any) => ( + + + + + + + + + + ))} + +
UUIDRFC EmisorNombre EmisorRFC Receptor
{cfdi.uuid?.substring(0, 8)}{new Date(cfdi.fechaEmision).toLocaleDateString('es-MX')}{cfdi.rfcEmisor}{cfdi.nombreEmisor}{cfdi.rfcReceptor}{formatCurrency(Number(cfdi.totalMxn))} + +
+
+ )} +
+
+ + setSelectedCfdi(null)} + /> +
+ ); +} diff --git a/apps/web/app/(dashboard)/alertas/lista-negra-clientes/page.tsx b/apps/web/app/(dashboard)/alertas/lista-negra-clientes/page.tsx new file mode 100644 index 0000000..40e7b3a --- /dev/null +++ b/apps/web/app/(dashboard)/alertas/lista-negra-clientes/page.tsx @@ -0,0 +1,63 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import { DashboardShell } from '@/components/layouts/dashboard-shell'; +import { Card, CardContent, CardHeader, CardTitle } from '@horux/shared-ui'; +import { apiClient } from '@/lib/api/client'; +import { formatCurrency } from '@/lib/utils'; + +export default function ListaNegraClientesPage() { + const { data, isLoading } = useQuery({ + queryKey: ['drilldown-lista-negra-clientes'], + queryFn: async () => { + const res = await apiClient.get('/alertas/drilldown/lista-negra-clientes'); + return res.data; + }, + }); + + return ( + + + + Clientes a los que has facturado que aparecen en la lista del Art. 69-B + + + {isLoading ? ( +
Cargando...
+ ) : !data || data.length === 0 ? ( +
Ningun cliente en lista negra
+ ) : ( +
+ + + + + + + + + + + + {data.map((d: any) => ( + + + + + + + + ))} + +
RFCNombreSituacion SATCFDIsTotal Facturado
{d.rfc}{d.nombre} + {d.situacionSat} + {d.cantidad}{formatCurrency(d.total)}
+
+ )} +
+
+
+ ); +} diff --git a/apps/web/app/(dashboard)/alertas/lista-negra-proveedores/page.tsx b/apps/web/app/(dashboard)/alertas/lista-negra-proveedores/page.tsx new file mode 100644 index 0000000..e1d09d6 --- /dev/null +++ b/apps/web/app/(dashboard)/alertas/lista-negra-proveedores/page.tsx @@ -0,0 +1,63 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import { DashboardShell } from '@/components/layouts/dashboard-shell'; +import { Card, CardContent, CardHeader, CardTitle } from '@horux/shared-ui'; +import { apiClient } from '@/lib/api/client'; +import { formatCurrency } from '@/lib/utils'; + +export default function ListaNegraProveedoresPage() { + const { data, isLoading } = useQuery({ + queryKey: ['drilldown-lista-negra-proveedores'], + queryFn: async () => { + const res = await apiClient.get('/alertas/drilldown/lista-negra-proveedores'); + return res.data; + }, + }); + + return ( + + + + Proveedores de los que has recibido facturas que aparecen en la lista del Art. 69-B + + + {isLoading ? ( +
Cargando...
+ ) : !data || data.length === 0 ? ( +
Ningun proveedor en lista negra
+ ) : ( +
+ + + + + + + + + + + + {data.map((d: any) => ( + + + + + + + + ))} + +
RFCNombreSituacion SATCFDIsTotal Facturado
{d.rfc}{d.nombre} + {d.situacionSat} + {d.cantidad}{formatCurrency(d.total)}
+
+ )} +
+
+
+ ); +} diff --git a/apps/web/app/(dashboard)/alertas/page.tsx b/apps/web/app/(dashboard)/alertas/page.tsx new file mode 100644 index 0000000..99e52ae --- /dev/null +++ b/apps/web/app/(dashboard)/alertas/page.tsx @@ -0,0 +1,241 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useRouter } from 'next/navigation'; +import { DashboardShell } from '@/components/layouts/dashboard-shell'; +import { Card, CardContent, CardHeader, CardTitle, Button } from '@horux/shared-ui'; +import { useAlertas, useAlertasStats, useUpdateAlerta, useDeleteAlerta, useMarkAllAsRead } from '@/lib/hooks/use-alertas'; +import { apiClient } from '@/lib/api/client'; +import { Bell, Check, Trash2, AlertTriangle, Info, AlertCircle, CheckCircle, ShieldAlert, ChevronRight, Clock } from 'lucide-react'; +import { cn } from '@horux/shared-ui'; +import { useContribuyenteStore } from '@/stores/contribuyente-store'; + +interface AlertaAuto { + id: string; + tipo: string; + titulo: string; + mensaje: string; + prioridad: 'alta' | 'media' | 'baja'; + detalle?: string; + valor?: number; +} + +const prioridadStyles = { + alta: 'border-l-4 border-l-destructive bg-destructive/5', + media: 'border-l-4 border-l-warning bg-warning/5', + baja: 'border-l-4 border-l-muted bg-muted/5', +}; + +const prioridadIcons = { + alta: AlertCircle, + media: AlertTriangle, + baja: Info, +}; + +export default function AlertasPage() { + const [filter, setFilter] = useState<'todas' | 'pendientes' | 'resueltas'>('pendientes'); + const { data: alertas, isLoading } = useAlertas({ + resuelta: filter === 'resueltas' ? true : filter === 'pendientes' ? false : undefined, + }); + const { data: stats } = useAlertasStats(); + const updateAlerta = useUpdateAlerta(); + const deleteAlerta = useDeleteAlerta(); + const markAllAsRead = useMarkAllAsRead(); + const router = useRouter(); + const { selectedContribuyenteId } = useContribuyenteStore(); + + const queryClient = useQueryClient(); + + const { data: alertasAuto } = useQuery({ + queryKey: ['alertas-automaticas', selectedContribuyenteId], + queryFn: async () => { + const params = new URLSearchParams(); + if (selectedContribuyenteId) params.set('contribuyenteId', selectedContribuyenteId); + const res = await apiClient.get(`/alertas/automaticas?${params}`); + return res.data; + }, + }); + + const { data: alertasManuales } = useQuery({ + queryKey: ['alertas-manuales', selectedContribuyenteId], + queryFn: async () => { + const params = new URLSearchParams(); + if (selectedContribuyenteId) params.set('contribuyenteId', selectedContribuyenteId); + const res = await apiClient.get(`/alertas/manuales?${params}`); + return res.data; + }, + }); + + const handleResolver = async (id: string) => { + await apiClient.patch(`/alertas/manuales/${id}/resolver`); + queryClient.invalidateQueries({ queryKey: ['alertas-manuales'] }); + }; + + const handleMarkAsRead = (id: number) => { + updateAlerta.mutate({ id, data: { leida: true } }); + }; + + const handleResolve = (id: number) => { + updateAlerta.mutate({ id, data: { resuelta: true } }); + }; + + const handleDelete = (id: number) => { + if (confirm('¿Eliminar esta alerta?')) { + deleteAlerta.mutate(id); + } + }; + + return ( + +
+ {/* Stats */} +
+ + + Alertas del Sistema + + + +
{alertasAuto?.length || 0}
+
+
+ + + Obligaciones Pendientes + + + +
{alertasManuales?.length || 0}
+
+
+ + + Total Alertas + + + +
+ {(alertasAuto?.length || 0) + (alertasManuales?.length || 0)} +
+
+
+
+ + {/* Alertas Automáticas */} + {alertasAuto && alertasAuto.length > 0 && ( + + + + + Alertas del Sistema ({alertasAuto.length}) + + + + {alertasAuto.map((alerta) => { + const Icon = alerta.prioridad === 'alta' ? AlertCircle : AlertTriangle; + return ( +
+
+ +
+

{alerta.titulo}

+

{alerta.mensaje}

+
+
+ {alerta.detalle && ( +
+ +
+ )} +
+ ); + })} +
+
+ )} + + {/* Obligaciones Fiscales Pendientes */} + + + + + Obligaciones Fiscales Pendientes ({alertasManuales?.length || 0}) + + + + {!alertasManuales || alertasManuales.length === 0 ? ( +
+ +

Todas las obligaciones fiscales estan al dia

+
+ ) : ( +
+ {alertasManuales.map((alerta: any) => { + const esPago = alerta.tipo.startsWith('pago-'); + const Icon = prioridadIcons[alerta.prioridad as keyof typeof prioridadIcons] || AlertTriangle; + return ( +
+
+ +
+

{alerta.titulo}

+

{alerta.mensaje}

+
+
+
+ + Vencio: {(() => { + const d = new Date(alerta.fechaVencimiento); + return isNaN(d.getTime()) ? '' : d.toLocaleDateString('es-MX', { day: 'numeric', month: 'short', year: 'numeric' }); + })()} + + +
+
+ ); + })} +
+ )} +
+
+
+
+ ); +} diff --git a/apps/web/app/(dashboard)/alertas/tipo-relacion-sospechosa/page.tsx b/apps/web/app/(dashboard)/alertas/tipo-relacion-sospechosa/page.tsx new file mode 100644 index 0000000..183eb25 --- /dev/null +++ b/apps/web/app/(dashboard)/alertas/tipo-relacion-sospechosa/page.tsx @@ -0,0 +1,341 @@ +'use client'; + +import { useState, useMemo } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { DashboardShell } from '@/components/layouts/dashboard-shell'; +import { Card, CardContent, CardHeader, CardTitle, Button, SortableHeader, Input } from '@horux/shared-ui'; +import { apiClient } from '@/lib/api/client'; +import { formatCurrency } from '@/lib/utils'; +import { exportToExcel } from '@/lib/export-excel'; +import { useTableSort } from '@horux/shared-ui'; +import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal'; +import { Eye, Download, CheckSquare, Square, EyeOff, Filter, RotateCcw } from 'lucide-react'; +import type { Cfdi } from '@horux/shared'; +import { useContribuyenteStore } from '@/stores/contribuyente-store'; + +const TIPO_ALERTA = 'tipo-relacion-sospechosa'; + +const EXCEL_COLUMNS = [ + { header: 'UUID', key: 'uuid', width: 40 }, + { header: 'Fecha', key: '_fecha', width: 15 }, + { header: 'RFC Emisor', key: 'rfcEmisor', width: 15 }, + { header: 'Nombre Emisor', key: 'nombreEmisor', width: 30 }, + { header: 'RFC Receptor', key: 'rfcReceptor', width: 15 }, + { header: 'Nombre Receptor', key: 'nombreReceptor', width: 30 }, + { header: 'TipoRelacion', key: 'cfdiTipoRelacion', width: 14 }, + { header: 'CFDIs Relacionados', key: 'cfdisRelacionados', width: 50 }, + { header: 'Total MXN', key: '_totalMxn', width: 15 }, +]; + +function prepareRows(data: any[]) { + return data.map((c) => ({ + ...c, + _fecha: new Date(c.fechaEmision).toLocaleDateString('es-MX'), + _totalMxn: Number(c.totalMxn || 0), + })); +} + +export default function TipoRelacionSospechosaPage() { + const [selectedCfdi, setSelectedCfdi] = useState(null); + const [checked, setChecked] = useState>(new Set()); + const [view, setView] = useState<'activos' | 'descartados'>('activos'); + const { selectedContribuyenteId } = useContribuyenteStore(); + const queryClient = useQueryClient(); + + const [fechaDesde, setFechaDesde] = useState(''); + const [fechaHasta, setFechaHasta] = useState(''); + const [tipoRelFilter, setTipoRelFilter] = useState(''); + + const activosQ = useQuery({ + queryKey: ['drilldown-tipo-relacion-sospechosa', selectedContribuyenteId], + queryFn: async () => { + const params = new URLSearchParams(); + if (selectedContribuyenteId) params.set('contribuyenteId', selectedContribuyenteId); + const res = await apiClient.get(`/alertas/drilldown/${TIPO_ALERTA}?${params}`); + return res.data; + }, + enabled: view === 'activos', + }); + + const descartadosQ = useQuery({ + queryKey: ['descartados-tipo-relacion-sospechosa', selectedContribuyenteId], + queryFn: async () => { + const params = new URLSearchParams({ tipoAlerta: TIPO_ALERTA }); + if (selectedContribuyenteId) params.set('contribuyenteId', selectedContribuyenteId); + const res = await apiClient.get<{ data: Cfdi[] }>(`/alertas/descartados?${params}`); + return res.data.data; + }, + enabled: view === 'descartados', + }); + + const data = view === 'activos' ? activosQ.data : descartadosQ.data; + const isLoading = view === 'activos' ? activosQ.isLoading : descartadosQ.isLoading; + + const tiposRelUnicos = useMemo(() => { + if (!data) return []; + const set = new Set(); + data.forEach((c: any) => { + if (c.cfdiTipoRelacion) set.add(c.cfdiTipoRelacion); + }); + return [...set].sort(); + }, [data]); + + const visibleData = useMemo(() => { + if (!data) return []; + let filtered = data; + if (fechaDesde) filtered = filtered.filter(c => c.fechaEmision >= fechaDesde); + if (fechaHasta) filtered = filtered.filter(c => c.fechaEmision <= fechaHasta + 'T23:59:59'); + if (tipoRelFilter) filtered = filtered.filter((c: any) => c.cfdiTipoRelacion === tipoRelFilter); + return filtered; + }, [data, fechaDesde, fechaHasta, tipoRelFilter]); + + const { sortedData, toggleSort, getSortIndicator } = useTableSort( + visibleData, + { + fecha: (c) => new Date(c.fechaEmision).getTime(), + total: (c) => Number(c.totalMxn || 0), + }, + 'fecha', + ); + + const handleExport = () => { + if (!sortedData || sortedData.length === 0) return; + exportToExcel(prepareRows(sortedData), EXCEL_COLUMNS, 'cfdis-tipo-relacion-sospechosa'); + }; + + const toggleCheck = (id: string) => { + setChecked(prev => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); else next.add(id); + return next; + }); + }; + + const toggleSelectAll = () => { + if (!sortedData) return; + if (checked.size === sortedData.length) { + setChecked(new Set()); + } else { + setChecked(new Set(sortedData.map(c => String(c.id)))); + } + }; + + const invalidateAll = () => { + queryClient.invalidateQueries({ queryKey: ['drilldown-tipo-relacion-sospechosa'] }); + queryClient.invalidateQueries({ queryKey: ['descartados-tipo-relacion-sospechosa'] }); + queryClient.invalidateQueries({ queryKey: ['alertas-automaticas'] }); + queryClient.invalidateQueries({ queryKey: ['alertas'] }); + }; + + const handleDescartar = async () => { + const cfdiIds = [...checked].map(id => Number(id)); + try { + await apiClient.post('/alertas/descartar', { cfdiIds, tipoAlerta: TIPO_ALERTA }); + setChecked(new Set()); + invalidateAll(); + } catch { + alert('Error al descartar'); + } + }; + + const handleRestaurar = async () => { + const cfdiIds = [...checked].map(id => Number(id)); + try { + await apiClient.delete('/alertas/descartar', { data: { cfdiIds, tipoAlerta: TIPO_ALERTA } }); + setChecked(new Set()); + invalidateAll(); + } catch { + alert('Error al restaurar'); + } + }; + + const handleChangeView = (next: 'activos' | 'descartados') => { + setView(next); + setChecked(new Set()); + }; + + const handleClearFilters = () => { + setFechaDesde(''); + setFechaHasta(''); + setTipoRelFilter(''); + }; + + const hasActiveFilters = fechaDesde || fechaHasta || tipoRelFilter; + const allChecked = sortedData && sortedData.length > 0 && checked.size === sortedData.length; + + return ( + + + +
+ + {view === 'activos' + ? 'Notas de crédito (E) que referencian un CFDI tratado como anticipo por otra factura — posible error de emisor (debería ser TipoRelacion 07)' + : 'CFDIs descartados manualmente — ignorados en la alerta'} + +
+
+ + +
+ {checked.size > 0 && view === 'activos' && ( + + )} + {checked.size > 0 && view === 'descartados' && ( + + )} + {data && data.length > 0 && ( + + )} +
+
+ +
+
+ + Filtros: +
+
+ + setFechaDesde(e.target.value)} + className="h-8 w-[150px] text-sm" + /> +
+
+ + setFechaHasta(e.target.value)} + className="h-8 w-[150px] text-sm" + /> +
+
+ + +
+ {hasActiveFilters && ( + + )} +
+
+ + {isLoading ? ( +
Cargando...
+ ) : !sortedData || sortedData.length === 0 ? ( +
+ {hasActiveFilters + ? 'No hay resultados con los filtros seleccionados' + : view === 'activos' + ? 'No hay CFDIs sospechosos' + : 'No hay CFDIs descartados'} +
+ ) : ( +
+ + + + + + toggleSort('fecha')} /> + + + + + toggleSort('total')} /> + + + + + {sortedData.map((cfdi: any) => { + const refs = (cfdi.cfdisRelacionados || '').split('|').filter(Boolean); + return ( + + + + + + + + + + + + ); + })} + +
+ + UUIDEmisorReceptorTipoRelReferenciados
+ + {cfdi.uuid?.substring(0, 8)}{new Date(cfdi.fechaEmision).toLocaleDateString('es-MX')} +
{cfdi.rfcEmisor}
+
{cfdi.nombreEmisor}
+
+
{cfdi.rfcReceptor}
+
{cfdi.nombreReceptor}
+
{cfdi.cfdiTipoRelacion} + {refs.map((u: string) => ( +
{u.substring(0, 8)}
+ ))} +
{formatCurrency(Number(cfdi.totalMxn))} + +
+

+ {sortedData.length} CFDI{sortedData.length !== 1 ? 's' : ''} {view === 'activos' ? 'sospechosos' : 'descartados'} + {hasActiveFilters && data && ` (de ${data.length} total)`} +

+
+ )} +
+
+ + setSelectedCfdi(null)} + /> +
+ ); +} diff --git a/apps/web/app/(dashboard)/calendario/page.tsx b/apps/web/app/(dashboard)/calendario/page.tsx new file mode 100644 index 0000000..1fa148e --- /dev/null +++ b/apps/web/app/(dashboard)/calendario/page.tsx @@ -0,0 +1,437 @@ +'use client'; + +import { useState } from 'react'; +import { DashboardShell } from '@/components/layouts/dashboard-shell'; +import { Card, CardContent, CardHeader, CardTitle, Button, Input, Label } from '@horux/shared-ui'; +import { useEventos, useCreateEvento, useUpdateEvento, useDeleteEvento } from '@/lib/hooks/use-calendario'; +import { useAuthStore } from '@/stores/auth-store'; +import { + Calendar, ChevronLeft, ChevronRight, Check, Clock, FileText, + CreditCard, Plus, X, Pencil, Trash2, Lock, Globe, AlertTriangle, +} from 'lucide-react'; +import { cn } from '@horux/shared-ui'; +import type { EventoFiscal } from '@horux/shared'; + +const meses = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre']; + +const tipoIcons: Record = { + declaracion: FileText, + pago: CreditCard, + obligacion: Clock, + informativa: FileText, + custom: Calendar, + 'obligacion-pendiente': Clock, + 'obligacion-completada': Check, + 'obligacion-atrasada': AlertTriangle, +}; + +const tipoColors: Record = { + declaracion: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200', + pago: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', + obligacion: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200', + informativa: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200', + custom: 'bg-violet-100 text-violet-800 dark:bg-violet-900 dark:text-violet-200', + 'obligacion-pendiente': 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200', + 'obligacion-completada': 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', + 'obligacion-atrasada': 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200', +}; + +interface RecordatorioForm { + titulo: string; + descripcion: string; + fechaLimite: string; + notas: string; + privado: boolean; +} + +const emptyForm: RecordatorioForm = { + titulo: '', + descripcion: '', + fechaLimite: '', + notas: '', + privado: false, +}; + +export default function CalendarioPage() { + const [año, setAño] = useState(new Date().getFullYear()); + const [mes, setMes] = useState(new Date().getMonth() + 1); + const { data: eventos, isLoading } = useEventos(año); + const createEvento = useCreateEvento(); + const updateEvento = useUpdateEvento(); + const deleteEvento = useDeleteEvento(); + const { user } = useAuthStore(); + + const [showForm, setShowForm] = useState(false); + const [editingId, setEditingId] = useState(null); + const [form, setForm] = useState(emptyForm); + + const canEdit = ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'].includes(user?.role || ''); + + const handlePrevMonth = () => { + if (mes === 1) { setMes(12); setAño(año - 1); } + else setMes(mes - 1); + }; + + const handleNextMonth = () => { + if (mes === 12) { setMes(1); setAño(año + 1); } + else setMes(mes + 1); + }; + + const handleToggleComplete = (evento: EventoFiscal) => { + if (!evento.id) return; + if (evento.tipo === 'custom') { + updateEvento.mutate({ id: evento.id, data: { completado: !evento.completado } }); + } + }; + + const handleOpenCreate = () => { + setEditingId(null); + const defaultDate = `${año}-${String(mes).padStart(2, '0')}-15`; + setForm({ ...emptyForm, fechaLimite: defaultDate }); + setShowForm(true); + }; + + const handleOpenEdit = (evento: EventoFiscal) => { + if (!evento.id || evento.tipo !== 'custom') return; + setEditingId(evento.id); + setForm({ + titulo: evento.titulo, + descripcion: evento.descripcion || '', + fechaLimite: evento.fechaLimite, + notas: evento.notas || '', + privado: (evento as any).privado ?? false, + }); + setShowForm(true); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + try { + if (editingId) { + await updateEvento.mutateAsync({ + id: editingId, + data: { titulo: form.titulo, descripcion: form.descripcion, fechaLimite: form.fechaLimite, notas: form.notas, privado: form.privado } as any, + }); + } else { + await createEvento.mutateAsync({ + titulo: form.titulo, + descripcion: form.descripcion, + tipo: 'custom', + fechaLimite: form.fechaLimite, + recurrencia: 'unica', + notas: form.notas, + privado: form.privado, + } as any); + } + setShowForm(false); + setForm(emptyForm); + setEditingId(null); + } catch { + alert('Error al guardar recordatorio'); + } + }; + + const handleDelete = async (id: number) => { + if (!confirm('¿Eliminar este recordatorio?')) return; + try { + await deleteEvento.mutateAsync(id); + } catch { + alert('Error al eliminar'); + } + }; + + const handleCancelForm = () => { + setShowForm(false); + setForm(emptyForm); + setEditingId(null); + }; + + // Generate calendar days + const firstDay = new Date(año, mes - 1, 1).getDay(); + const daysInMonth = new Date(año, mes, 0).getDate(); + const days = Array.from({ length: 42 }, (_, i) => { + const day = i - firstDay + 1; + if (day < 1 || day > daysInMonth) return null; + return day; + }); + + const getEventosForDay = (day: number) => { + return eventos?.filter(e => { + const fecha = new Date(e.fechaLimite + 'T00:00:00'); + return fecha.getFullYear() === año && fecha.getMonth() + 1 === mes && fecha.getDate() === day; + }) || []; + }; + + const eventosDelMes = eventos?.filter(e => { + const f = new Date(e.fechaLimite + 'T00:00:00'); + return f.getFullYear() === año && f.getMonth() + 1 === mes; + }) || []; + + return ( + + {/* Modal de crear/editar */} + {showForm && ( + + +
+ + {editingId ? 'Editar Recordatorio' : 'Nuevo Recordatorio'} + + +
+
+ +
+
+
+ + setForm({ ...form, titulo: e.target.value })} + placeholder="Reunión con contador" + required + /> +
+
+ + setForm({ ...form, fechaLimite: e.target.value })} + required + /> +
+
+
+ + setForm({ ...form, descripcion: e.target.value })} + placeholder="Revisión de declaración mensual" + /> +
+
+ + setForm({ ...form, notas: e.target.value })} + placeholder="Llevar estados de cuenta" + /> +
+
+ + + {form.privado ? 'Solo tú puedes verlo' : 'Visible para todo el equipo'} + +
+
+ + +
+
+
+
+ )} + +
+ {/* Calendar */} + + + + + {meses[mes - 1]} {año} + +
+ {canEdit && !showForm && ( + + )} + + +
+
+ + {/* Leyenda de colores por estado de obligación */} +
+ + + Pendiente + + + + Completada + + + + Atrasada + + + + Recordatorio custom + +
+
+ {['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb'].map(d => ( +
+ {d} +
+ ))} + {days.map((day, i) => { + const dayEventos = day ? getEventosForDay(day) : []; + const isToday = day === new Date().getDate() && mes === new Date().getMonth() + 1 && año === new Date().getFullYear(); + return ( +
+ {day && ( + <> +
{day}
+
+ {dayEventos.slice(0, 2).map((e, idx) => { + const Icon = tipoIcons[e.tipo] || Calendar; + return ( +
e.tipo === 'custom' && canEdit && handleOpenEdit(e)} + > + + {e.titulo} +
+ ); + })} + {dayEventos.length > 2 && ( +
+{dayEventos.length - 2} más
+ )} +
+ + )} +
+ ); + })} +
+
+
+ + {/* Event List */} + + + Eventos del Mes + + + {isLoading ? ( +
Cargando...
+ ) : eventosDelMes.length === 0 ? ( +
No hay eventos este mes
+ ) : ( +
+ {eventosDelMes.map((evento, idx) => { + const Icon = tipoIcons[evento.tipo] || FileText; + const isCustom = evento.tipo === 'custom'; + return ( +
+
+
+ +
+
+
+

+ {evento.titulo} +

+ {isCustom && (evento as any).privado && ( + + )} +
+ {evento.descripcion && ( +

{evento.descripcion}

+ )} +

+ {new Date(evento.fechaLimite + 'T00:00:00').toLocaleDateString('es-MX', { + day: 'numeric', + month: 'short', + })} +

+
+ {isCustom && canEdit && ( +
+ + + +
+ )} +
+
+ ); + })} +
+ )} +
+
+
+
+ ); +} diff --git a/apps/web/app/(dashboard)/carteras/page.tsx b/apps/web/app/(dashboard)/carteras/page.tsx new file mode 100644 index 0000000..2f24dda --- /dev/null +++ b/apps/web/app/(dashboard)/carteras/page.tsx @@ -0,0 +1,539 @@ +'use client'; + +import { useState } from 'react'; +import { + Button, Card, CardContent, CardHeader, CardTitle, Input, Label, + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, + cn, +} from '@horux/shared-ui'; +import { useQueryClient } from '@tanstack/react-query'; +import { + FolderOpen, Plus, Trash2, ChevronDown, ChevronUp, X, + Users, Building2, FolderPlus, UserCog, +} from 'lucide-react'; +import { + useCarteras, useCreateCartera, useDeleteCartera, + useCarteraEntidades, useSubcarteras, useCreateSubcartera, + useSupervisores, +} from '@/lib/hooks/use-carteras'; +import { + addEntidadToCartera, removeEntidadFromCartera, +} from '@/lib/api/carteras'; +import { useContribuyentes } from '@/lib/hooks/use-contribuyentes'; +import { useUsuarios } from '@/lib/hooks/use-usuarios'; +import { useAuthStore } from '@/stores/auth-store'; +import { DashboardShell } from '@/components/layouts/dashboard-shell'; +import type { Cartera } from '@/lib/api/carteras'; + +/* ------------------------------------------------------------------ */ +/* SubcarteraCard */ +/* ------------------------------------------------------------------ */ +function SubcarteraCard({ sub, usuarios, contribuyentes, onDelete }: { + sub: Cartera; + usuarios: any[]; + contribuyentes: any[]; + onDelete: () => void; +}) { + const [expanded, setExpanded] = useState(false); + const qc = useQueryClient(); + const { data: entidadIds, isLoading } = useCarteraEntidades(expanded ? sub.id : null); + const [addingEntidad, setAddingEntidad] = useState(false); + const [selectedEntidadId, setSelectedEntidadId] = useState(''); + const [busy, setBusy] = useState(false); + + const entidadMap = Object.fromEntries( + (contribuyentes ?? []).map((c: any) => [c.id, { rfc: c.rfc, nombre: c.nombre }]) + ); + + const available = (contribuyentes ?? []).filter( + (c: any) => !(entidadIds ?? []).includes(c.id) + ); + + const auxiliarUser = usuarios?.find((u: any) => u.id === sub.auxiliarUserId); + + const invalidate = () => { + qc.invalidateQueries({ queryKey: ['cartera-entidades', sub.id] }); + qc.invalidateQueries({ queryKey: ['subcarteras'] }); + qc.invalidateQueries({ queryKey: ['carteras'] }); + }; + + const handleAddEntidad = async () => { + if (!selectedEntidadId) return; + setBusy(true); + try { + await addEntidadToCartera(sub.id, selectedEntidadId); + setSelectedEntidadId(''); + setAddingEntidad(false); + invalidate(); + } finally { setBusy(false); } + }; + + const handleRemoveEntidad = async (entidadId: string) => { + setBusy(true); + try { + await removeEntidadFromCartera(sub.id, entidadId); + invalidate(); + } finally { setBusy(false); } + }; + + return ( +
+
+ + +
+ + {expanded && ( +
+ {!addingEntidad && ( + + )} + {addingEntidad && ( +
+ + + +
+ )} + {isLoading ? ( +

Cargando...

+ ) : !entidadIds || entidadIds.length === 0 ? ( +

Sin RFCs asignados a esta subcartera.

+ ) : ( +
    + {entidadIds.map(id => { + const info = entidadMap[id]; + return ( +
  • + {info ? <>{info.rfc} {info.nombre} : id} + +
  • + ); + })} +
+ )} +
+ )} +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* CarteraDetail */ +/* ------------------------------------------------------------------ */ +function CarteraDetail({ cartera, canEdit = true, canManageSubcarteras = true }: { cartera: Cartera; canEdit?: boolean; canManageSubcarteras?: boolean }) { + const qc = useQueryClient(); + const { data: contribuyentes } = useContribuyentes(); + const { data: usuarios } = useUsuarios(); + const { data: entidadIds, isLoading: loadingEntidades } = useCarteraEntidades(cartera.id); + const { data: subcarteras, isLoading: loadingSubs } = useSubcarteras(cartera.id); + const createSub = useCreateSubcartera(); + + const [addingEntidad, setAddingEntidad] = useState(false); + const [selectedEntidadId, setSelectedEntidadId] = useState(''); + const [showCreateSub, setShowCreateSub] = useState(false); + const [subForm, setSubForm] = useState({ nombre: '', auxiliarUserId: '' }); + const [busy, setBusy] = useState(false); + + const entidadMap = Object.fromEntries( + (contribuyentes ?? []).map((c) => [c.id, { rfc: c.rfc, nombre: c.nombre }]) + ); + + const available = (contribuyentes ?? []).filter( + (c) => !(entidadIds ?? []).includes(c.id) + ); + + // Auxiliares available for subcarteras (those assigned to this supervisor) + const auxiliares = (usuarios ?? []).filter((u: any) => u.role === 'auxiliar'); + + const supervisorUser = usuarios?.find((u: any) => u.id === cartera.supervisorUserId); + + const invalidate = () => { + qc.invalidateQueries({ queryKey: ['cartera-entidades', cartera.id] }); + qc.invalidateQueries({ queryKey: ['subcarteras', cartera.id] }); + qc.invalidateQueries({ queryKey: ['carteras'] }); + }; + + const handleAddEntidad = async () => { + if (!selectedEntidadId) return; + setBusy(true); + try { + await addEntidadToCartera(cartera.id, selectedEntidadId); + setSelectedEntidadId(''); + setAddingEntidad(false); + invalidate(); + } finally { setBusy(false); } + }; + + const handleRemoveEntidad = async (entidadId: string) => { + setBusy(true); + try { + await removeEntidadFromCartera(cartera.id, entidadId); + invalidate(); + } finally { setBusy(false); } + }; + + const handleCreateSubcartera = async () => { + if (!subForm.nombre.trim() || !subForm.auxiliarUserId) return; + try { + await createSub.mutateAsync({ + carteraId: cartera.id, + nombre: subForm.nombre.trim(), + auxiliarUserId: subForm.auxiliarUserId, + }); + setSubForm({ nombre: '', auxiliarUserId: '' }); + setShowCreateSub(false); + } catch (err: any) { + alert(err.response?.data?.message || 'Error al crear subcartera'); + } + }; + + const handleDeleteSubcartera = async (subId: string) => { + if (!confirm('¿Eliminar esta subcartera?')) return; + try { + const { deleteCartera } = await import('@/lib/api/carteras'); + await deleteCartera(subId); + invalidate(); + } catch (err: any) { + alert(err.response?.data?.message || 'Error al eliminar'); + } + }; + + return ( +
+ {/* Supervisor info */} + {supervisorUser && ( +
+ + Supervisor: {supervisorUser.nombre} ({supervisorUser.email}) +
+ )} + + {/* ---- Contribuyentes ---- */} +
+
+

+ + Contribuyentes ({entidadIds?.length || 0}) +

+ {canEdit && !addingEntidad && ( + + )} +
+ + {canEdit && addingEntidad && ( +
+ + + +
+ )} + + {loadingEntidades ? ( +

Cargando...

+ ) : !entidadIds || entidadIds.length === 0 ? ( +

Sin contribuyentes asignados.

+ ) : ( +
    + {entidadIds.map(id => { + const info = entidadMap[id]; + return ( +
  • + {info ? <>{info.rfc} {info.nombre} : {id}} + {canEdit && } +
  • + ); + })} +
+ )} +
+ + {/* ---- Subcarteras ---- */} +
+
+

+ + Subcarteras ({subcarteras?.length || 0}) +

+ {canManageSubcarteras && !showCreateSub && ( + + )} +
+ + {canManageSubcarteras && showCreateSub && ( +
+
+
+ + setSubForm(p => ({ ...p, nombre: e.target.value }))} placeholder="Ej. Cartera de María" className="h-8 text-sm mt-1" /> +
+
+ + +
+
+
+ + +
+
+ )} + + {loadingSubs ? ( +

Cargando...

+ ) : !subcarteras || subcarteras.length === 0 ? ( +

Sin subcarteras. Crea una para asignar RFCs a un auxiliar.

+ ) : ( +
+ {subcarteras.map(sub => ( + handleDeleteSubcartera(sub.id)} + /> + ))} +
+ )} +
+
+ ); +} + +/* ------------------------------------------------------------------ */ +/* CarteraCard */ +/* ------------------------------------------------------------------ */ +function CarteraCard({ cartera, expanded, onToggle, onDelete, usuarios, canEdit, canManageSubcarteras }: { + cartera: Cartera; + expanded: boolean; + onToggle: () => void; + onDelete: () => void; + usuarios: any[]; + canEdit: boolean; + canManageSubcarteras: boolean; +}) { + const supervisorUser = usuarios?.find((u: any) => u.id === cartera.supervisorUserId); + return ( + + +
+ + {canEdit && ( + + )} +
+
+ {supervisorUser && ( + + + {supervisorUser.nombre} + + )} + + + {cartera.entidadesCount} RFCs + + + + {cartera.subcarterasCount} subcarteras + +
+
+ {expanded && ( + + + + )} +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Page */ +/* ------------------------------------------------------------------ */ +export default function CarterasPage() { + const { user } = useAuthStore(); + const userRole = user?.role || 'visor'; + const canCreate = userRole === 'owner'; // Create top-level carteras + const canEditCartera = userRole === 'owner'; // Edit/delete top-level carteras + add/remove RFCs + const canManageSubcarteras = userRole === 'owner' || userRole === 'supervisor'; // Create subcarteras + const isAuxiliar = userRole === 'auxiliar'; + const { data: carteras, isLoading } = useCarteras(); + const { data: supervisores } = useSupervisores(); + const { data: usuarios } = useUsuarios(); + const createMut = useCreateCartera(); + const deleteMut = useDeleteCartera(); + + const [expandedId, setExpandedId] = useState(null); + const [showCreate, setShowCreate] = useState(false); + const [form, setForm] = useState({ nombre: '', descripcion: '', supervisorUserId: '' }); + + const hasSupervisores = supervisores && supervisores.length > 0; + + const resetForm = () => { + setForm({ nombre: '', descripcion: '', supervisorUserId: '' }); + setShowCreate(false); + }; + + const handleCreate = async () => { + if (!form.nombre.trim()) return; + try { + const supervisorUserId = form.supervisorUserId && form.supervisorUserId !== '__self__' + ? form.supervisorUserId : undefined; + const cartera = await createMut.mutateAsync({ + nombre: form.nombre.trim(), + descripcion: form.descripcion.trim() || undefined, + supervisorUserId, + }); + resetForm(); + setExpandedId(cartera.id); + } catch (err: any) { + alert(err.response?.data?.message || 'Error al crear cartera'); + } + }; + + const handleDelete = async (cartera: Cartera) => { + if (!confirm(`¿Eliminar la cartera "${cartera.nombre}"? Se eliminarán también sus subcarteras.`)) return; + try { + await deleteMut.mutateAsync(cartera.id); + if (expandedId === cartera.id) setExpandedId(null); + } catch (err: any) { + alert(err.response?.data?.message || 'Error al eliminar cartera'); + } + }; + + return ( + +
+ {/* Header */} +
+
+

+ {isAuxiliar ? 'Carteras asignadas a ti' : 'Organiza contribuyentes en carteras y asigna subcarteras a cada auxiliar'} +

+
+ {canCreate && ( + + )} +
+ + {/* List */} + {isLoading ? ( +

Cargando...

+ ) : !carteras || carteras.length === 0 ? ( + + + +

Sin carteras

+

+ Crea la primera cartera para organizar tus contribuyentes. +

+ +
+
+ ) : ( +
+ {carteras.map(cartera => ( + setExpandedId(expandedId === cartera.id ? null : cartera.id)} + onDelete={() => handleDelete(cartera)} + usuarios={usuarios ?? []} + canEdit={canEditCartera} + canManageSubcarteras={canManageSubcarteras} + /> + ))} +
+ )} + + {/* Create dialog */} + { if (!open) resetForm(); }}> + + + Nueva cartera + +
+
+ + setForm(p => ({ ...p, nombre: e.target.value }))} placeholder="Ej. Clientes CDMX" autoFocus /> +
+
+ + setForm(p => ({ ...p, descripcion: e.target.value }))} placeholder="Descripcion breve" /> +
+ {hasSupervisores ? ( +
+ + +

Si no seleccionas, la cartera se asigna a ti.

+
+ ) : ( +

+ No hay supervisores registrados. La cartera se asignará a ti como owner. +

+ )} +
+ + + + +
+
+
+
+ ); +} diff --git a/apps/web/app/(dashboard)/cfdi/page.tsx b/apps/web/app/(dashboard)/cfdi/page.tsx new file mode 100644 index 0000000..bc0c127 --- /dev/null +++ b/apps/web/app/(dashboard)/cfdi/page.tsx @@ -0,0 +1,2211 @@ +'use client'; + +import { useState, useRef, useCallback, useEffect } from 'react'; +import { useDebounce } from '@horux/shared-ui'; +import { Header } from '@/components/layouts/header'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription, Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, Popover, PopoverTrigger, PopoverContent } from '@horux/shared-ui'; +import { useCfdis, useCreateCfdi, useDeleteCfdi } from '@/lib/hooks/use-cfdi'; +import { createManyCfdis, searchEmisores, searchReceptores, getCfdis, getConceptosList, type EmisorReceptor } from '@/lib/api/cfdi'; +import { cancelarFactura, downloadPdf } from '@/lib/api/facturacion'; +import type { CfdiFilters, TipoCfdi, Cfdi } from '@horux/shared'; +import type { CreateCfdiData } from '@/lib/api/cfdi'; +import { FileText, Search, ChevronLeft, ChevronRight, Plus, Upload, Trash2, X, FileUp, CheckCircle, AlertCircle, Loader2, Eye, Filter, XCircle, Calendar, User, Building2, Download, Printer } from 'lucide-react'; +import * as XLSX from 'xlsx'; +import { saveAs } from 'file-saver'; +import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal'; +import { getCfdiById } from '@/lib/api/cfdi'; +import { useAuthStore } from '@/stores/auth-store'; +import { useTenantViewStore } from '@/stores/tenant-view-store'; +import { useContribuyenteStore } from '@/stores/contribuyente-store'; +import { useQueryClient, useQuery } from '@tanstack/react-query'; + +// Upload progress state +interface UploadProgress { + status: 'idle' | 'parsing' | 'uploading' | 'complete' | 'error'; + totalFiles: number; + parsedFiles: number; + validFiles: number; + currentBatch: number; + totalBatches: number; + uploaded: number; + duplicates: number; + errors: number; + errorMessages: string[]; +} + +type CfdiTipo = 'EMITIDO' | 'RECIBIDO'; + +const initialFormData: CreateCfdiData = { + uuid: '', + type: 'EMITIDO', + serie: '', + folio: '', + fechaEmision: new Date().toISOString().split('T')[0], + rfcEmisor: '', + nombreEmisor: '', + rfcReceptor: '', + nombreReceptor: '', + subtotal: 0, + descuento: 0, + ivaTraslado: 0, + isrRetencion: 0, + ivaRetencion: 0, + total: 0, + moneda: 'MXN', + metodoPago: 'PUE', + formaPago: '03', + usoCfdi: 'G03', +}; + +// Helper function to find element regardless of namespace prefix +function findElement(doc: Document, localName: string): Element | null { + // Try common prefixes first (most reliable for CFDI) + const prefixes = ['cfdi', 'tfd', 'pago20', 'pago10', 'nomina12', '']; + for (const prefix of prefixes) { + const tagName = prefix ? `${prefix}:${localName}` : localName; + const el = doc.getElementsByTagName(tagName)[0] as Element; + if (el) return el; + } + + // Try with wildcard - search all elements by localName + const elements = doc.getElementsByTagName('*'); + for (let i = 0; i < elements.length; i++) { + if (elements[i].localName === localName) { + return elements[i]; + } + } + + return null; +} + +// Parse CFDI XML and extract data +function parseCfdiXml(xmlString: string, tenantRfc: string): CreateCfdiData | null { + try { + const parser = new DOMParser(); + const doc = parser.parseFromString(xmlString, 'text/xml'); + + // Check for parse errors + const parseError = doc.querySelector('parsererror'); + if (parseError) { + console.error('XML parse error:', parseError.textContent); + return null; + } + + // Get the Comprobante element (root) + const comprobante = findElement(doc, 'Comprobante'); + if (!comprobante) { + console.error('No se encontro elemento Comprobante'); + return null; + } + + // Get TimbreFiscalDigital for UUID + const timbre = findElement(doc, 'TimbreFiscalDigital'); + const uuid = timbre?.getAttribute('UUID') || ''; + const fechaTimbradoRaw = timbre?.getAttribute('FechaTimbrado') || ''; + + // Get Emisor + const emisor = findElement(doc, 'Emisor'); + const rfcEmisor = emisor?.getAttribute('Rfc') || emisor?.getAttribute('rfc') || ''; + const nombreEmisor = emisor?.getAttribute('Nombre') || emisor?.getAttribute('nombre') || ''; + + // Get Receptor + const receptor = findElement(doc, 'Receptor'); + const rfcReceptor = receptor?.getAttribute('Rfc') || receptor?.getAttribute('rfc') || ''; + const nombreReceptor = receptor?.getAttribute('Nombre') || receptor?.getAttribute('nombre') || ''; + const usoCfdi = receptor?.getAttribute('UsoCFDI') || ''; + + // Determine type based on tenant RFC + // If tenant is emisor -> ingreso (we issued the invoice) + // If tenant is receptor -> egreso (we received the invoice) + const tenantRfcUpper = tenantRfc.toUpperCase(); + let tipoFinal: CreateCfdiData['type']; + if (rfcEmisor.toUpperCase() === tenantRfcUpper) { + tipoFinal = 'EMITIDO'; + } else { + tipoFinal = 'RECIBIDO'; + } + + // Get impuestos - search for the Impuestos element that is direct child of Comprobante + // (not the ones inside Conceptos) + let totalImpuestosTrasladados = 0; + let totalImpuestosRetenidos = 0; + + // Try to get TotalImpuestosTrasladados from Comprobante's direct Impuestos child + const allImpuestos = doc.getElementsByTagName('*'); + for (let i = 0; i < allImpuestos.length; i++) { + const el = allImpuestos[i]; + if (el.localName === 'Impuestos' && el.parentElement?.localName === 'Comprobante') { + totalImpuestosTrasladados = parseFloat(el.getAttribute('TotalImpuestosTrasladados') || '0'); + totalImpuestosRetenidos = parseFloat(el.getAttribute('TotalImpuestosRetenidos') || '0'); + break; + } + } + + // Fallback: calculate IVA from total - subtotal if not found + const subtotal = parseFloat(comprobante.getAttribute('SubTotal') || '0'); + const descuento = parseFloat(comprobante.getAttribute('Descuento') || '0'); + const total = parseFloat(comprobante.getAttribute('Total') || '0'); + + if (totalImpuestosTrasladados === 0 && total > subtotal) { + totalImpuestosTrasladados = Math.max(0, total - subtotal + descuento + totalImpuestosRetenidos); + } + + // Get retenciones breakdown + let isrRetenido = 0; + let ivaRetenido = 0; + const retenciones = doc.querySelectorAll('[localName="Retencion"], Retencion, cfdi\\:Retencion'); + retenciones.forEach((ret: Element) => { + const impuesto = ret.getAttribute('Impuesto'); + const importe = parseFloat(ret.getAttribute('Importe') || '0'); + if (impuesto === '001') isrRetenido = importe; // ISR + if (impuesto === '002') ivaRetenido = importe; // IVA + }); + + // Parse dates - handle both ISO format and datetime format + const fechaEmisionRaw = comprobante.getAttribute('Fecha') || ''; + const fechaEmision = fechaEmisionRaw.includes('T') ? fechaEmisionRaw.split('T')[0] : fechaEmisionRaw; + const fechaTimbrado = fechaTimbradoRaw.includes('T') ? fechaTimbradoRaw.split('T')[0] : fechaTimbradoRaw; + + // Validate required fields + if (!uuid) { + console.error('UUID no encontrado en el XML'); + return null; + } + if (!rfcEmisor || !rfcReceptor) { + console.error('RFC emisor o receptor no encontrado'); + return null; + } + if (!fechaEmision) { + console.error('Fecha de emision no encontrada'); + return null; + } + + return { + uuid: uuid.toUpperCase(), + type: tipoFinal, + serie: comprobante.getAttribute('Serie') || '', + folio: comprobante.getAttribute('Folio') || '', + fechaEmision, + rfcEmisor, + nombreEmisor: nombreEmisor || 'Sin nombre', + rfcReceptor, + nombreReceptor: nombreReceptor || 'Sin nombre', + subtotal, + descuento, + ivaTraslado: totalImpuestosTrasladados, + isrRetencion: isrRetenido, + ivaRetencion: ivaRetenido, + total, + moneda: comprobante.getAttribute('Moneda') || 'MXN', + tipoCambio: parseFloat(comprobante.getAttribute('TipoCambio') || '1'), + tipoComprobante: comprobante.getAttribute('TipoDeComprobante') || '', + metodoPago: comprobante.getAttribute('MetodoPago') || '', + formaPago: comprobante.getAttribute('FormaPago') || '', + usoCfdi, + }; + } catch (error) { + console.error('Error parsing XML:', error); + return null; + } +} + +const TIPO_COMPROBANTE_LABELS: Record = { + I: 'Ingreso', + E: 'Egreso', + P: 'Pago', + T: 'Traslado', + N: 'Nómina', +}; + +function formatTipoComprobante(value: string | null | undefined): string { + if (!value) return ''; + const upper = value.toUpperCase(); + return TIPO_COMPROBANTE_LABELS[upper] ? `${upper} - ${TIPO_COMPROBANTE_LABELS[upper]}` : upper; +} + +// Chunk size for batch uploads +const PARSE_CHUNK_SIZE = 500; // Parse 500 files at a time +const UPLOAD_CHUNK_SIZE = 200; // Upload 200 CFDIs per request + +export default function CfdiPage() { + const { user } = useAuthStore(); + const { viewingTenantRfc } = useTenantViewStore(); + const { selectedContribuyenteId } = useContribuyenteStore(); + const fileInputRef = useRef(null); + const queryClient = useQueryClient(); + + // Get the effective tenant RFC (viewing tenant or user's tenant) + const tenantRfc = viewingTenantRfc || user?.tenantRfc || ''; + const [filters, setFilters] = useState({ + page: 1, + limit: 20, + }); + + const [searchTerm, setSearchTerm] = useState(''); + const [columnFilters, setColumnFilters] = useState({ + fechaInicio: '', + fechaFin: '', + emisor: '', + receptor: '', + }); + + // Reset pagination and filters when contribuyente changes + useEffect(() => { + setFilters({ page: 1, limit: 20 }); + setSearchTerm(''); + setColumnFilters({ fechaInicio: '', fechaFin: '', emisor: '', receptor: '' }); + }, [selectedContribuyenteId]); + const [openFilter, setOpenFilter] = useState<'fecha' | 'emisor' | 'receptor' | null>(null); + const [emisorSuggestions, setEmisorSuggestions] = useState([]); + const [receptorSuggestions, setReceptorSuggestions] = useState([]); + const [loadingEmisor, setLoadingEmisor] = useState(false); + const [loadingReceptor, setLoadingReceptor] = useState(false); + const [showForm, setShowForm] = useState(false); + + // Debounced values for autocomplete + const debouncedEmisor = useDebounce(columnFilters.emisor, 300); + const debouncedReceptor = useDebounce(columnFilters.receptor, 300); + + // Fetch emisor suggestions when debounced value changes + useEffect(() => { + if (debouncedEmisor.length < 2) { + setEmisorSuggestions([]); + return; + } + setLoadingEmisor(true); + searchEmisores(debouncedEmisor) + .then(setEmisorSuggestions) + .catch(() => setEmisorSuggestions([])) + .finally(() => setLoadingEmisor(false)); + }, [debouncedEmisor]); + + // Fetch receptor suggestions when debounced value changes + useEffect(() => { + if (debouncedReceptor.length < 2) { + setReceptorSuggestions([]); + return; + } + setLoadingReceptor(true); + searchReceptores(debouncedReceptor) + .then(setReceptorSuggestions) + .catch(() => setReceptorSuggestions([])) + .finally(() => setLoadingReceptor(false)); + }, [debouncedReceptor]); + + const [showBulkForm, setShowBulkForm] = useState(false); + const [formData, setFormData] = useState(initialFormData); + const [bulkData, setBulkData] = useState(''); + const [uploadMode, setUploadMode] = useState<'xml' | 'json'>('xml'); + const [jsonUploading, setJsonUploading] = useState(false); + + // Optimized upload state + const [uploadProgress, setUploadProgress] = useState({ + status: 'idle', + totalFiles: 0, + parsedFiles: 0, + validFiles: 0, + currentBatch: 0, + totalBatches: 0, + uploaded: 0, + duplicates: 0, + errors: 0, + errorMessages: [] + }); + const [parsedCfdis, setParsedCfdis] = useState([]); + const uploadAbortRef = useRef(false); + + const { data, isLoading } = useCfdis(filters); + + // Pestañas: CFDIs (lista actual) | Conceptos (tabla cross-CFDI con conceptos). + // Conceptos hereda los mismos filtros aplicados a CFDIs + tiene filtros propios. + const [activeTab, setActiveTab] = useState<'cfdis' | 'conceptos'>('cfdis'); + // Filtros locales de la pestaña Conceptos (no compartidos con CFDIs). + // Popovers en headers UUID, Clave, Descripción + ordenamiento por importe. + const [conceptosFilters, setConceptosFilters] = useState<{ + uuidLike: string; + claveProdServ: string; + descripcionConcepto: string; + orderBy?: 'fecha' | 'importe'; + orderDir?: 'asc' | 'desc'; + }>({ uuidLike: '', claveProdServ: '', descripcionConcepto: '' }); + const [conceptosOpenFilter, setConceptosOpenFilter] = useState<'uuid' | 'clave' | 'descripcion' | null>(null); + + const conceptosQuery = useQuery({ + queryKey: ['cfdi-conceptos', filters, selectedContribuyenteId, conceptosFilters], + queryFn: () => getConceptosList({ + ...filters, + contribuyenteId: selectedContribuyenteId || undefined, + uuidLike: conceptosFilters.uuidLike || undefined, + claveProdServ: conceptosFilters.claveProdServ || undefined, + descripcionConcepto: conceptosFilters.descripcionConcepto || undefined, + orderBy: conceptosFilters.orderBy, + orderDir: conceptosFilters.orderDir, + }), + enabled: activeTab === 'conceptos', + }); + + const toggleImporteSort = () => { + setConceptosFilters(prev => { + // null → asc → desc → null (o ciclo simple asc ↔ desc si prefieres) + const isImporte = prev.orderBy === 'importe'; + if (!isImporte) return { ...prev, orderBy: 'importe', orderDir: 'desc' }; + if (prev.orderDir === 'desc') return { ...prev, orderBy: 'importe', orderDir: 'asc' }; + return { ...prev, orderBy: undefined, orderDir: undefined }; + }); + setFilters(f => ({ ...f, page: 1 })); + }; + const createCfdi = useCreateCfdi(); + const deleteCfdi = useDeleteCfdi(); + + // CFDI Viewer state + const [viewingCfdi, setViewingCfdi] = useState(null); + const [loadingCfdi, setLoadingCfdi] = useState(null); + + // Cancelación Facturapi state + const [cancelTarget, setCancelTarget] = useState(null); + const [cancelMotive, setCancelMotive] = useState<'01' | '02' | '03' | '04'>('02'); + const [cancelSubstitution, setCancelSubstitution] = useState(''); + const [cancelling, setCancelling] = useState(false); + + const handleViewCfdi = async (id: string) => { + setLoadingCfdi(id); + try { + const cfdi = await getCfdiById(id); + setViewingCfdi(cfdi); + } catch (error) { + console.error('Error loading CFDI:', error); + alert('Error al cargar el CFDI'); + } finally { + setLoadingCfdi(null); + } + }; + + const canEdit = user?.role === 'owner' || user?.role === 'cfo' || user?.role === 'contador' || user?.role === 'auxiliar'; + + const handleSearch = () => { + setFilters({ ...filters, search: searchTerm, page: 1 }); + }; + + // Export to Excel + const [exporting, setExporting] = useState(false); + + const exportToExcel = async () => { + if (!data?.data.length) return; + + setExporting(true); + try { + // Fetch TODOS los CFDIs que cumplen los filtros (no solo la página visible). + // Topamos a 10,000 filas — Excel maneja 1M, pero >10k es más reporte que + // exploración, conviene empujar al user a filtrar más fino. + // NOTA: el hook `useCfdis` inyecta contribuyenteId automáticamente; al + // bypassearlo aquí (fetch directo) hay que inyectarlo manualmente o el + // export trae CFDIs de TODO el despacho en lugar del contribuyente activo. + const EXPORT_MAX = 10_000; + const fullResponse = await getCfdis({ + ...filters, + contribuyenteId: selectedContribuyenteId || undefined, + page: 1, + limit: EXPORT_MAX, + }); + const allRows = fullResponse.data; + + if (fullResponse.total > EXPORT_MAX) { + const proceed = confirm( + `Hay ${fullResponse.total.toLocaleString('es-MX')} CFDIs que cumplen los filtros, ` + + `pero el export está topado a ${EXPORT_MAX.toLocaleString('es-MX')} filas (los más recientes). ` + + `Ajusta filtros para precisar. ¿Continuar con las primeras ${EXPORT_MAX.toLocaleString('es-MX')} filas?` + ); + if (!proceed) { setExporting(false); return; } + } + + const exportData = allRows.map(cfdi => ({ + 'Fecha Emisión': new Date(cfdi.fechaEmision).toLocaleDateString('es-MX'), + 'Tipo Comprobante': formatTipoComprobante(cfdi.tipoComprobante), + 'Uso CFDI': (cfdi as any).usoCfdi || '', + 'Serie': cfdi.serie || '', + 'Folio': cfdi.folio || '', + 'RFC Emisor': cfdi.rfcEmisor, + 'Nombre Emisor': cfdi.nombreEmisor, + 'RFC Receptor': cfdi.rfcReceptor, + 'Nombre Receptor': cfdi.nombreReceptor, + 'Subtotal': cfdi.subtotal, + 'Descuento': cfdi.descuento || 0, + 'IVA': cfdi.ivaTraslado, + 'Total': cfdi.total, + 'Moneda': cfdi.moneda, + 'Método Pago': cfdi.metodoPago || '', + // Usamos `saldoPendienteMxn` (valor calculado por utils/saldo.ts) porque + // `saldoPendiente` (moneda original) no se backfilleó — todos NULL. + // PUE / P / E no tienen saldo conceptual → null en BD; lo dejamos + // vacío en Excel para no confundir "0 = pagado" con "no aplica". + 'Saldo Pendiente': cfdi.saldoPendienteMxn ?? '', + 'Estatus': cfdi.status === 'Vigente' || cfdi.status === '1' ? 'Vigente' : 'Cancelado', + 'Fecha Cancelación': cfdi.fechaCancelacion + ? new Date(cfdi.fechaCancelacion).toLocaleDateString('es-MX') + : '', + 'UUID': cfdi.uuid, + })); + + const ws = XLSX.utils.json_to_sheet(exportData); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, 'CFDIs'); + + // Auto-size columns + const colWidths = Object.keys(exportData[0]).map(key => ({ + wch: Math.max(key.length, ...exportData.map(row => String(row[key as keyof typeof row]).length)) + })); + ws['!cols'] = colWidths; + + const excelBuffer = XLSX.write(wb, { bookType: 'xlsx', type: 'array' }); + const blob = new Blob([excelBuffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); + + const fileName = `cfdis_${new Date().toISOString().split('T')[0]}.xlsx`; + saveAs(blob, fileName); + } catch (error) { + console.error('Error exporting:', error); + alert('Error al exportar'); + } finally { + setExporting(false); + } + }; + + // Export de la pestaña Conceptos: trae todos los conceptos que cumplen los + // filtros actuales, descartando todas las columnas que terminan en `_mxn`. + const exportConceptosToExcel = async () => { + setExporting(true); + try { + const EXPORT_MAX = 10_000; + const fullResponse = await getConceptosList({ + ...filters, + contribuyenteId: selectedContribuyenteId || undefined, + uuidLike: conceptosFilters.uuidLike || undefined, + claveProdServ: conceptosFilters.claveProdServ || undefined, + descripcionConcepto: conceptosFilters.descripcionConcepto || undefined, + orderBy: conceptosFilters.orderBy, + orderDir: conceptosFilters.orderDir, + page: 1, + limit: EXPORT_MAX, + }); + const allRows = fullResponse.data; + if (!allRows.length) { alert('No hay conceptos que cumplan los filtros'); setExporting(false); return; } + + if (fullResponse.total > EXPORT_MAX) { + const proceed = confirm( + `Hay ${fullResponse.total.toLocaleString('es-MX')} conceptos que cumplen los filtros, ` + + `pero el export está topado a ${EXPORT_MAX.toLocaleString('es-MX')} filas. ` + + `¿Continuar con las primeras ${EXPORT_MAX.toLocaleString('es-MX')}?` + ); + if (!proceed) { setExporting(false); return; } + } + + // Filtrar columnas: quitar todas las que terminan en _mxn (per requerimiento). + // También quitamos `id`/`cfdi_id` (internas, sin valor para el contador). + const exportData = allRows.map(row => { + const out: Record = {}; + for (const [key, val] of Object.entries(row)) { + if (key.endsWith('_mxn') || key === 'id' || key === 'cfdi_id') continue; + // Formatear fecha si aplica + if (key === 'fechaEmision' && typeof val === 'string') { + out['Fecha Emisión'] = new Date(val).toLocaleDateString('es-MX'); + } else { + out[key] = val; + } + } + return out; + }); + + const ws = XLSX.utils.json_to_sheet(exportData); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, 'Conceptos'); + + const colWidths = Object.keys(exportData[0]).map(key => ({ + wch: Math.max(key.length, ...exportData.map(row => String(row[key as keyof typeof row] ?? '').length)) + })); + ws['!cols'] = colWidths; + + const buf = XLSX.write(wb, { bookType: 'xlsx', type: 'array' }); + const blob = new Blob([buf], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); + saveAs(blob, `cfdi_conceptos_${new Date().toISOString().split('T')[0]}.xlsx`); + } catch (error) { + console.error('Error exportando conceptos:', error); + alert('Error al exportar conceptos'); + } finally { + setExporting(false); + } + }; + + const exportSingleCfdiToExcel = (cfdi: Cfdi) => { + const row = { + 'Fecha Emisión': new Date(cfdi.fechaEmision).toLocaleDateString('es-MX'), + 'Tipo Comprobante': formatTipoComprobante(cfdi.tipoComprobante), + 'Uso CFDI': (cfdi as any).usoCfdi || '', + 'Serie': cfdi.serie || '', + 'Folio': cfdi.folio || '', + 'RFC Emisor': cfdi.rfcEmisor, + 'Nombre Emisor': cfdi.nombreEmisor, + 'RFC Receptor': cfdi.rfcReceptor, + 'Nombre Receptor': cfdi.nombreReceptor, + 'Subtotal': cfdi.subtotal, + 'Descuento': cfdi.descuento || 0, + 'IVA': cfdi.ivaTraslado, + 'Total': cfdi.total, + 'Moneda': cfdi.moneda, + 'Método Pago': cfdi.metodoPago || '', + // Usamos `saldoPendienteMxn` (valor calculado por utils/saldo.ts) porque + // `saldoPendiente` (moneda original) no se backfilleó — todos NULL. + // PUE / P / E no tienen saldo conceptual → null en BD; lo dejamos + // vacío en Excel para no confundir "0 = pagado" con "no aplica". + 'Saldo Pendiente': cfdi.saldoPendienteMxn ?? '', + 'Estatus': cfdi.status === 'Vigente' || cfdi.status === '1' ? 'Vigente' : 'Cancelado', + 'Fecha Cancelación': cfdi.fechaCancelacion + ? new Date(cfdi.fechaCancelacion).toLocaleDateString('es-MX') + : '', + 'UUID': cfdi.uuid, + }; + + const ws = XLSX.utils.json_to_sheet([row]); + ws['!cols'] = Object.keys(row).map((key) => ({ + wch: Math.max(key.length, String(row[key as keyof typeof row] ?? '').length), + })); + + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, 'CFDI'); + + const buffer = XLSX.write(wb, { bookType: 'xlsx', type: 'array' }); + const blob = new Blob([buffer], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }); + + const idParte = [cfdi.serie, cfdi.folio].filter(Boolean).join('-') || cfdi.uuid.slice(0, 8); + saveAs(blob, `cfdi_${idParte}.xlsx`); + }; + + const selectEmisor = (emisor: EmisorReceptor) => { + setColumnFilters(prev => ({ ...prev, emisor: emisor.nombre })); + setEmisorSuggestions([]); + }; + + const selectReceptor = (receptor: EmisorReceptor) => { + setColumnFilters(prev => ({ ...prev, receptor: receptor.nombre })); + setReceptorSuggestions([]); + }; + + const applyDateFilter = () => { + setFilters({ + ...filters, + fechaInicio: columnFilters.fechaInicio || undefined, + fechaFin: columnFilters.fechaFin || undefined, + page: 1, + }); + setOpenFilter(null); + }; + + const applyEmisorFilter = () => { + setFilters({ + ...filters, + emisor: columnFilters.emisor || undefined, + page: 1, + }); + setOpenFilter(null); + }; + + const applyReceptorFilter = () => { + setFilters({ + ...filters, + receptor: columnFilters.receptor || undefined, + page: 1, + }); + setOpenFilter(null); + }; + + const clearDateFilter = () => { + setColumnFilters({ ...columnFilters, fechaInicio: '', fechaFin: '' }); + setFilters({ ...filters, fechaInicio: undefined, fechaFin: undefined, page: 1 }); + setOpenFilter(null); + }; + + const clearEmisorFilter = () => { + setColumnFilters({ ...columnFilters, emisor: '' }); + setFilters({ ...filters, emisor: undefined, page: 1 }); + setOpenFilter(null); + }; + + const clearReceptorFilter = () => { + setColumnFilters({ ...columnFilters, receptor: '' }); + setFilters({ ...filters, receptor: undefined, page: 1 }); + setOpenFilter(null); + }; + + const hasDateFilter = filters.fechaInicio || filters.fechaFin; + const hasEmisorFilter = filters.emisor; + const hasReceptorFilter = filters.receptor; + const hasActiveColumnFilters = hasDateFilter || hasEmisorFilter || hasReceptorFilter; + + const handleFilterType = (tipo?: TipoCfdi) => { + setFilters({ ...filters, tipo, page: 1 }); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + try { + await createCfdi.mutateAsync(formData); + setFormData(initialFormData); + setShowForm(false); + } catch (error: any) { + alert(error.response?.data?.message || 'Error al crear CFDI'); + } + }; + + const handleBulkSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setJsonUploading(true); + try { + const cfdis = JSON.parse(bulkData); + if (!Array.isArray(cfdis)) { + throw new Error('El formato debe ser un array de CFDIs'); + } + const result = await createManyCfdis(cfdis); + alert(`Se crearon ${result.inserted} CFDIs exitosamente`); + setBulkData(''); + setShowBulkForm(false); + queryClient.invalidateQueries({ queryKey: ['cfdis'] }); + } catch (error: any) { + alert(error.message || 'Error al procesar CFDIs'); + } finally { + setJsonUploading(false); + } + }; + + // Optimized: Parse files in chunks to prevent memory issues + const handleXmlFilesChange = useCallback(async (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []); + if (files.length === 0) return; + + uploadAbortRef.current = false; + setUploadProgress({ + status: 'parsing', + totalFiles: files.length, + parsedFiles: 0, + validFiles: 0, + currentBatch: 0, + totalBatches: 0, + uploaded: 0, + duplicates: 0, + errors: 0, + errorMessages: [] + }); + setParsedCfdis([]); + + const validCfdis: CreateCfdiData[] = []; + let parsedCount = 0; + let errorCount = 0; + + // Process in chunks to prevent memory issues + for (let i = 0; i < files.length; i += PARSE_CHUNK_SIZE) { + if (uploadAbortRef.current) break; + + const chunk = files.slice(i, i + PARSE_CHUNK_SIZE); + + // Parse chunk in parallel + const results = await Promise.all( + chunk.map(async (file) => { + try { + const text = await file.text(); + const data = parseCfdiXml(text, tenantRfc); + return data; + } catch { + return null; + } + }) + ); + + // Collect valid results + results.forEach((data) => { + if (data && data.uuid) { + validCfdis.push(data); + } else { + errorCount++; + } + }); + + parsedCount += chunk.length; + + setUploadProgress(prev => ({ + ...prev, + parsedFiles: parsedCount, + validFiles: validCfdis.length, + errors: errorCount + })); + + // Small delay to allow UI to update + await new Promise(r => setTimeout(r, 10)); + } + + setParsedCfdis(validCfdis); + setUploadProgress(prev => ({ + ...prev, + status: uploadAbortRef.current ? 'idle' : 'idle', + totalBatches: Math.ceil(validCfdis.length / UPLOAD_CHUNK_SIZE) + })); + }, [tenantRfc]); + + // Optimized: Upload in batches with progress + const handleXmlBulkSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (parsedCfdis.length === 0) { + alert('No hay CFDIs validos para cargar'); + return; + } + + uploadAbortRef.current = false; + const totalBatches = Math.ceil(parsedCfdis.length / UPLOAD_CHUNK_SIZE); + + setUploadProgress(prev => ({ + ...prev, + status: 'uploading', + currentBatch: 0, + totalBatches, + uploaded: 0, + duplicates: 0, + errors: 0, + errorMessages: [] + })); + + let totalUploaded = 0; + let totalDuplicates = 0; + let totalErrors = 0; + const allErrors: string[] = []; + + // Upload in batches + for (let i = 0; i < parsedCfdis.length; i += UPLOAD_CHUNK_SIZE) { + if (uploadAbortRef.current) break; + + const batchNumber = Math.floor(i / UPLOAD_CHUNK_SIZE) + 1; + const chunk = parsedCfdis.slice(i, i + UPLOAD_CHUNK_SIZE); + + setUploadProgress(prev => ({ + ...prev, + currentBatch: batchNumber + })); + + try { + const result = await createManyCfdis(chunk, batchNumber, totalBatches, parsedCfdis.length); + + totalUploaded += result.inserted; + totalDuplicates += result.duplicates; + totalErrors += result.errors; + if (result.errorMessages) { + allErrors.push(...result.errorMessages); + } + + setUploadProgress(prev => ({ + ...prev, + uploaded: totalUploaded, + duplicates: totalDuplicates, + errors: prev.errors + result.errors, + errorMessages: allErrors.slice(0, 20) // Limit error messages + })); + } catch (error: any) { + console.error(`Error en lote ${batchNumber}:`, error); + totalErrors += chunk.length; + allErrors.push(`Lote ${batchNumber}: ${error.message || 'Error desconocido'}`); + + setUploadProgress(prev => ({ + ...prev, + errors: prev.errors + chunk.length, + errorMessages: allErrors.slice(0, 20) + })); + } + + // Small delay between batches + await new Promise(r => setTimeout(r, 100)); + } + + setUploadProgress(prev => ({ + ...prev, + status: 'complete' + })); + + // Invalidate queries to refresh the list + queryClient.invalidateQueries({ queryKey: ['cfdis'] }); + }; + + const clearXmlFiles = () => { + uploadAbortRef.current = true; + setParsedCfdis([]); + setUploadProgress({ + status: 'idle', + totalFiles: 0, + parsedFiles: 0, + validFiles: 0, + currentBatch: 0, + totalBatches: 0, + uploaded: 0, + duplicates: 0, + errors: 0, + errorMessages: [] + }); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + // Keyboard shortcuts - Esc to close popovers and forms + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + // Close open filter popovers + if (openFilter !== null) { + setOpenFilter(null); + return; + } + // Close forms + if (showForm) { + setShowForm(false); + return; + } + if (showBulkForm) { + setShowBulkForm(false); + clearXmlFiles(); + return; + } + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [openFilter, showForm, showBulkForm]); + + const cancelUpload = () => { + uploadAbortRef.current = true; + setUploadProgress(prev => ({ ...prev, status: 'idle' })); + }; + + const handleDelete = async (id: string) => { + if (confirm('¿Eliminar este CFDI?')) { + try { + await deleteCfdi.mutateAsync(id); + } catch (error) { + console.error('Error deleting CFDI:', error); + } + } + }; + + const openCancelDialog = (cfdi: any) => { + setCancelTarget(cfdi); + setCancelMotive('02'); + setCancelSubstitution(''); + }; + + const handleCancelFactura = async () => { + if (!cancelTarget) return; + if (cancelMotive === '01' && cancelSubstitution.trim().length !== 36) { + alert('El motivo 01 requiere el UUID completo (36 caracteres) de la factura que sustituye a esta.'); + return; + } + setCancelling(true); + try { + await cancelarFactura(cancelTarget.uuid, cancelMotive, cancelMotive === '01' ? cancelSubstitution.trim() : undefined); + await queryClient.invalidateQueries({ queryKey: ['cfdis'] }); + setCancelTarget(null); + alert('Factura cancelada. El estatus final depende del SAT (puede quedar en "pendiente" si requiere aceptación del receptor).'); + } catch (err: any) { + alert(err?.response?.data?.message || err?.message || 'Error al cancelar la factura'); + } finally { + setCancelling(false); + } + }; + + const calculateTotal = () => { + const subtotal = formData.subtotal || 0; + const descuento = formData.descuento || 0; + const iva = formData.ivaTrasladoTraslado || 0; + const isrRetencion = formData.isrRetencion || 0; + const ivaRetencion = formData.ivaTrasladoRetencion || 0; + return subtotal - descuento + iva - isrRetencion - ivaRetencion; + }; + + const formatCurrency = (value: number) => + new Intl.NumberFormat('es-MX', { + style: 'currency', + currency: 'MXN', + }).format(value); + + const formatDate = (dateString: string) => + new Date(dateString).toLocaleDateString('es-MX', { + day: '2-digit', + month: 'short', + year: 'numeric', + }); + + const generateUUID = () => { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = Math.random() * 16 | 0; + const v = c === 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }).toUpperCase(); + }; + + return ( + <> +
+
+ {/* Filters */} + + +
+
+ setSearchTerm(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSearch()} + /> + +
+
+ + + +
+
+ +
+
+ + {canEdit && ( + <> + + + + )} +
+
+
+
+ + {/* Add CFDI Form */} + {showForm && canEdit && ( + + +
+
+ Agregar CFDI + Ingresa los datos del comprobante fiscal +
+ +
+
+ +
+
+
+ +
+ setFormData({ ...formData, uuid: e.target.value.toUpperCase() })} + placeholder="XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" + required + /> + +
+
+
+ + +
+
+
+ + setFormData({ ...formData, serie: e.target.value })} + placeholder="A" + /> +
+
+ + setFormData({ ...formData, folio: e.target.value })} + placeholder="001" + /> +
+
+
+ +
+
+ + setFormData({ ...formData, fechaEmision: e.target.value })} + required + /> +
+
+ + setFormData({ ...formData, moneda: e.target.value.toUpperCase() })} + placeholder="MXN" + /> +
+
+ +
+
+

Emisor

+
+ + setFormData({ ...formData, rfcEmisor: e.target.value.toUpperCase() })} + placeholder="XAXX010101000" + maxLength={13} + required + /> +
+
+ + setFormData({ ...formData, nombreEmisor: e.target.value })} + placeholder="Empresa Emisora SA de CV" + required + /> +
+
+
+

Receptor

+
+ + setFormData({ ...formData, rfcReceptor: e.target.value.toUpperCase() })} + placeholder="XAXX010101000" + maxLength={13} + required + /> +
+
+ + setFormData({ ...formData, nombreReceptor: e.target.value })} + placeholder="Empresa Receptora SA de CV" + required + /> +
+
+
+ +
+
+ + setFormData({ ...formData, subtotal: parseFloat(e.target.value) || 0 })} + required + /> +
+
+ + setFormData({ ...formData, descuento: parseFloat(e.target.value) || 0 })} + /> +
+
+ + setFormData({ ...formData, ivaTraslado: parseFloat(e.target.value) || 0 })} + /> +
+
+ + setFormData({ ...formData, isrRetencion: parseFloat(e.target.value) || 0 })} + /> +
+
+ + setFormData({ ...formData, ivaRetencion: parseFloat(e.target.value) || 0 })} + /> +
+
+ + setFormData({ ...formData, total: parseFloat(e.target.value) || 0 })} + required + /> +
+
+ +
+ + +
+
+
+
+ )} + + {/* Bulk Upload Form */} + {showBulkForm && canEdit && ( + + +
+
+ Carga Masiva de CFDIs + Sube archivos XML o pega datos en formato JSON +
+ +
+
+ + {/* Mode selector */} +
+ + +
+ + {uploadMode === 'xml' ? ( +
+ {/* File input - only show when idle */} + {uploadProgress.status === 'idle' && ( +
+ +
+ + +
+
+ )} + + {/* Parsing progress */} + {uploadProgress.status === 'parsing' && ( +
+
+ +
+

Analizando archivos XML...

+

+ {uploadProgress.parsedFiles.toLocaleString()} de {uploadProgress.totalFiles.toLocaleString()} archivos +

+
+ +
+
+
+
+
+ {uploadProgress.validFiles.toLocaleString()} validos + {Math.round((uploadProgress.parsedFiles / uploadProgress.totalFiles) * 100)}% +
+
+ )} + + {/* Upload progress */} + {uploadProgress.status === 'uploading' && ( +
+
+ +
+

Subiendo CFDIs al servidor...

+

+ Lote {uploadProgress.currentBatch} de {uploadProgress.totalBatches} +

+
+ +
+
+
+
+
+
+

{uploadProgress.uploaded.toLocaleString()}

+

Cargados

+
+
+

{uploadProgress.duplicates.toLocaleString()}

+

Duplicados

+
+
+

{uploadProgress.errors.toLocaleString()}

+

Errores

+
+
+
+ )} + + {/* Upload complete */} + {uploadProgress.status === 'complete' && ( +
+
+ +
+

Carga completada

+

+ Se procesaron {uploadProgress.validFiles.toLocaleString()} archivos +

+
+
+
+
+

{uploadProgress.uploaded.toLocaleString()}

+

Cargados

+
+
+

{uploadProgress.duplicates.toLocaleString()}

+

Duplicados

+
+
+

{uploadProgress.errors.toLocaleString()}

+

Errores

+
+
+ {uploadProgress.errorMessages.length > 0 && ( +
+

Errores:

+
    + {uploadProgress.errorMessages.map((err, i) => ( +
  • {err}
  • + ))} +
+
+ )} +
+ + +
+
+ )} + + {/* Ready to upload - show summary and upload button */} + {uploadProgress.status === 'idle' && parsedCfdis.length > 0 && ( +
+
+
+
+

{parsedCfdis.length.toLocaleString()} CFDIs listos para cargar

+

+ Se enviaran en {Math.ceil(parsedCfdis.length / UPLOAD_CHUNK_SIZE)} lotes de {UPLOAD_CHUNK_SIZE} registros +

+
+ +
+
+
+ + +
+
+ )} + + {/* Initial state - no files */} + {uploadProgress.status === 'idle' && parsedCfdis.length === 0 && uploadProgress.totalFiles === 0 && ( +
+ +
+ )} + + ) : ( +
+
+ +