Compare commits
27 Commits
63908f9e9d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5636427be | ||
|
|
ad6eec51ef | ||
|
|
a008659eda | ||
|
|
b43c9f334a | ||
|
|
099f34c903 | ||
|
|
3de0014e80 | ||
|
|
b39bbcdd0a | ||
|
|
5489c84e6b | ||
|
|
24d35333df | ||
|
|
3f31e25ae7 | ||
|
|
284c7620a9 | ||
|
|
dfc0183c12 | ||
|
|
b5701b603c | ||
|
|
dbdb6f3e5c | ||
|
|
8a61fcadfa | ||
|
|
ad72436c25 | ||
|
|
1b202bc542 | ||
|
|
57c4908e68 | ||
|
|
0bded50e57 | ||
|
|
1130a1833c | ||
|
|
a1727321c3 | ||
|
|
cc002adbd2 | ||
|
|
3c7758a599 | ||
|
|
7df27ce66d | ||
|
|
b217342a96 | ||
|
|
8a1fbceb38 | ||
|
|
3f3253d41b |
@@ -49,6 +49,17 @@ SMTP_FROM=Horux360 <noreply@horuxfin.com>
|
|||||||
# ----- Notificaciones admin --------------------------------------------------
|
# ----- Notificaciones admin --------------------------------------------------
|
||||||
ADMIN_EMAIL=carlos@horuxfin.com # destino de "nuevo cliente" + alertas internas
|
ADMIN_EMAIL=carlos@horuxfin.com # destino de "nuevo cliente" + alertas internas
|
||||||
|
|
||||||
|
# ----- Monitoreo sincronización SAT ------------------------------------------
|
||||||
|
# Email separado para alertas de SAT (fallos, jobs atorados, FIEL sin sync inicial).
|
||||||
|
# Si no se configura, usa ADMIN_EMAIL.
|
||||||
|
SAT_ALERT_EMAIL=
|
||||||
|
# Cron del monitor (default: cada 2 horas). Ej: 0 8 * * * para digest diario 8 AM.
|
||||||
|
SAT_MONITOR_SCHEDULE=0 */2 * * *
|
||||||
|
# Horas para considerar un job running/pending como atorado (default 2h).
|
||||||
|
SAT_STUCK_RUNNING_HOURS=2
|
||||||
|
# Ventana hacia atrás para reportar jobs fallados (default 24h).
|
||||||
|
SAT_FAILED_LOOKBACK_HOURS=24
|
||||||
|
|
||||||
# ----- Facturapi (emisión CFDI) — opcional -----------------------------------
|
# ----- Facturapi (emisión CFDI) — opcional -----------------------------------
|
||||||
# Sin esto, los tenants no pueden emitir facturas, pero la app arranca.
|
# Sin esto, los tenants no pueden emitir facturas, pero la app arranca.
|
||||||
FACTURAPI_USER_KEY= # sk_user_... (cuenta maestra Horux 360)
|
FACTURAPI_USER_KEY= # sk_user_... (cuenta maestra Horux 360)
|
||||||
@@ -80,3 +91,16 @@ METABASE_PG_PASSWORD=
|
|||||||
|
|
||||||
# ----- SAT Playwright headless toggle (debug temporal) ----------------------
|
# ----- SAT Playwright headless toggle (debug temporal) ----------------------
|
||||||
# SAT_HEADLESS=false # solo dev — muestra browser para debug de scrapers
|
# SAT_HEADLESS=false # solo dev — muestra browser para debug de scrapers
|
||||||
|
|
||||||
|
# ----- Proxies SAT (opcional) ------------------------------------------------
|
||||||
|
# Lista de proxies HTTP/HTTPS para descargas masivas del SAT. Formato:
|
||||||
|
# http://user:pass@host:port,http://user:pass@host:port,...
|
||||||
|
# Si se deja vacío, el sistema usa la IP pública del servidor (comportamiento actual).
|
||||||
|
SAT_PROXY_LIST=
|
||||||
|
# Estrategia de rotación: round-robin | random (default: round-robin)
|
||||||
|
SAT_PROXY_STRATEGY=round-robin
|
||||||
|
# Si es true, cuando todos los proxies fallan se intenta con la IP directa del servidor.
|
||||||
|
SAT_PROXY_FALLBACK_DIRECT=true
|
||||||
|
# Máximo de contribuyentes sincronizados en paralelo por el scheduler (default: 10).
|
||||||
|
# Aprovecha el pool de proxies; cada RFC suele usar una IP distinta en round-robin.
|
||||||
|
SAT_CONCURRENT_CONTRIBUYENTES=10
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
"facturapi": "^4.14.2",
|
"facturapi": "^4.14.2",
|
||||||
"fast-xml-parser": "^5.3.3",
|
"fast-xml-parser": "^5.3.3",
|
||||||
"helmet": "^8.0.0",
|
"helmet": "^8.0.0",
|
||||||
|
"https-proxy-agent": "^7.0.6",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"mercadopago": "^2.12.0",
|
"mercadopago": "^2.12.0",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "subscriptions" ADD COLUMN "mp_preference_id" TEXT;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Agrega proxy_used a sat_sync_jobs para diagnosticar bloqueos por IP
|
||||||
|
ALTER TABLE "sat_sync_jobs" ADD COLUMN IF NOT EXISTS "proxy_used" VARCHAR(255);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- Tabla de errores SAT por proxy para reportes diarios
|
||||||
|
CREATE TABLE IF NOT EXISTS "sat_proxy_errors" (
|
||||||
|
"id" TEXT PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"job_id" TEXT NOT NULL REFERENCES "sat_sync_jobs"("id") ON DELETE CASCADE,
|
||||||
|
"proxy_used" VARCHAR(255),
|
||||||
|
"error_code" VARCHAR(50),
|
||||||
|
"stage" VARCHAR(255),
|
||||||
|
"message" TEXT,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "sat_proxy_errors_created_at_idx" ON "sat_proxy_errors"("created_at");
|
||||||
|
CREATE INDEX IF NOT EXISTS "sat_proxy_errors_proxy_used_created_at_idx" ON "sat_proxy_errors"("proxy_used", "created_at");
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Deshabilita SAT incremental para business_control; solo daily + retry programado.
|
||||||
|
UPDATE "despacho_plan_prices"
|
||||||
|
SET "permite_sat_incremental" = false
|
||||||
|
WHERE "plan" = 'business_control';
|
||||||
@@ -358,6 +358,7 @@ model Subscription {
|
|||||||
tenantId String @map("tenant_id")
|
tenantId String @map("tenant_id")
|
||||||
plan Plan
|
plan Plan
|
||||||
mpPreapprovalId String? @map("mp_preapproval_id")
|
mpPreapprovalId String? @map("mp_preapproval_id")
|
||||||
|
mpPreferenceId String? @map("mp_preference_id")
|
||||||
status String @default("pending")
|
status String @default("pending")
|
||||||
amount Decimal @db.Decimal(10, 2)
|
amount Decimal @db.Decimal(10, 2)
|
||||||
frequency String @default("monthly")
|
frequency String @default("monthly")
|
||||||
@@ -676,8 +677,12 @@ model SatSyncJob {
|
|||||||
// usuario (botón UI). Cambia la política de retry: 2 intentos vs 3 del
|
// usuario (botón UI). Cambia la política de retry: 2 intentos vs 3 del
|
||||||
// bootstrap puro. Daily/incremental ignoran este campo.
|
// bootstrap puro. Daily/incremental ignoran este campo.
|
||||||
isCustomRange Boolean @default(false) @map("is_custom_range")
|
isCustomRange Boolean @default(false) @map("is_custom_range")
|
||||||
|
// Proxy usado en el último request SAT que falló (host:port). Ayuda a diagnosticar
|
||||||
|
// bloqueos por IP y a generar reportes diarios de errores por proxy.
|
||||||
|
proxyUsed String? @map("proxy_used") @db.VarChar(255)
|
||||||
|
|
||||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
|
proxyErrors SatProxyError[]
|
||||||
|
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@ -685,6 +690,24 @@ model SatSyncJob {
|
|||||||
@@map("sat_sync_jobs")
|
@@map("sat_sync_jobs")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Errores de bloqueo/devolución del SAT por proxy.
|
||||||
|
// Permite reportes diarios de cuántos 404/500X/etc. ocurrieron en cada IP.
|
||||||
|
model SatProxyError {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
jobId String @map("job_id")
|
||||||
|
proxyUsed String? @map("proxy_used") @db.VarChar(255)
|
||||||
|
errorCode String? @map("error_code") @db.VarChar(50)
|
||||||
|
stage String? @map("stage") @db.VarChar(255)
|
||||||
|
message String? @map("message")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
job SatSyncJob @relation(fields: [jobId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([createdAt])
|
||||||
|
@@index([proxyUsed, createdAt])
|
||||||
|
@@map("sat_proxy_errors")
|
||||||
|
}
|
||||||
|
|
||||||
enum SatSyncType {
|
enum SatSyncType {
|
||||||
initial
|
initial
|
||||||
daily
|
daily
|
||||||
|
|||||||
279
apps/api/scripts/add-demo-cfdis.ts
Normal file
279
apps/api/scripts/add-demo-cfdis.ts
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
/**
|
||||||
|
* Script: add-demo-cfdis.ts
|
||||||
|
*
|
||||||
|
* Agrega CFDIs sintéticos adicionales a los contribuyentes del tenant
|
||||||
|
* "Demo Ventas" (horux_demoventas). Los CFDIs se generan con UUIDs
|
||||||
|
* deterministas, por lo que el script es idempotente: volverlo a correr no
|
||||||
|
* duplica registros.
|
||||||
|
*
|
||||||
|
* Uso:
|
||||||
|
* cd apps/api && npx tsx scripts/add-demo-cfdis.ts
|
||||||
|
*
|
||||||
|
* Opciones via env:
|
||||||
|
* DEMO_CFDIS_POR_CONTRIBUYENTE=80 # default: 80
|
||||||
|
* DEMO_DIAS_ATRAS=540 # default: 540 (~18 meses)
|
||||||
|
*/
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
import { tenantDb } from '../src/config/database.ts';
|
||||||
|
import { markForInvalidation } from '../src/services/metricas.service.js';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const DEMO_RFC = 'DEMO2501019X2';
|
||||||
|
const CFDIS_POR_CONTRIBUYENTE = parseInt(process.env.DEMO_CFDIS_POR_CONTRIBUYENTE || '80', 10);
|
||||||
|
const DIAS_ATRAS = parseInt(process.env.DEMO_DIAS_ATRAS || '540', 10);
|
||||||
|
|
||||||
|
const CLIENTES = [
|
||||||
|
{ rfc: 'CLI123456AB1', nombre: 'Cliente Alfa SA' },
|
||||||
|
{ rfc: 'CLI123456AB2', nombre: 'Cliente Beta SA' },
|
||||||
|
{ rfc: 'CLI123456AB3', nombre: 'Cliente Gamma SA' },
|
||||||
|
{ rfc: 'CLI123456AB4', nombre: 'Cliente Delta SA' },
|
||||||
|
{ rfc: 'CLI123456AB5', nombre: 'Cliente Epsilon SA' },
|
||||||
|
{ rfc: 'CLI123456AB6', nombre: 'Cliente Zeta SA' },
|
||||||
|
{ rfc: 'CLI123456AB7', nombre: 'Cliente Eta SA' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PROVEEDORES = [
|
||||||
|
{ rfc: 'PRO123456AB1', nombre: 'Proveedor Materiales SA' },
|
||||||
|
{ rfc: 'PRO123456AB2', nombre: 'Proveedor Servicios SA' },
|
||||||
|
{ rfc: 'PRO123456AB3', nombre: 'Proveedor Logistica SA' },
|
||||||
|
{ rfc: 'PRO123456AB4', nombre: 'Proveedor Tecnologia SA' },
|
||||||
|
{ rfc: 'PRO123456AB5', nombre: 'Proveedor Papeleria SA' },
|
||||||
|
{ rfc: 'PRO123456AB6', nombre: 'Proveedor Telecom SA' },
|
||||||
|
{ rfc: 'PRO123456AB7', nombre: 'Proveedor Asesoria SA' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PRODUCTOS = [
|
||||||
|
{ clave: '84111506', descripcion: 'Servicio de consultoria', unidad: 'Servicio' },
|
||||||
|
{ clave: '43232408', descripcion: 'Licencia de software', unidad: 'Licencia' },
|
||||||
|
{ clave: '81141500', descripcion: 'Soporte tecnico', unidad: 'Servicio' },
|
||||||
|
{ clave: '81121700', descripcion: 'Desarrollo web', unidad: 'Servicio' },
|
||||||
|
{ clave: '86101500', descripcion: 'Capacitacion', unidad: 'Servicio' },
|
||||||
|
{ clave: '50151500', descripcion: 'Materiales de oficina', unidad: 'Pieza' },
|
||||||
|
{ clave: '80181600', descripcion: 'Publicidad', unidad: 'Servicio' },
|
||||||
|
{ clave: '81112200', descripcion: 'Diseno grafico', unidad: 'Servicio' },
|
||||||
|
{ clave: '72121000', descripcion: 'Renta de oficinas', unidad: 'Servicio' },
|
||||||
|
{ clave: '73101500', descripcion: 'Servicios de telecomunicaciones', unidad: 'Servicio' },
|
||||||
|
{ clave: '43231500', descripcion: 'Infraestructura en la nube', unidad: 'Servicio' },
|
||||||
|
{ clave: '81141800', descripcion: 'Mantenimiento de sistemas', unidad: 'Servicio' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const FORMAS_PAGO = ['01', '02', '03', '04', '28', '99'];
|
||||||
|
|
||||||
|
function deterministicUuid(seed: string): string {
|
||||||
|
const hex = createHash('sha256').update(seed).digest('hex');
|
||||||
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function round2(n: number): number {
|
||||||
|
return Math.round(n * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomDateWithin(daysBack: number): Date {
|
||||||
|
// Sesgar hacia fechas recientes: random^2 produce valores pequenos con mayor probabilidad
|
||||||
|
const daysAgo = Math.floor(Math.pow(Math.random(), 2) * daysBack);
|
||||||
|
const fecha = new Date();
|
||||||
|
fecha.setDate(fecha.getDate() - daysAgo);
|
||||||
|
fecha.setHours(8 + Math.floor(Math.random() * 10), Math.floor(Math.random() * 60), 0, 0);
|
||||||
|
return fecha;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`🌱 Agregando ${CFDIS_POR_CONTRIBUYENTE} CFDIs adicionales por contribuyente en Demo Ventas...\n`);
|
||||||
|
|
||||||
|
const tenant = await prisma.tenant.findUnique({ where: { rfc: DEMO_RFC } });
|
||||||
|
if (!tenant) throw new Error(`Tenant ${DEMO_RFC} no encontrado`);
|
||||||
|
|
||||||
|
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||||
|
|
||||||
|
const { rows: contribuyentes } = await pool.query<{ entidad_id: string; rfc: string; nombre: string }>(`
|
||||||
|
SELECT c.entidad_id, c.rfc, eg.nombre
|
||||||
|
FROM contribuyentes c
|
||||||
|
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
|
||||||
|
ORDER BY c.rfc
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (contribuyentes.length === 0) throw new Error('No hay contribuyentes demo');
|
||||||
|
|
||||||
|
let totalCreados = 0;
|
||||||
|
let totalExistentes = 0;
|
||||||
|
|
||||||
|
for (const c of contribuyentes) {
|
||||||
|
const { creados, existentes } = await agregarCfdisContribuyente(pool, c);
|
||||||
|
console.log(`✅ ${c.rfc}: ${creados} CFDIs creados, ${existentes} ya existian`);
|
||||||
|
totalCreados += creados;
|
||||||
|
totalExistentes += existentes;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n🎉 Total: ${totalCreados} CFDIs nuevos, ${totalExistentes} ya existian`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function agregarCfdisContribuyente(
|
||||||
|
pool: Pool,
|
||||||
|
contribuyente: { entidad_id: string; rfc: string; nombre: string },
|
||||||
|
): Promise<{ creados: number; existentes: number }> {
|
||||||
|
const client = await pool.connect();
|
||||||
|
const mesesAfectados = new Set<string>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Asegurar RFCs de clientes/proveedores y el contribuyente mismo
|
||||||
|
const rfcs = new Map<string, number>();
|
||||||
|
for (const p of [...CLIENTES, ...PROVEEDORES]) {
|
||||||
|
const { rows: [r] } = await client.query<{ id: number }>(`
|
||||||
|
INSERT INTO rfcs (rfc, razon_social, regimen_fiscal)
|
||||||
|
VALUES ($1, $2, '601')
|
||||||
|
ON CONFLICT (rfc) DO UPDATE SET razon_social = EXCLUDED.razon_social
|
||||||
|
RETURNING id
|
||||||
|
`, [p.rfc, p.nombre]);
|
||||||
|
rfcs.set(p.rfc, r.id);
|
||||||
|
}
|
||||||
|
const { rows: [principal] } = await client.query<{ id: number }>(`
|
||||||
|
INSERT INTO rfcs (rfc, razon_social, regimen_fiscal)
|
||||||
|
VALUES ($1, $2, '601')
|
||||||
|
ON CONFLICT (rfc) DO UPDATE SET razon_social = EXCLUDED.razon_social
|
||||||
|
RETURNING id
|
||||||
|
`, [contribuyente.rfc, contribuyente.nombre]);
|
||||||
|
rfcs.set(contribuyente.rfc, principal.id);
|
||||||
|
|
||||||
|
let creados = 0;
|
||||||
|
let existentes = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < CFDIS_POR_CONTRIBUYENTE; i++) {
|
||||||
|
const esEmitido = i % 2 === 0;
|
||||||
|
const contraparte = esEmitido
|
||||||
|
? CLIENTES[i % CLIENTES.length]
|
||||||
|
: PROVEEDORES[i % PROVEEDORES.length];
|
||||||
|
|
||||||
|
// Distribucion sesgada: muchos CFDIs pequenos, pocos grandes
|
||||||
|
const raw = Math.random() * Math.random();
|
||||||
|
const subtotal = Math.floor(raw * 60000) + 1500;
|
||||||
|
const iva = round2(subtotal * 0.16);
|
||||||
|
const total = round2(subtotal + iva);
|
||||||
|
|
||||||
|
const fecha = randomDateWithin(DIAS_ATRAS);
|
||||||
|
const year = String(fecha.getFullYear());
|
||||||
|
const month = String(fecha.getMonth() + 1).padStart(2, '0');
|
||||||
|
const fechaStr = fecha.toISOString();
|
||||||
|
|
||||||
|
const metodoPago = Math.random() > 0.35 ? 'PUE' : 'PPD';
|
||||||
|
const formaPago = FORMAS_PAGO[i % FORMAS_PAGO.length];
|
||||||
|
const usoCfdi = esEmitido ? 'G03' : 'G01';
|
||||||
|
const tipo = esEmitido ? 'EMITIDO' : 'RECIBIDO';
|
||||||
|
|
||||||
|
const rfcEmisor = esEmitido ? contribuyente.rfc : contraparte.rfc;
|
||||||
|
const nombreEmisor = esEmitido ? contribuyente.nombre : contraparte.nombre;
|
||||||
|
const rfcReceptor = esEmitido ? contraparte.rfc : contribuyente.rfc;
|
||||||
|
const nombreReceptor = esEmitido ? contraparte.nombre : contribuyente.nombre;
|
||||||
|
|
||||||
|
const uuid = deterministicUuid(`${contribuyente.rfc}-add-demo-cfdis-${i}`);
|
||||||
|
|
||||||
|
// Idempotencia: si el UUID ya existe, lo contamos y saltamos
|
||||||
|
const { rows: duplicados } = await client.query(`SELECT 1 FROM cfdis WHERE lower(uuid) = lower($1) LIMIT 1`, [uuid]);
|
||||||
|
if (duplicados.length > 0) {
|
||||||
|
existentes++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows: [cfdi] } = await client.query<{ id: number }>(`
|
||||||
|
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, moneda, tipo_cambio, tipo_comprobante,
|
||||||
|
metodo_pago, forma_pago, uso_cfdi,
|
||||||
|
iva_traslado, iva_traslado_mxn,
|
||||||
|
regimen_fiscal_emisor, regimen_fiscal_receptor,
|
||||||
|
contribuyente_id, fecha_efectiva, meses_global, año_global
|
||||||
|
) 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
|
||||||
|
) RETURNING id
|
||||||
|
`, [
|
||||||
|
year, month, tipo, uuid, 'DEMO', String(100000 + i),
|
||||||
|
'Vigente', fechaStr,
|
||||||
|
rfcs.get(rfcEmisor), rfcEmisor, nombreEmisor,
|
||||||
|
rfcs.get(rfcReceptor), rfcReceptor, nombreReceptor,
|
||||||
|
subtotal, subtotal, 0, 0,
|
||||||
|
total, total, 'MXN', 1, 'I',
|
||||||
|
metodoPago, formaPago, usoCfdi,
|
||||||
|
iva, iva,
|
||||||
|
'601', '601',
|
||||||
|
contribuyente.entidad_id, fechaStr, month, year,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Conceptos: de 1 a 3, repartiendo exactamente el subtotal
|
||||||
|
const numConceptos = Math.floor(Math.random() * 3) + 1;
|
||||||
|
let importeRestante = round2(subtotal);
|
||||||
|
for (let j = 0; j < numConceptos; j++) {
|
||||||
|
const prod = PRODUCTOS[(i + j) % PRODUCTOS.length];
|
||||||
|
const esUltimo = j === numConceptos - 1;
|
||||||
|
const cantidad = Math.floor(Math.random() * 5) + 1;
|
||||||
|
|
||||||
|
let importe: number;
|
||||||
|
if (esUltimo) {
|
||||||
|
importe = importeRestante;
|
||||||
|
} else {
|
||||||
|
const promedio = importeRestante / (numConceptos - j);
|
||||||
|
const factor = 0.7 + Math.random() * 0.6; // 70% - 130% del promedio
|
||||||
|
importe = round2(promedio * factor);
|
||||||
|
importe = Math.min(importe, importeRestante - 0.01);
|
||||||
|
}
|
||||||
|
importeRestante = round2(importeRestante - importe);
|
||||||
|
|
||||||
|
const valorUnitario = round2(importe / cantidad);
|
||||||
|
const ivaConcepto = round2(importe * 0.16);
|
||||||
|
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO cfdi_conceptos (
|
||||||
|
cfdi_id, clave_prod_serv, descripcion, cantidad, clave_unidad, unidad,
|
||||||
|
valor_unitario, valor_unitario_mxn, importe, importe_mxn,
|
||||||
|
iva_traslado, iva_traslado_mxn
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
|
`, [
|
||||||
|
cfdi.id, prod.clave, prod.descripcion, cantidad, 'E48', prod.unidad,
|
||||||
|
valorUnitario, valorUnitario, importe, importe,
|
||||||
|
ivaConcepto, ivaConcepto,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
creados++;
|
||||||
|
mesesAfectados.add(`${year}-${month}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marcar meses afectados para recomputo de metricas
|
||||||
|
for (const ym of mesesAfectados) {
|
||||||
|
const [anio, mes] = ym.split('-').map(Number);
|
||||||
|
await markForInvalidation(pool, contribuyente.entidad_id, anio, mes, 'add-demo-cfdis');
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
return { creados, existentes };
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK').catch(() => {});
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('\n❌ Error:', e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await tenantDb.shutdown();
|
||||||
|
});
|
||||||
259
apps/api/scripts/add-demo-notas-credito.ts
Normal file
259
apps/api/scripts/add-demo-notas-credito.ts
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
/**
|
||||||
|
* Script: add-demo-notas-credito.ts
|
||||||
|
*
|
||||||
|
* Agrega notas de crédito (NC) sintéticas a los contribuyentes del tenant
|
||||||
|
* "Demo Ventas" (horux_demoventas). Cada NC se relaciona con una factura
|
||||||
|
* existente (tipo_comprobante = 'I', metodo_pago = 'PUE') mediante
|
||||||
|
* cfdi_tipo_relacion = '01' y cfdis_relacionados = uuid de la factura origen.
|
||||||
|
*
|
||||||
|
* El script es idempotente: usa UUIDs deterministas, por lo que volverlo a
|
||||||
|
* correr no duplica registros.
|
||||||
|
*
|
||||||
|
* Uso:
|
||||||
|
* cd apps/api && npx tsx scripts/add-demo-notas-credito.ts
|
||||||
|
*
|
||||||
|
* Opciones via env:
|
||||||
|
* DEMO_NC_POR_CONTRIBUYENTE=4 # default: 4 (2 emitidas + 2 recibidas)
|
||||||
|
* DEMO_NC_DIAS_DESPUES=90 # default: 90 (max dias despues de la factura)
|
||||||
|
*/
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { Pool, type PoolClient } from 'pg';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
import { tenantDb } from '../src/config/database.ts';
|
||||||
|
import { markForInvalidation } from '../src/services/metricas.service.js';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const DEMO_RFC = 'DEMO2501019X2';
|
||||||
|
const NC_POR_CONTRIBUYENTE = parseInt(process.env.DEMO_NC_POR_CONTRIBUYENTE || '4', 10);
|
||||||
|
const MAX_DIAS_DESPUES = parseInt(process.env.DEMO_NC_DIAS_DESPUES || '90', 10);
|
||||||
|
|
||||||
|
function deterministicUuid(seed: string): string {
|
||||||
|
const hex = createHash('sha256').update(seed).digest('hex');
|
||||||
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function round2(n: number): number {
|
||||||
|
return Math.round(n * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(fecha: Date, dias: number): Date {
|
||||||
|
const r = new Date(fecha);
|
||||||
|
r.setDate(r.getDate() + dias);
|
||||||
|
r.setHours(8 + Math.floor(Math.random() * 10), Math.floor(Math.random() * 60), 0, 0);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FacturaOrigen {
|
||||||
|
id: number;
|
||||||
|
uuid: string;
|
||||||
|
total: number;
|
||||||
|
fecha_emision: Date;
|
||||||
|
type: 'EMITIDO' | 'RECIBIDO';
|
||||||
|
rfc_emisor_id: number;
|
||||||
|
rfc_emisor: string;
|
||||||
|
nombre_emisor: string;
|
||||||
|
rfc_receptor_id: number;
|
||||||
|
rfc_receptor: string;
|
||||||
|
nombre_receptor: string;
|
||||||
|
forma_pago: string;
|
||||||
|
uso_cfdi: string;
|
||||||
|
regimen_fiscal_emisor: string;
|
||||||
|
regimen_fiscal_receptor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`🌱 Agregando ${NC_POR_CONTRIBUYENTE} notas de crédito por contribuyente en Demo Ventas...\n`);
|
||||||
|
|
||||||
|
const tenant = await prisma.tenant.findUnique({ where: { rfc: DEMO_RFC } });
|
||||||
|
if (!tenant) throw new Error(`Tenant ${DEMO_RFC} no encontrado`);
|
||||||
|
|
||||||
|
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||||
|
|
||||||
|
const { rows: contribuyentes } = await pool.query<{ entidad_id: string; rfc: string; nombre: string }>(`
|
||||||
|
SELECT c.entidad_id, c.rfc, eg.nombre
|
||||||
|
FROM contribuyentes c
|
||||||
|
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
|
||||||
|
ORDER BY c.rfc
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (contribuyentes.length === 0) throw new Error('No hay contribuyentes demo');
|
||||||
|
|
||||||
|
let totalCreadas = 0;
|
||||||
|
let totalExistentes = 0;
|
||||||
|
|
||||||
|
for (const c of contribuyentes) {
|
||||||
|
const { creadas, existentes } = await agregarNcContribuyente(pool, c);
|
||||||
|
console.log(`✅ ${c.rfc}: ${creadas} NCs creadas, ${existentes} ya existian`);
|
||||||
|
totalCreadas += creadas;
|
||||||
|
totalExistentes += existentes;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n🎉 Total: ${totalCreadas} notas de crédito nuevas, ${totalExistentes} ya existian`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function agregarNcContribuyente(
|
||||||
|
pool: Pool,
|
||||||
|
contribuyente: { entidad_id: string; rfc: string; nombre: string },
|
||||||
|
): Promise<{ creadas: number; existentes: number }> {
|
||||||
|
const client = await pool.connect();
|
||||||
|
const mesesAfectados = new Set<string>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
const mitad = Math.ceil(NC_POR_CONTRIBUYENTE / 2);
|
||||||
|
const facturasEmitidas = await obtenerFacturasPUE(client, contribuyente.entidad_id, 'EMITIDO', mitad);
|
||||||
|
const facturasRecibidas = await obtenerFacturasPUE(client, contribuyente.entidad_id, 'RECIBIDO', NC_POR_CONTRIBUYENTE - mitad);
|
||||||
|
|
||||||
|
let creadas = 0;
|
||||||
|
let existentes = 0;
|
||||||
|
const usadas = new Set<string>();
|
||||||
|
let idxEmitida = 0;
|
||||||
|
let idxRecibida = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < NC_POR_CONTRIBUYENTE; i++) {
|
||||||
|
const esEmitida = i % 2 === 0;
|
||||||
|
const origen = esEmitida
|
||||||
|
? facturasEmitidas[idxEmitida++ % facturasEmitidas.length]
|
||||||
|
: facturasRecibidas[idxRecibida++ % facturasRecibidas.length];
|
||||||
|
|
||||||
|
if (!origen || usadas.has(origen.uuid)) {
|
||||||
|
// Si no hay suficientes facturas distintas, saltar
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
usadas.add(origen.uuid);
|
||||||
|
|
||||||
|
// Monto de la NC: entre 10% y 40% del total de la factura origen
|
||||||
|
const porcentaje = 0.1 + Math.random() * 0.3;
|
||||||
|
const ncTotal = round2(origen.total * porcentaje);
|
||||||
|
const ncSubtotal = round2(ncTotal / 1.16);
|
||||||
|
const ncIva = round2(ncTotal - ncSubtotal);
|
||||||
|
|
||||||
|
// Fecha: entre 5 y MAX_DIAS_DESPUES dias despues de la factura origen, sin pasar de hoy
|
||||||
|
const diasDespues = 5 + Math.floor(Math.random() * (MAX_DIAS_DESPUES - 5));
|
||||||
|
let ncFecha = addDays(origen.fecha_emision, diasDespues);
|
||||||
|
const ahora = new Date();
|
||||||
|
if (ncFecha > ahora) ncFecha = ahora;
|
||||||
|
|
||||||
|
const year = String(ncFecha.getFullYear());
|
||||||
|
const month = String(ncFecha.getMonth() + 1).padStart(2, '0');
|
||||||
|
const fechaStr = ncFecha.toISOString();
|
||||||
|
|
||||||
|
const uuid = deterministicUuid(`${contribuyente.rfc}-demo-nc-${esEmitida ? 'E' : 'R'}-${i}`);
|
||||||
|
const { rows: duplicados } = await client.query(
|
||||||
|
`SELECT 1 FROM cfdis WHERE lower(uuid) = lower($1) LIMIT 1`,
|
||||||
|
[uuid],
|
||||||
|
);
|
||||||
|
if (duplicados.length > 0) {
|
||||||
|
existentes++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows: [nc] } = await client.query<{ id: number }>(`
|
||||||
|
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, moneda, tipo_cambio, tipo_comprobante,
|
||||||
|
metodo_pago, forma_pago, uso_cfdi,
|
||||||
|
iva_traslado, iva_traslado_mxn,
|
||||||
|
regimen_fiscal_emisor, regimen_fiscal_receptor,
|
||||||
|
contribuyente_id, fecha_efectiva, meses_global, año_global,
|
||||||
|
cfdi_tipo_relacion, cfdis_relacionados
|
||||||
|
) 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
|
||||||
|
) RETURNING id
|
||||||
|
`, [
|
||||||
|
year, month, esEmitida ? 'EMITIDO' : 'RECIBIDO', uuid, 'NC', String(200000 + i),
|
||||||
|
'Vigente', fechaStr,
|
||||||
|
origen.rfc_emisor_id, origen.rfc_emisor, origen.nombre_emisor,
|
||||||
|
origen.rfc_receptor_id, origen.rfc_receptor, origen.nombre_receptor,
|
||||||
|
ncSubtotal, ncSubtotal, 0, 0,
|
||||||
|
ncTotal, ncTotal, 'MXN', 1, 'E',
|
||||||
|
'PUE', origen.forma_pago, origen.uso_cfdi,
|
||||||
|
ncIva, ncIva,
|
||||||
|
origen.regimen_fiscal_emisor, origen.regimen_fiscal_receptor,
|
||||||
|
contribuyente.entidad_id, fechaStr, month, year,
|
||||||
|
'01', origen.uuid.toLowerCase(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO cfdi_conceptos (
|
||||||
|
cfdi_id, clave_prod_serv, descripcion, cantidad, clave_unidad, unidad,
|
||||||
|
valor_unitario, valor_unitario_mxn, importe, importe_mxn,
|
||||||
|
iva_traslado, iva_traslado_mxn
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
|
`, [
|
||||||
|
nc.id, '84111506', 'Descuento por nota de credito', 1, 'E48', 'Servicio',
|
||||||
|
ncSubtotal, ncSubtotal, ncSubtotal, ncSubtotal,
|
||||||
|
ncIva, ncIva,
|
||||||
|
]);
|
||||||
|
|
||||||
|
creadas++;
|
||||||
|
mesesAfectados.add(`${year}-${month}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ym of mesesAfectados) {
|
||||||
|
const [anio, mes] = ym.split('-').map(Number);
|
||||||
|
await markForInvalidation(pool, contribuyente.entidad_id, anio, mes, 'demo-nc');
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
return { creadas, existentes };
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK').catch(() => {});
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function obtenerFacturasPUE(
|
||||||
|
client: PoolClient,
|
||||||
|
contribuyenteId: string,
|
||||||
|
type: 'EMITIDO' | 'RECIBIDO',
|
||||||
|
limite: number,
|
||||||
|
): Promise<FacturaOrigen[]> {
|
||||||
|
const { rows } = await client.query<FacturaOrigen>(`
|
||||||
|
SELECT
|
||||||
|
id, uuid, total_mxn AS total, fecha_emision, type,
|
||||||
|
rfc_emisor_id, rfc_emisor, nombre_emisor,
|
||||||
|
rfc_receptor_id, rfc_receptor, nombre_receptor,
|
||||||
|
forma_pago, uso_cfdi,
|
||||||
|
regimen_fiscal_emisor, regimen_fiscal_receptor
|
||||||
|
FROM cfdis
|
||||||
|
WHERE contribuyente_id = $1
|
||||||
|
AND type = $2
|
||||||
|
AND tipo_comprobante = 'I'
|
||||||
|
AND metodo_pago = 'PUE'
|
||||||
|
AND status = 'Vigente'
|
||||||
|
AND total_mxn > 5000
|
||||||
|
ORDER BY random()
|
||||||
|
LIMIT $3
|
||||||
|
`, [contribuyenteId, type, Math.max(limite * 3, 20)]);
|
||||||
|
|
||||||
|
// Mezclar y retornar hasta `limite`
|
||||||
|
const mezcladas = rows.sort(() => Math.random() - 0.5);
|
||||||
|
return mezcladas.slice(0, limite);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('\n❌ Error:', e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await tenantDb.shutdown();
|
||||||
|
});
|
||||||
75
apps/api/scripts/change-user-email.ts
Normal file
75
apps/api/scripts/change-user-email.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* Script: change-user-email
|
||||||
|
*
|
||||||
|
* Cambia el correo de un usuario, resetea su contraseña a una temporal
|
||||||
|
* y reenvía el correo de bienvenida con las nuevas credenciales.
|
||||||
|
*
|
||||||
|
* Ejecución:
|
||||||
|
* cd apps/api && npx tsx scripts/change-user-email.ts
|
||||||
|
*/
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { randomBytes } from 'crypto';
|
||||||
|
import { emailService } from '../src/services/email/email.service.js';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const OLD_EMAIL = 'eduardo.corona@corpcyl.com';
|
||||||
|
const NEW_EMAIL = 'miguel.corona@corpcyl.com';
|
||||||
|
|
||||||
|
function generateTempPassword(length = 12): string {
|
||||||
|
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789';
|
||||||
|
let result = '';
|
||||||
|
const bytes = randomBytes(length);
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
result += chars[bytes[i] % chars.length];
|
||||||
|
}
|
||||||
|
return result + '!';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const user = await prisma.user.findUnique({ where: { email: OLD_EMAIL } });
|
||||||
|
if (!user) {
|
||||||
|
console.error(`❌ No existe un usuario con el correo ${OLD_EMAIL}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.user.findUnique({ where: { email: NEW_EMAIL } });
|
||||||
|
if (existing) {
|
||||||
|
console.error(`❌ Ya existe un usuario con el correo ${NEW_EMAIL}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempPassword = generateTempPassword();
|
||||||
|
const passwordHash = await bcrypt.hash(tempPassword, 12);
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
email: NEW_EMAIL,
|
||||||
|
passwordHash,
|
||||||
|
tokenVersion: { increment: 1 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await emailService.sendWelcome(NEW_EMAIL, {
|
||||||
|
nombre: user.nombre,
|
||||||
|
email: NEW_EMAIL,
|
||||||
|
tempPassword,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Correo actualizado:', OLD_EMAIL, '→', NEW_EMAIL);
|
||||||
|
console.log('✅ Contraseña temporal generada y enviada por correo');
|
||||||
|
console.log(' Nombre:', user.nombre);
|
||||||
|
console.log(' Email:', NEW_EMAIL);
|
||||||
|
console.log(' Contraseña temporal:', tempPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('\n❌ Error:', e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
457
apps/api/scripts/create-demo-ventas.ts
Normal file
457
apps/api/scripts/create-demo-ventas.ts
Normal file
@@ -0,0 +1,457 @@
|
|||||||
|
/**
|
||||||
|
* Script: create-demo-ventas
|
||||||
|
*
|
||||||
|
* Crea una cuenta demo completa para ventas:
|
||||||
|
* - Tenant "Demo Ventas SA de CV" (plan custom, sin cobro)
|
||||||
|
* - Usuario owner: demo@horuxfin.com / Demo12345!
|
||||||
|
* - Base de datos propia con datos ficticios de contabilidad
|
||||||
|
* - Contribuyente, clientes/proveedores, CFDIs, bancos, conciliaciones,
|
||||||
|
* obligaciones fiscales y cartera.
|
||||||
|
*
|
||||||
|
* Ejecución:
|
||||||
|
* cd apps/api && npx tsx scripts/create-demo-ventas.ts
|
||||||
|
*/
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { tenantDb } from '../src/config/database.ts';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const DEMO = {
|
||||||
|
rfc: 'DEMO2501019X2',
|
||||||
|
nombre: 'Demo Ventas SA de CV',
|
||||||
|
email: 'demo@horuxfin.com',
|
||||||
|
password: 'Demo12345!',
|
||||||
|
databaseName: 'horux_demoventas',
|
||||||
|
codigoPostal: '01000',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CLIENTES = [
|
||||||
|
{ rfc: 'CLI123456AB1', nombre: 'Cliente Alfa SA' },
|
||||||
|
{ rfc: 'CLI123456AB2', nombre: 'Cliente Beta SA' },
|
||||||
|
{ rfc: 'CLI123456AB3', nombre: 'Cliente Gamma SA' },
|
||||||
|
{ rfc: 'CLI123456AB4', nombre: 'Cliente Delta SA' },
|
||||||
|
{ rfc: 'CLI123456AB5', nombre: 'Cliente Epsilon SA' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PROVEEDORES = [
|
||||||
|
{ rfc: 'PRO123456AB1', nombre: 'Proveedor Materiales SA' },
|
||||||
|
{ rfc: 'PRO123456AB2', nombre: 'Proveedor Servicios SA' },
|
||||||
|
{ rfc: 'PRO123456AB3', nombre: 'Proveedor Logistica SA' },
|
||||||
|
{ rfc: 'PRO123456AB4', nombre: 'Proveedor Tecnologia SA' },
|
||||||
|
{ rfc: 'PRO123456AB5', nombre: 'Proveedor Papeleria SA' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PRODUCTOS = [
|
||||||
|
{ clave: '84111506', descripcion: 'Servicio de consultoria', unidad: 'Servicio' },
|
||||||
|
{ clave: '43232408', descripcion: 'Licencia de software', unidad: 'Licencia' },
|
||||||
|
{ clave: '81141500', descripcion: 'Soporte tecnico', unidad: 'Servicio' },
|
||||||
|
{ clave: '81121700', descripcion: 'Desarrollo web', unidad: 'Servicio' },
|
||||||
|
{ clave: '86101500', descripcion: 'Capacitacion', unidad: 'Servicio' },
|
||||||
|
{ clave: '50151500', descripcion: 'Materiales de oficina', unidad: 'Pieza' },
|
||||||
|
{ clave: '80181600', descripcion: 'Publicidad', unidad: 'Servicio' },
|
||||||
|
{ clave: '81112200', descripcion: 'Diseno grafico', unidad: 'Servicio' },
|
||||||
|
];
|
||||||
|
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🌱 Creando cuenta demo "Demo Ventas"...\n');
|
||||||
|
|
||||||
|
const ownerRole = await prisma.rol.findUnique({ where: { nombre: 'owner' } });
|
||||||
|
if (!ownerRole) throw new Error('Rol owner no encontrado en BD central');
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 1. Tenant
|
||||||
|
// ============================================================
|
||||||
|
let tenant = await prisma.tenant.findUnique({ where: { rfc: DEMO.rfc } });
|
||||||
|
if (!tenant) {
|
||||||
|
tenant = await prisma.tenant.create({
|
||||||
|
data: {
|
||||||
|
nombre: DEMO.nombre,
|
||||||
|
rfc: DEMO.rfc,
|
||||||
|
plan: 'custom',
|
||||||
|
databaseName: DEMO.databaseName,
|
||||||
|
verticalProfile: 'CONTABLE',
|
||||||
|
dbMode: 'MANAGED',
|
||||||
|
dbSchemaVersion: 0,
|
||||||
|
codigoPostal: DEMO.codigoPostal,
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log('✅ Tenant creado:', tenant.nombre, `(${tenant.rfc})`);
|
||||||
|
} else {
|
||||||
|
await prisma.tenant.update({
|
||||||
|
where: { id: tenant.id },
|
||||||
|
data: { plan: 'custom', active: true, verticalProfile: 'CONTABLE' },
|
||||||
|
});
|
||||||
|
console.log('✅ Tenant actualizado:', tenant.nombre, `(${tenant.rfc})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 2. Usuario owner
|
||||||
|
// ============================================================
|
||||||
|
let user = await prisma.user.findUnique({ where: { email: DEMO.email } });
|
||||||
|
const passwordHash = await bcrypt.hash(DEMO.password, 12);
|
||||||
|
if (!user) {
|
||||||
|
user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: DEMO.email,
|
||||||
|
passwordHash,
|
||||||
|
nombre: 'Usuario Demo',
|
||||||
|
lastTenantId: tenant.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log('✅ Usuario creado:', user.email);
|
||||||
|
} else {
|
||||||
|
user = await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { passwordHash, lastTenantId: tenant.id },
|
||||||
|
});
|
||||||
|
console.log('✅ Usuario actualizado:', user.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 3. Membership
|
||||||
|
// ============================================================
|
||||||
|
await prisma.tenantMembership.upsert({
|
||||||
|
where: { userId_tenantId: { userId: user.id, tenantId: tenant.id } },
|
||||||
|
update: { rolId: ownerRole.id, isOwner: true, active: true },
|
||||||
|
create: {
|
||||||
|
userId: user.id,
|
||||||
|
tenantId: tenant.id,
|
||||||
|
rolId: ownerRole.id,
|
||||||
|
isOwner: true,
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log('✅ Membership owner asignada');
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 4. Suscripción custom gratis/ilimitada (status authorized)
|
||||||
|
// ============================================================
|
||||||
|
const now = new Date();
|
||||||
|
const periodEnd = new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000);
|
||||||
|
const existingSub = await prisma.subscription.findFirst({
|
||||||
|
where: { tenantId: tenant.id },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingSub) {
|
||||||
|
await prisma.subscription.create({
|
||||||
|
data: {
|
||||||
|
tenantId: tenant.id,
|
||||||
|
plan: 'custom',
|
||||||
|
status: 'authorized',
|
||||||
|
amount: 0,
|
||||||
|
frequency: 'monthly',
|
||||||
|
currentPeriodStart: now,
|
||||||
|
currentPeriodEnd: periodEnd,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await prisma.subscription.update({
|
||||||
|
where: { id: existingSub.id },
|
||||||
|
data: {
|
||||||
|
plan: 'custom',
|
||||||
|
status: 'authorized',
|
||||||
|
amount: 0,
|
||||||
|
currentPeriodStart: now,
|
||||||
|
currentPeriodEnd: periodEnd,
|
||||||
|
pendingPlan: null,
|
||||||
|
pendingFrequency: null,
|
||||||
|
pendingEffectiveAt: null,
|
||||||
|
upgradePreferenceId: null,
|
||||||
|
upgradeTargetPlan: null,
|
||||||
|
upgradeTargetAmount: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log('✅ Suscripción custom activa (gratis)');
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 5. Régimen fiscal activo del tenant
|
||||||
|
// ============================================================
|
||||||
|
const regimen = await prisma.regimen.findUnique({ where: { clave: '601' } });
|
||||||
|
if (regimen) {
|
||||||
|
await prisma.tenantRegimenActivo.upsert({
|
||||||
|
where: { tenantId_regimenId: { tenantId: tenant.id, regimenId: regimen.id } },
|
||||||
|
update: {},
|
||||||
|
create: { tenantId: tenant.id, regimenId: regimen.id },
|
||||||
|
});
|
||||||
|
console.log('✅ Régimen 601 activado para el tenant');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 6. Base de datos del tenant
|
||||||
|
// ============================================================
|
||||||
|
await tenantDb.provisionDatabase(DEMO.rfc, DEMO.databaseName);
|
||||||
|
const pool = await tenantDb.getPool(tenant.id, DEMO.databaseName);
|
||||||
|
console.log('✅ Base de datos del tenant provisionada:', DEMO.databaseName);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 7. Datos ficticios en BD del tenant
|
||||||
|
// ============================================================
|
||||||
|
await seedTenantData(pool, tenant.id, user.id);
|
||||||
|
|
||||||
|
console.log('\n🎉 Demo Ventas lista');
|
||||||
|
console.log(' Login:', DEMO.email, '/', DEMO.password);
|
||||||
|
console.log(' Tenant:', DEMO.nombre, `(${DEMO.rfc})`);
|
||||||
|
console.log(' BD:', DEMO.databaseName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedTenantData(pool: Pool, tenantId: string, ownerId: string) {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Contribuyente principal
|
||||||
|
const { rows: [entidad] } = await client.query<{ id: string }>(`
|
||||||
|
INSERT INTO entidades_gestionadas (tipo, nombre, identificador, supervisor_user_id)
|
||||||
|
VALUES ('CONTRIBUYENTE', $1, $2, $3)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
RETURNING id
|
||||||
|
`, [DEMO.nombre, DEMO.rfc, ownerId]);
|
||||||
|
|
||||||
|
let contribuyenteId: string;
|
||||||
|
if (entidad) {
|
||||||
|
contribuyenteId = entidad.id;
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO contribuyentes (entidad_id, rfc, regimen_fiscal, codigo_postal)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (entidad_id) DO NOTHING
|
||||||
|
`, [contribuyenteId, DEMO.rfc, '601', DEMO.codigoPostal]);
|
||||||
|
} else {
|
||||||
|
const { rows: [existing] } = await client.query<{ id: string }>(`
|
||||||
|
SELECT e.id FROM entidades_gestionadas e
|
||||||
|
JOIN contribuyentes c ON c.entidad_id = e.id
|
||||||
|
WHERE e.identificador = $1
|
||||||
|
`, [DEMO.rfc]);
|
||||||
|
contribuyenteId = existing.id;
|
||||||
|
}
|
||||||
|
console.log('✅ Contribuyente principal creado:', DEMO.rfc);
|
||||||
|
|
||||||
|
// RFCs de clientes y proveedores
|
||||||
|
const rfcs = new Map<string, number>();
|
||||||
|
for (const c of [...CLIENTES, ...PROVEEDORES]) {
|
||||||
|
const { rows: [r] } = await client.query<{ id: number }>(`
|
||||||
|
INSERT INTO rfcs (rfc, razon_social, regimen_fiscal)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (rfc) DO UPDATE SET razon_social = EXCLUDED.razon_social
|
||||||
|
RETURNING id
|
||||||
|
`, [c.rfc, c.nombre, c.rfc.startsWith('CLI') ? '601' : '601']);
|
||||||
|
rfcs.set(c.rfc, r.id);
|
||||||
|
}
|
||||||
|
// RFC del contribuyente principal
|
||||||
|
const { rows: [rfcPrincipal] } = await client.query<{ id: number }>(`
|
||||||
|
INSERT INTO rfcs (rfc, razon_social, regimen_fiscal)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (rfc) DO UPDATE SET razon_social = EXCLUDED.razon_social
|
||||||
|
RETURNING id
|
||||||
|
`, [DEMO.rfc, DEMO.nombre, '601']);
|
||||||
|
rfcs.set(DEMO.rfc, rfcPrincipal.id);
|
||||||
|
|
||||||
|
// Bancos del contribuyente
|
||||||
|
const { rows: [banco1] } = await client.query<{ id: number }>(`
|
||||||
|
INSERT INTO bancos (banco, terminacion_cuenta, contribuyente_id)
|
||||||
|
VALUES ($1, $2, $3) RETURNING id
|
||||||
|
`, ['BBVA', '1234', contribuyenteId]);
|
||||||
|
const { rows: [banco2] } = await client.query<{ id: number }>(`
|
||||||
|
INSERT INTO bancos (banco, terminacion_cuenta, contribuyente_id)
|
||||||
|
VALUES ($1, $2, $3) RETURNING id
|
||||||
|
`, ['Santander', '5678', contribuyenteId]);
|
||||||
|
console.log('✅ Bancos creados');
|
||||||
|
|
||||||
|
// Generar CFDIs
|
||||||
|
const tipos: Array<'EMITIDO' | 'RECIBIDO'> = ['EMITIDO', 'RECIBIDO'];
|
||||||
|
const cfdiIds: number[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
const tipo = tipos[i % 2];
|
||||||
|
const esEmitido = tipo === 'EMITIDO';
|
||||||
|
const contraparte = esEmitido
|
||||||
|
? CLIENTES[i % CLIENTES.length]
|
||||||
|
: PROVEEDORES[i % PROVEEDORES.length];
|
||||||
|
|
||||||
|
const subtotal = Math.floor(Math.random() * 40000) + 2000;
|
||||||
|
const iva = Math.round(subtotal * 0.16 * 100) / 100;
|
||||||
|
const total = Math.round((subtotal + iva) * 100) / 100;
|
||||||
|
|
||||||
|
const daysAgo = Math.floor(Math.random() * 540); // hasta ~18 meses atrás
|
||||||
|
const fecha = new Date();
|
||||||
|
fecha.setDate(fecha.getDate() - daysAgo);
|
||||||
|
fecha.setHours(10 + (i % 8), 0, 0, 0);
|
||||||
|
|
||||||
|
const year = String(fecha.getFullYear());
|
||||||
|
const month = String(fecha.getMonth() + 1).padStart(2, '0');
|
||||||
|
const fechaStr = fecha.toISOString();
|
||||||
|
|
||||||
|
const metodoPago = Math.random() > 0.3 ? 'PUE' : 'PPD';
|
||||||
|
const formasPago = ['01', '02', '03', '04'];
|
||||||
|
const formaPago = formasPago[i % formasPago.length];
|
||||||
|
const usoCfdi = esEmitido ? 'G03' : 'G01';
|
||||||
|
|
||||||
|
const rfcEmisor = esEmitido ? DEMO.rfc : contraparte.rfc;
|
||||||
|
const nombreEmisor = esEmitido ? DEMO.nombre : contraparte.nombre;
|
||||||
|
const rfcReceptor = esEmitido ? contraparte.rfc : DEMO.rfc;
|
||||||
|
const nombreReceptor = esEmitido ? contraparte.nombre : DEMO.nombre;
|
||||||
|
|
||||||
|
const { rows: [cfdi] } = await client.query<{ id: number }>(`
|
||||||
|
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, moneda, tipo_cambio, tipo_comprobante,
|
||||||
|
metodo_pago, forma_pago, uso_cfdi,
|
||||||
|
iva_traslado, iva_traslado_mxn,
|
||||||
|
regimen_fiscal_emisor, regimen_fiscal_receptor,
|
||||||
|
contribuyente_id, fecha_efectiva, meses_global, año_global
|
||||||
|
) 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
|
||||||
|
) RETURNING id
|
||||||
|
`, [
|
||||||
|
year, month, tipo, randomUUID(), 'DEMO', String(1000 + i),
|
||||||
|
'Vigente', fechaStr,
|
||||||
|
rfcs.get(rfcEmisor), rfcEmisor, nombreEmisor,
|
||||||
|
rfcs.get(rfcReceptor), rfcReceptor, nombreReceptor,
|
||||||
|
subtotal, subtotal, 0, 0,
|
||||||
|
total, total, 'MXN', 1, 'I',
|
||||||
|
metodoPago, formaPago, usoCfdi,
|
||||||
|
iva, iva,
|
||||||
|
'601', '601',
|
||||||
|
contribuyenteId, fechaStr, month, year,
|
||||||
|
]);
|
||||||
|
cfdiIds.push(cfdi.id);
|
||||||
|
|
||||||
|
// Conceptos
|
||||||
|
const numConceptos = Math.floor(Math.random() * 3) + 1;
|
||||||
|
for (let j = 0; j < numConceptos; j++) {
|
||||||
|
const prod = PRODUCTOS[(i + j) % PRODUCTOS.length];
|
||||||
|
const cantidad = Math.floor(Math.random() * 5) + 1;
|
||||||
|
const valorUnitario = Math.floor(Math.random() * 4000) + 500;
|
||||||
|
const importe = Math.round(cantidad * valorUnitario * 100) / 100;
|
||||||
|
const ivaConcepto = Math.round(importe * 0.16 * 100) / 100;
|
||||||
|
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO cfdi_conceptos (
|
||||||
|
cfdi_id, clave_prod_serv, descripcion, cantidad, clave_unidad, unidad,
|
||||||
|
valor_unitario, valor_unitario_mxn, importe, importe_mxn,
|
||||||
|
iva_traslado, iva_traslado_mxn
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
|
`, [
|
||||||
|
cfdi.id, prod.clave, prod.descripcion, cantidad, 'E48', prod.unidad,
|
||||||
|
valorUnitario, valorUnitario, importe, importe,
|
||||||
|
ivaConcepto, ivaConcepto,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('✅ 60 CFDIs y conceptos creados');
|
||||||
|
|
||||||
|
// Conciliaciones para algunos CFDIs PPD pagados con transferencia (forma 02/03)
|
||||||
|
const { rows: cfdisPpd } = await client.query<{ id: number; year: string; month: string }>(`
|
||||||
|
SELECT id, year, month FROM cfdis
|
||||||
|
WHERE metodo_pago = 'PPD' AND forma_pago IN ('02', '03')
|
||||||
|
ORDER BY id LIMIT 15
|
||||||
|
`);
|
||||||
|
|
||||||
|
for (const c of cfdisPpd) {
|
||||||
|
const bancoId = Math.random() > 0.5 ? banco1.id : banco2.id;
|
||||||
|
const fechaPago = new Date();
|
||||||
|
fechaPago.setDate(fechaPago.getDate() - Math.floor(Math.random() * 30));
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO conciliaciones (anio, mes, id_cfdi, fecha_de_pago, id_banco)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (id_cfdi) DO NOTHING
|
||||||
|
`, [c.year, c.month, c.id, fechaPago.toISOString().split('T')[0], bancoId]);
|
||||||
|
}
|
||||||
|
console.log('✅ Conciliaciones creadas');
|
||||||
|
|
||||||
|
// Obligaciones fiscales asignadas al contribuyente
|
||||||
|
const obligaciones = [
|
||||||
|
{ id: 'isr-provisional', nombre: 'Pago provisional de ISR', categoria: 'Federal mensual' },
|
||||||
|
{ id: 'iva-mensual', nombre: 'Pago mensual definitivo de IVA', categoria: 'Federal mensual' },
|
||||||
|
{ id: 'ret-isr-honorarios', nombre: 'Retenciones de ISR por honorarios y arrendamiento a PF', categoria: 'Federal mensual' },
|
||||||
|
{ id: 'diot', nombre: 'DIOT', categoria: 'Informativa mensual' },
|
||||||
|
{ id: 'imss-cuotas', nombre: 'Cuotas obrero-patronales IMSS', categoria: 'Seguridad social' },
|
||||||
|
{ id: 'anual-isr-pm', nombre: 'Declaración Anual de ISR PM', categoria: 'Anual' },
|
||||||
|
{ id: 'isn', nombre: 'ISN - Impuesto Sobre Nómina', categoria: 'Estatal' },
|
||||||
|
{ id: 'isrtp', nombre: 'Impuesto sobre remuneración al trabajo', categoria: 'Estatal' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const o of obligaciones) {
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO obligaciones_contribuyente (
|
||||||
|
contribuyente_id, catalogo_id, nombre, frecuencia, fecha_limite, categoria, activa, es_recomendada
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, true, true)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
`, [contribuyenteId, o.id, o.nombre, 'mensual', 'Día 17 del mes siguiente', o.categoria]);
|
||||||
|
}
|
||||||
|
console.log('✅ Obligaciones fiscales asignadas');
|
||||||
|
|
||||||
|
// Cartera principal con el contribuyente
|
||||||
|
const { rows: [cartera] } = await client.query<{ id: string }>(`
|
||||||
|
INSERT INTO carteras (supervisor_user_id, nombre, descripcion)
|
||||||
|
VALUES ($1, $2, $3) RETURNING id
|
||||||
|
`, [ownerId, 'Cartera Principal', 'Clientes y prospectos de Demo Ventas']);
|
||||||
|
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO cartera_entidades (cartera_id, entidad_id)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
`, [cartera.id, contribuyenteId]);
|
||||||
|
console.log('✅ Cartera principal creada');
|
||||||
|
|
||||||
|
// Alertas y recordatorios de ejemplo
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO alertas (tipo, titulo, mensaje, prioridad, fecha_vencimiento)
|
||||||
|
VALUES
|
||||||
|
('obligacion', 'Declaración mensual de IVA', 'Pago de IVA correspondiente a mayo 2026', 'alta', NOW() + INTERVAL '10 days'),
|
||||||
|
('obligacion', 'Pago provisional ISR', 'Pago provisional de ISR de mayo 2026', 'alta', NOW() + INTERVAL '10 days'),
|
||||||
|
('sat', 'Sincronización SAT pendiente', 'Última sincronización hace más de 7 días', 'media', NOW() + INTERVAL '3 days')
|
||||||
|
`);
|
||||||
|
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO recordatorios (titulo, descripcion, fecha_limite, notas, completado, privado, creado_por)
|
||||||
|
VALUES
|
||||||
|
('Revisar estados de cuenta', 'Conciliar pagos de clientes', NOW() + INTERVAL '5 days', 'Prioridad alta', false, false, $1),
|
||||||
|
('Enviar facturas del mes', 'Facturación recurrente a clientes', NOW() + INTERVAL '7 days', 'Clientes Alfa y Beta', false, false, $1)
|
||||||
|
`, [ownerId]);
|
||||||
|
console.log('✅ Alertas y recordatorios de ejemplo creados');
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('\n❌ Error creando demo:', e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await tenantDb.shutdown();
|
||||||
|
});
|
||||||
119
apps/api/scripts/create-vendedor-fernando.ts
Normal file
119
apps/api/scripts/create-vendedor-fernando.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* Script: create-vendedor-fernando.ts
|
||||||
|
*
|
||||||
|
* Crea la cuenta de Fernando (fernando@horuxfin.com) como Vendedor de Horux 360.
|
||||||
|
* Rol de plataforma: platform_sales (Vendedor).
|
||||||
|
* Membership en el tenant Horux 360 con rol cliente (minimo, solo para login
|
||||||
|
* y acceso a configuracion/cambio de contraseña).
|
||||||
|
*
|
||||||
|
* Si el usuario ya existe, le asigna/actualiza los permisos y envia un correo
|
||||||
|
* de notificacion. Si es nuevo, genera password temporal y envia bienvenida.
|
||||||
|
*
|
||||||
|
* Uso:
|
||||||
|
* cd apps/api && npx tsx scripts/create-vendedor-fernando.ts
|
||||||
|
*/
|
||||||
|
import { randomBytes } from 'crypto';
|
||||||
|
import { prisma } from '../src/config/database.js';
|
||||||
|
import { hashPassword } from '../src/auth/passwords.js';
|
||||||
|
import { emailService } from '../src/services/email/email.service.js';
|
||||||
|
import { invalidatePlatformRolesCache } from '../src/utils/platform-admin.js';
|
||||||
|
|
||||||
|
const EMAIL = 'fernando@horuxfin.com';
|
||||||
|
const NOMBRE = 'Fernando';
|
||||||
|
const HORUX_RFC = 'HTS240708LJA';
|
||||||
|
|
||||||
|
function generarPassword(): string {
|
||||||
|
return randomBytes(6).toString('hex'); // 12 caracteres hex
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`🌱 Creando cuenta de Vendedor para ${EMAIL}...\n`);
|
||||||
|
|
||||||
|
// 1. Tenant raiz Horux 360
|
||||||
|
const tenant = await prisma.tenant.findUnique({ where: { rfc: HORUX_RFC } });
|
||||||
|
if (!tenant) throw new Error(`Tenant Horux 360 (${HORUX_RFC}) no encontrado. Ejecuta primero el bootstrap admin global.`);
|
||||||
|
|
||||||
|
// 2. Rol "cliente" para la membership (minimo acceso)
|
||||||
|
const clienteRol = await prisma.rol.findUnique({ where: { nombre: 'cliente' } });
|
||||||
|
if (!clienteRol) throw new Error('Rol "cliente" no encontrado en BD central');
|
||||||
|
|
||||||
|
// 3. Buscar o crear usuario
|
||||||
|
let user = await prisma.user.findUnique({ where: { email: EMAIL } });
|
||||||
|
let tempPassword: string | null = null;
|
||||||
|
let esNuevo = false;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
tempPassword = generarPassword();
|
||||||
|
const passwordHash = await hashPassword(tempPassword);
|
||||||
|
user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: EMAIL,
|
||||||
|
passwordHash,
|
||||||
|
nombre: NOMBRE,
|
||||||
|
lastTenantId: tenant.id,
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
esNuevo = true;
|
||||||
|
console.log(`✅ Usuario creado: ${user.email}`);
|
||||||
|
console.log(` Password temporal: ${tempPassword}`);
|
||||||
|
} else {
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { lastTenantId: tenant.id, active: true },
|
||||||
|
});
|
||||||
|
console.log(`ℹ️ Usuario ya existia: ${user.email}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Membership en Horux 360 con rol cliente
|
||||||
|
await prisma.tenantMembership.upsert({
|
||||||
|
where: { userId_tenantId: { userId: user.id, tenantId: tenant.id } },
|
||||||
|
update: { rolId: clienteRol.id, active: true, isOwner: false },
|
||||||
|
create: {
|
||||||
|
userId: user.id,
|
||||||
|
tenantId: tenant.id,
|
||||||
|
rolId: clienteRol.id,
|
||||||
|
active: true,
|
||||||
|
isOwner: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(`✅ Membership "cliente" en ${tenant.nombre}`);
|
||||||
|
|
||||||
|
// 5. Rol de plataforma Vendedor (platform_sales)
|
||||||
|
await prisma.userPlatformRole.upsert({
|
||||||
|
where: { userId_role: { userId: user.id, role: 'platform_sales' } },
|
||||||
|
update: {},
|
||||||
|
create: { userId: user.id, role: 'platform_sales' },
|
||||||
|
});
|
||||||
|
console.log(`✅ Rol de plataforma "Vendedor" (platform_sales) asignado`);
|
||||||
|
|
||||||
|
// 6. Invalidar cache de roles de plataforma
|
||||||
|
invalidatePlatformRolesCache(user.id);
|
||||||
|
|
||||||
|
// 7. Enviar correo con accesos (solo si es nuevo; si ya existia, no se reenvia password)
|
||||||
|
if (esNuevo && tempPassword) {
|
||||||
|
await emailService.sendWelcome(EMAIL, {
|
||||||
|
nombre: NOMBRE,
|
||||||
|
email: EMAIL,
|
||||||
|
tempPassword,
|
||||||
|
});
|
||||||
|
console.log(`✅ Correo de bienvenida con credenciales enviado a ${EMAIL}`);
|
||||||
|
} else {
|
||||||
|
console.log(`ℹ️ El usuario ya existia; no se envio correo con password`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n🎉 Cuenta de Vendedor lista');
|
||||||
|
console.log(` Email: ${EMAIL}`);
|
||||||
|
if (tempPassword) console.log(` Password temporal: ${tempPassword}`);
|
||||||
|
console.log(` Tenant: ${tenant.nombre} (${tenant.rfc})`);
|
||||||
|
console.log(` Rol de plataforma: platform_sales (Vendedor)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('\n❌ Error:', err.message || err);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
126
apps/api/scripts/fix-demo-carteras-asignaciones.ts
Normal file
126
apps/api/scripts/fix-demo-carteras-asignaciones.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* Script: fix-demo-carteras-asignaciones
|
||||||
|
*
|
||||||
|
* Corrige la estructura de carteras de Demo Ventas para que las asignaciones
|
||||||
|
* de obligaciones/tareas al auxiliar sean válidas:
|
||||||
|
* - La cartera principal queda solo para el supervisor.
|
||||||
|
* - Se crea una subcartera asignada al auxiliar.
|
||||||
|
* - Los contribuyentes se mueven a la subcartera del auxiliar.
|
||||||
|
* - Se mantiene la relación auxiliar → supervisor.
|
||||||
|
*
|
||||||
|
* Ejecución:
|
||||||
|
* cd apps/api && npx tsx scripts/fix-demo-carteras-asignaciones.ts
|
||||||
|
*/
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { tenantDb } from '../src/config/database.ts';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const DEMO_RFC = 'DEMO2501019X2';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🔧 Corrigiendo carteras y asignaciones de Demo Ventas...\n');
|
||||||
|
|
||||||
|
const tenant = await prisma.tenant.findUnique({ where: { rfc: DEMO_RFC } });
|
||||||
|
if (!tenant) throw new Error(`Tenant ${DEMO_RFC} no encontrado`);
|
||||||
|
|
||||||
|
const [supervisor, auxiliar] = await Promise.all([
|
||||||
|
prisma.user.findUnique({ where: { email: 'supervisor@horuxfin.com' } }),
|
||||||
|
prisma.user.findUnique({ where: { email: 'auxiliar@horuxfin.com' } }),
|
||||||
|
]);
|
||||||
|
if (!supervisor) throw new Error('Usuario supervisor no encontrado');
|
||||||
|
if (!auxiliar) throw new Error('Usuario auxiliar no encontrado');
|
||||||
|
|
||||||
|
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Obtener cartera principal
|
||||||
|
const { rows: [carteraPrincipal] } = await client.query<{ id: string }>(`
|
||||||
|
SELECT id FROM carteras WHERE parent_id IS NULL ORDER BY created_at LIMIT 1
|
||||||
|
`);
|
||||||
|
if (!carteraPrincipal) throw new Error('No existe cartera principal');
|
||||||
|
|
||||||
|
// Crear subcartera para el auxiliar
|
||||||
|
const { rows: [subcartera] } = await client.query<{ id: string }>(`
|
||||||
|
INSERT INTO carteras (supervisor_user_id, auxiliar_user_id, nombre, descripcion, parent_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
RETURNING id
|
||||||
|
`, [supervisor.id, auxiliar.id, 'Cartera Auxiliar Demo', 'RFCs asignados al auxiliar de demo', carteraPrincipal.id]);
|
||||||
|
|
||||||
|
const subcarteraId = subcartera?.id;
|
||||||
|
if (!subcarteraId) {
|
||||||
|
// Si ya existía, recuperarla
|
||||||
|
const { rows: [existing] } = await client.query<{ id: string }>(`
|
||||||
|
SELECT id FROM carteras WHERE parent_id = $1 AND auxiliar_user_id = $2 LIMIT 1
|
||||||
|
`, [carteraPrincipal.id, auxiliar.id]);
|
||||||
|
if (!existing) throw new Error('No se pudo crear ni recuperar la subcartera del auxiliar');
|
||||||
|
// Asegurar que tenga supervisor
|
||||||
|
await client.query(`UPDATE carteras SET supervisor_user_id = $1 WHERE id = $2`, [supervisor.id, existing.id]);
|
||||||
|
}
|
||||||
|
const finalSubcarteraId = subcarteraId || (await client.query<{ id: string }>(`SELECT id FROM carteras WHERE parent_id = $1 AND auxiliar_user_id = $2 LIMIT 1`, [carteraPrincipal.id, auxiliar.id])).rows[0].id;
|
||||||
|
|
||||||
|
console.log('✅ Subcartera del auxiliar creada/recuperada');
|
||||||
|
|
||||||
|
// Mover contribuyentes de la cartera principal a la subcartera del auxiliar
|
||||||
|
const { rows: entidades } = await client.query<{ entidad_id: string }>(`
|
||||||
|
SELECT entidad_id FROM cartera_entidades WHERE cartera_id = $1
|
||||||
|
`, [carteraPrincipal.id]);
|
||||||
|
|
||||||
|
for (const e of entidades) {
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO cartera_entidades (cartera_id, entidad_id)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
`, [finalSubcarteraId, e.entidad_id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quitar contribuyentes de la cartera principal (ahora están en la subcartera)
|
||||||
|
await client.query(`DELETE FROM cartera_entidades WHERE cartera_id = $1`, [carteraPrincipal.id]);
|
||||||
|
|
||||||
|
// La cartera principal ya no tiene auxiliar asignado
|
||||||
|
await client.query(`UPDATE carteras SET auxiliar_user_id = NULL WHERE id = $1`, [carteraPrincipal.id]);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
console.log(`✅ ${entidades.length} contribuyentes movidos a la subcartera del auxiliar`);
|
||||||
|
console.log('✅ Cartera principal limpia (sin auxiliar)');
|
||||||
|
|
||||||
|
// Asegurar relación auxiliar → supervisor
|
||||||
|
await pool.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 = EXCLUDED.supervisor_user_id
|
||||||
|
`, [auxiliar.id, supervisor.id]);
|
||||||
|
console.log('✅ Relación auxiliar → supervisor registrada');
|
||||||
|
|
||||||
|
// Validar: el auxiliar debe ser elegible para todos los contribuyentes
|
||||||
|
const { rows: elegibles } = await pool.query<{ entidad_id: string }>(`
|
||||||
|
SELECT DISTINCT ce.entidad_id
|
||||||
|
FROM carteras c
|
||||||
|
JOIN cartera_entidades ce ON ce.cartera_id = c.id
|
||||||
|
WHERE c.auxiliar_user_id = $1
|
||||||
|
`, [auxiliar.id]);
|
||||||
|
console.log(`✅ Auxiliar elegible para ${elegibles.length} contribuyentes`);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n🎉 Estructura de carteras corregida');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('\n❌ Error:', e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await tenantDb.shutdown();
|
||||||
|
});
|
||||||
56
apps/api/scripts/import-clave-prod-serv.ts
Normal file
56
apps/api/scripts/import-clave-prod-serv.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import readline from 'readline';
|
||||||
|
import { prisma } from '../src/config/database.js';
|
||||||
|
|
||||||
|
const BATCH_SIZE = 2000;
|
||||||
|
const CSV_PATH = process.argv[2] || '/tmp/claves_prod_serv.csv';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
if (!fs.existsSync(CSV_PATH)) {
|
||||||
|
console.error(`Archivo no encontrado: ${CSV_PATH}`);
|
||||||
|
console.error('Uso: npx tsx scripts/import-clave-prod-serv.ts [ruta/al/csv]');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.catClaveProdServ.count();
|
||||||
|
console.log(`Registros existentes: ${existing}`);
|
||||||
|
if (existing > 0) {
|
||||||
|
console.log('El catálogo ya tiene datos. No se importará nada.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileStream = fs.createReadStream(CSV_PATH, { encoding: 'utf-8' });
|
||||||
|
const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
|
||||||
|
|
||||||
|
let batch: { clave: string; descripcion: string }[] = [];
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
for await (const line of rl) {
|
||||||
|
const idx = line.indexOf(',');
|
||||||
|
if (idx === -1) continue;
|
||||||
|
const clave = line.slice(0, idx).trim();
|
||||||
|
const descripcion = line.slice(idx + 1).trim();
|
||||||
|
if (!clave || !descripcion) continue;
|
||||||
|
batch.push({ clave, descripcion });
|
||||||
|
|
||||||
|
if (batch.length >= BATCH_SIZE) {
|
||||||
|
await prisma.catClaveProdServ.createMany({ data: batch, skipDuplicates: true });
|
||||||
|
total += batch.length;
|
||||||
|
console.log(`Importados: ${total}`);
|
||||||
|
batch = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (batch.length > 0) {
|
||||||
|
await prisma.catClaveProdServ.createMany({ data: batch, skipDuplicates: true });
|
||||||
|
total += batch.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Importación completada. Total: ${total}`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
67
apps/api/scripts/resend-welcome.ts
Normal file
67
apps/api/scripts/resend-welcome.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* Script: resend-welcome
|
||||||
|
*
|
||||||
|
* Genera una nueva contraseña temporal para el usuario y reenvía el correo
|
||||||
|
* de bienvenida. Útil cuando el envío anterior falló o se perdió.
|
||||||
|
*
|
||||||
|
* Ejecución:
|
||||||
|
* cd apps/api && npx tsx scripts/resend-welcome.ts
|
||||||
|
*/
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { randomBytes } from 'crypto';
|
||||||
|
import { emailService } from '../src/services/email/email.service.js';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const EMAIL = 'miguel.corona@corpcyl.com';
|
||||||
|
|
||||||
|
function generateTempPassword(length = 12): string {
|
||||||
|
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789';
|
||||||
|
let result = '';
|
||||||
|
const bytes = randomBytes(length);
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
result += chars[bytes[i] % chars.length];
|
||||||
|
}
|
||||||
|
return result + '!';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const user = await prisma.user.findUnique({ where: { email: EMAIL } });
|
||||||
|
if (!user) {
|
||||||
|
console.error(`❌ No existe un usuario con el correo ${EMAIL}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempPassword = generateTempPassword();
|
||||||
|
const passwordHash = await bcrypt.hash(tempPassword, 12);
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
passwordHash,
|
||||||
|
tokenVersion: { increment: 1 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('⏳ Enviando correo de bienvenida a', EMAIL, '...');
|
||||||
|
await emailService.sendWelcome(EMAIL, {
|
||||||
|
nombre: user.nombre,
|
||||||
|
email: EMAIL,
|
||||||
|
tempPassword,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Correo de bienvenida enviado');
|
||||||
|
console.log(' Nombre:', user.nombre);
|
||||||
|
console.log(' Email:', EMAIL);
|
||||||
|
console.log(' Contraseña temporal:', tempPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('\n❌ Error enviando correo:', e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
112
apps/api/scripts/reset-demo-asignaciones.ts
Normal file
112
apps/api/scripts/reset-demo-asignaciones.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* Script: reset-demo-asignaciones
|
||||||
|
*
|
||||||
|
* Deja el tenant Demo Ventas listo para que el usuario haga manualmente
|
||||||
|
* el flujo de asignación de carteras, obligaciones y tareas (útiles para tutoriales):
|
||||||
|
* - Elimina la subcartera del auxiliar.
|
||||||
|
* - Deja todos los contribuyentes en la cartera principal (sin auxiliar).
|
||||||
|
* - Elimina asignaciones de obligaciones y tareas.
|
||||||
|
* - Elimina la relación auxiliar → supervisor.
|
||||||
|
*
|
||||||
|
* Ejecución:
|
||||||
|
* cd apps/api && npx tsx scripts/reset-demo-asignaciones.ts
|
||||||
|
*/
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { tenantDb } from '../src/config/database.ts';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const DEMO_RFC = 'DEMO2501019X2';
|
||||||
|
|
||||||
|
async function findUserIdByEmail(email: string): Promise<string | null> {
|
||||||
|
const rows = await prisma.$queryRawUnsafe<{ id: string }[]>(
|
||||||
|
`SELECT id FROM users WHERE email = $1 LIMIT 1`,
|
||||||
|
email,
|
||||||
|
);
|
||||||
|
return rows[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🔄 Reseteando asignaciones de Demo Ventas para tutoriales...\n');
|
||||||
|
|
||||||
|
const tenants = await prisma.$queryRawUnsafe<{ id: string; database_name: string }[]>(
|
||||||
|
`SELECT id, database_name FROM tenants WHERE rfc = $1 LIMIT 1`,
|
||||||
|
DEMO_RFC,
|
||||||
|
);
|
||||||
|
const tenant = tenants[0];
|
||||||
|
if (!tenant) throw new Error(`Tenant ${DEMO_RFC} no encontrado`);
|
||||||
|
|
||||||
|
const supervisorId = await findUserIdByEmail('supervisor@horuxfin.com');
|
||||||
|
if (!supervisorId) throw new Error('Usuario supervisor no encontrado');
|
||||||
|
|
||||||
|
const auxiliarId = await findUserIdByEmail('auxiliar@horuxfin.com');
|
||||||
|
|
||||||
|
const pool = await tenantDb.getPool(tenant.id, tenant.database_name);
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Eliminar asignaciones de obligaciones y tareas
|
||||||
|
await client.query('DELETE FROM obligacion_asignaciones');
|
||||||
|
await client.query('DELETE FROM tarea_asignaciones');
|
||||||
|
console.log('✅ Asignaciones de obligaciones y tareas eliminadas');
|
||||||
|
|
||||||
|
// Obtener cartera principal
|
||||||
|
const { rows: [carteraPrincipal] } = await client.query<{ id: string }>(`
|
||||||
|
SELECT id FROM carteras WHERE parent_id IS NULL ORDER BY created_at LIMIT 1
|
||||||
|
`);
|
||||||
|
if (!carteraPrincipal) throw new Error('No existe cartera principal');
|
||||||
|
|
||||||
|
// Eliminar subcarteras (borra también cartera_entidades en cascade si hay FK)
|
||||||
|
await client.query('DELETE FROM cartera_entidades WHERE cartera_id != $1', [carteraPrincipal.id]);
|
||||||
|
await client.query('DELETE FROM carteras WHERE parent_id = $1', [carteraPrincipal.id]);
|
||||||
|
console.log('✅ Subcarteras eliminadas');
|
||||||
|
|
||||||
|
// Limpiar cartera principal: sin auxiliar, supervisor demo
|
||||||
|
await client.query(`
|
||||||
|
UPDATE carteras SET auxiliar_user_id = NULL, supervisor_user_id = $1 WHERE id = $2
|
||||||
|
`, [supervisorId, carteraPrincipal.id]);
|
||||||
|
|
||||||
|
// Agregar todos los contribuyentes a la cartera principal
|
||||||
|
const { rows: contribuyentes } = await client.query<{ entidad_id: string }>(`
|
||||||
|
SELECT entidad_id FROM contribuyentes
|
||||||
|
`);
|
||||||
|
|
||||||
|
for (const c of contribuyentes) {
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO cartera_entidades (cartera_id, entidad_id)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
`, [carteraPrincipal.id, c.entidad_id]);
|
||||||
|
}
|
||||||
|
console.log(`✅ ${contribuyentes.length} contribuyentes dejados en Cartera Principal`);
|
||||||
|
|
||||||
|
// Eliminar relación auxiliar → supervisor para que se cree en el tutorial
|
||||||
|
if (auxiliarId) {
|
||||||
|
await client.query('DELETE FROM auxiliar_supervisores WHERE auxiliar_user_id = $1', [auxiliarId]);
|
||||||
|
console.log('✅ Relación auxiliar → supervisor eliminada');
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n🎉 Demo Ventas listo para tutoriales');
|
||||||
|
console.log(' - Cartera Principal con 6 contribuyentes, sin auxiliar');
|
||||||
|
console.log(' - 48 obligaciones y 24 tareas sin asignar');
|
||||||
|
console.log(' - Usuarios: owner, supervisor, auxiliar, cliente');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('\n❌ Error:', e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await tenantDb.shutdown();
|
||||||
|
});
|
||||||
124
apps/api/scripts/seed-demo-obligaciones-tareas.ts
Normal file
124
apps/api/scripts/seed-demo-obligaciones-tareas.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* Script: seed-demo-obligaciones-tareas
|
||||||
|
*
|
||||||
|
* Crea obligaciones fiscales y tareas recurrentes para todos los contribuyentes
|
||||||
|
* del tenant Demo Ventas. Además asigna el usuario auxiliar a las tareas y
|
||||||
|
* obligaciones, y lo vincula a la cartera principal.
|
||||||
|
*
|
||||||
|
* Ejecución:
|
||||||
|
* cd apps/api && npx tsx scripts/seed-demo-obligaciones-tareas.ts
|
||||||
|
*/
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { tenantDb } from '../src/config/database.ts';
|
||||||
|
import { seedTareasDefault, materializarPeriodos } from '../src/services/tareas.service.ts';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const DEMO_RFC = 'DEMO2501019X2';
|
||||||
|
|
||||||
|
const OBLIGACIONES = [
|
||||||
|
{ id: 'isr-provisional', nombre: 'Pago provisional de ISR', fundamento: 'Art. 14 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', categoria: 'Federal mensual' },
|
||||||
|
{ id: 'iva-mensual', nombre: 'Pago mensual definitivo de IVA', fundamento: 'Art. 5-D LIVA', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', categoria: 'Federal mensual' },
|
||||||
|
{ 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', categoria: 'Federal mensual' },
|
||||||
|
{ id: 'diot', nombre: 'DIOT (Declaración Informativa de Operaciones con Terceros)', fundamento: 'Art. 32 LIVA', frecuencia: 'mensual', fechaLimite: 'Último día del mes siguiente', categoria: 'Informativa mensual' },
|
||||||
|
{ id: 'imss-cuotas', nombre: 'Cuotas obrero-patronales IMSS', fundamento: 'LSS', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', categoria: 'Seguridad social' },
|
||||||
|
{ id: 'anual-isr-pm', nombre: 'Declaración Anual de ISR PM', fundamento: 'Art. 76 LISR', frecuencia: 'anual', fechaLimite: '31 de marzo', categoria: 'Anual' },
|
||||||
|
{ id: 'isn', nombre: 'ISN - Impuesto Sobre Nómina', fundamento: 'Ley estatal', frecuencia: 'mensual', fechaLimite: 'Varía por estado (CDMX día 17)', categoria: 'Estatal' },
|
||||||
|
{ id: 'isrtp', nombre: 'Impuesto sobre remuneración al trabajo', fundamento: 'Ley estatal', frecuencia: 'mensual', fechaLimite: 'Día 10 del mes siguiente', categoria: 'Estatal' },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🌱 Sembrando obligaciones y tareas en Demo Ventas...\n');
|
||||||
|
|
||||||
|
const tenant = await prisma.tenant.findUnique({ where: { rfc: DEMO_RFC } });
|
||||||
|
if (!tenant) throw new Error(`Tenant ${DEMO_RFC} no encontrado`);
|
||||||
|
|
||||||
|
const auxUser = await prisma.user.findUnique({ where: { email: 'auxiliar@horuxfin.com' } });
|
||||||
|
if (!auxUser) throw new Error('Usuario auxiliar no encontrado');
|
||||||
|
|
||||||
|
const supervisorUser = await prisma.user.findUnique({ where: { email: 'supervisor@horuxfin.com' } });
|
||||||
|
if (!supervisorUser) throw new Error('Usuario supervisor no encontrado');
|
||||||
|
|
||||||
|
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||||
|
|
||||||
|
const { rows: contribuyentes } = await pool.query<{ id: string; rfc: string }>(`
|
||||||
|
SELECT entidad_id AS id, rfc FROM contribuyentes ORDER BY rfc
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (contribuyentes.length === 0) throw new Error('No hay contribuyentes en el tenant demo');
|
||||||
|
|
||||||
|
for (const c of contribuyentes) {
|
||||||
|
// Obligaciones fiscales (idempotente: evita duplicados por contribuyente + catalogo_id)
|
||||||
|
let obligacionesCreadas = 0;
|
||||||
|
for (const o of OBLIGACIONES) {
|
||||||
|
const { rows: existing } = await pool.query(
|
||||||
|
`SELECT 1 FROM obligaciones_contribuyente WHERE contribuyente_id = $1 AND catalogo_id = $2 LIMIT 1`,
|
||||||
|
[c.id, o.id],
|
||||||
|
);
|
||||||
|
if (existing.length > 0) continue;
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
INSERT INTO obligaciones_contribuyente (
|
||||||
|
contribuyente_id, catalogo_id, nombre, fundamento, frecuencia, fecha_limite, categoria, activa, es_recomendada
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, true, true)
|
||||||
|
`, [c.id, o.id, o.nombre, o.fundamento, o.frecuencia, o.fechaLimite, o.categoria]);
|
||||||
|
obligacionesCreadas++;
|
||||||
|
}
|
||||||
|
console.log(`✅ ${c.rfc}: ${obligacionesCreadas} obligaciones creadas`);
|
||||||
|
|
||||||
|
// Tareas default
|
||||||
|
const tareasCreadas = await seedTareasDefault(pool, c.id);
|
||||||
|
if (tareasCreadas > 0) {
|
||||||
|
await materializarPeriodos(pool, c.id);
|
||||||
|
console.log(`✅ ${c.rfc}: ${tareasCreadas} tareas creadas y periodos materializados`);
|
||||||
|
} else {
|
||||||
|
console.log(`ℹ️ ${c.rfc}: tareas default ya existían`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asignar auxiliar a todas las obligaciones y tareas activas
|
||||||
|
await pool.query(`
|
||||||
|
INSERT INTO obligacion_asignaciones (obligacion_id, auxiliar_user_id, asignado_por)
|
||||||
|
SELECT oc.id, $1, $2
|
||||||
|
FROM obligaciones_contribuyente oc
|
||||||
|
WHERE oc.activa = true
|
||||||
|
ON CONFLICT (obligacion_id) DO UPDATE SET auxiliar_user_id = EXCLUDED.auxiliar_user_id, asignado_por = EXCLUDED.asignado_por
|
||||||
|
`, [auxUser.id, supervisorUser.id]);
|
||||||
|
console.log('✅ Auxiliar asignado a obligaciones');
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
INSERT INTO tarea_asignaciones (tarea_id, auxiliar_user_id, asignado_por)
|
||||||
|
SELECT tc.id, $1, $2
|
||||||
|
FROM tareas_catalogo tc
|
||||||
|
WHERE tc.active = true
|
||||||
|
ON CONFLICT (tarea_id) DO UPDATE SET auxiliar_user_id = EXCLUDED.auxiliar_user_id, asignado_por = EXCLUDED.asignado_por
|
||||||
|
`, [auxUser.id, supervisorUser.id]);
|
||||||
|
console.log('✅ Auxiliar asignado a tareas');
|
||||||
|
|
||||||
|
// Asignar auxiliar a la cartera principal
|
||||||
|
await pool.query(`
|
||||||
|
UPDATE carteras SET auxiliar_user_id = $1
|
||||||
|
WHERE parent_id IS NULL
|
||||||
|
`, [auxUser.id]);
|
||||||
|
console.log('✅ Auxiliar asignado a la cartera principal');
|
||||||
|
|
||||||
|
// Asegurar relación auxiliar-supervisor
|
||||||
|
await pool.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 = EXCLUDED.supervisor_user_id
|
||||||
|
`, [auxUser.id, supervisorUser.id]);
|
||||||
|
console.log('✅ Relación auxiliar → supervisor registrada');
|
||||||
|
|
||||||
|
console.log('\n🎉 Obligaciones y tareas listas en Demo Ventas');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('\n❌ Error:', e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await tenantDb.shutdown();
|
||||||
|
});
|
||||||
20
apps/api/scripts/test-proxy-rotation.ts
Normal file
20
apps/api/scripts/test-proxy-rotation.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import { ProxyManager } from '../src/services/sat/proxy.service.js';
|
||||||
|
|
||||||
|
const manager = new ProxyManager(
|
||||||
|
process.env.SAT_PROXY_LIST || '',
|
||||||
|
(process.env.SAT_PROXY_STRATEGY as any) || 'round-robin',
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Total de proxies: ${manager.getProxyCount()}`);
|
||||||
|
console.log(`Estrategia: ${process.env.SAT_PROXY_STRATEGY || 'round-robin'}`);
|
||||||
|
console.log('Próximos 10 proxies seleccionados:');
|
||||||
|
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const proxy = manager.getNextProxy();
|
||||||
|
if (!proxy) {
|
||||||
|
console.log(` ${i + 1}. (sin proxy configurado)`);
|
||||||
|
} else {
|
||||||
|
console.log(` ${i + 1}. ${proxy.host}:${proxy.port}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
337
apps/api/scripts/update-demo-ventas.ts
Normal file
337
apps/api/scripts/update-demo-ventas.ts
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
/**
|
||||||
|
* Script: update-demo-ventas
|
||||||
|
*
|
||||||
|
* Agrega al tenant Demo Ventas:
|
||||||
|
* - 5 contribuyentes adicionales
|
||||||
|
* - Usuarios supervisor, auxiliar y cliente con sus memberships
|
||||||
|
* - CFDIs de ejemplo para los nuevos contribuyentes
|
||||||
|
* - Accesos de cliente a los contribuyentes
|
||||||
|
* - Ajusta el plan custom para soportar más RFCs/usuarios
|
||||||
|
*
|
||||||
|
* Ejecución:
|
||||||
|
* cd apps/api && npx tsx scripts/update-demo-ventas.ts
|
||||||
|
*/
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { tenantDb } from '../src/config/database.ts';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const DEMO_RFC = 'DEMO2501019X2';
|
||||||
|
const DEFAULT_PASSWORD = 'Demo12345!';
|
||||||
|
|
||||||
|
const NUEVOS_CONTRIBUYENTES = [
|
||||||
|
{ rfc: 'COM2501019X1', nombre: 'Comercial del Norte SA de CV', cp: '64000' },
|
||||||
|
{ rfc: 'DIS2501019X1', nombre: 'Distribuidora del Centro SA de CV', cp: '44100' },
|
||||||
|
{ rfc: 'SIS2501019X1', nombre: 'Servicios Integrales del Sur SA de CV', cp: '86000' },
|
||||||
|
{ rfc: 'IMP2501019X1', nombre: 'Importadora del Pacifico SA de CV', cp: '82140' },
|
||||||
|
{ rfc: 'EXA2501019X1', nombre: 'Exportadora del Atlantico SA de CV', cp: '94270' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const USUARIOS = [
|
||||||
|
{ email: 'supervisor@horuxfin.com', nombre: 'Supervisor Demo', rol: 'supervisor' },
|
||||||
|
{ email: 'auxiliar@horuxfin.com', nombre: 'Auxiliar Demo', rol: 'auxiliar' },
|
||||||
|
{ email: 'cliente@horuxfin.com', nombre: 'Cliente Demo', rol: 'cliente' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CLIENTES = [
|
||||||
|
{ rfc: 'CLI123456AB1', nombre: 'Cliente Alfa SA' },
|
||||||
|
{ rfc: 'CLI123456AB2', nombre: 'Cliente Beta SA' },
|
||||||
|
{ rfc: 'CLI123456AB3', nombre: 'Cliente Gamma SA' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PROVEEDORES = [
|
||||||
|
{ rfc: 'PRO123456AB1', nombre: 'Proveedor Materiales SA' },
|
||||||
|
{ rfc: 'PRO123456AB2', nombre: 'Proveedor Servicios SA' },
|
||||||
|
{ rfc: 'PRO123456AB3', nombre: 'Proveedor Logistica SA' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PRODUCTOS = [
|
||||||
|
{ clave: '84111506', descripcion: 'Servicio de consultoria', unidad: 'Servicio' },
|
||||||
|
{ clave: '43232408', descripcion: 'Licencia de software', unidad: 'Licencia' },
|
||||||
|
{ clave: '81141500', descripcion: 'Soporte tecnico', unidad: 'Servicio' },
|
||||||
|
{ clave: '81121700', descripcion: 'Desarrollo web', unidad: 'Servicio' },
|
||||||
|
{ clave: '86101500', descripcion: 'Capacitacion', unidad: 'Servicio' },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🌱 Actualizando Demo Ventas...\n');
|
||||||
|
|
||||||
|
const tenant = await prisma.tenant.findUnique({ where: { rfc: DEMO_RFC } });
|
||||||
|
if (!tenant) throw new Error(`Tenant ${DEMO_RFC} no encontrado`);
|
||||||
|
|
||||||
|
// Ajustar catálogo del plan custom para soportar la demo completa
|
||||||
|
await prisma.despachoPlanPrice.update({
|
||||||
|
where: { plan: 'custom' },
|
||||||
|
data: { maxRfcs: 10, maxUsers: 10 },
|
||||||
|
});
|
||||||
|
console.log('✅ Plan custom actualizado: maxRfcs=10, maxUsers=10');
|
||||||
|
|
||||||
|
// Crear/actualizar usuarios y memberships
|
||||||
|
const createdUsers: Record<string, { id: string; rolId: number }> = {};
|
||||||
|
for (const u of USUARIOS) {
|
||||||
|
const rol = await prisma.rol.findUnique({ where: { nombre: u.rol } });
|
||||||
|
if (!rol) throw new Error(`Rol ${u.rol} no encontrado`);
|
||||||
|
|
||||||
|
let user = await prisma.user.findUnique({ where: { email: u.email } });
|
||||||
|
const passwordHash = await bcrypt.hash(DEFAULT_PASSWORD, 12);
|
||||||
|
if (!user) {
|
||||||
|
user = await prisma.user.create({
|
||||||
|
data: { email: u.email, passwordHash, nombre: u.nombre, lastTenantId: tenant.id },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
user = await prisma.user.update({ where: { id: user.id }, data: { passwordHash, lastTenantId: tenant.id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.tenantMembership.upsert({
|
||||||
|
where: { userId_tenantId: { userId: user.id, tenantId: tenant.id } },
|
||||||
|
update: { rolId: rol.id, active: true, isOwner: false },
|
||||||
|
create: { userId: user.id, tenantId: tenant.id, rolId: rol.id, active: true, isOwner: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
createdUsers[u.rol] = { id: user.id, rolId: rol.id };
|
||||||
|
console.log(`✅ Usuario ${u.rol}:`, u.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
const supervisorId = createdUsers.supervisor.id;
|
||||||
|
const clienteId = createdUsers.cliente.id;
|
||||||
|
|
||||||
|
// Conectar a BD del tenant
|
||||||
|
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||||
|
|
||||||
|
// Crear contribuyentes, CFDIs y accesos
|
||||||
|
const contribuyenteIds: string[] = [];
|
||||||
|
for (const c of NUEVOS_CONTRIBUYENTES) {
|
||||||
|
const id = await crearContribuyente(pool, c, supervisorId, tenant.id);
|
||||||
|
contribuyenteIds.push(id);
|
||||||
|
console.log(`✅ Contribuyente creado: ${c.rfc}`);
|
||||||
|
|
||||||
|
await crearCfdis(pool, id, c.rfc, c.nombre);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asignar accesos de cliente a todos los contribuyentes (incluido el original)
|
||||||
|
const { rows: todasEntidades } = await pool.query<{ id: string }>(`
|
||||||
|
SELECT entidad_id AS id FROM contribuyentes
|
||||||
|
`);
|
||||||
|
for (const e of todasEntidades) {
|
||||||
|
await pool.query(`
|
||||||
|
INSERT INTO cliente_accesos (user_id, entidad_id) VALUES ($1, $2)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
`, [clienteId, e.id]);
|
||||||
|
}
|
||||||
|
console.log('✅ Accesos de cliente asignados a', todasEntidades.length, 'contribuyentes');
|
||||||
|
|
||||||
|
// Agregar nuevos contribuyentes a la cartera principal
|
||||||
|
const { rows: [cartera] } = await pool.query<{ id: string }>(`
|
||||||
|
SELECT id FROM carteras ORDER BY created_at LIMIT 1
|
||||||
|
`);
|
||||||
|
if (cartera) {
|
||||||
|
for (const id of contribuyenteIds) {
|
||||||
|
await pool.query(`
|
||||||
|
INSERT INTO cartera_entidades (cartera_id, entidad_id) VALUES ($1, $2)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
`, [cartera.id, id]);
|
||||||
|
}
|
||||||
|
console.log('✅ Nuevos contribuyentes agregados a cartera principal');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n🎉 Demo Ventas actualizada');
|
||||||
|
console.log(' Nuevos contribuyentes:', NUEVOS_CONTRIBUYENTES.length);
|
||||||
|
console.log(' Usuos adicionales:');
|
||||||
|
for (const u of USUARIOS) {
|
||||||
|
console.log(` ${u.rol}: ${u.email} / ${DEFAULT_PASSWORD}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function crearContribuyente(pool: Pool, data: { rfc: string; nombre: string; cp: string }, supervisorId: string, tenantId: string): Promise<string> {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Buscar si ya existe la entidad para este RFC
|
||||||
|
const { rows: existingEntidad } = await client.query<{ id: string }>(`
|
||||||
|
SELECT e.id FROM entidades_gestionadas e
|
||||||
|
WHERE e.identificador = $1 AND e.tipo = 'CONTRIBUYENTE'
|
||||||
|
`, [data.rfc]);
|
||||||
|
|
||||||
|
let entidadId: string;
|
||||||
|
if (existingEntidad.length > 0) {
|
||||||
|
entidadId = existingEntidad[0].id;
|
||||||
|
await client.query(`
|
||||||
|
UPDATE entidades_gestionadas
|
||||||
|
SET nombre = $1, supervisor_user_id = $2, updated_at = now()
|
||||||
|
WHERE id = $3
|
||||||
|
`, [data.nombre, supervisorId, entidadId]);
|
||||||
|
} else {
|
||||||
|
const { rows: [entidad] } = await client.query<{ id: string }>(`
|
||||||
|
INSERT INTO entidades_gestionadas (tipo, nombre, identificador, supervisor_user_id)
|
||||||
|
VALUES ('CONTRIBUYENTE', $1, $2, $3)
|
||||||
|
RETURNING id
|
||||||
|
`, [data.nombre, data.rfc, supervisorId]);
|
||||||
|
entidadId = entidad.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows: existingContrib } = await client.query<{ entidad_id: string }>(`
|
||||||
|
SELECT entidad_id FROM contribuyentes WHERE entidad_id = $1
|
||||||
|
`, [entidadId]);
|
||||||
|
|
||||||
|
if (existingContrib.length > 0) {
|
||||||
|
await client.query(`
|
||||||
|
UPDATE contribuyentes
|
||||||
|
SET rfc = $1, regimen_fiscal = $2, codigo_postal = $3
|
||||||
|
WHERE entidad_id = $4
|
||||||
|
`, [data.rfc, '601', data.cp, entidadId]);
|
||||||
|
} else {
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO contribuyentes (entidad_id, rfc, regimen_fiscal, codigo_postal)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
`, [entidadId, data.rfc, '601', data.cp]);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO rfcs (rfc, razon_social, regimen_fiscal)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (rfc) DO UPDATE SET razon_social = EXCLUDED.razon_social
|
||||||
|
`, [data.rfc, data.nombre, '601']);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
return entidadId;
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function crearCfdis(pool: Pool, contribuyenteId: string, rfcContribuyente: string, nombreContribuyente: string) {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Asegurar RFCs de clientes/proveedores
|
||||||
|
const rfcs = new Map<string, number>();
|
||||||
|
for (const c of [...CLIENTES, ...PROVEEDORES]) {
|
||||||
|
const { rows: [r] } = await client.query<{ id: number }>(`
|
||||||
|
INSERT INTO rfcs (rfc, razon_social, regimen_fiscal)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (rfc) DO UPDATE SET razon_social = EXCLUDED.razon_social
|
||||||
|
RETURNING id
|
||||||
|
`, [c.rfc, c.nombre, '601']);
|
||||||
|
rfcs.set(c.rfc, r.id);
|
||||||
|
}
|
||||||
|
const { rows: [rfcPrincipal] } = await client.query<{ id: number }>(`
|
||||||
|
INSERT INTO rfcs (rfc, razon_social, regimen_fiscal)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (rfc) DO UPDATE SET razon_social = EXCLUDED.razon_social
|
||||||
|
RETURNING id
|
||||||
|
`, [rfcContribuyente, nombreContribuyente, '601']);
|
||||||
|
rfcs.set(rfcContribuyente, rfcPrincipal.id);
|
||||||
|
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const esEmitido = i < 5;
|
||||||
|
const contraparte = esEmitido
|
||||||
|
? CLIENTES[i % CLIENTES.length]
|
||||||
|
: PROVEEDORES[i % PROVEEDORES.length];
|
||||||
|
|
||||||
|
const subtotal = Math.floor(Math.random() * 30000) + 1500;
|
||||||
|
const iva = Math.round(subtotal * 0.16 * 100) / 100;
|
||||||
|
const total = Math.round((subtotal + iva) * 100) / 100;
|
||||||
|
|
||||||
|
const daysAgo = Math.floor(Math.random() * 360);
|
||||||
|
const fecha = new Date();
|
||||||
|
fecha.setDate(fecha.getDate() - daysAgo);
|
||||||
|
fecha.setHours(9 + (i % 8), 0, 0, 0);
|
||||||
|
|
||||||
|
const year = String(fecha.getFullYear());
|
||||||
|
const month = String(fecha.getMonth() + 1).padStart(2, '0');
|
||||||
|
const fechaStr = fecha.toISOString();
|
||||||
|
const metodoPago = Math.random() > 0.4 ? 'PUE' : 'PPD';
|
||||||
|
const formasPago = ['01', '02', '03'];
|
||||||
|
const formaPago = formasPago[i % formasPago.length];
|
||||||
|
const usoCfdi = esEmitido ? 'G03' : 'G01';
|
||||||
|
const tipo = esEmitido ? 'EMITIDO' : 'RECIBIDO';
|
||||||
|
|
||||||
|
const rfcEmisor = esEmitido ? rfcContribuyente : contraparte.rfc;
|
||||||
|
const nombreEmisor = esEmitido ? nombreContribuyente : contraparte.nombre;
|
||||||
|
const rfcReceptor = esEmitido ? contraparte.rfc : rfcContribuyente;
|
||||||
|
const nombreReceptor = esEmitido ? contraparte.nombre : nombreContribuyente;
|
||||||
|
|
||||||
|
const { rows: [cfdi] } = await client.query<{ id: number }>(`
|
||||||
|
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, moneda, tipo_cambio, tipo_comprobante,
|
||||||
|
metodo_pago, forma_pago, uso_cfdi,
|
||||||
|
iva_traslado, iva_traslado_mxn,
|
||||||
|
regimen_fiscal_emisor, regimen_fiscal_receptor,
|
||||||
|
contribuyente_id, fecha_efectiva, meses_global, año_global
|
||||||
|
) 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
|
||||||
|
) RETURNING id
|
||||||
|
`, [
|
||||||
|
year, month, tipo, randomUUID(), 'DEMO', String(2000 + i),
|
||||||
|
'Vigente', fechaStr,
|
||||||
|
rfcs.get(rfcEmisor), rfcEmisor, nombreEmisor,
|
||||||
|
rfcs.get(rfcReceptor), rfcReceptor, nombreReceptor,
|
||||||
|
subtotal, subtotal, 0, 0,
|
||||||
|
total, total, 'MXN', 1, 'I',
|
||||||
|
metodoPago, formaPago, usoCfdi,
|
||||||
|
iva, iva,
|
||||||
|
'601', '601',
|
||||||
|
contribuyenteId, fechaStr, month, year,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const numConceptos = Math.floor(Math.random() * 2) + 1;
|
||||||
|
for (let j = 0; j < numConceptos; j++) {
|
||||||
|
const prod = PRODUCTOS[(i + j) % PRODUCTOS.length];
|
||||||
|
const cantidad = Math.floor(Math.random() * 4) + 1;
|
||||||
|
const valorUnitario = Math.floor(Math.random() * 3000) + 500;
|
||||||
|
const importe = Math.round(cantidad * valorUnitario * 100) / 100;
|
||||||
|
const ivaConcepto = Math.round(importe * 0.16 * 100) / 100;
|
||||||
|
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO cfdi_conceptos (
|
||||||
|
cfdi_id, clave_prod_serv, descripcion, cantidad, clave_unidad, unidad,
|
||||||
|
valor_unitario, valor_unitario_mxn, importe, importe_mxn,
|
||||||
|
iva_traslado, iva_traslado_mxn
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
|
`, [
|
||||||
|
cfdi.id, prod.clave, prod.descripcion, cantidad, 'E48', prod.unidad,
|
||||||
|
valorUnitario, valorUnitario, importe, importe,
|
||||||
|
ivaConcepto, ivaConcepto,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
console.log(` 📄 10 CFDIs creados para ${rfcContribuyente}`);
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('\n❌ Error actualizando demo:', e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await tenantDb.shutdown();
|
||||||
|
});
|
||||||
@@ -53,6 +53,12 @@ const envSchema = z.object({
|
|||||||
// Admin notification email
|
// Admin notification email
|
||||||
ADMIN_EMAIL: z.string().default('carlos@horuxfin.com'),
|
ADMIN_EMAIL: z.string().default('carlos@horuxfin.com'),
|
||||||
|
|
||||||
|
// SAT sync monitoring alerts (optional; falls back to ADMIN_EMAIL)
|
||||||
|
SAT_ALERT_EMAIL: z.string().email().optional(),
|
||||||
|
SAT_MONITOR_SCHEDULE: z.string().default('0 */2 * * *'),
|
||||||
|
SAT_STUCK_RUNNING_HOURS: z.string().transform(v => parseInt(v, 10)).default('2'),
|
||||||
|
SAT_FAILED_LOOKBACK_HOURS: z.string().transform(v => parseInt(v, 10)).default('24'),
|
||||||
|
|
||||||
// Facturapi
|
// Facturapi
|
||||||
FACTURAPI_USER_KEY: z.string().optional(),
|
FACTURAPI_USER_KEY: z.string().optional(),
|
||||||
|
|
||||||
|
|||||||
@@ -2,53 +2,67 @@ export interface ObligacionFiscal {
|
|||||||
id: string;
|
id: string;
|
||||||
nombre: string;
|
nombre: string;
|
||||||
fundamento: string;
|
fundamento: string;
|
||||||
frecuencia: 'mensual' | 'bimestral' | 'trimestral' | 'anual' | 'eventual';
|
frecuencia: 'mensual' | 'bimestral' | 'trimestral' | 'cuatrimestral' | 'anual' | 'eventual';
|
||||||
fechaLimite: string;
|
fechaLimite: string;
|
||||||
aplica: 'PM' | 'PF' | 'ambos';
|
aplica: 'PM' | 'PF' | 'ambos';
|
||||||
regimenes: string[] | null; // null = all regimes
|
regimenes: string[] | null; // null = all regimes
|
||||||
condicion: string | null;
|
condicion: string | null;
|
||||||
categoria: string;
|
categoria: string;
|
||||||
recomendadaPorDefecto: boolean;
|
recomendadaPorDefecto: boolean;
|
||||||
|
/** Si true, la obligación requiere comprobante de pago para cerrarse. */
|
||||||
|
requierePago: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OBLIGACIONES_CATALOGO: ObligacionFiscal[] = [
|
export const OBLIGACIONES_CATALOGO: ObligacionFiscal[] = [
|
||||||
// === FEDERALES MENSUALES (día 17) ===
|
// === 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: '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', requierePago: true, 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: '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', requierePago: true, 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: 'actividades-vulnerables', nombre: 'Aviso de actividades vulnerables', fundamento: 'LFPIORPI', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Federal mensual', requierePago: false, 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-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', requierePago: false, 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-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', requierePago: false, 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: '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', requierePago: false, 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 },
|
{ 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', requierePago: false, 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', requierePago: true, recomendadaPorDefecto: false },
|
||||||
|
|
||||||
// === INFORMATIVAS MENSUALES ===
|
// === 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: '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', requierePago: false, 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-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', requierePago: false, 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 },
|
{ 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', requierePago: false, recomendadaPorDefecto: false },
|
||||||
|
|
||||||
|
// === FEDERALES TRIMESTRALES ===
|
||||||
|
{ id: 'ieps-trimestral', nombre: 'Declaración Informativa Múltiple del IEPS', fundamento: 'LIEPS', frecuencia: 'trimestral', fechaLimite: 'Día 17 de abril, julio, octubre y enero', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Federal trimestral', requierePago: false, recomendadaPorDefecto: false },
|
||||||
|
|
||||||
// === RESICO PM ===
|
// === 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 },
|
{ 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', requierePago: true, recomendadaPorDefecto: true },
|
||||||
|
|
||||||
// === RESICO PF ===
|
// === 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 },
|
{ 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', requierePago: true, recomendadaPorDefecto: true },
|
||||||
|
|
||||||
// === ANUALES PM ===
|
// === 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: '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', requierePago: true, 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: 'declaracion-transparencia', nombre: 'Declaración Informativa de transparencia', fundamento: 'LFTAIPG', frecuencia: 'anual', fechaLimite: 'Día 31 de mayo', aplica: 'PM', regimenes: null, condicion: null, categoria: 'Federal anual', requierePago: false, 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: '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', requierePago: false, 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 },
|
{ 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', requierePago: false, 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', requierePago: false, recomendadaPorDefecto: false },
|
||||||
|
|
||||||
// === ANUALES PF ===
|
// === 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 },
|
{ 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', requierePago: true, recomendadaPorDefecto: true },
|
||||||
|
|
||||||
// === SEGURIDAD SOCIAL ===
|
// === 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: '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', requierePago: true, 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: 'sipare', nombre: 'SIPARE - Cuotas obrero-patronales', fundamento: 'LSS', frecuencia: 'mensual', fechaLimite: 'Día 15 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', requierePago: true, 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: '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', requierePago: true, 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 },
|
{ 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', requierePago: true, recomendadaPorDefecto: false },
|
||||||
|
{ id: 'sisub', nombre: 'Sistema de Información de Subcontratación', fundamento: 'LFT', frecuencia: 'cuatrimestral', fechaLimite: 'Día 17 de enero, mayo y septiembre', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', requierePago: false, 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', requierePago: true, recomendadaPorDefecto: false },
|
||||||
|
|
||||||
|
// === CRÉDITOS DE LOS TRABAJADORES ===
|
||||||
|
{ id: 'fonacot', nombre: 'Crédito FONACOT', fundamento: 'Ley FONACOT', frecuencia: 'mensual', fechaLimite: 'Día 5 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Créditos de los trabajadores', requierePago: true, recomendadaPorDefecto: false },
|
||||||
|
|
||||||
// === ESTATALES ===
|
// === 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 },
|
{ 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', requierePago: true, recomendadaPorDefecto: false },
|
||||||
|
{ id: 'isrtp', nombre: 'Impuesto sobre remuneración al trabajo', fundamento: 'Ley estatal', frecuencia: 'mensual', fechaLimite: 'Día 10 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Estatal', requierePago: true, recomendadaPorDefecto: false },
|
||||||
|
{ id: 'ish', nombre: 'ISH - Impuesto Sobre Hospedaje', fundamento: 'Ley estatal', frecuencia: 'mensual', fechaLimite: 'Día 15 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Estatal', requierePago: true, recomendadaPorDefecto: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ const createRecordatorioSchema = z.object({
|
|||||||
fechaLimite: z.string().min(8), // ISO date o yyyy-mm-dd
|
fechaLimite: z.string().min(8), // ISO date o yyyy-mm-dd
|
||||||
notas: z.string().max(2000).optional(),
|
notas: z.string().max(2000).optional(),
|
||||||
privado: z.boolean().optional(),
|
privado: z.boolean().optional(),
|
||||||
|
recurrencia: z.enum(['unica', 'mensual', 'bimestral', 'trimestral', 'anual']).default('unica'),
|
||||||
|
fechaFin: z.string().min(8).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateRecordatorioSchema = z.object({
|
const updateRecordatorioSchema = z.object({
|
||||||
@@ -107,7 +109,7 @@ export async function createRecordatorio(req: Request, res: Response, next: Next
|
|||||||
const evento = await recordatoriosService.createRecordatorio(
|
const evento = await recordatoriosService.createRecordatorio(
|
||||||
req.tenantPool!,
|
req.tenantPool!,
|
||||||
req.user!.userId,
|
req.user!.userId,
|
||||||
{ ...data, tipo: 'custom', recurrencia: 'unica' }
|
{ ...data, tipo: 'custom' }
|
||||||
);
|
);
|
||||||
|
|
||||||
res.status(201).json(evento);
|
res.status(201).json(evento);
|
||||||
|
|||||||
@@ -260,7 +260,11 @@ export async function removeAuxiliar(req: Request, res: Response, next: NextFunc
|
|||||||
// Supervisores available (for dropdown)
|
// Supervisores available (for dropdown)
|
||||||
export async function getSupervisores(req: Request, res: Response, next: NextFunction) {
|
export async function getSupervisores(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const supervisores = await carteraService.getSupervisores(req.tenantPool!, req.user!.tenantId);
|
const allSupervisores = await carteraService.getSupervisores(req.tenantPool!, req.user!.tenantId);
|
||||||
|
// Un supervisor solo se ve a si mismo en el dropdown (no puede asignar a otro supervisor)
|
||||||
|
const supervisores = isSupervisor(req)
|
||||||
|
? allSupervisores.filter(s => s.userId === req.user!.userId)
|
||||||
|
: allSupervisores;
|
||||||
return res.json({ data: supervisores });
|
return res.json({ data: supervisores });
|
||||||
} catch (err) { return next(err); }
|
} catch (err) { return next(err); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ export async function getClavesUnidad(req: Request, res: Response, next: NextFun
|
|||||||
} catch (error) { next(error); }
|
} catch (error) { next(error); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeRegex(str: string): string {
|
||||||
|
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
export async function searchClaveProdServ(req: Request, res: Response, next: NextFunction) {
|
export async function searchClaveProdServ(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const q = (req.query.q as string || '').trim();
|
const q = (req.query.q as string || '').trim();
|
||||||
@@ -44,11 +48,10 @@ export async function searchClaveProdServ(req: Request, res: Response, next: Nex
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Buscar por clave o descripción
|
// Buscar por clave o descripción
|
||||||
// Primero buscar por clave, luego por texto
|
|
||||||
const data = await prisma.catClaveProdServ.findMany({
|
const data = await prisma.catClaveProdServ.findMany({
|
||||||
where: {
|
where: {
|
||||||
OR: [
|
OR: [
|
||||||
{ clave: { startsWith: q } },
|
{ clave: { startsWith: q, mode: 'insensitive' } },
|
||||||
{ descripcion: { contains: q, mode: 'insensitive' } },
|
{ descripcion: { contains: q, mode: 'insensitive' } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -68,8 +71,8 @@ export async function searchClaveProdServ(req: Request, res: Response, next: Nex
|
|||||||
return res.json(fallback);
|
return res.json(fallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Buscar con variantes comunes de acentos
|
// Buscar con variantes comunes de acentos, escapando caracteres regex primero
|
||||||
const withAccents = normalized
|
const withAccents = escapeRegex(normalized)
|
||||||
.replace(/a/gi, '[aá]').replace(/e/gi, '[eé]')
|
.replace(/a/gi, '[aá]').replace(/e/gi, '[eé]')
|
||||||
.replace(/i/gi, '[ií]').replace(/o/gi, '[oó]').replace(/u/gi, '[uú]')
|
.replace(/i/gi, '[ií]').replace(/o/gi, '[oó]').replace(/u/gi, '[uú]')
|
||||||
.replace(/n/gi, '[nñ]');
|
.replace(/n/gi, '[nñ]');
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ export async function listConceptos(req: Request, res: Response, next: NextFunct
|
|||||||
uuidLike?: string;
|
uuidLike?: string;
|
||||||
claveProdServ?: string;
|
claveProdServ?: string;
|
||||||
descripcionConcepto?: string;
|
descripcionConcepto?: string;
|
||||||
|
noIdentificacion?: string;
|
||||||
orderBy?: 'fecha' | 'importe';
|
orderBy?: 'fecha' | 'importe';
|
||||||
orderDir?: 'asc' | 'desc';
|
orderDir?: 'asc' | 'desc';
|
||||||
} = {
|
} = {
|
||||||
@@ -146,6 +147,7 @@ export async function listConceptos(req: Request, res: Response, next: NextFunct
|
|||||||
uuidLike: req.query.uuidLike as string,
|
uuidLike: req.query.uuidLike as string,
|
||||||
claveProdServ: req.query.claveProdServ as string,
|
claveProdServ: req.query.claveProdServ as string,
|
||||||
descripcionConcepto: req.query.descripcionConcepto as string,
|
descripcionConcepto: req.query.descripcionConcepto as string,
|
||||||
|
noIdentificacion: req.query.noIdentificacion as string,
|
||||||
orderBy: req.query.orderBy as 'fecha' | 'importe',
|
orderBy: req.query.orderBy as 'fecha' | 'importe',
|
||||||
orderDir: req.query.orderDir as 'asc' | 'desc',
|
orderDir: req.query.orderDir as 'asc' | 'desc',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export async function createInvitation(req: Request, res: Response, next: NextFu
|
|||||||
return res.status(400).json({ message: 'El email es requerido' });
|
return res.status(400).json({ message: 'El email es requerido' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Solo platform_admin puede crear invitaciones
|
// Solo platform_admin puede crear invitaciones de cliente
|
||||||
const isAdmin = await hasAnyPlatformRole(req.user!.userId, 'platform_admin');
|
const isAdmin = await hasAnyPlatformRole(req.user!.userId, 'platform_admin');
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
return res.status(403).json({ message: 'Solo administradores pueden crear invitaciones' });
|
return res.status(403).json({ message: 'Solo administradores pueden crear invitaciones' });
|
||||||
|
|||||||
@@ -38,10 +38,14 @@ const createSchema = z.object({
|
|||||||
|
|
||||||
const updateSchema = createSchema.partial();
|
const updateSchema = createSchema.partial();
|
||||||
|
|
||||||
|
function effectiveTenantId(req: Request): string {
|
||||||
|
return req.viewingTenantId || req.user!.tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
export async function list(req: Request, res: Response, next: NextFunction) {
|
export async function list(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const visibleIds = await getEntidadesVisibles(req.tenantPool!, req.user!.userId, req.user!.role);
|
const visibleIds = await getEntidadesVisibles(req.tenantPool!, req.user!.userId, req.user!.role);
|
||||||
const rows = await contribuyenteService.listContribuyentes(req.tenantPool!, visibleIds, req.user!.tenantId);
|
const rows = await contribuyenteService.listContribuyentes(req.tenantPool!, visibleIds, effectiveTenantId(req));
|
||||||
|
|
||||||
// Batch lookup de nombres de supervisores
|
// Batch lookup de nombres de supervisores
|
||||||
const supervisorIds = [...new Set(rows.map(r => r.supervisorUserId).filter(Boolean))] as string[];
|
const supervisorIds = [...new Set(rows.map(r => r.supervisorUserId).filter(Boolean))] as string[];
|
||||||
@@ -65,7 +69,7 @@ export async function list(req: Request, res: Response, next: NextFunction) {
|
|||||||
|
|
||||||
export async function getById(req: Request, res: Response, next: NextFunction) {
|
export async function getById(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const row = await contribuyenteService.getContribuyenteById(req.tenantPool!, String(req.params.id), req.user!.tenantId);
|
const row = await contribuyenteService.getContribuyenteById(req.tenantPool!, String(req.params.id), effectiveTenantId(req));
|
||||||
if (!row) return next(new AppError(404, 'Contribuyente no encontrado'));
|
if (!row) return next(new AppError(404, 'Contribuyente no encontrado'));
|
||||||
return res.json(row);
|
return res.json(row);
|
||||||
} catch (err) { return next(err); }
|
} catch (err) { return next(err); }
|
||||||
@@ -93,7 +97,7 @@ export async function create(req: Request, res: Response, next: NextFunction) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const row = await contribuyenteService.createContribuyente(req.tenantPool!, data);
|
const { row, reactivated } = await contribuyenteService.createContribuyente(req.tenantPool!, data);
|
||||||
|
|
||||||
// Si se asignó un supervisor, agregar el contribuyente a todas las carteras
|
// Si se asignó un supervisor, agregar el contribuyente a todas las carteras
|
||||||
// top-level de ese supervisor para que aparezca directamente en su vista.
|
// top-level de ese supervisor para que aparezca directamente en su vista.
|
||||||
@@ -119,7 +123,7 @@ export async function create(req: Request, res: Response, next: NextFunction) {
|
|||||||
console.error('[Contribuyente] Overage adjust failed (non-blocking):', err.message || err);
|
console.error('[Contribuyente] Overage adjust failed (non-blocking):', err.message || err);
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.status(201).json({ ...row, overage });
|
return res.status(reactivated ? 200 : 201).json({ ...row, reactivated, overage });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message));
|
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'));
|
if (err.code === '23505') return next(new AppError(409, 'Ya existe un contribuyente con este RFC'));
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { z } from 'zod';
|
|||||||
import { signupDespacho } from '../services/despacho.service.js';
|
import { signupDespacho } from '../services/despacho.service.js';
|
||||||
import { AppError } from '../middlewares/error.middleware.js';
|
import { AppError } from '../middlewares/error.middleware.js';
|
||||||
import { prisma } from '../config/database.js';
|
import { prisma } from '../config/database.js';
|
||||||
|
import { getPlanPrice } from '../services/payment/subscription.service.js';
|
||||||
|
|
||||||
const signupSchema = z.object({
|
const signupSchema = z.object({
|
||||||
despacho: z.object({
|
despacho: z.object({
|
||||||
@@ -47,7 +48,7 @@ export async function getMyPlan(req: Request, res: Response, next: NextFunction)
|
|||||||
// business_control desde una TrialInvitation), respetamos ese plan
|
// business_control desde una TrialInvitation), respetamos ese plan
|
||||||
// para que el feature-gate y los límites funcionen correctamente.
|
// para que el feature-gate y los límites funcionen correctamente.
|
||||||
const subscription = await prisma.subscription.findFirst({
|
const subscription = await prisma.subscription.findFirst({
|
||||||
where: { tenantId, status: { in: ['authorized', 'pending', 'paused', 'trial'] } },
|
where: { tenantId, status: { in: ['authorized', 'pending', 'paused', 'trial', 'trial_expired'] } },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
select: {
|
select: {
|
||||||
status: true, amount: true, plan: true,
|
status: true, amount: true, plan: true,
|
||||||
@@ -64,6 +65,18 @@ export async function getMyPlan(req: Request, res: Response, next: NextFunction)
|
|||||||
currentPlan = String(tenant.plan);
|
currentPlan = String(tenant.plan);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Precio de catálogo del plan actual (primer año, anual). La UI lo usa
|
||||||
|
// cuando la suscripción aún no tiene monto (trial/trial_expired) para
|
||||||
|
// mostrar el CTA de pago.
|
||||||
|
let planPrice: number | null = null;
|
||||||
|
if (currentPlan && currentPlan !== 'trial' && currentPlan !== 'custom') {
|
||||||
|
try {
|
||||||
|
planPrice = await getPlanPrice(currentPlan as any, 'annual', 'firstYear');
|
||||||
|
} catch {
|
||||||
|
planPrice = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Estado de suscripción activa (si hay) — alimenta la UI con el monto
|
// 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
|
// recurrente actual, fecha de próxima renovación y si el primer pago
|
||||||
// (cuando aplica dualidad firstYear) ya fue completado.
|
// (cuando aplica dualidad firstYear) ya fue completado.
|
||||||
@@ -72,6 +85,7 @@ export async function getMyPlan(req: Request, res: Response, next: NextFunction)
|
|||||||
dbMode: tenant.dbMode,
|
dbMode: tenant.dbMode,
|
||||||
trialEndsAt: tenant.trialEndsAt?.toISOString() ?? null,
|
trialEndsAt: tenant.trialEndsAt?.toISOString() ?? null,
|
||||||
isTrialActive,
|
isTrialActive,
|
||||||
|
planPrice,
|
||||||
subscription: subscription
|
subscription: subscription
|
||||||
? {
|
? {
|
||||||
status: subscription.status,
|
status: subscription.status,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { getOpiniones, getOpinionPdf, consultarOpinion, consultarOpinionContribu
|
|||||||
import * as declaracionesService from '../services/declaraciones.service.js';
|
import * as declaracionesService from '../services/declaraciones.service.js';
|
||||||
import * as constanciaService from '../services/constancia.service.js';
|
import * as constanciaService from '../services/constancia.service.js';
|
||||||
import * as extrasService from '../services/documentos-extras.service.js';
|
import * as extrasService from '../services/documentos-extras.service.js';
|
||||||
|
import * as obligacionEvidenciasService from '../services/obligacion-evidencias.service.js';
|
||||||
import { notifyDocumentoSubido } from '../services/notify-upload.service.js';
|
import { notifyDocumentoSubido } from '../services/notify-upload.service.js';
|
||||||
import { AppError } from '../middlewares/error.middleware.js';
|
import { AppError } from '../middlewares/error.middleware.js';
|
||||||
|
|
||||||
@@ -81,8 +82,9 @@ const createDeclaracionSchema = z.object({
|
|||||||
año: z.number().int().min(2020).max(2100),
|
año: z.number().int().min(2020).max(2100),
|
||||||
mes: z.number().int().min(1).max(12),
|
mes: z.number().int().min(1).max(12),
|
||||||
tipo: z.enum(['normal', 'complementaria']),
|
tipo: z.enum(['normal', 'complementaria']),
|
||||||
periodicidad: z.enum(['mensual', 'bimestral', 'trimestral', 'semestral', 'anual']).optional(),
|
periodicidad: z.enum(['mensual', 'bimestral', 'trimestral', 'cuatrimestral', 'semestral', 'anual']).optional(),
|
||||||
impuestos: z.array(z.enum(['IVA', 'ISR', 'IEPS', 'ISN', 'DIOT', 'OTRO', 'ISH'])).min(1, 'Selecciona al menos un impuesto'),
|
impuestos: z.array(z.enum(['IVA', 'ISR', 'IEPS', 'ISN', 'DIOT', 'OTRO', 'ISH'])).optional(),
|
||||||
|
obligacionesIds: z.array(z.string().uuid()).optional(),
|
||||||
montoPago: z.number().min(0).optional(),
|
montoPago: z.number().min(0).optional(),
|
||||||
pdfBase64: z.string().min(100),
|
pdfBase64: z.string().min(100),
|
||||||
pdfFilename: z.string().min(1).max(255),
|
pdfFilename: z.string().min(1).max(255),
|
||||||
@@ -92,6 +94,9 @@ const createDeclaracionSchema = z.object({
|
|||||||
}).refine(
|
}).refine(
|
||||||
d => !d.ligaPagoBase64 || !!d.ligaPagoFilename,
|
d => !d.ligaPagoBase64 || !!d.ligaPagoFilename,
|
||||||
{ message: 'Si incluyes liga de pago, también debes mandar su nombre de archivo', path: ['ligaPagoFilename'] },
|
{ message: 'Si incluyes liga de pago, también debes mandar su nombre de archivo', path: ['ligaPagoFilename'] },
|
||||||
|
).refine(
|
||||||
|
d => (d.obligacionesIds && d.obligacionesIds.length > 0) || (d.impuestos && d.impuestos.length > 0),
|
||||||
|
{ message: 'Selecciona al menos una obligación fiscal o un impuesto', path: ['obligacionesIds'] },
|
||||||
);
|
);
|
||||||
|
|
||||||
export async function listarDeclaraciones(req: Request, res: Response, next: NextFunction) {
|
export async function listarDeclaraciones(req: Request, res: Response, next: NextFunction) {
|
||||||
@@ -119,6 +124,7 @@ export async function crearDeclaracion(req: Request, res: Response, next: NextFu
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Notificación fire-and-forget a owners del despacho + supervisor del RFC.
|
// Notificación fire-and-forget a owners del despacho + supervisor del RFC.
|
||||||
|
// Incluye como adjuntos el acuse de declaración y la liga de pago (si se subió).
|
||||||
// No bloquea la respuesta ni falla la creación si SMTP no está configurado.
|
// No bloquea la respuesta ni falla la creación si SMTP no está configurado.
|
||||||
notifyDocumentoSubido({
|
notifyDocumentoSubido({
|
||||||
pool: req.tenantPool!,
|
pool: req.tenantPool!,
|
||||||
@@ -126,6 +132,7 @@ export async function crearDeclaracion(req: Request, res: Response, next: NextFu
|
|||||||
contribuyenteId: contribuyenteId ?? null,
|
contribuyenteId: contribuyenteId ?? null,
|
||||||
subidoPor: req.user!.email,
|
subidoPor: req.user!.email,
|
||||||
kind: 'declaracion',
|
kind: 'declaracion',
|
||||||
|
declaracionId: result.declaracion.id,
|
||||||
declaracion: {
|
declaracion: {
|
||||||
periodo: `${MESES[data.mes - 1]} ${data.año}`,
|
periodo: `${MESES[data.mes - 1]} ${data.año}`,
|
||||||
tipo: data.tipo,
|
tipo: data.tipo,
|
||||||
@@ -334,3 +341,91 @@ export async function listarCategoriasExtras(req: Request, res: Response, next:
|
|||||||
res.json(data);
|
res.json(data);
|
||||||
} catch (error) { next(error); }
|
} catch (error) { next(error); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Obligación evidencias — documentos que cierran obligaciones fiscales
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const createEvidenciaObligacionSchema = z.object({
|
||||||
|
contribuyenteId: z.string().uuid('contribuyenteId inválido'),
|
||||||
|
obligacionId: z.string().uuid('obligacionId inválido'),
|
||||||
|
periodo: z.string().regex(/^\d{4}-\d{2}$/, 'periodo debe ser YYYY-MM'),
|
||||||
|
tipoDocumento: z.enum(['declaracion', 'pago', 'acuse', 'complemento']),
|
||||||
|
pdfBase64: z.string().min(100, 'PDF requerido'),
|
||||||
|
pdfFilename: z.string().min(1).max(255),
|
||||||
|
notas: z.string().max(2000).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function listarEvidenciasObligacion(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const contribuyenteId = req.query.contribuyenteId as string | undefined;
|
||||||
|
if (!contribuyenteId) return next(new AppError(400, 'contribuyenteId requerido'));
|
||||||
|
const periodo = req.query.periodo as string | undefined;
|
||||||
|
const obligacionId = req.query.obligacionId as string | undefined;
|
||||||
|
const data = await obligacionEvidenciasService.listEvidencias(req.tenantPool!, contribuyenteId, {
|
||||||
|
periodo,
|
||||||
|
obligacionId,
|
||||||
|
});
|
||||||
|
res.json(data);
|
||||||
|
} catch (error) { next(error); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function crearEvidenciaObligacion(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!canUpload(req)) return res.status(403).json({ message: 'No tienes permiso para subir documentos' });
|
||||||
|
const data = createEvidenciaObligacionSchema.parse(req.body);
|
||||||
|
const result = await obligacionEvidenciasService.createEvidencia(req.tenantPool!, {
|
||||||
|
...data,
|
||||||
|
subidoPor: req.user!.userId,
|
||||||
|
subidoPorEmail: req.user!.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notificación fire-and-forget a owners + supervisor del contribuyente.
|
||||||
|
const { rows: obRows } = await req.tenantPool!.query<{ nombre: string }>(
|
||||||
|
'SELECT nombre FROM obligaciones_contribuyente WHERE id = $1',
|
||||||
|
[data.obligacionId],
|
||||||
|
);
|
||||||
|
notifyDocumentoSubido({
|
||||||
|
pool: req.tenantPool!,
|
||||||
|
tenantId: req.viewingTenantId ?? req.user!.tenantId,
|
||||||
|
contribuyenteId: data.contribuyenteId,
|
||||||
|
subidoPor: req.user!.email,
|
||||||
|
kind: 'obligacion_evidencia',
|
||||||
|
evidencia: {
|
||||||
|
obligacionNombre: obRows[0]?.nombre || 'Obligación fiscal',
|
||||||
|
periodo: data.periodo,
|
||||||
|
tipoDocumento: data.tipoDocumento,
|
||||||
|
filename: data.pdfFilename,
|
||||||
|
},
|
||||||
|
pdfBase64: data.pdfBase64,
|
||||||
|
}).catch((err: any) => console.error('[notifyDocumentoSubido obligacion_evidencia]', 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 descargarEvidenciaObligacion(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 obligacionEvidenciasService.getEvidenciaPdf(req.tenantPool!, id);
|
||||||
|
if (!pdf) return next(new AppError(404, 'Evidencia no encontrada'));
|
||||||
|
res.setHeader('Content-Type', pdf.mime);
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${pdf.filename}"`);
|
||||||
|
res.send(pdf.buffer);
|
||||||
|
} catch (error) { next(error); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function eliminarEvidenciaObligacion(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 result = await obligacionEvidenciasService.deleteEvidencia(req.tenantPool!, id);
|
||||||
|
if (!result) return next(new AppError(404, 'Evidencia no encontrada'));
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (error) { next(error); }
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,29 +3,42 @@ import { z } from 'zod';
|
|||||||
import { AppError } from '../middlewares/error.middleware.js';
|
import { AppError } from '../middlewares/error.middleware.js';
|
||||||
import {
|
import {
|
||||||
EMAIL_TYPES,
|
EMAIL_TYPES,
|
||||||
getEmailPreferencesPorContribuyente,
|
NOTIFICATION_ROLES,
|
||||||
setContribuyenteEmailPreferences,
|
getRoleEmailPreferences,
|
||||||
|
setRoleEmailPreference,
|
||||||
|
type EmailType,
|
||||||
|
type NotificationRole,
|
||||||
} from '../services/notification-preferences.service.js';
|
} from '../services/notification-preferences.service.js';
|
||||||
|
|
||||||
export async function listPreferences(req: Request, res: Response, next: NextFunction) {
|
export async function listPreferences(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const data = await getEmailPreferencesPorContribuyente(req.tenantPool!);
|
const preferences = await getRoleEmailPreferences(req.tenantPool!);
|
||||||
res.json({ emailTypes: EMAIL_TYPES, data });
|
res.json({
|
||||||
|
emailTypes: EMAIL_TYPES,
|
||||||
|
roles: NOTIFICATION_ROLES,
|
||||||
|
preferences,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateSchema = z.object({
|
const updateSchema = z.object({
|
||||||
contribuyenteId: z.string().uuid(),
|
emailType: z.enum([...EMAIL_TYPES] as [string, ...string[]]),
|
||||||
preferences: z.record(z.string(), z.boolean()),
|
role: z.enum([...NOTIFICATION_ROLES] as [string, ...string[]]),
|
||||||
|
enabled: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function updatePreferences(req: Request, res: Response, next: NextFunction) {
|
export async function updatePreferences(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const { contribuyenteId, preferences } = updateSchema.parse(req.body);
|
const { emailType, role, enabled } = updateSchema.parse(req.body);
|
||||||
const updated = await setContribuyenteEmailPreferences(req.tenantPool!, contribuyenteId, preferences);
|
const preferences = await setRoleEmailPreference(
|
||||||
res.json({ contribuyenteId, preferences: updated });
|
req.tenantPool!,
|
||||||
|
emailType as EmailType,
|
||||||
|
role as NotificationRole,
|
||||||
|
enabled,
|
||||||
|
);
|
||||||
|
res.json({ preferences });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
|
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -184,7 +184,18 @@ export async function subscribeMe(req: Request, res: Response, next: NextFunctio
|
|||||||
if (msg.includes('MercadoPago no está configurado')) {
|
if (msg.includes('MercadoPago no está configurado')) {
|
||||||
return res.status(503).json({ message: msg });
|
return res.status(503).json({ message: msg });
|
||||||
}
|
}
|
||||||
// Otros errores de MP al crear preapproval (monto inválido, email inválido, etc.)
|
// Errores de negocio de MP (monto fuera de límites, payer igual collector, etc.)
|
||||||
|
if (msg.includes('Cannot pay an amount greater than')) {
|
||||||
|
return res.status(400).json({
|
||||||
|
message: 'El monto del plan supera el límite de cobro recurrente de MercadoPago ($10,000 MXN). Usa el pago anual único o contacta a soporte.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (msg.includes('Payer and collector cannot be the same user')) {
|
||||||
|
return res.status(400).json({
|
||||||
|
message: 'El correo del pagador no puede ser el mismo que el de la cuenta de MercadoPago del vendedor.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Otros errores de MP al crear preapproval/preference
|
||||||
if (msg.includes('Unauthorized access') || error?.status === 401) {
|
if (msg.includes('Unauthorized access') || error?.status === 401) {
|
||||||
return res.status(503).json({
|
return res.status(503).json({
|
||||||
message: 'MercadoPago rechazó la solicitud. Verifica que MP_ACCESS_TOKEN sea válido y esté vigente.',
|
message: 'MercadoPago rechazó la solicitud. Verifica que MP_ACCESS_TOKEN sea válido y esté vigente.',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { z } from 'zod';
|
|||||||
import * as tenantsService from '../services/tenants.service.js';
|
import * as tenantsService from '../services/tenants.service.js';
|
||||||
import { AppError } from '../middlewares/error.middleware.js';
|
import { AppError } from '../middlewares/error.middleware.js';
|
||||||
import { isGlobalAdmin } from '../utils/global-admin.js';
|
import { isGlobalAdmin } from '../utils/global-admin.js';
|
||||||
|
import { hasAnyPlatformRole } from '../utils/platform-admin.js';
|
||||||
import { isOwnerSomewhere } from '../utils/memberships.js';
|
import { isOwnerSomewhere } from '../utils/memberships.js';
|
||||||
|
|
||||||
async function requireGlobalAdmin(req: Request): Promise<void> {
|
async function requireGlobalAdmin(req: Request): Promise<void> {
|
||||||
@@ -13,8 +14,10 @@ async function requireGlobalAdmin(req: Request): Promise<void> {
|
|||||||
|
|
||||||
export async function getAllTenants(req: Request, res: Response, next: NextFunction) {
|
export async function getAllTenants(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId);
|
// Admin global, TI y Vendedor pueden ver el listado completo de tenants.
|
||||||
if (!isAdmin) {
|
// Vendedor lo necesita para enviar invitaciones de trial.
|
||||||
|
const canList = await hasAnyPlatformRole(req.user!.userId, 'platform_admin', 'platform_ti', 'platform_sales');
|
||||||
|
if (!canList) {
|
||||||
// Evita 403 en consola del frontend cuando componentes sin-gate hacen polling
|
// Evita 403 en consola del frontend cuando componentes sin-gate hacen polling
|
||||||
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||||
return res.json([]);
|
return res.json([]);
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
import type { Request, Response, NextFunction } from 'express';
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
import * as trialInvitationService from '../services/trial-invitations.service.js';
|
import * as trialInvitationService from '../services/trial-invitations.service.js';
|
||||||
import { isGlobalAdmin } from '../utils/global-admin.js';
|
import { hasAnyPlatformRole } from '../utils/platform-admin.js';
|
||||||
import { prisma } from '../config/database.js';
|
import { prisma } from '../config/database.js';
|
||||||
|
|
||||||
async function requireGlobalAdmin(req: Request, res: Response): Promise<boolean> {
|
async function requireAdminOrSales(req: Request, res: Response): Promise<boolean> {
|
||||||
const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId);
|
const isAdmin = await hasAnyPlatformRole(req.user!.userId, 'platform_admin', 'platform_sales');
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
res.status(403).json({ message: 'Solo el administrador global puede gestionar invitaciones de trial' });
|
res.status(403).json({ message: 'Solo administradores o vendedores pueden gestionar invitaciones de trial' });
|
||||||
}
|
}
|
||||||
return isAdmin;
|
return isAdmin;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createInvitation(req: Request, res: Response, next: NextFunction) {
|
export async function createInvitation(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
if (!(await requireGlobalAdmin(req, res))) return;
|
if (!(await requireAdminOrSales(req, res))) return;
|
||||||
|
|
||||||
const { tenantId, plan, durationDays } = req.body;
|
const { tenantId, plan, durationDays } = req.body;
|
||||||
if (!tenantId || !durationDays || durationDays < 1 || durationDays > 365) {
|
if (!tenantId || !durationDays || durationDays < 1 || durationDays > 365) {
|
||||||
@@ -38,7 +38,7 @@ export async function createInvitation(req: Request, res: Response, next: NextFu
|
|||||||
|
|
||||||
export async function getAllInvitations(req: Request, res: Response, next: NextFunction) {
|
export async function getAllInvitations(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
if (!(await requireGlobalAdmin(req, res))) return;
|
if (!(await requireAdminOrSales(req, res))) return;
|
||||||
|
|
||||||
const { tenantId, status } = req.query;
|
const { tenantId, status } = req.query;
|
||||||
const invitations = await trialInvitationService.getInvitations({
|
const invitations = await trialInvitationService.getInvitations({
|
||||||
@@ -85,7 +85,7 @@ export async function acceptInvitation(req: Request, res: Response, next: NextFu
|
|||||||
|
|
||||||
export async function cancelInvitation(req: Request, res: Response, next: NextFunction) {
|
export async function cancelInvitation(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
if (!(await requireGlobalAdmin(req, res))) return;
|
if (!(await requireAdminOrSales(req, res))) return;
|
||||||
|
|
||||||
const id = typeof req.params.id === 'string' ? req.params.id : '';
|
const id = typeof req.params.id === 'string' ? req.params.id : '';
|
||||||
const result = await trialInvitationService.cancelInvitation(id);
|
const result = await trialInvitationService.cancelInvitation(id);
|
||||||
|
|||||||
@@ -70,14 +70,24 @@ export async function inviteUsuario(req: Request, res: Response, next: NextFunct
|
|||||||
}
|
}
|
||||||
const data = inviteSchema.parse(req.body);
|
const data = inviteSchema.parse(req.body);
|
||||||
|
|
||||||
// Los supervisores solo pueden invitar clientes
|
// Los supervisores solo pueden invitar clientes y auxiliares
|
||||||
if (req.user!.role === 'supervisor' && data.role !== 'cliente') {
|
if (req.user!.role === 'supervisor' && !['cliente', 'auxiliar'].includes(data.role)) {
|
||||||
throw new AppError(403, 'Los supervisores solo pueden invitar clientes');
|
throw new AppError(403, 'Los supervisores solo pueden invitar clientes y auxiliares');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate: auxiliar requires a supervisor
|
// Validate: auxiliar requires a supervisor
|
||||||
if (data.role === 'auxiliar' && !data.supervisorUserId) {
|
if (data.role === 'auxiliar' && !data.supervisorUserId) {
|
||||||
throw new AppError(400, 'Debes asignar un supervisor al auxiliar');
|
// Un supervisor que invita un auxiliar se asigna a sí mismo por defecto
|
||||||
|
if (req.user!.role === 'supervisor') {
|
||||||
|
data.supervisorUserId = req.user!.userId;
|
||||||
|
} else {
|
||||||
|
throw new AppError(400, 'Debes asignar un supervisor al auxiliar');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un supervisor solo puede asignar auxiliares a sí mismo
|
||||||
|
if (req.user!.role === 'supervisor' && data.role === 'auxiliar' && data.supervisorUserId !== req.user!.userId) {
|
||||||
|
throw new AppError(403, 'Solo puedes asignar auxiliares a tu propia supervisión');
|
||||||
}
|
}
|
||||||
|
|
||||||
const usuario = await usuariosService.inviteUsuario(req.user!.tenantId, data);
|
const usuario = await usuariosService.inviteUsuario(req.user!.tenantId, data);
|
||||||
|
|||||||
@@ -174,6 +174,57 @@ async function handlePaymentNotification(paymentId: string) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detecta pagos únicos de suscripción anual (planes >$10k). external_reference = `subscription:${tenantId}:${subscriptionId}`
|
||||||
|
if (payment.externalReference.startsWith('subscription:')) {
|
||||||
|
const parts = payment.externalReference.split(':');
|
||||||
|
const tenantId = parts[1];
|
||||||
|
const subscriptionId = parts[2];
|
||||||
|
if (!tenantId || !subscriptionId) {
|
||||||
|
console.warn('[WEBHOOK] external_reference de subscription malformado:', payment.externalReference);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const paymentRecord = await subscriptionService.recordPayment({
|
||||||
|
tenantId,
|
||||||
|
subscriptionId,
|
||||||
|
mpPaymentId: paymentId,
|
||||||
|
amount: payment.transactionAmount || 0,
|
||||||
|
status: payment.status || 'unknown',
|
||||||
|
paymentMethod: payment.paymentMethodId || 'unknown',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (payment.status === 'approved') {
|
||||||
|
const subscription = await prisma.subscription.findUnique({ where: { id: subscriptionId } });
|
||||||
|
if (subscription) {
|
||||||
|
const now = new Date();
|
||||||
|
const periodEnd = computeNextPeriodEnd(now, 'annual');
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.subscription.update({
|
||||||
|
where: { id: subscription.id },
|
||||||
|
data: {
|
||||||
|
status: 'authorized',
|
||||||
|
currentPeriodStart: now,
|
||||||
|
currentPeriodEnd: periodEnd,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.tenant.update({
|
||||||
|
where: { id: tenantId },
|
||||||
|
data: { plan: subscription.plan },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
subscriptionService.invalidateSubscriptionCache(tenantId);
|
||||||
|
console.log(`[WEBHOOK] Suscripción ${subscriptionId} activada por pago único anual hasta ${periodEnd.toISOString()}`);
|
||||||
|
}
|
||||||
|
// Auto-emisión de factura (fail-soft)
|
||||||
|
await invoicingService.emitInvoiceIfApplicable(paymentRecord.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof process.send === 'function') {
|
||||||
|
process.send({ type: 'invalidate-tenant-cache', tenantId });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Flujo normal: pago recurrente del preapproval
|
// Flujo normal: pago recurrente del preapproval
|
||||||
const tenantId = payment.externalReference;
|
const tenantId = payment.externalReference;
|
||||||
const subscription = await prisma.subscription.findFirst({
|
const subscription = await prisma.subscription.findFirst({
|
||||||
@@ -202,12 +253,19 @@ async function handlePaymentNotification(paymentId: string) {
|
|||||||
// precio de renewal. Se detecta comparando el monto cobrado contra lo que
|
// precio de renewal. Se detecta comparando el monto cobrado contra lo que
|
||||||
// `getPlanPrice(phase='firstYear')` devolvería para este plan.
|
// `getPlanPrice(phase='firstYear')` devolvería para este plan.
|
||||||
const esPrimerPago = subscription.status === 'pending';
|
const esPrimerPago = subscription.status === 'pending';
|
||||||
const updateData: { status: string; currentPeriodEnd?: Date } = { status: 'authorized' };
|
const updateData: { status: string; currentPeriodStart?: Date; currentPeriodEnd?: Date } = { status: 'authorized' };
|
||||||
|
|
||||||
// Extender currentPeriodEnd para renovaciones recurrentes.
|
if (esPrimerPago) {
|
||||||
// El primer pago ya tiene currentPeriodEnd establecido al crear la suscripción;
|
// El primer pago aprobado define el inicio del período activo.
|
||||||
// solo extendemos en pagos subsecuentes para reflejar el nuevo período cobrado.
|
// Algunos flujos (cambio de plan, creación manual) dejan currentPeriodEnd
|
||||||
if (!esPrimerPago && subscription.currentPeriodEnd) {
|
// en null, así que lo establecemos aquí para evitar que la suscripción
|
||||||
|
// aparezca vencida aunque esté authorized.
|
||||||
|
const periodStart = payment.dateApproved ? new Date(payment.dateApproved) : new Date();
|
||||||
|
updateData.currentPeriodStart = periodStart;
|
||||||
|
updateData.currentPeriodEnd = computeNextPeriodEnd(periodStart, subscription.frequency);
|
||||||
|
console.log(`[WEBHOOK] Subscription ${subscription.id} primer pago aprobado: período ${updateData.currentPeriodStart.toISOString()} → ${updateData.currentPeriodEnd.toISOString()} (${subscription.frequency})`);
|
||||||
|
} else if (subscription.currentPeriodEnd) {
|
||||||
|
// Extender currentPeriodEnd para renovaciones recurrentes.
|
||||||
const nextPeriodEnd = computeNextPeriodEnd(subscription.currentPeriodEnd, subscription.frequency);
|
const nextPeriodEnd = computeNextPeriodEnd(subscription.currentPeriodEnd, subscription.frequency);
|
||||||
updateData.currentPeriodEnd = nextPeriodEnd;
|
updateData.currentPeriodEnd = nextPeriodEnd;
|
||||||
console.log(`[WEBHOOK] Subscription ${subscription.id} extended to ${nextPeriodEnd.toISOString()} (${subscription.frequency})`);
|
console.log(`[WEBHOOK] Subscription ${subscription.id} extended to ${nextPeriodEnd.toISOString()} (${subscription.frequency})`);
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import { startSatSyncJob } from './jobs/sat-sync.job.js';
|
|||||||
import { startWeeklyUpdateJob } from './jobs/weekly-update.job.js';
|
import { startWeeklyUpdateJob } from './jobs/weekly-update.job.js';
|
||||||
import { startMetricasInvalidationsJob } from './jobs/metricas-invalidations.job.js';
|
import { startMetricasInvalidationsJob } from './jobs/metricas-invalidations.job.js';
|
||||||
import { startNotificationsJob } from './jobs/notifications.job.js';
|
import { startNotificationsJob } from './jobs/notifications.job.js';
|
||||||
|
import { startSatSyncMonitorJob } from './jobs/sat-sync-monitor.job.js';
|
||||||
|
import { startSatProxyReportJob } from './jobs/sat-proxy-report.job.js';
|
||||||
|
import { startRecordatoriosPeriodicosJob } from './jobs/recordatorios-periodicos.job.js';
|
||||||
|
|
||||||
const PORT = parseInt(env.PORT, 10);
|
const PORT = parseInt(env.PORT, 10);
|
||||||
|
|
||||||
@@ -23,13 +26,16 @@ const server = app.listen(PORT, '0.0.0.0', () => {
|
|||||||
if (cronsEnabled) {
|
if (cronsEnabled) {
|
||||||
startSatSyncJob();
|
startSatSyncJob();
|
||||||
startMetricasInvalidationsJob();
|
startMetricasInvalidationsJob();
|
||||||
|
startSatSyncMonitorJob();
|
||||||
|
startSatProxyReportJob();
|
||||||
|
startRecordatoriosPeriodicosJob();
|
||||||
if (sendRealEmails) {
|
if (sendRealEmails) {
|
||||||
startWeeklyUpdateJob();
|
startWeeklyUpdateJob();
|
||||||
startNotificationsJob();
|
startNotificationsJob();
|
||||||
} else {
|
} else {
|
||||||
console.log('[Cron] weekly-update + notifications omitidos en dev (evita emails reales)');
|
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'})`);
|
console.log(`[Cron] SAT + metricas + SAT monitor activos (NODE_ENV=${env.NODE_ENV}, ENABLE_CRONS_IN_DEV=${process.env.ENABLE_CRONS_IN_DEV ?? 'unset'})`);
|
||||||
} else {
|
} else {
|
||||||
console.log('[Cron] Jobs omitidos en dev (usar ENABLE_CRONS_IN_DEV=1 para activar)');
|
console.log('[Cron] Jobs omitidos en dev (usar ENABLE_CRONS_IN_DEV=1 para activar)');
|
||||||
}
|
}
|
||||||
|
|||||||
75
apps/api/src/jobs/recordatorios-periodicos.job.ts
Normal file
75
apps/api/src/jobs/recordatorios-periodicos.job.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* Extensión periódica de recordatorios recurrentes.
|
||||||
|
*
|
||||||
|
* Cada vez que corre, itera los tenants activos y para cada serie periódica
|
||||||
|
* activa genera nuevas instancias futuras hasta mantener un horizonte mínimo
|
||||||
|
* (default 24 meses).
|
||||||
|
*
|
||||||
|
* Programado diariamente a las 6:00 AM (America/Mexico_City) porque es una
|
||||||
|
* tarea liviana y nos asegura que siempre haya instancias disponibles.
|
||||||
|
*/
|
||||||
|
import cron from 'node-cron';
|
||||||
|
import { prisma, tenantDb } from '../config/database.js';
|
||||||
|
import { extenderSeriesActivas } from '../services/recordatorios.service.js';
|
||||||
|
|
||||||
|
const SCHEDULE = '0 6 * * *'; // 06:00 AM diario
|
||||||
|
|
||||||
|
let task: ReturnType<typeof cron.schedule> | null = null;
|
||||||
|
|
||||||
|
export async function runRecordatoriosPeriodicosJob(): Promise<{
|
||||||
|
tenants: number;
|
||||||
|
series: number;
|
||||||
|
instancias: number;
|
||||||
|
}> {
|
||||||
|
const tenants = await prisma.tenant.findMany({
|
||||||
|
where: { active: true },
|
||||||
|
select: { id: true, rfc: true, databaseName: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
let seriesTotal = 0;
|
||||||
|
let instanciasTotal = 0;
|
||||||
|
|
||||||
|
for (const tenant of tenants) {
|
||||||
|
if (!tenant.databaseName) continue;
|
||||||
|
try {
|
||||||
|
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||||
|
const result = await extenderSeriesActivas(pool);
|
||||||
|
if (result.series > 0) {
|
||||||
|
console.log(`[Recordatorios Periodicos] ${tenant.rfc}: ${result.instancias} instancias en ${result.series} series`);
|
||||||
|
}
|
||||||
|
seriesTotal += result.series;
|
||||||
|
instanciasTotal += result.instancias;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`[Recordatorios Periodicos] Error en ${tenant.rfc}:`, err.message || err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { tenants: tenants.length, series: seriesTotal, instancias: instanciasTotal };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startRecordatoriosPeriodicosJob(): void {
|
||||||
|
if (task) {
|
||||||
|
console.warn('[Recordatorios Periodicos Cron] Ya iniciado');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
task = cron.schedule(SCHEDULE, async () => {
|
||||||
|
try {
|
||||||
|
const result = await runRecordatoriosPeriodicosJob();
|
||||||
|
if (result.series > 0) {
|
||||||
|
console.log(`[Recordatorios Periodicos Cron] ${result.tenants} tenants — ${result.instancias} instancias en ${result.series} series`);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[Recordatorios Periodicos Cron] Error general:', err.message || err);
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
timezone: 'America/Mexico_City',
|
||||||
|
});
|
||||||
|
console.log(`[Recordatorios Periodicos Cron] Programado: ${SCHEDULE} (06:00 AM diario America/Mexico_City)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopRecordatoriosPeriodicosJob(): void {
|
||||||
|
if (task) {
|
||||||
|
task.stop();
|
||||||
|
task = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
93
apps/api/src/jobs/sat-proxy-report.job.ts
Normal file
93
apps/api/src/jobs/sat-proxy-report.job.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import cron from 'node-cron';
|
||||||
|
import { prisma } from '../config/database.js';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
|
import { emailService } from '../services/email/email.service.js';
|
||||||
|
import type { SatProxyReportData } from '../services/email/templates/sat-proxy-report.js';
|
||||||
|
|
||||||
|
const PROXY_REPORT_CRON_SCHEDULE = '0 8 * * *'; // 8:00 AM CDMX diario
|
||||||
|
|
||||||
|
let reportTask: ReturnType<typeof cron.schedule> | null = null;
|
||||||
|
|
||||||
|
function cdmxDateString(d: Date): string {
|
||||||
|
return d.toLocaleDateString('es-MX', { timeZone: 'America/Mexico_City' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function hoursAgo(hours: number): Date {
|
||||||
|
return new Date(Date.now() - hours * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runSatProxyReport(): Promise<void> {
|
||||||
|
console.log('[SAT Proxy Report] Generando reporte diario de errores por proxy');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cutoff = hoursAgo(24);
|
||||||
|
const rows = await prisma.$queryRaw<Array<{ proxy: string | null; errorCode: string | null; count: bigint }>>`
|
||||||
|
SELECT proxy_used AS proxy, error_code AS "errorCode", COUNT(*) AS count
|
||||||
|
FROM sat_proxy_errors
|
||||||
|
WHERE created_at >= ${cutoff}
|
||||||
|
GROUP BY proxy_used, error_code
|
||||||
|
ORDER BY count DESC, proxy_used ASC, error_code ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const byProxy = rows.map(r => ({
|
||||||
|
proxy: r.proxy || 'IP directa del servidor',
|
||||||
|
errorCode: r.errorCode || '—',
|
||||||
|
count: Number(r.count),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const totalErrors = byProxy.reduce((sum, r) => sum + r.count, 0);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const data: SatProxyReportData = {
|
||||||
|
generatedAt: now.toLocaleString('es-MX', { timeZone: 'America/Mexico_City' }),
|
||||||
|
recipient: env.ADMIN_EMAIL,
|
||||||
|
dateFrom: cdmxDateString(hoursAgo(24)),
|
||||||
|
dateTo: cdmxDateString(now),
|
||||||
|
totalErrors,
|
||||||
|
byProxy,
|
||||||
|
};
|
||||||
|
|
||||||
|
const recipient = env.ADMIN_EMAIL;
|
||||||
|
if (!recipient) {
|
||||||
|
console.warn('[SAT Proxy Report] ADMIN_EMAIL no configurado, no se envía reporte');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[SAT Proxy Report] Enviando reporte a ${recipient}: ${totalErrors} errores en ${byProxy.length} grupos`);
|
||||||
|
await emailService.sendSatProxyReport(recipient, data);
|
||||||
|
console.log('[SAT Proxy Report] Reporte enviado');
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[SAT Proxy Report] Error generando reporte:', error.message || error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startSatProxyReportJob(): void {
|
||||||
|
if (reportTask) {
|
||||||
|
console.log('[SAT Proxy Report] Job ya está programado');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cron.validate(PROXY_REPORT_CRON_SCHEDULE)) {
|
||||||
|
console.error('[SAT Proxy Report] Expresión cron inválida:', PROXY_REPORT_CRON_SCHEDULE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reportTask = cron.schedule(PROXY_REPORT_CRON_SCHEDULE, async () => {
|
||||||
|
try {
|
||||||
|
await runSatProxyReport();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[SAT Proxy Report Cron] Error:', error.message || error);
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
timezone: 'America/Mexico_City',
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[SAT Proxy Report] Programado: ${PROXY_REPORT_CRON_SCHEDULE} (America/Mexico_City)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopSatProxyReportJob(): void {
|
||||||
|
if (reportTask) {
|
||||||
|
reportTask.stop();
|
||||||
|
reportTask = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
303
apps/api/src/jobs/sat-sync-monitor.job.ts
Normal file
303
apps/api/src/jobs/sat-sync-monitor.job.ts
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
import cron from 'node-cron';
|
||||||
|
import { prisma, tenantDb } from '../config/database.js';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
|
import { emailService } from '../services/email/email.service.js';
|
||||||
|
import { sweepStaleSatJobs } from '../services/sat/sweep-stale-jobs.service.js';
|
||||||
|
import type { SatSyncAlertData } from '../services/email/templates/sat-sync-alert.js';
|
||||||
|
|
||||||
|
let monitorTask: ReturnType<typeof cron.schedule> | null = null;
|
||||||
|
|
||||||
|
interface TenantInfo {
|
||||||
|
id: string;
|
||||||
|
rfc: string;
|
||||||
|
nombre: string;
|
||||||
|
databaseName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContribuyenteInfo {
|
||||||
|
id: string;
|
||||||
|
rfc: string;
|
||||||
|
nombre: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hoursAgo(hours: number): Date {
|
||||||
|
return new Date(Date.now() - hours * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadActiveTenants(): Promise<Map<string, TenantInfo>> {
|
||||||
|
const tenants = await prisma.tenant.findMany({
|
||||||
|
where: { active: true },
|
||||||
|
select: { id: true, rfc: true, nombre: true, databaseName: true },
|
||||||
|
});
|
||||||
|
const map = new Map<string, TenantInfo>();
|
||||||
|
for (const t of tenants) {
|
||||||
|
map.set(t.id, t);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadContribuyentesForTenant(tenant: TenantInfo): Promise<Map<string, ContribuyenteInfo>> {
|
||||||
|
const map = new Map<string, ContribuyenteInfo>();
|
||||||
|
if (!tenant.databaseName) return map;
|
||||||
|
try {
|
||||||
|
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||||
|
const { rows } = await pool.query(`
|
||||||
|
SELECT c.entidad_id AS id, c.rfc, eg.nombre
|
||||||
|
FROM contribuyentes c
|
||||||
|
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
|
||||||
|
`);
|
||||||
|
for (const r of rows) {
|
||||||
|
map.set(r.id, { id: r.id, rfc: r.rfc, nombre: r.nombre });
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`[SAT Monitor] Error cargando contribuyentes para tenant ${tenant.rfc}:`, err.message);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findFailedJobs(lookbackHours: number) {
|
||||||
|
const cutoff = hoursAgo(lookbackHours);
|
||||||
|
return prisma.satSyncJob.findMany({
|
||||||
|
where: {
|
||||||
|
status: 'failed',
|
||||||
|
completedAt: { gte: cutoff },
|
||||||
|
},
|
||||||
|
orderBy: { completedAt: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findPendingOldJobs(pendingHours: number) {
|
||||||
|
const cutoff = hoursAgo(pendingHours);
|
||||||
|
return prisma.satSyncJob.findMany({
|
||||||
|
where: {
|
||||||
|
status: 'pending',
|
||||||
|
createdAt: { lte: cutoff },
|
||||||
|
OR: [
|
||||||
|
{ nextRetryAt: null },
|
||||||
|
{ nextRetryAt: { lte: new Date() } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findMissingInitialSync(tenants: Map<string, TenantInfo>): Promise<SatSyncAlertData['missingInitial']> {
|
||||||
|
const missing: SatSyncAlertData['missingInitial'] = [];
|
||||||
|
|
||||||
|
for (const tenant of tenants.values()) {
|
||||||
|
if (!tenant.databaseName) continue;
|
||||||
|
try {
|
||||||
|
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||||
|
const { rows: contribuyentes } = await pool.query(`
|
||||||
|
SELECT c.entidad_id AS id, c.rfc, eg.nombre
|
||||||
|
FROM contribuyentes c
|
||||||
|
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
|
||||||
|
JOIN fiel_contribuyente f ON f.contribuyente_id = c.entidad_id
|
||||||
|
WHERE f.is_active = true
|
||||||
|
AND f.valid_until >= NOW()
|
||||||
|
ORDER BY eg.nombre
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (contribuyentes.length === 0) continue;
|
||||||
|
|
||||||
|
const contribuyenteIds = contribuyentes.map((c: any) => c.id);
|
||||||
|
const initialJobs = await prisma.satSyncJob.findMany({
|
||||||
|
where: {
|
||||||
|
tenantId: tenant.id,
|
||||||
|
contribuyenteId: { in: contribuyenteIds },
|
||||||
|
type: 'initial',
|
||||||
|
status: { in: ['completed', 'running', 'pending'] },
|
||||||
|
},
|
||||||
|
select: { contribuyenteId: true, status: true },
|
||||||
|
});
|
||||||
|
const completedOrInProgressIds = new Set(initialJobs.map(j => j.contribuyenteId));
|
||||||
|
|
||||||
|
for (const c of contribuyentes) {
|
||||||
|
if (!completedOrInProgressIds.has(c.id)) {
|
||||||
|
missing.push({
|
||||||
|
tenantName: tenant.nombre,
|
||||||
|
tenantRfc: tenant.rfc,
|
||||||
|
contribuyenteName: c.nombre,
|
||||||
|
contribuyenteRfc: c.rfc,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`[SAT Monitor] Error revisando FIEL sin sync inicial para tenant ${tenant.rfc}:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return missing;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAlertData(
|
||||||
|
tenants: Map<string, TenantInfo>,
|
||||||
|
contribuyenteMaps: Map<string, Map<string, ContribuyenteInfo>>,
|
||||||
|
failed: Awaited<ReturnType<typeof findFailedJobs>>,
|
||||||
|
stale: Array<{ id: string; tenantId: string; kind: 'pending-stale' | 'running-stale'; ageHours: number }>,
|
||||||
|
staleJobsById: Map<string, Awaited<ReturnType<typeof prisma.satSyncJob.findFirst>>>,
|
||||||
|
pendingOld: Awaited<ReturnType<typeof findPendingOldJobs>>,
|
||||||
|
missingInitial: SatSyncAlertData['missingInitial']
|
||||||
|
): SatSyncAlertData {
|
||||||
|
const now = new Date();
|
||||||
|
const generatedAt = now.toLocaleString('es-MX', { timeZone: 'America/Mexico_City' });
|
||||||
|
|
||||||
|
const resolveJob = (job: { tenantId: string; contribuyenteId: string | null }) => {
|
||||||
|
const tenant = tenants.get(job.tenantId);
|
||||||
|
const contribMap = contribuyenteMaps.get(job.tenantId);
|
||||||
|
const contrib = job.contribuyenteId ? contribMap?.get(job.contribuyenteId) : undefined;
|
||||||
|
return {
|
||||||
|
tenantName: tenant?.nombre || job.tenantId,
|
||||||
|
tenantRfc: tenant?.rfc || '—',
|
||||||
|
contribuyenteName: contrib?.nombre || null,
|
||||||
|
contribuyenteRfc: contrib?.rfc || null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
generatedAt,
|
||||||
|
recipient: env.SAT_ALERT_EMAIL ?? env.ADMIN_EMAIL,
|
||||||
|
summary: {
|
||||||
|
failed: failed.length,
|
||||||
|
stale: stale.length,
|
||||||
|
stuckRunning: 0,
|
||||||
|
pendingOld: pendingOld.length,
|
||||||
|
missingInitial: missingInitial.length,
|
||||||
|
},
|
||||||
|
failed: failed.map(j => ({
|
||||||
|
...resolveJob(j),
|
||||||
|
type: j.type,
|
||||||
|
errorMessage: j.errorMessage,
|
||||||
|
completedAt: j.completedAt,
|
||||||
|
})),
|
||||||
|
stale: stale.map(e => {
|
||||||
|
const job = staleJobsById.get(e.id);
|
||||||
|
return {
|
||||||
|
id: e.id,
|
||||||
|
...resolveJob({ tenantId: e.tenantId, contribuyenteId: job?.contribuyenteId ?? null }),
|
||||||
|
type: job?.type || '—',
|
||||||
|
kind: e.kind,
|
||||||
|
ageHours: e.ageHours,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
stuckRunning: [],
|
||||||
|
pendingOld: pendingOld.map(j => ({
|
||||||
|
id: j.id,
|
||||||
|
...resolveJob(j),
|
||||||
|
type: j.type,
|
||||||
|
createdAt: j.createdAt,
|
||||||
|
nextRetryAt: j.nextRetryAt,
|
||||||
|
hoursPending: Math.round((now.getTime() - j.createdAt.getTime()) / 3_600_000),
|
||||||
|
})),
|
||||||
|
missingInitial,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runSatSyncMonitor(): Promise<void> {
|
||||||
|
console.log('[SAT Monitor] Iniciando revisión de sincronizaciones SAT');
|
||||||
|
|
||||||
|
const pendingHours = env.SAT_STUCK_RUNNING_HOURS;
|
||||||
|
const failedLookbackHours = env.SAT_FAILED_LOOKBACK_HOURS;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tenants = await loadActiveTenants();
|
||||||
|
|
||||||
|
const [failed, staleResult, pendingOld, missingInitial] = await Promise.all([
|
||||||
|
findFailedJobs(failedLookbackHours),
|
||||||
|
sweepStaleSatJobs({ apply: false }),
|
||||||
|
findPendingOldJobs(pendingHours),
|
||||||
|
findMissingInitialSync(tenants),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const jobTenantIds = new Set<string>();
|
||||||
|
for (const j of [...failed, ...pendingOld]) {
|
||||||
|
jobTenantIds.add(j.tenantId);
|
||||||
|
}
|
||||||
|
for (const e of staleResult.entries) {
|
||||||
|
jobTenantIds.add(e.tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const staleJobsById = new Map(
|
||||||
|
(
|
||||||
|
await prisma.satSyncJob.findMany({
|
||||||
|
where: { id: { in: staleResult.entries.map(e => e.id) } },
|
||||||
|
})
|
||||||
|
).map(j => [j.id, j])
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const e of staleResult.entries) {
|
||||||
|
const job = staleJobsById.get(e.id);
|
||||||
|
if (job?.contribuyenteId && job.tenantId) {
|
||||||
|
jobTenantIds.add(job.tenantId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const contribuyenteMaps = new Map<string, Map<string, ContribuyenteInfo>>();
|
||||||
|
for (const tenantId of jobTenantIds) {
|
||||||
|
const tenant = tenants.get(tenantId);
|
||||||
|
if (!tenant) continue;
|
||||||
|
const map = await loadContribuyentesForTenant(tenant);
|
||||||
|
contribuyenteMaps.set(tenantId, map);
|
||||||
|
}
|
||||||
|
|
||||||
|
const alertData = buildAlertData(
|
||||||
|
tenants,
|
||||||
|
contribuyenteMaps,
|
||||||
|
failed,
|
||||||
|
staleResult.entries,
|
||||||
|
staleJobsById,
|
||||||
|
pendingOld,
|
||||||
|
missingInitial
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasIssues =
|
||||||
|
alertData.summary.failed > 0 ||
|
||||||
|
alertData.summary.stale > 0 ||
|
||||||
|
alertData.summary.pendingOld > 0 ||
|
||||||
|
alertData.summary.missingInitial > 0;
|
||||||
|
|
||||||
|
if (!hasIssues) {
|
||||||
|
console.log('[SAT Monitor] Sin anomalías detectadas');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipient = env.SAT_ALERT_EMAIL ?? env.ADMIN_EMAIL;
|
||||||
|
console.log(`[SAT Monitor] Enviando alerta a ${recipient}:`, alertData.summary);
|
||||||
|
await emailService.sendSatSyncAlert(recipient, alertData);
|
||||||
|
console.log('[SAT Monitor] Alerta enviada');
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[SAT Monitor] Error en revisión:', error.message || error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startSatSyncMonitorJob(): void {
|
||||||
|
if (monitorTask) {
|
||||||
|
console.log('[SAT Monitor] Job ya está programado');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const schedule = env.SAT_MONITOR_SCHEDULE;
|
||||||
|
if (!cron.validate(schedule)) {
|
||||||
|
console.error('[SAT Monitor] Expresión cron inválida:', schedule);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
monitorTask = cron.schedule(schedule, async () => {
|
||||||
|
try {
|
||||||
|
await runSatSyncMonitor();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[SAT Monitor Cron] Error:', error.message || error);
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
timezone: 'America/Mexico_City',
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[SAT Monitor] Programado: ${schedule}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopSatSyncMonitorJob(): void {
|
||||||
|
if (monitorTask) {
|
||||||
|
monitorTask.stop();
|
||||||
|
monitorTask = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import cron from 'node-cron';
|
import cron from 'node-cron';
|
||||||
import { prisma } from '../config/database.js';
|
import { prisma } from '../config/database.js';
|
||||||
import { startSync, getSyncStatus, retryTimedOutJobs } from '../services/sat/sat.service.js';
|
import { startSync, getSyncStatus, retryTimedOutJobs, continuePendingDailyRequests } from '../services/sat/sat.service.js';
|
||||||
import { sweepStaleSatJobs } from '../services/sat/sweep-stale-jobs.service.js';
|
import { sweepStaleSatJobs } from '../services/sat/sweep-stale-jobs.service.js';
|
||||||
import { hasFielConfigured } from '../services/fiel.service.js';
|
import { hasFielConfigured } from '../services/fiel.service.js';
|
||||||
import { consultarOpinion, limpiarOpinionesAntiguas } from '../services/opinion-cumplimiento.service.js';
|
import { consultarOpinion, limpiarOpinionesAntiguas } from '../services/opinion-cumplimiento.service.js';
|
||||||
@@ -11,18 +11,22 @@ import { consultarConstancia, purgeConstanciasAntiguas } from '../services/const
|
|||||||
import { tenantDb } from '../config/database.js';
|
import { tenantDb } from '../config/database.js';
|
||||||
import type { Pool } from 'pg';
|
import type { Pool } from 'pg';
|
||||||
|
|
||||||
const SYNC_CRON_SCHEDULE = '0 3 * * *'; // 3:00 AM todos los días
|
const SYNC_CRON_SCHEDULE = '0 6-10 * * *'; // 6:00–10:00 AM CDMX — ~20% de tenants por hora (5 grupos); el SAT cierra el servicio en la noche
|
||||||
const RECOVERY_CRON_SCHEDULE = '0 10 * * *'; // 10:00 AM todos los días
|
const RECOVERY_CRON_SCHEDULE = '0 10 * * *'; // 10:00 AM todos los días
|
||||||
const CONCURRENT_SYNCS = 3; // Máximo de sincronizaciones simultáneas
|
const RETRY_9AM_CRON_SCHEDULE = '0 9 * * *'; // 9:00 AM todos los días
|
||||||
|
const RETRY_4PM_CRON_SCHEDULE = '0 16 * * *'; // 4:00 PM todos los días
|
||||||
|
const CONCURRENT_SYNCS = 3; // Máximo de sincronizaciones simultáneas (legacy, se mantiene por compatibilidad)
|
||||||
|
const CONCURRENT_CONTRIBUYENTES = Number(process.env.SAT_CONCURRENT_CONTRIBUYENTES || '10'); // Máximo de contribuyentes en paralelo
|
||||||
const OPINION_CRON_SCHEDULE = '0 4 * * 0'; // Sundays 4:00 AM
|
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 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 INCREMENTAL_CRON_SCHEDULE = '0 11,15,19 * * *'; // 11:00, 15:00 y 19:00; fuera de ese rango el daily (6-10 AM) cubre
|
||||||
const SUBSCRIPTION_LIFECYCLE_CRON = '30 2 * * *'; // 2:30 AM diario — aplica pending changes + expira trials
|
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)
|
const EXPIRY_REMINDERS_CRON = '0 9 * * *'; // 9:00 AM diario — avisos pre-vencimiento (7d/3d/1d/0d)
|
||||||
|
|
||||||
let isRunning = false;
|
let isRunning = false;
|
||||||
let isIncrementalRunning = false;
|
let isIncrementalRunning = false;
|
||||||
let isRecoveryRunning = false;
|
let isRecoveryRunning = false;
|
||||||
|
let isDailyRetryRunning = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifica si un tenant tiene FIEL a nivel tenant (legacy Horux 360)
|
* Verifica si un tenant tiene FIEL a nivel tenant (legacy Horux 360)
|
||||||
@@ -46,7 +50,7 @@ async function hasAnyFielConfigured(tenantId: string, databaseName?: string | nu
|
|||||||
try {
|
try {
|
||||||
const pool = await tenantDb.getPool(tenantId, databaseName);
|
const pool = await tenantDb.getPool(tenantId, databaseName);
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT 1 FROM fiel_contribuyente WHERE is_active = true LIMIT 1`
|
`SELECT 1 FROM fiel_contribuyente WHERE is_active = true AND valid_until > NOW() LIMIT 1`
|
||||||
);
|
);
|
||||||
return rows.length > 0;
|
return rows.length > 0;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -95,7 +99,135 @@ async function needsInitialSync(tenantId: string, contribuyenteId?: string): Pro
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ejecuta sincronización para un tenant y sus contribuyentes
|
* Devuelve los entidad_id de contribuyentes con FIEL vigente.
|
||||||
|
* Si el tenant tiene FIEL legacy vigente a nivel tenant, devuelve todos
|
||||||
|
* (startSync hace fallback por RFC). `total` permite distinguir "tenant sin
|
||||||
|
* contribuyentes" (path legacy) de "ninguno con FIEL vigente" (se omite).
|
||||||
|
*/
|
||||||
|
async function getContribuyentesParaSync(
|
||||||
|
tenantId: string,
|
||||||
|
databaseName: string,
|
||||||
|
logPrefix: string
|
||||||
|
): Promise<{ ids: string[]; total: number }> {
|
||||||
|
const pool = await tenantDb.getPool(tenantId, databaseName);
|
||||||
|
const { rows: allRows } = await pool.query('SELECT entidad_id FROM contribuyentes');
|
||||||
|
const allIds: string[] = allRows.map((r: any) => r.entidad_id);
|
||||||
|
if (allIds.length === 0) return { ids: [], total: 0 };
|
||||||
|
|
||||||
|
const hasLegacyFiel = await hasFielConfigured(tenantId);
|
||||||
|
if (hasLegacyFiel) return { ids: allIds, total: allIds.length };
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT c.entidad_id FROM contribuyentes c
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1 FROM fiel_contribuyente f
|
||||||
|
WHERE f.contribuyente_id = c.entidad_id
|
||||||
|
AND f.is_active = true AND f.valid_until > NOW()
|
||||||
|
)`
|
||||||
|
);
|
||||||
|
const ids: string[] = rows.map((r: any) => r.entidad_id);
|
||||||
|
const skipped = allIds.length - ids.length;
|
||||||
|
if (skipped > 0) {
|
||||||
|
console.log(`${logPrefix} Tenant ${tenantId}: ${skipped} contribuyente(s) sin FIEL vigente, omitidos`);
|
||||||
|
}
|
||||||
|
return { ids, total: allIds.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unidad mínima de sincronización: un tenant (legacy) o un contribuyente.
|
||||||
|
*/
|
||||||
|
interface SyncUnit {
|
||||||
|
tenantId: string;
|
||||||
|
contribuyenteId?: string;
|
||||||
|
syncType: 'initial' | 'daily' | 'incremental';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recolecta todas las unidades de sync para un conjunto de tenants.
|
||||||
|
* - Modo incremental: solo incluye contribuyentes/tenants con initial completado.
|
||||||
|
* - Modo daily/initial: determina initial vs daily por contribuyente.
|
||||||
|
*/
|
||||||
|
async function getSyncUnits(
|
||||||
|
tenantIds: string[],
|
||||||
|
options: { incremental?: boolean; logPrefix?: string } = {}
|
||||||
|
): Promise<SyncUnit[]> {
|
||||||
|
const { incremental = false, logPrefix = '[SAT Cron]' } = options;
|
||||||
|
const units: SyncUnit[] = [];
|
||||||
|
|
||||||
|
for (const tenantId of tenantIds) {
|
||||||
|
try {
|
||||||
|
const tenant = await prisma.tenant.findUnique({
|
||||||
|
where: { id: tenantId },
|
||||||
|
select: { databaseName: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
let contribuyenteIds: string[] = [];
|
||||||
|
if (tenant?.databaseName) {
|
||||||
|
const { ids, total } = await getContribuyentesParaSync(tenantId, tenant.databaseName, logPrefix);
|
||||||
|
if (total > 0 && ids.length === 0) {
|
||||||
|
console.log(`${logPrefix} Tenant ${tenantId}: ningún contribuyente con FIEL vigente, se omite`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
contribuyenteIds = ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tenant legacy sin contribuyentes (Horux 360)
|
||||||
|
if (contribuyenteIds.length === 0) {
|
||||||
|
if (incremental) {
|
||||||
|
const hasInitial = await prisma.satSyncJob.findFirst({
|
||||||
|
where: { tenantId, contribuyenteId: null, type: 'initial', status: 'completed' },
|
||||||
|
});
|
||||||
|
if (!hasInitial) continue;
|
||||||
|
units.push({ tenantId, syncType: 'incremental' });
|
||||||
|
} else {
|
||||||
|
const needsInitial = await needsInitialSync(tenantId);
|
||||||
|
units.push({ tenantId, syncType: needsInitial ? 'initial' : 'daily' });
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contribuyentes del tenant
|
||||||
|
for (const contribuyenteId of contribuyenteIds) {
|
||||||
|
if (incremental) {
|
||||||
|
const hasInitial = await prisma.satSyncJob.findFirst({
|
||||||
|
where: { tenantId, contribuyenteId, type: 'initial', status: 'completed' },
|
||||||
|
});
|
||||||
|
if (!hasInitial) continue;
|
||||||
|
units.push({ tenantId, contribuyenteId, syncType: 'incremental' });
|
||||||
|
} else {
|
||||||
|
const needsInitial = await needsInitialSync(tenantId, contribuyenteId);
|
||||||
|
units.push({ tenantId, contribuyenteId, syncType: needsInitial ? 'initial' : 'daily' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`${logPrefix} Error recolectando unidades para tenant ${tenantId}:`, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return units;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ejecuta sync para una unidad (tenant o contribuyente), respetando locks.
|
||||||
|
*/
|
||||||
|
async function syncUnit(unit: SyncUnit, logPrefix: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const status = await getSyncStatus(unit.tenantId, unit.contribuyenteId);
|
||||||
|
if (status.hasActiveSync) {
|
||||||
|
console.log(`${logPrefix} ${unit.tenantId}${unit.contribuyenteId ? ` contribuyente ${unit.contribuyenteId}` : ''} ya tiene sync activo, omitiendo`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`${logPrefix} Iniciando sync ${unit.syncType} para ${unit.tenantId}${unit.contribuyenteId ? ` contribuyente ${unit.contribuyenteId}` : ''}`);
|
||||||
|
const jobId = await startSync(unit.tenantId, unit.syncType, undefined, undefined, unit.contribuyenteId);
|
||||||
|
console.log(`${logPrefix} Job ${jobId} iniciado`);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`${logPrefix} Error sincronizando ${unit.tenantId}${unit.contribuyenteId ? ` contribuyente ${unit.contribuyenteId}` : ''}:`, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ejecuta sincronización para un tenant y sus contribuyentes (modo secuencial legacy)
|
||||||
*/
|
*/
|
||||||
async function syncTenant(tenantId: string): Promise<void> {
|
async function syncTenant(tenantId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -107,9 +239,12 @@ async function syncTenant(tenantId: string): Promise<void> {
|
|||||||
|
|
||||||
let contribuyenteIds: string[] = [];
|
let contribuyenteIds: string[] = [];
|
||||||
if (tenant?.databaseName) {
|
if (tenant?.databaseName) {
|
||||||
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
|
const { ids, total } = await getContribuyentesParaSync(tenantId, tenant.databaseName, '[SAT Cron]');
|
||||||
const { rows } = await pool.query('SELECT entidad_id FROM contribuyentes');
|
if (total > 0 && ids.length === 0) {
|
||||||
contribuyenteIds = rows.map((r: any) => r.entidad_id);
|
console.log(`[SAT Cron] Tenant ${tenantId}: ningún contribuyente con FIEL vigente, se omite`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
contribuyenteIds = ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Si no hay contribuyentes, sincronizar a nivel tenant (legacy Horux 360)
|
// Si no hay contribuyentes, sincronizar a nivel tenant (legacy Horux 360)
|
||||||
@@ -153,6 +288,27 @@ async function syncTenant(tenantId: string): Promise<void> {
|
|||||||
/**
|
/**
|
||||||
* Ejecuta el job de sincronización para todos los tenants
|
* Ejecuta el job de sincronización para todos los tenants
|
||||||
*/
|
*/
|
||||||
|
const DAILY_GROUPS = 5; // ventanas 6,7,8,9,10 AM
|
||||||
|
const DAILY_WINDOW_START = 6; // primera ventana CDMX
|
||||||
|
|
||||||
|
/** Hash estable del tenantId → grupo 0..DAILY_GROUPS-1 (reparte ~20% por ventana) */
|
||||||
|
function tenantGroup(tenantId: string): number {
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < tenantId.length; i++) h = (h * 31 + tenantId.charCodeAt(i)) >>> 0;
|
||||||
|
return h % DAILY_GROUPS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hora actual en zona America/Mexico_City (0-23) */
|
||||||
|
function cdmxHour(): number {
|
||||||
|
return Number(
|
||||||
|
new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: 'America/Mexico_City',
|
||||||
|
hour: 'numeric',
|
||||||
|
hour12: false,
|
||||||
|
}).format(new Date())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function runSyncJob(): Promise<void> {
|
async function runSyncJob(): Promise<void> {
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
console.log('[SAT Cron] Job ya en ejecución, omitiendo');
|
console.log('[SAT Cron] Job ya en ejecución, omitiendo');
|
||||||
@@ -171,13 +327,36 @@ async function runSyncJob(): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Procesar en lotes para no saturar
|
const hour = cdmxHour();
|
||||||
for (let i = 0; i < tenantIds.length; i += CONCURRENT_SYNCS) {
|
const groupIndex = hour - DAILY_WINDOW_START; // 6→0 … 10→4
|
||||||
const batch = tenantIds.slice(i, i + CONCURRENT_SYNCS);
|
if (groupIndex < 0 || groupIndex >= DAILY_GROUPS) {
|
||||||
await Promise.all(batch.map(syncTenant));
|
console.log(`[SAT Cron] Hora CDMX ${hour} fuera de ventana 6-10 AM, omitiendo`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const groupTenants = tenantIds.filter(id => tenantGroup(id) === groupIndex);
|
||||||
|
console.log(`[SAT Cron] Ventana ${hour}:00 CDMX — grupo ${groupIndex + 1}/${DAILY_GROUPS}: ${groupTenants.length}/${tenantIds.length} tenants`);
|
||||||
|
|
||||||
|
if (groupTenants.length === 0) {
|
||||||
|
console.log('[SAT Cron] No hay tenants en este grupo');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recolectar unidades de sync (contribuyentes o tenants legacy)
|
||||||
|
const units = await getSyncUnits(groupTenants, { logPrefix: '[SAT Cron]' });
|
||||||
|
console.log(`[SAT Cron] Ventana ${hour}:00 CDMX — ${units.length} unidades de sync listas (max ${CONCURRENT_CONTRIBUYENTES} paralelas)`);
|
||||||
|
|
||||||
|
if (units.length === 0) {
|
||||||
|
console.log('[SAT Cron] No hay unidades de sync en este grupo');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Procesar en lotes de contribuyentes para aprovechar los proxies
|
||||||
|
for (let i = 0; i < units.length; i += CONCURRENT_CONTRIBUYENTES) {
|
||||||
|
const batch = units.slice(i, i + CONCURRENT_CONTRIBUYENTES);
|
||||||
|
await Promise.all(batch.map(unit => syncUnit(unit, '[SAT Cron]')));
|
||||||
|
|
||||||
// Pequeña pausa entre lotes
|
// Pequeña pausa entre lotes
|
||||||
if (i + CONCURRENT_SYNCS < tenantIds.length) {
|
if (i + CONCURRENT_CONTRIBUYENTES < units.length) {
|
||||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -194,7 +373,8 @@ async function runSyncJob(): Promise<void> {
|
|||||||
* Obtiene los tenants activos cuyo plan habilita SAT incremental (3 syncs/día
|
* 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`,
|
* adicionales al daily). El flag vive en `despacho_plan_prices.permite_sat_incremental`,
|
||||||
* editable por admin global desde `/configuracion/precios-suscripcion`.
|
* editable por admin global desde `/configuracion/precios-suscripcion`.
|
||||||
* Default backfill: mi_empresa_plus, business_control, business_cloud.
|
* Planes con incremental: mi_empresa_plus, business_cloud.
|
||||||
|
* business_control usa solo daily + retry programado.
|
||||||
*/
|
*/
|
||||||
async function getTenantsConSatIncremental(): Promise<string[]> {
|
async function getTenantsConSatIncremental(): Promise<string[]> {
|
||||||
const planesIncrementales = await prisma.despachoPlanPrice.findMany({
|
const planesIncrementales = await prisma.despachoPlanPrice.findMany({
|
||||||
@@ -232,9 +412,12 @@ async function incrementalSyncTenant(tenantId: string): Promise<void> {
|
|||||||
|
|
||||||
let contribuyenteIds: string[] = [];
|
let contribuyenteIds: string[] = [];
|
||||||
if (tenant?.databaseName) {
|
if (tenant?.databaseName) {
|
||||||
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
|
const { ids, total } = await getContribuyentesParaSync(tenantId, tenant.databaseName, '[SAT Cron Inc]');
|
||||||
const { rows } = await pool.query('SELECT entidad_id FROM contribuyentes');
|
if (total > 0 && ids.length === 0) {
|
||||||
contribuyenteIds = rows.map((r: any) => r.entidad_id);
|
console.log(`[SAT Cron Inc] Tenant ${tenantId}: ningún contribuyente con FIEL vigente, se omite`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
contribuyenteIds = ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Si no hay contribuyentes, sincronizar a nivel tenant (legacy)
|
// Si no hay contribuyentes, sincronizar a nivel tenant (legacy)
|
||||||
@@ -306,11 +489,19 @@ async function runIncrementalSyncJob(): Promise<void> {
|
|||||||
|
|
||||||
if (tenantIds.length === 0) return;
|
if (tenantIds.length === 0) return;
|
||||||
|
|
||||||
for (let i = 0; i < tenantIds.length; i += CONCURRENT_SYNCS) {
|
const units = await getSyncUnits(tenantIds, { incremental: true, logPrefix: '[SAT Cron Inc]' });
|
||||||
const batch = tenantIds.slice(i, i + CONCURRENT_SYNCS);
|
console.log(`[SAT Cron Inc] ${units.length} unidades de sync listas (max ${CONCURRENT_CONTRIBUYENTES} paralelas)`);
|
||||||
await Promise.all(batch.map(incrementalSyncTenant));
|
|
||||||
|
|
||||||
if (i + CONCURRENT_SYNCS < tenantIds.length) {
|
if (units.length === 0) {
|
||||||
|
console.log('[SAT Cron Inc] No hay unidades de sync');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < units.length; i += CONCURRENT_CONTRIBUYENTES) {
|
||||||
|
const batch = units.slice(i, i + CONCURRENT_CONTRIBUYENTES);
|
||||||
|
await Promise.all(batch.map(unit => syncUnit(unit, '[SAT Cron Inc]')));
|
||||||
|
|
||||||
|
if (i + CONCURRENT_CONTRIBUYENTES < units.length) {
|
||||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -402,7 +593,7 @@ async function hasIncompleteCfdis(pool: Pool, contribuyenteId: string): Promise<
|
|||||||
FROM cfdis
|
FROM cfdis
|
||||||
WHERE contribuyente_id = $1
|
WHERE contribuyente_id = $1
|
||||||
AND status = 'Vigente'
|
AND status = 'Vigente'
|
||||||
AND tipo_comprobante IN ('I', 'E')
|
AND tipo_comprobante IN ('I', 'E', 'P', 'N')
|
||||||
AND xml_original IS NULL
|
AND xml_original IS NULL
|
||||||
`, [contribuyenteId]);
|
`, [contribuyenteId]);
|
||||||
return Number(rows[0]?.count || 0) > 0;
|
return Number(rows[0]?.count || 0) > 0;
|
||||||
@@ -414,7 +605,7 @@ async function getOldestIncompleteCfdiDate(pool: Pool, contribuyenteId: string):
|
|||||||
FROM cfdis
|
FROM cfdis
|
||||||
WHERE contribuyente_id = $1
|
WHERE contribuyente_id = $1
|
||||||
AND status = 'Vigente'
|
AND status = 'Vigente'
|
||||||
AND tipo_comprobante IN ('I', 'E')
|
AND tipo_comprobante IN ('I', 'E', 'P', 'N')
|
||||||
AND xml_original IS NULL
|
AND xml_original IS NULL
|
||||||
`, [contribuyenteId]);
|
`, [contribuyenteId]);
|
||||||
return rows[0]?.fecha_emision || null;
|
return rows[0]?.fecha_emision || null;
|
||||||
@@ -504,7 +695,7 @@ async function recoverTenant(tenantId: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runRecoverySyncJob(): Promise<void> {
|
export async function runRecoverySyncJob(): Promise<void> {
|
||||||
if (isRecoveryRunning) {
|
if (isRecoveryRunning) {
|
||||||
console.log('[SAT Recovery] Ya en ejecución, omitiendo');
|
console.log('[SAT Recovery] Ya en ejecución, omitiendo');
|
||||||
return;
|
return;
|
||||||
@@ -529,9 +720,30 @@ async function runRecoverySyncJob(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runDailyRetryJob(): Promise<void> {
|
||||||
|
if (isDailyRetryRunning) {
|
||||||
|
console.log('[SAT Daily Retry] Ya en ejecución, omitiendo');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isDailyRetryRunning = true;
|
||||||
|
console.log('[SAT Daily Retry] Iniciando retry programado de daily syncs');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await continuePendingDailyRequests();
|
||||||
|
console.log('[SAT Daily Retry] Retry programado completado');
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[SAT Daily Retry] Error:', error.message);
|
||||||
|
} finally {
|
||||||
|
isDailyRetryRunning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let scheduledTask: ReturnType<typeof cron.schedule> | null = null;
|
let scheduledTask: ReturnType<typeof cron.schedule> | null = null;
|
||||||
let retryTask: ReturnType<typeof cron.schedule> | null = null;
|
let retryTask: ReturnType<typeof cron.schedule> | null = null;
|
||||||
let recoveryTask: ReturnType<typeof cron.schedule> | null = null;
|
let recoveryTask: ReturnType<typeof cron.schedule> | null = null;
|
||||||
|
let retry9amTask: ReturnType<typeof cron.schedule> | null = null;
|
||||||
|
let retry4pmTask: ReturnType<typeof cron.schedule> | null = null;
|
||||||
let opinionTask: ReturnType<typeof cron.schedule> | null = null;
|
let opinionTask: ReturnType<typeof cron.schedule> | null = null;
|
||||||
let csfTask: ReturnType<typeof cron.schedule> | null = null;
|
let csfTask: ReturnType<typeof cron.schedule> | null = null;
|
||||||
let incrementalTask: ReturnType<typeof cron.schedule> | null = null;
|
let incrementalTask: ReturnType<typeof cron.schedule> | null = null;
|
||||||
@@ -585,6 +797,28 @@ export function startSatSyncJob(): void {
|
|||||||
timezone: 'America/Mexico_City',
|
timezone: 'America/Mexico_City',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Retomar jobs diarios que quedaron pending por timeout de polling.
|
||||||
|
// 9:00 AM y 4:00 PM CDMX, complemento a los retries automáticos de 6h/12h.
|
||||||
|
retry9amTask = cron.schedule(RETRY_9AM_CRON_SCHEDULE, async () => {
|
||||||
|
try {
|
||||||
|
await runDailyRetryJob();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[SAT Daily Retry 9AM] Error:', error.message);
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
timezone: 'America/Mexico_City',
|
||||||
|
});
|
||||||
|
|
||||||
|
retry4pmTask = cron.schedule(RETRY_4PM_CRON_SCHEDULE, async () => {
|
||||||
|
try {
|
||||||
|
await runDailyRetryJob();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[SAT Daily Retry 4PM] Error:', error.message);
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
timezone: 'America/Mexico_City',
|
||||||
|
});
|
||||||
|
|
||||||
// Cron watchdog: cada 2h marca como `failed` los jobs que quedaron stale
|
// Cron watchdog: cada 2h marca como `failed` los jobs que quedaron stale
|
||||||
// (pending con nextRetryAt > 12h atrás, running con startedAt > 4h atrás).
|
// (pending con nextRetryAt > 12h atrás, running con startedAt > 4h atrás).
|
||||||
// Thresholds sobreescribibles vía env (STALE_PENDING_HOURS / STALE_RUNNING_HOURS)
|
// Thresholds sobreescribibles vía env (STALE_PENDING_HOURS / STALE_RUNNING_HOURS)
|
||||||
@@ -691,6 +925,7 @@ export function startSatSyncJob(): void {
|
|||||||
console.log(`[SAT Cron] Job programado para: ${SYNC_CRON_SCHEDULE} (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(`[SAT Cron] Retry programado cada hora`);
|
||||||
console.log(`[SAT Recovery Cron] Programado para: ${RECOVERY_CRON_SCHEDULE} (America/Mexico_City)`);
|
console.log(`[SAT Recovery Cron] Programado para: ${RECOVERY_CRON_SCHEDULE} (America/Mexico_City)`);
|
||||||
|
console.log(`[SAT Daily Retry] Programado para: ${RETRY_9AM_CRON_SCHEDULE} y ${RETRY_4PM_CRON_SCHEDULE} (America/Mexico_City)`);
|
||||||
console.log(`[Opinion Cron] Programado para: ${OPINION_CRON_SCHEDULE} (America/Mexico_City)`);
|
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(`[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(`[SAT Cron Inc] Incremental Enterprise programado para: ${INCREMENTAL_CRON_SCHEDULE} (America/Mexico_City)`);
|
||||||
@@ -714,6 +949,14 @@ export function stopSatSyncJob(): void {
|
|||||||
recoveryTask.stop();
|
recoveryTask.stop();
|
||||||
recoveryTask = null;
|
recoveryTask = null;
|
||||||
}
|
}
|
||||||
|
if (retry9amTask) {
|
||||||
|
retry9amTask.stop();
|
||||||
|
retry9amTask = null;
|
||||||
|
}
|
||||||
|
if (retry4pmTask) {
|
||||||
|
retry4pmTask.stop();
|
||||||
|
retry4pmTask = null;
|
||||||
|
}
|
||||||
if (opinionTask) {
|
if (opinionTask) {
|
||||||
opinionTask.stop();
|
opinionTask.stop();
|
||||||
opinionTask = null;
|
opinionTask = null;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { tenantDb } from '../config/database.js';
|
|||||||
import { getKpis } from '../services/dashboard.service.js';
|
import { getKpis } from '../services/dashboard.service.js';
|
||||||
import { generarAlertasAutomaticas, getDiscrepanciasPorMes } from '../services/alertas-auto.service.js';
|
import { generarAlertasAutomaticas, getDiscrepanciasPorMes } from '../services/alertas-auto.service.js';
|
||||||
import { emailService } from '../services/email/email.service.js';
|
import { emailService } from '../services/email/email.service.js';
|
||||||
|
import { filterRecipientsByRole } from '../services/notification-preferences.service.js';
|
||||||
|
|
||||||
const SCHEDULE = '0 8 * * 1'; // Lunes 8:00 AM
|
const SCHEDULE = '0 8 * * 1'; // Lunes 8:00 AM
|
||||||
|
|
||||||
@@ -45,19 +46,27 @@ export async function sendWeeklyUpdateForTenant(tenantId: string): Promise<{ sen
|
|||||||
return { sent: 0 };
|
return { sent: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recipientes: owners activos del tenant
|
// Pool del tenant para queries de preferencias y CFDI
|
||||||
|
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
|
||||||
|
|
||||||
|
// Recipientes: owners activos del tenant (filtrados por preferencias de rol)
|
||||||
const owners = await prisma.tenantMembership.findMany({
|
const owners = await prisma.tenantMembership.findMany({
|
||||||
where: { tenantId, isOwner: true, active: true },
|
where: { tenantId, isOwner: true, active: true },
|
||||||
include: { user: { select: { email: true, nombre: true, active: true } } },
|
include: { user: { select: { email: true, nombre: true, active: true } } },
|
||||||
});
|
});
|
||||||
const recipients = owners.filter(o => o.user.active);
|
const activeOwners = owners.filter(o => o.user.active);
|
||||||
if (recipients.length === 0) {
|
if (activeOwners.length === 0) {
|
||||||
console.log(`[Weekly] Tenant ${tenant.rfc} sin owners activos, skip`);
|
console.log(`[Weekly] Tenant ${tenant.rfc} sin owners activos, skip`);
|
||||||
return { sent: 0 };
|
return { sent: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pool del tenant para queries de CFDI
|
const recipientsWithRole = activeOwners.map(o => ({ email: o.user.email, role: 'owner' as const }));
|
||||||
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
|
const allowedEmails = new Set(await filterRecipientsByRole(pool, 'weekly_update', recipientsWithRole));
|
||||||
|
const recipients = activeOwners.filter(o => allowedEmails.has(o.user.email));
|
||||||
|
if (recipients.length === 0) {
|
||||||
|
console.log(`[Weekly] Tenant ${tenant.rfc} sin owners con weekly_update habilitado, skip`);
|
||||||
|
return { sent: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
const { fechaInicio, fechaFin, periodoLabel } = currentMonthRange();
|
const { fechaInicio, fechaFin, periodoLabel } = currentMonthRange();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS notification_role_preferences (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
email_type VARCHAR(50) NOT NULL,
|
||||||
|
role VARCHAR(20) NOT NULL,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
UNIQUE (email_type, role)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO notification_role_preferences (email_type, role, enabled)
|
||||||
|
VALUES
|
||||||
|
('documento_subido','owner',true),
|
||||||
|
('documento_subido','supervisor',true),
|
||||||
|
('documento_subido','auxiliar',true),
|
||||||
|
('documento_subido','cliente',true),
|
||||||
|
('weekly_update','owner',true),
|
||||||
|
('weekly_update','supervisor',true),
|
||||||
|
('weekly_update','auxiliar',true),
|
||||||
|
('weekly_update','cliente',true),
|
||||||
|
('subscription_expiring','owner',true),
|
||||||
|
('subscription_expiring','supervisor',true),
|
||||||
|
('subscription_expiring','auxiliar',true),
|
||||||
|
('subscription_expiring','cliente',true),
|
||||||
|
('recordatorio_fiscal','owner',true),
|
||||||
|
('recordatorio_fiscal','supervisor',true),
|
||||||
|
('recordatorio_fiscal','auxiliar',true),
|
||||||
|
('recordatorio_fiscal','cliente',true),
|
||||||
|
('alertas_nuevas','owner',true),
|
||||||
|
('alertas_nuevas','supervisor',true),
|
||||||
|
('alertas_nuevas','auxiliar',true),
|
||||||
|
('alertas_nuevas','cliente',true),
|
||||||
|
('recordatorio_proximo','owner',true),
|
||||||
|
('recordatorio_proximo','supervisor',true),
|
||||||
|
('recordatorio_proximo','auxiliar',true),
|
||||||
|
('recordatorio_proximo','cliente',true)
|
||||||
|
ON CONFLICT (email_type, role) DO NOTHING;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Extender periodicidad para soportar declaraciones cuatrimestrales (ej. SISUB)
|
||||||
|
ALTER TABLE declaraciones_provisionales
|
||||||
|
DROP CONSTRAINT IF EXISTS declaraciones_provisionales_periodicidad_check;
|
||||||
|
|
||||||
|
ALTER TABLE declaraciones_provisionales
|
||||||
|
ADD CONSTRAINT declaraciones_provisionales_periodicidad_check
|
||||||
|
CHECK (periodicidad IN ('mensual', 'bimestral', 'trimestral', 'cuatrimestral', 'semestral', 'anual'));
|
||||||
25
apps/api/src/migrations/tenant/053_obligacion_evidencias.sql
Normal file
25
apps/api/src/migrations/tenant/053_obligacion_evidencias.sql
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
-- Evidencias de cumplimiento para obligaciones fiscales.
|
||||||
|
-- Permite subir cualquier documento (declaración, pago, acuse, complemento)
|
||||||
|
-- vinculado a una obligación y periodo específicos.
|
||||||
|
CREATE TABLE IF NOT EXISTS obligacion_evidencias (
|
||||||
|
id serial PRIMARY KEY,
|
||||||
|
obligacion_id uuid NOT NULL REFERENCES obligaciones_contribuyente(id) ON DELETE CASCADE,
|
||||||
|
periodo varchar(7) NOT NULL, -- "2026-04"
|
||||||
|
contribuyente_id uuid NOT NULL REFERENCES contribuyentes(entidad_id) ON DELETE CASCADE,
|
||||||
|
tipo_documento varchar(30) NOT NULL CHECK (tipo_documento IN (
|
||||||
|
'declaracion', 'pago', 'acuse', 'complemento'
|
||||||
|
)),
|
||||||
|
archivo bytea NOT NULL,
|
||||||
|
archivo_filename varchar(255) NOT NULL,
|
||||||
|
archivo_mime varchar(100) DEFAULT 'application/pdf',
|
||||||
|
notas text,
|
||||||
|
subido_por uuid, -- UUID del usuario en horux360 (sin FK local)
|
||||||
|
subido_por_email varchar(255),
|
||||||
|
created_at timestamptz DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_obligacion_evidencias_obligacion_periodo
|
||||||
|
ON obligacion_evidencias (obligacion_id, periodo);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_obligacion_evidencias_contribuyente
|
||||||
|
ON obligacion_evidencias (contribuyente_id);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Estados de declaración y pago por separado para obligaciones que requieren ambos.
|
||||||
|
ALTER TABLE obligacion_periodos
|
||||||
|
ADD COLUMN IF NOT EXISTS declaracion_presentada boolean DEFAULT false,
|
||||||
|
ADD COLUMN IF NOT EXISTS pago_presentado boolean DEFAULT false;
|
||||||
|
|
||||||
|
-- Backfill: periodos ya completados se consideran con declaración y pago presentados.
|
||||||
|
UPDATE obligacion_periodos
|
||||||
|
SET declaracion_presentada = true,
|
||||||
|
pago_presentado = true
|
||||||
|
WHERE completada = true
|
||||||
|
AND (declaracion_presentada IS NULL OR pago_presentado IS NULL);
|
||||||
|
|
||||||
|
-- Asegurar que declaracion_presentada y pago_presentado no sean NULL.
|
||||||
|
ALTER TABLE obligacion_periodos
|
||||||
|
ALTER COLUMN declaracion_presentada SET NOT NULL,
|
||||||
|
ALTER COLUMN pago_presentado SET NOT NULL;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Relación entre declaraciones provisionales y obligaciones fiscales.
|
||||||
|
-- Permite saber exactamente qué obligaciones cierra una declaración
|
||||||
|
-- y aplicar el comprobante de pago a las mismas obligaciones.
|
||||||
|
CREATE TABLE IF NOT EXISTS declaracion_obligaciones (
|
||||||
|
declaracion_id INT NOT NULL REFERENCES declaraciones_provisionales(id) ON DELETE CASCADE,
|
||||||
|
obligacion_id UUID NOT NULL REFERENCES obligaciones_contribuyente(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (declaracion_id, obligacion_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_declaracion_obligaciones_obligacion
|
||||||
|
ON declaracion_obligaciones (obligacion_id);
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- Recordatorios periódicos: maestro + instancias materializadas en la misma tabla
|
||||||
|
|
||||||
|
ALTER TABLE recordatorios
|
||||||
|
ADD COLUMN IF NOT EXISTS recurrencia VARCHAR(20) DEFAULT 'unica' NOT NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS serie_id INTEGER REFERENCES recordatorios(id) ON DELETE CASCADE,
|
||||||
|
ADD COLUMN IF NOT EXISTS activo BOOLEAN DEFAULT true NOT NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS fecha_inicio DATE,
|
||||||
|
ADD COLUMN IF NOT EXISTS fecha_fin DATE;
|
||||||
|
|
||||||
|
-- Backfill seguro para filas existentes (el default ya cubre recurrencia/activo, pero dejamos explícito)
|
||||||
|
UPDATE recordatorios
|
||||||
|
SET recurrencia = 'unica',
|
||||||
|
activo = true
|
||||||
|
WHERE recurrencia IS NULL
|
||||||
|
OR activo IS NULL;
|
||||||
|
|
||||||
|
-- Índices para consultas de serie y regeneración
|
||||||
|
CREATE INDEX IF NOT EXISTS recordatorios_serie_id_idx ON recordatorios(serie_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS recordatorios_recurrencia_activo_idx ON recordatorios(recurrencia, activo);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- Fix: la constraint unique de declaraciones normales solo consideraba
|
||||||
|
-- (año, mes, contribuyente_id). Esto impedía subir una declaración normal de
|
||||||
|
-- ISRTP si ya existía una normal de ISN para el mismo mes y contribuyente.
|
||||||
|
-- Ahora la unicidad se valida por (año, mes, contribuyente_id, impuestos),
|
||||||
|
-- permitiendo una declaración normal distinta por cada conjunto de impuestos.
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS uniq_declaracion_normal_mes_contrib;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uniq_declaracion_normal_mes_contrib_impuestos
|
||||||
|
ON declaraciones_provisionales(año, mes, contribuyente_id, impuestos)
|
||||||
|
WHERE tipo = 'normal';
|
||||||
|
|
||||||
|
INSERT INTO tenant_migrations (scope, version, name)
|
||||||
|
VALUES ('vertical-contable', 57, '057_declaraciones_unique_por_impuestos')
|
||||||
|
ON CONFLICT (scope, version) DO NOTHING;
|
||||||
@@ -6,10 +6,10 @@ import { strictLimit } from '../middlewares/rate-limit.middleware.js';
|
|||||||
|
|
||||||
const router: IRouter = Router();
|
const router: IRouter = Router();
|
||||||
|
|
||||||
// Rate limiting: 10 login attempts per 15 minutes per IP
|
// Rate limiting: 25 login attempts per 15 minutes per IP
|
||||||
const loginLimiter = rateLimit({
|
const loginLimiter = rateLimit({
|
||||||
windowMs: 15 * 60 * 1000,
|
windowMs: 15 * 60 * 1000,
|
||||||
max: 10,
|
max: 25,
|
||||||
message: { message: 'Demasiados intentos de login. Intenta de nuevo en 15 minutos.' },
|
message: { message: 'Demasiados intentos de login. Intenta de nuevo en 15 minutos.' },
|
||||||
standardHeaders: true,
|
standardHeaders: true,
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ router.use(authenticate);
|
|||||||
router.use(tenantMiddleware);
|
router.use(tenantMiddleware);
|
||||||
|
|
||||||
// Static routes first
|
// Static routes first
|
||||||
router.get('/supervisores', authorize('owner'), ctrl.getSupervisores);
|
router.get('/supervisores', authorize('owner', 'supervisor'), ctrl.getSupervisores);
|
||||||
|
|
||||||
// Asignaciones de obligaciones/tareas a auxiliares (antes de /:id para evitar match dinámico)
|
// Asignaciones de obligaciones/tareas a auxiliares (antes de /:id para evitar match dinámico)
|
||||||
router.get('/asignaciones', authorize('owner', 'supervisor'), asignacionesCtrl.listPorSupervisor);
|
router.get('/asignaciones', authorize('owner', 'supervisor'), asignacionesCtrl.listPorSupervisor);
|
||||||
|
|||||||
@@ -35,4 +35,10 @@ router.post('/extras', documentosController.crearExtra);
|
|||||||
router.get('/extras/:id/pdf', documentosController.descargarExtraPdf);
|
router.get('/extras/:id/pdf', documentosController.descargarExtraPdf);
|
||||||
router.delete('/extras/:id', documentosController.eliminarExtra);
|
router.delete('/extras/:id', documentosController.eliminarExtra);
|
||||||
|
|
||||||
|
// Evidencias de obligaciones fiscales
|
||||||
|
router.get('/obligacion-evidencias', documentosController.listarEvidenciasObligacion);
|
||||||
|
router.post('/obligacion-evidencias', documentosController.crearEvidenciaObligacion);
|
||||||
|
router.get('/obligacion-evidencias/:id/pdf', documentosController.descargarEvidenciaObligacion);
|
||||||
|
router.delete('/obligacion-evidencias/:id', documentosController.eliminarEvidenciaObligacion);
|
||||||
|
|
||||||
export { router as documentosRoutes };
|
export { router as documentosRoutes };
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ function appliesToPeriod(frecuencia: string | null, periodo: string): boolean {
|
|||||||
case 'mensual': return true;
|
case 'mensual': return true;
|
||||||
case 'bimestral': return month % 2 === 1;
|
case 'bimestral': return month % 2 === 1;
|
||||||
case 'trimestral': return [1, 4, 7, 10].includes(month);
|
case 'trimestral': return [1, 4, 7, 10].includes(month);
|
||||||
|
case 'cuatrimestral': return [1, 5, 9].includes(month);
|
||||||
case 'anual': return month === 3 || month === 4;
|
case 'anual': return month === 3 || month === 4;
|
||||||
case 'eventual': return false;
|
case 'eventual': return false;
|
||||||
default: return true;
|
default: return true;
|
||||||
|
|||||||
@@ -323,7 +323,7 @@ export async function logout(token: string): Promise<void> {
|
|||||||
// Password reset
|
// Password reset
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
const PASSWORD_RESET_EXPIRY_MS = 60 * 60 * 1000; // 1 hora
|
const PASSWORD_RESET_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 horas
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Solicita recuperación de contraseña. No revela si el email existe (anti-enumeration).
|
* Solicita recuperación de contraseña. No revela si el email existe (anti-enumeration).
|
||||||
@@ -590,18 +590,6 @@ export async function switchTenant(params: {
|
|||||||
throw new AppError(404, 'Empresa no encontrada o desactivada');
|
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([
|
const [platformRoles, tenants] = await Promise.all([
|
||||||
getPlatformRoles(user.id),
|
getPlatformRoles(user.id),
|
||||||
getUserTenants(user.id),
|
getUserTenants(user.id),
|
||||||
@@ -619,13 +607,26 @@ export async function switchTenant(params: {
|
|||||||
const accessToken = generateAccessToken(tokenPayload);
|
const accessToken = generateAccessToken(tokenPayload);
|
||||||
const refreshToken = generateRefreshToken(tokenPayload);
|
const refreshToken = generateRefreshToken(tokenPayload);
|
||||||
|
|
||||||
await prisma.refreshToken.create({
|
// Persiste el target como "último tenant activo" y atomiza la rotacion del
|
||||||
data: {
|
// refresh token (delete + create) para evitar race conditions con requests
|
||||||
userId: user.id,
|
// concurrentes que intenten refrescar con el token anterior.
|
||||||
token: refreshToken,
|
const previousTenantId = user.lastTenantId;
|
||||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
await prisma.$transaction([
|
||||||
},
|
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).
|
||||||
|
prisma.refreshToken.deleteMany({ where: { token: params.currentRefreshToken } }),
|
||||||
|
prisma.refreshToken.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
token: refreshToken,
|
||||||
|
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
auditLog({
|
auditLog({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
|||||||
@@ -214,6 +214,7 @@ export async function generarEventosDesdeObligaciones(
|
|||||||
if (freq === 'mensual') monthsToGenerate.push(m);
|
if (freq === 'mensual') monthsToGenerate.push(m);
|
||||||
else if (freq === 'bimestral' && m % 2 === 1) 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 === 'trimestral' && [1, 4, 7, 10].includes(m)) monthsToGenerate.push(m);
|
||||||
|
else if (freq === 'cuatrimestral' && [1, 5, 9].includes(m)) monthsToGenerate.push(m);
|
||||||
else if (freq === 'anual' && (m === 3 || m === 4)) monthsToGenerate.push(m);
|
else if (freq === 'anual' && (m === 3 || m === 4)) monthsToGenerate.push(m);
|
||||||
// 'eventual' and unknown: skip auto-generation
|
// 'eventual' and unknown: skip auto-generation
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,12 +102,12 @@ export async function getCfdis(pool: Pool, filters: CfdiFilters): Promise<CfdiLi
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (filters.fechaInicio) {
|
if (filters.fechaInicio) {
|
||||||
whereClause += ` AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $${paramIndex++}::date`;
|
whereClause += ` AND fecha_emision::date >= $${paramIndex++}::date`;
|
||||||
params.push(filters.fechaInicio);
|
params.push(filters.fechaInicio);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filters.fechaFin) {
|
if (filters.fechaFin) {
|
||||||
whereClause += ` AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') <= ($${paramIndex++}::date + interval '1 day')`;
|
whereClause += ` AND fecha_emision::date <= $${paramIndex++}::date`;
|
||||||
params.push(filters.fechaFin);
|
params.push(filters.fechaFin);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,11 +214,11 @@ export async function getConceptosList(
|
|||||||
params.push(filters.estado);
|
params.push(filters.estado);
|
||||||
}
|
}
|
||||||
if (filters.fechaInicio) {
|
if (filters.fechaInicio) {
|
||||||
whereClause += ` AND COALESCE(c.fecha_efectiva, c.fecha_emision - interval '1 hour') >= $${paramIndex++}::date`;
|
whereClause += ` AND c.fecha_emision::date >= $${paramIndex++}::date`;
|
||||||
params.push(filters.fechaInicio);
|
params.push(filters.fechaInicio);
|
||||||
}
|
}
|
||||||
if (filters.fechaFin) {
|
if (filters.fechaFin) {
|
||||||
whereClause += ` AND COALESCE(c.fecha_efectiva, c.fecha_emision - interval '1 hour') <= ($${paramIndex++}::date + interval '1 day')`;
|
whereClause += ` AND c.fecha_emision::date <= $${paramIndex++}::date`;
|
||||||
params.push(filters.fechaFin);
|
params.push(filters.fechaFin);
|
||||||
}
|
}
|
||||||
if (filters.rfc) {
|
if (filters.rfc) {
|
||||||
@@ -385,11 +385,11 @@ export async function getCfdiXmlsForZip(
|
|||||||
params.push(filters.estado);
|
params.push(filters.estado);
|
||||||
}
|
}
|
||||||
if (filters.fechaInicio) {
|
if (filters.fechaInicio) {
|
||||||
whereClause += ` AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $${paramIndex++}::date`;
|
whereClause += ` AND fecha_emision::date >= $${paramIndex++}::date`;
|
||||||
params.push(filters.fechaInicio);
|
params.push(filters.fechaInicio);
|
||||||
}
|
}
|
||||||
if (filters.fechaFin) {
|
if (filters.fechaFin) {
|
||||||
whereClause += ` AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') <= ($${paramIndex++}::date + interval '1 day')`;
|
whereClause += ` AND fecha_emision::date <= $${paramIndex++}::date`;
|
||||||
params.push(filters.fechaFin);
|
params.push(filters.fechaFin);
|
||||||
}
|
}
|
||||||
if (filters.rfc) {
|
if (filters.rfc) {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { loginSatCsf } from './sat/sat-csf-login.js';
|
|||||||
import { extractCsfPdf } from './sat/sat-csf-scraper.js';
|
import { extractCsfPdf } from './sat/sat-csf-scraper.js';
|
||||||
import { parseCsfPdf, type ConstanciaSituacionFiscal, type Domicilio, type RegimenCsf } from './sat/sat-csf-parser.js';
|
import { parseCsfPdf, type ConstanciaSituacionFiscal, type Domicilio, type RegimenCsf } from './sat/sat-csf-parser.js';
|
||||||
|
|
||||||
const PROCESS_TIMEOUT = 180_000;
|
const PROCESS_TIMEOUT = 300_000;
|
||||||
|
|
||||||
export interface ConstanciaRow {
|
export interface ConstanciaRow {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -135,10 +135,61 @@ export async function getContribuyenteById(pool: Pool, id: string, tenantId?: st
|
|||||||
return mergeContribuyenteWithTenant(row, tenantData);
|
return mergeContribuyenteWithTenant(row, tenantData);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createContribuyente(pool: Pool, data: CreateContribuyenteData): Promise<ContribuyenteRow> {
|
export async function createContribuyente(
|
||||||
|
pool: Pool,
|
||||||
|
data: CreateContribuyenteData,
|
||||||
|
): Promise<{ row: ContribuyenteRow; reactivated: boolean }> {
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// 1. Si el RFC ya existe, reactivar la entidad desactivada en lugar de
|
||||||
|
// violar el UNIQUE de contribuyentes.rfc. Si está activa, lanzar
|
||||||
|
// error 23505 para que el controller devuelva 409.
|
||||||
|
const { rows: existing } = await client.query<{ entidad_id: string; active: boolean }>(`
|
||||||
|
SELECT c.entidad_id, e.active
|
||||||
|
FROM contribuyentes c
|
||||||
|
JOIN entidades_gestionadas e ON e.id = c.entidad_id
|
||||||
|
WHERE UPPER(c.rfc) = UPPER($1)
|
||||||
|
`, [data.rfc]);
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
const { entidad_id, active } = existing[0];
|
||||||
|
if (active) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
const err: any = new Error('Ya existe un contribuyente activo con este RFC');
|
||||||
|
err.code = '23505';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query(`
|
||||||
|
UPDATE entidades_gestionadas
|
||||||
|
SET active = true,
|
||||||
|
nombre = $1,
|
||||||
|
identificador = $2,
|
||||||
|
supervisor_user_id = $3,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $4
|
||||||
|
`, [data.razonSocial, data.rfc.toUpperCase(), data.supervisorUserId ?? null, entidad_id]);
|
||||||
|
|
||||||
|
await client.query(`
|
||||||
|
UPDATE contribuyentes
|
||||||
|
SET regimen_fiscal = $1,
|
||||||
|
codigo_postal = $2,
|
||||||
|
domicilio = $3
|
||||||
|
WHERE entidad_id = $4
|
||||||
|
`, [data.regimenFiscal ?? null, data.codigoPostal ?? null, data.domicilio ? JSON.stringify(data.domicilio) : null, entidad_id]);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
await backfillCfdiContribuyente(pool, entidad_id, data.rfc.toUpperCase()).catch(
|
||||||
|
(err) => console.error('[Contribuyente] Backfill CFDIs failed (non-blocking):', err)
|
||||||
|
);
|
||||||
|
|
||||||
|
return { row: (await getContribuyenteById(pool, entidad_id))!, reactivated: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Caso normal: crear nuevo contribuyente
|
||||||
const { rows: [entidad] } = await client.query(`
|
const { rows: [entidad] } = await client.query(`
|
||||||
INSERT INTO entidades_gestionadas (tipo, nombre, identificador, supervisor_user_id)
|
INSERT INTO entidades_gestionadas (tipo, nombre, identificador, supervisor_user_id)
|
||||||
VALUES ('CONTRIBUYENTE', $1, $2, $3)
|
VALUES ('CONTRIBUYENTE', $1, $2, $3)
|
||||||
@@ -157,7 +208,7 @@ export async function createContribuyente(pool: Pool, data: CreateContribuyenteD
|
|||||||
(err) => console.error('[Contribuyente] Backfill CFDIs failed (non-blocking):', err)
|
(err) => console.error('[Contribuyente] Backfill CFDIs failed (non-blocking):', err)
|
||||||
);
|
);
|
||||||
|
|
||||||
return (await getContribuyenteById(pool, entidad.id))!;
|
return { row: (await getContribuyenteById(pool, entidad.id))!, reactivated: false };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await client.query('ROLLBACK');
|
await client.query('ROLLBACK');
|
||||||
throw err;
|
throw err;
|
||||||
@@ -220,11 +271,39 @@ export async function updateContribuyente(pool: Pool, id: string, data: Partial<
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deactivateContribuyente(pool: Pool, id: string): Promise<boolean> {
|
export async function deactivateContribuyente(pool: Pool, id: string): Promise<boolean> {
|
||||||
const { rowCount } = await pool.query(
|
const client = await pool.connect();
|
||||||
'UPDATE entidades_gestionadas SET active = false, updated_at = now() WHERE id = $1',
|
try {
|
||||||
[id]
|
await client.query('BEGIN');
|
||||||
);
|
|
||||||
return (rowCount ?? 0) > 0;
|
const { rowCount } = await client.query(
|
||||||
|
'UPDATE entidades_gestionadas SET active = false, updated_at = now() WHERE id = $1',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
const ok = (rowCount ?? 0) > 0;
|
||||||
|
|
||||||
|
if (ok) {
|
||||||
|
// Limpiar asignaciones para que no aparezca en carteras ni accesos de cliente
|
||||||
|
await client.query('DELETE FROM cartera_entidades WHERE entidad_id = $1', [id]).catch((err) => {
|
||||||
|
console.error('[Contribuyente] Error limpiando cartera_entidades:', err);
|
||||||
|
});
|
||||||
|
await client.query('DELETE FROM cliente_accesos WHERE entidad_id = $1', [id]).catch((err) => {
|
||||||
|
console.error('[Contribuyente] Error limpiando cliente_accesos:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Desactivar FIEL para que no siga sincronizándose mientras está inactivo
|
||||||
|
await client.query('UPDATE fiel_contribuyente SET is_active = false WHERE contribuyente_id = $1', [id]).catch((err) => {
|
||||||
|
console.error('[Contribuyente] Error desactivando FIEL:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
return ok;
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,38 @@
|
|||||||
import type { Pool } from 'pg';
|
import type { Pool } from 'pg';
|
||||||
|
import { createEvidencia } from './obligacion-evidencias.service.js';
|
||||||
|
|
||||||
|
function normalize(s: string): string {
|
||||||
|
return s
|
||||||
|
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[.,;:()]/g, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dadas las obligaciones seleccionadas para una declaración, infiere los
|
||||||
|
* impuestos que cubre. Se usa para mantener la resolución de alertas legacy
|
||||||
|
* (decl-*, pago-*) sin exponer el campo en la UI.
|
||||||
|
*/
|
||||||
|
function inferirImpuestosDeObligaciones(
|
||||||
|
obligaciones: Array<{ id: string; nombre: string; catalogoId?: string | null }>,
|
||||||
|
): Impuesto[] {
|
||||||
|
const set = new Set<Impuesto>();
|
||||||
|
for (const ob of obligaciones) {
|
||||||
|
const nombre = normalize(ob.nombre);
|
||||||
|
const catalogoId = normalize(ob.catalogoId || '');
|
||||||
|
if (nombre.includes('diot') || catalogoId.includes('diot')) {
|
||||||
|
set.add('DIOT');
|
||||||
|
} else if (nombre.includes('iva') || catalogoId.includes('iva')) {
|
||||||
|
set.add('IVA');
|
||||||
|
}
|
||||||
|
if (nombre.includes('isr') || catalogoId.includes('isr')) set.add('ISR');
|
||||||
|
if (nombre.includes('ieps') || catalogoId.includes('ieps')) set.add('IEPS');
|
||||||
|
if (nombre.includes('isn') || catalogoId.includes('isn')) set.add('ISN');
|
||||||
|
if (nombre.includes('ish') || catalogoId.includes('ish')) set.add('ISH');
|
||||||
|
}
|
||||||
|
return Array.from(set);
|
||||||
|
}
|
||||||
|
|
||||||
// Mapeo: impuesto de la declaración → reglas para matchear obligaciones del
|
// Mapeo: impuesto de la declaración → reglas para matchear obligaciones del
|
||||||
// contribuyente. `include` son substrings que DEBE contener el nombre de la
|
// contribuyente. `include` son substrings que DEBE contener el nombre de la
|
||||||
@@ -25,17 +59,28 @@ const IMPUESTO_A_OBLIGACION_KEYWORDS: Record<string, { include: string[]; exclud
|
|||||||
* periodo sigue marcado completado — el usuario decidirá si re-abrirlo
|
* periodo sigue marcado completado — el usuario decidirá si re-abrirlo
|
||||||
* manualmente.
|
* manualmente.
|
||||||
*/
|
*/
|
||||||
async function completarObligacionesPorDeclaracion(
|
/**
|
||||||
|
* Al subir una declaración o comprobante de pago, registra una evidencia para
|
||||||
|
* cada obligación del contribuyente que corresponda al impuesto declarado.
|
||||||
|
*
|
||||||
|
* - Obligaciones informativas (`requierePago = false`) se marcan completadas al
|
||||||
|
* recibir cualquier documento de declaración/acuse.
|
||||||
|
* - Obligaciones de pago (`requierePago = true`) se marcan completadas solo al
|
||||||
|
* recibir un comprobante de pago (`tipo_documento = 'pago'`).
|
||||||
|
*/
|
||||||
|
async function registrarEvidenciasPorDeclaracion(
|
||||||
pool: Pool,
|
pool: Pool,
|
||||||
contribuyenteId: string,
|
contribuyenteId: string,
|
||||||
impuestos: string[],
|
impuestos: string[],
|
||||||
periodo: string,
|
periodo: string,
|
||||||
/** UUID del usuario que subió la declaración (obligacion_periodos.completada_por es uuid). */
|
/** UUID del usuario que subió el documento. */
|
||||||
completadaPor: string,
|
subidoPor: string,
|
||||||
declaracionId: number,
|
pdfBase64: string,
|
||||||
/** Periodicidad de la declaración. Si no se provee, se asume 'mensual'. */
|
pdfFilename: string,
|
||||||
|
tipoDocumento: 'declaracion' | 'pago',
|
||||||
|
/** Periodicidad de la declaración. Si no se provee, asume 'mensual'. */
|
||||||
periodicidad: string = 'mensual',
|
periodicidad: string = 'mensual',
|
||||||
): Promise<number> {
|
): Promise<{ count: number; obligacionesAfectadas: string[] }> {
|
||||||
// Get active obligations for this contribuyente (incluye frecuencia para filtrar)
|
// Get active obligations for this contribuyente (incluye frecuencia para filtrar)
|
||||||
const { rows: obligaciones } = await pool.query<{ id: string; nombre: string; frecuencia: string | null }>(
|
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`,
|
`SELECT id, nombre, frecuencia FROM obligaciones_contribuyente WHERE contribuyente_id = $1 AND activa = true`,
|
||||||
@@ -43,6 +88,7 @@ async function completarObligacionesPorDeclaracion(
|
|||||||
);
|
);
|
||||||
|
|
||||||
let count = 0;
|
let count = 0;
|
||||||
|
const obligacionesAfectadas: string[] = [];
|
||||||
|
|
||||||
for (const impuesto of impuestos) {
|
for (const impuesto of impuestos) {
|
||||||
const rules = IMPUESTO_A_OBLIGACION_KEYWORDS[impuesto];
|
const rules = IMPUESTO_A_OBLIGACION_KEYWORDS[impuesto];
|
||||||
@@ -55,33 +101,109 @@ async function completarObligacionesPorDeclaracion(
|
|||||||
if (!matches) continue;
|
if (!matches) continue;
|
||||||
|
|
||||||
// Filtro por periodicidad/frecuencia: una declaración mensual no debe
|
// Filtro por periodicidad/frecuencia: una declaración mensual no debe
|
||||||
// cerrar obligaciones anuales del mismo impuesto (ej. ISR mensual no
|
// cerrar obligaciones anuales del mismo impuesto.
|
||||||
// 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();
|
const obFrec = (ob.frecuencia || '').toLowerCase();
|
||||||
if (obFrec === 'eventual') continue;
|
if (obFrec === 'eventual') continue;
|
||||||
if (obFrec && obFrec !== periodicidad.toLowerCase()) continue;
|
if (obFrec && obFrec !== periodicidad.toLowerCase()) continue;
|
||||||
|
|
||||||
// Mark obligation as completed for this period, with FK a la declaración
|
await createEvidencia(pool, {
|
||||||
await pool.query(`
|
obligacionId: ob.id,
|
||||||
INSERT INTO obligacion_periodos (obligacion_id, periodo, completada, completada_at, completada_por, notas, declaracion_id)
|
periodo,
|
||||||
VALUES ($1, $2, true, now(), $3, $4, $5)
|
contribuyenteId,
|
||||||
ON CONFLICT (obligacion_id, periodo)
|
tipoDocumento,
|
||||||
DO UPDATE SET completada = true, completada_at = now(), completada_por = $3, declaracion_id = $5
|
pdfBase64,
|
||||||
`, [ob.id, periodo, completadaPor, `Declaración ${impuesto} subida`, declaracionId]);
|
pdfFilename,
|
||||||
|
notas: `${tipoDocumento === 'pago' ? 'Pago' : 'Declaración'} ${impuesto}`,
|
||||||
// Resolve the ob-* alert for this obligation+period
|
subidoPor,
|
||||||
await pool.query(
|
});
|
||||||
`UPDATE alertas SET resuelta = true WHERE tipo = $1 AND resuelta = false`,
|
|
||||||
[`ob-${ob.id}-${periodo}`],
|
|
||||||
);
|
|
||||||
|
|
||||||
|
if (!obligacionesAfectadas.includes(ob.id)) obligacionesAfectadas.push(ob.id);
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return count;
|
return { count, obligacionesAfectadas };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cuando una declaración tiene monto $0, no se requiere comprobante de pago.
|
||||||
|
* Esta función marca `pago_presentado = true` (y `completada = true`) en los
|
||||||
|
* periodos de las obligaciones afectadas para reflejar que el pago está saldado.
|
||||||
|
*/
|
||||||
|
async function confirmarPagoPeriodoSinComprobante(
|
||||||
|
pool: Pool,
|
||||||
|
obligacionesAfectadas: string[],
|
||||||
|
periodo: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const now = new Date();
|
||||||
|
for (const obligacionId of obligacionesAfectadas) {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO obligacion_periodos
|
||||||
|
(obligacion_id, periodo, declaracion_presentada, pago_presentado, completada, completada_at, completada_por)
|
||||||
|
VALUES ($1, $2, true, true, true, $3, $4)
|
||||||
|
ON CONFLICT (obligacion_id, periodo)
|
||||||
|
DO UPDATE SET
|
||||||
|
pago_presentado = true,
|
||||||
|
completada = true,
|
||||||
|
completada_at = COALESCE(obligacion_periodos.completada_at, $3),
|
||||||
|
completada_por = COALESCE(obligacion_periodos.completada_por, $4)`,
|
||||||
|
[obligacionId, periodo, now, userId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Resolver alerta ob-* si existe
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE alertas SET resuelta = true WHERE tipo = $1 AND resuelta = false`,
|
||||||
|
[`ob-${obligacionId}-${periodo}`],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registra una evidencia por cada obligación seleccionada.
|
||||||
|
* - Obligaciones informativas se completan con `declaracion`/`acuse`/`complemento`.
|
||||||
|
* - Obligaciones de pago requieren evidencia `pago` para cerrarse.
|
||||||
|
*/
|
||||||
|
async function registrarEvidenciasPorObligaciones(
|
||||||
|
pool: Pool,
|
||||||
|
obligaciones: Array<{ id: string; nombre: string; catalogoId?: string | null }>,
|
||||||
|
contribuyenteId: string,
|
||||||
|
periodo: string,
|
||||||
|
subidoPor: string,
|
||||||
|
pdfBase64: string,
|
||||||
|
pdfFilename: string,
|
||||||
|
tipoDocumento: 'declaracion' | 'pago',
|
||||||
|
notas?: string,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const afectadas: string[] = [];
|
||||||
|
for (const ob of obligaciones) {
|
||||||
|
await createEvidencia(pool, {
|
||||||
|
obligacionId: ob.id,
|
||||||
|
periodo,
|
||||||
|
contribuyenteId,
|
||||||
|
tipoDocumento,
|
||||||
|
pdfBase64,
|
||||||
|
pdfFilename,
|
||||||
|
notas: notas || `${tipoDocumento === 'pago' ? 'Comprobante de pago' : 'Declaración'}: ${ob.nombre}`,
|
||||||
|
subidoPor,
|
||||||
|
});
|
||||||
|
afectadas.push(ob.id);
|
||||||
|
}
|
||||||
|
return afectadas;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getObligacionesPorIds(
|
||||||
|
pool: Pool,
|
||||||
|
contribuyenteId: string,
|
||||||
|
obligacionesIds: string[],
|
||||||
|
): Promise<Array<{ id: string; nombre: string; catalogoId: string | null }>> {
|
||||||
|
const { rows } = await pool.query<{ id: string; nombre: string; catalogo_id: string | null }>(
|
||||||
|
`SELECT id, nombre, catalogo_id
|
||||||
|
FROM obligaciones_contribuyente
|
||||||
|
WHERE contribuyente_id = $1 AND id = ANY($2::uuid[]) AND activa = true`,
|
||||||
|
[contribuyenteId, obligacionesIds],
|
||||||
|
);
|
||||||
|
return rows.map(r => ({ id: r.id, nombre: r.nombre, catalogoId: r.catalogo_id }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -96,7 +218,7 @@ async function completarObligacionesPorDeclaracion(
|
|||||||
|
|
||||||
export type Impuesto = 'IVA' | 'ISR' | 'IEPS' | 'ISN' | 'DIOT' | 'OTRO' | 'ISH';
|
export type Impuesto = 'IVA' | 'ISR' | 'IEPS' | 'ISN' | 'DIOT' | 'OTRO' | 'ISH';
|
||||||
|
|
||||||
export type Periodicidad = 'mensual' | 'bimestral' | 'trimestral' | 'semestral' | 'anual';
|
export type Periodicidad = 'mensual' | 'bimestral' | 'trimestral' | 'cuatrimestral' | 'semestral' | 'anual';
|
||||||
|
|
||||||
export interface DeclaracionRow {
|
export interface DeclaracionRow {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -232,7 +354,10 @@ export async function createDeclaracion(
|
|||||||
mes: number;
|
mes: number;
|
||||||
tipo: 'normal' | 'complementaria';
|
tipo: 'normal' | 'complementaria';
|
||||||
periodicidad?: Periodicidad;
|
periodicidad?: Periodicidad;
|
||||||
impuestos: string[];
|
/** Legacy: se infiere de obligacionesIds si no se envía. */
|
||||||
|
impuestos?: string[];
|
||||||
|
/** Obligaciones fiscales que cubre esta declaración. */
|
||||||
|
obligacionesIds?: string[];
|
||||||
montoPago?: number | null;
|
montoPago?: number | null;
|
||||||
pdfBase64: string; // PDF de la declaración (base64)
|
pdfBase64: string; // PDF de la declaración (base64)
|
||||||
pdfFilename: string;
|
pdfFilename: string;
|
||||||
@@ -253,6 +378,16 @@ export async function createDeclaracion(
|
|||||||
// If monto_pago is exactly 0, auto-mark as paid (no payment receipt needed)
|
// If monto_pago is exactly 0, auto-mark as paid (no payment receipt needed)
|
||||||
const pagadoAt = montoPago === 0 ? new Date() : null;
|
const pagadoAt = montoPago === 0 ? new Date() : null;
|
||||||
|
|
||||||
|
// Resolvemos obligaciones e impuestos.
|
||||||
|
let obligacionesSeleccionadas: Array<{ id: string; nombre: string; catalogoId: string | null }> = [];
|
||||||
|
let impuestos: string[] = data.impuestos ?? [];
|
||||||
|
if (data.contribuyenteId && data.obligacionesIds && data.obligacionesIds.length > 0) {
|
||||||
|
obligacionesSeleccionadas = await getObligacionesPorIds(pool, data.contribuyenteId, data.obligacionesIds);
|
||||||
|
if (impuestos.length === 0) {
|
||||||
|
impuestos = inferirImpuestosDeObligaciones(obligacionesSeleccionadas);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`INSERT INTO declaraciones_provisionales
|
`INSERT INTO declaraciones_provisionales
|
||||||
@@ -262,46 +397,55 @@ export async function createDeclaracion(
|
|||||||
RETURNING id, año, mes, tipo, periodicidad, impuestos, monto_pago, pdf_filename,
|
RETURNING id, año, mes, tipo, periodicidad, impuestos, monto_pago, pdf_filename,
|
||||||
pdf_liga_pago_filename, pdf_pago_filename, pagado_at, creado_por, notas,
|
pdf_liga_pago_filename, pdf_pago_filename, pagado_at, creado_por, notas,
|
||||||
created_at, updated_at`,
|
created_at, updated_at`,
|
||||||
[data.año, data.mes, data.tipo, periodicidad, data.impuestos, montoPago,
|
[data.año, data.mes, data.tipo, periodicidad, impuestos, montoPago,
|
||||||
buf, data.pdfFilename, ligaBuf, data.ligaPagoFilename ?? null,
|
buf, data.pdfFilename, ligaBuf, data.ligaPagoFilename ?? null,
|
||||||
data.notas ?? null, data.creadoPor, pagadoAt, data.contribuyenteId ?? null],
|
data.notas ?? null, data.creadoPor, pagadoAt, data.contribuyenteId ?? null],
|
||||||
);
|
);
|
||||||
|
|
||||||
const declaracion = rowToDeclaracion(rows[0]);
|
const declaracion = rowToDeclaracion(rows[0]);
|
||||||
|
|
||||||
// Auto-resolver alertas. Reglas:
|
// Guardar relación con obligaciones para que el comprobante de pago
|
||||||
// - tipo='normal': resuelve alertas de declaración (decl-*) del mes.
|
// posterior se aplique a las mismas obligaciones.
|
||||||
// El pago se resuelve por separado al subir comprobante.
|
if (obligacionesSeleccionadas.length > 0) {
|
||||||
// - tipo='complementaria': sustituye a la normal en términos de
|
const values = obligacionesSeleccionadas.map((_, i) => `($1, $${i + 2})`).join(',');
|
||||||
// obligación de pago — al subirla se resuelven AMBAS (decl-* y
|
await pool.query(
|
||||||
// pago-*) porque el cliente pagará usando la complementaria,
|
`INSERT INTO declaracion_obligaciones (declaracion_id, obligacion_id) VALUES ${values}`,
|
||||||
// no la normal. La alerta de declaración ya estaría resuelta
|
[declaracion.id, ...obligacionesSeleccionadas.map(o => o.id)],
|
||||||
// si la normal se subió antes; el resolver es idempotente.
|
);
|
||||||
const prefijosDecl = data.impuestos.flatMap(i => IMPUESTO_A_PREFIJO_DECL[i] || []);
|
}
|
||||||
|
|
||||||
|
// Auto-resolver alertas legacy (decl-*, pago-*).
|
||||||
|
const prefijosDecl = impuestos.flatMap(i => IMPUESTO_A_PREFIJO_DECL[i] || []);
|
||||||
let alertasResueltas = await resolverAlertasPorPeriodo(pool, prefijosDecl, data.año, data.mes);
|
let alertasResueltas = await resolverAlertasPorPeriodo(pool, prefijosDecl, data.año, data.mes);
|
||||||
if (data.tipo === 'complementaria' || montoPago === 0) {
|
if (data.tipo === 'complementaria' || montoPago === 0) {
|
||||||
// complementaria: sustituye normal para pago → resolver ambas
|
const prefijosPago = impuestos.flatMap(i => IMPUESTO_A_PREFIJO_PAGO[i] || []);
|
||||||
// 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);
|
alertasResueltas += await resolverAlertasPorPeriodo(pool, prefijosPago, data.año, data.mes);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-complete obligaciones del contribuyente SOLO si la declaración
|
// Registrar evidencias de declaración en las obligaciones seleccionadas.
|
||||||
// también cubre el pago (complementaria sustituye a la normal para el
|
// Fallback legacy: si no se enviaron obligaciones, se usa el keyword matching
|
||||||
// pago; monto=0 significa "nada que pagar"). Una declaración normal con
|
// anterior a partir de impuestos.
|
||||||
// monto>0 solo presenta el acuse — la obligación de pago sigue abierta
|
let obligacionesAfectadas: string[] = obligacionesSeleccionadas.map(o => o.id);
|
||||||
// y se marca completada hasta que se suba el comprobante via
|
if (data.contribuyenteId && data.creadoPorUserId) {
|
||||||
// `uploadComprobantePago`. Esto mantiene las alertas `pago-*` y `ob-*`
|
const periodo = `${data.año}-${String(data.mes).padStart(2, '0')}`;
|
||||||
// visibles hasta que realmente se cierre el ciclo.
|
|
||||||
const cubrePago = data.tipo === 'complementaria' || montoPago === 0;
|
if (obligacionesSeleccionadas.length > 0) {
|
||||||
if (data.contribuyenteId && cubrePago) {
|
await registrarEvidenciasPorObligaciones(
|
||||||
if (!data.creadoPorUserId) {
|
pool, obligacionesSeleccionadas, data.contribuyenteId, periodo, data.creadoPorUserId,
|
||||||
console.warn('[createDeclaracion] Sin creadoPorUserId — no se auto-completan obligaciones del contribuyente');
|
data.pdfBase64, data.pdfFilename, 'declaracion', data.notas,
|
||||||
} 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,
|
|
||||||
);
|
);
|
||||||
|
} else if (impuestos.length > 0) {
|
||||||
|
const { obligacionesAfectadas: afectadas } = await registrarEvidenciasPorDeclaracion(
|
||||||
|
pool, data.contribuyenteId, impuestos, periodo, data.creadoPorUserId,
|
||||||
|
data.pdfBase64, data.pdfFilename, 'declaracion', periodicidad,
|
||||||
|
);
|
||||||
|
obligacionesAfectadas = afectadas;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si la declaración es por $0, no se requiere comprobante de pago:
|
||||||
|
// marcar el pago como presentado automáticamente.
|
||||||
|
if (montoPago === 0 && obligacionesAfectadas.length > 0) {
|
||||||
|
await confirmarPagoPeriodoSinComprobante(pool, obligacionesAfectadas, periodo, data.creadoPorUserId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,20 +484,35 @@ export async function uploadComprobantePago(
|
|||||||
const row = rows[0];
|
const row = rows[0];
|
||||||
const declaracion = rowToDeclaracion(row);
|
const declaracion = rowToDeclaracion(row);
|
||||||
|
|
||||||
// Auto-resolver alertas de pago para los impuestos del periodo
|
// Auto-resolver alertas de pago legacy.
|
||||||
const prefijosPago = declaracion.impuestos.flatMap(i => IMPUESTO_A_PREFIJO_PAGO[i] || []);
|
const prefijosPago = declaracion.impuestos.flatMap(i => IMPUESTO_A_PREFIJO_PAGO[i] || []);
|
||||||
let alertasResueltas = await resolverAlertasPorPeriodo(pool, prefijosPago, declaracion.año, declaracion.mes);
|
let alertasResueltas = await resolverAlertasPorPeriodo(pool, prefijosPago, declaracion.año, declaracion.mes);
|
||||||
|
|
||||||
// Al subirse el comprobante de pago, la obligación ahora SÍ está completada
|
// Registrar evidencias de pago en las obligaciones vinculadas a esta declaración.
|
||||||
// (declaración + pago). Marcar `obligacion_periodos.completada=true` y
|
// Fallback legacy: si no hay relaciones, se usa keyword matching por impuestos.
|
||||||
// resolver los `ob-*` alerts. Requires contribuyenteId (guardado en la
|
|
||||||
// declaración) y userId (del caller).
|
|
||||||
if (row.contribuyente_id && data.uploadedByUserId) {
|
if (row.contribuyente_id && data.uploadedByUserId) {
|
||||||
const periodo = `${declaracion.año}-${String(declaracion.mes).padStart(2, '0')}`;
|
const periodo = `${declaracion.año}-${String(declaracion.mes).padStart(2, '0')}`;
|
||||||
const periodicidad = row.periodicidad || 'mensual';
|
|
||||||
alertasResueltas += await completarObligacionesPorDeclaracion(
|
const { rows: relaciones } = await pool.query<{ obligacion_id: string }>(
|
||||||
pool, row.contribuyente_id, declaracion.impuestos, periodo, data.uploadedByUserId, declaracion.id, periodicidad,
|
`SELECT obligacion_id FROM declaracion_obligaciones WHERE declaracion_id = $1`,
|
||||||
|
[id],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (relaciones.length > 0) {
|
||||||
|
const obligaciones = await getObligacionesPorIds(
|
||||||
|
pool, row.contribuyente_id, relaciones.map(r => r.obligacion_id),
|
||||||
|
);
|
||||||
|
await registrarEvidenciasPorObligaciones(
|
||||||
|
pool, obligaciones, row.contribuyente_id, periodo, data.uploadedByUserId,
|
||||||
|
data.pdfBase64, data.pdfFilename, 'pago', declaracion.notas ?? undefined,
|
||||||
|
);
|
||||||
|
} else if (declaracion.impuestos.length > 0) {
|
||||||
|
const periodicidad = row.periodicidad || 'mensual';
|
||||||
|
await registrarEvidenciasPorDeclaracion(
|
||||||
|
pool, row.contribuyente_id, declaracion.impuestos, periodo, data.uploadedByUserId,
|
||||||
|
data.pdfBase64, data.pdfFilename, 'pago', periodicidad,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { declaracion, alertasResueltas };
|
return { declaracion, alertasResueltas };
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createEmailTransport } from '@horux/core';
|
import { createEmailTransport, type EmailAttachment } from '@horux/core';
|
||||||
import { env } from '../../config/env.js';
|
import { env } from '../../config/env.js';
|
||||||
|
|
||||||
const transport = createEmailTransport(
|
const transport = createEmailTransport(
|
||||||
@@ -13,8 +13,8 @@ const transport = createEmailTransport(
|
|||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
|
|
||||||
async function sendEmail(to: string, subject: string, html: string) {
|
async function sendEmail(to: string, subject: string, html: string, attachments?: EmailAttachment[]) {
|
||||||
await transport.send(to, subject, html);
|
await transport.send(to, subject, html, attachments);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const emailService = {
|
export const emailService = {
|
||||||
@@ -44,6 +44,17 @@ export const emailService = {
|
|||||||
await sendEmail(env.ADMIN_EMAIL, `Pago fallido: ${data.nombre}`, paymentFailedEmail(data));
|
await sendEmail(env.ADMIN_EMAIL, `Pago fallido: ${data.nombre}`, paymentFailedEmail(data));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
sendSatSyncAlert: async (to: string, data: import('./templates/sat-sync-alert.js').SatSyncAlertData) => {
|
||||||
|
const { satSyncAlertEmail } = await import('./templates/sat-sync-alert.js');
|
||||||
|
const total = data.summary.failed + data.summary.stale + data.summary.stuckRunning + data.summary.pendingOld + data.summary.missingInitial;
|
||||||
|
await sendEmail(to, `🚨 Alerta SAT: ${total} anomalía${total === 1 ? '' : 's'} detectada${total === 1 ? '' : 's'}`, satSyncAlertEmail(data));
|
||||||
|
},
|
||||||
|
|
||||||
|
sendSatProxyReport: async (to: string, data: import('./templates/sat-proxy-report.js').SatProxyReportData) => {
|
||||||
|
const { satProxyReportEmail } = await import('./templates/sat-proxy-report.js');
|
||||||
|
await sendEmail(to, `📊 Reporte diario SAT: ${data.totalErrors} errores por proxy`, satProxyReportEmail(data));
|
||||||
|
},
|
||||||
|
|
||||||
sendSubscriptionExpiring: async (to: string, data: { nombre: string; plan: string; expiresAt: string }) => {
|
sendSubscriptionExpiring: async (to: string, data: { nombre: string; plan: string; expiresAt: string }) => {
|
||||||
const { subscriptionExpiringEmail } = await import('./templates/subscription-expiring.js');
|
const { subscriptionExpiringEmail } = await import('./templates/subscription-expiring.js');
|
||||||
await sendEmail(to, 'Tu suscripción vence en 5 días', subscriptionExpiringEmail(data));
|
await sendEmail(to, 'Tu suscripción vence en 5 días', subscriptionExpiringEmail(data));
|
||||||
@@ -128,10 +139,14 @@ export const emailService = {
|
|||||||
* Notifica la subida de una declaración o documento extra al despacho.
|
* Notifica la subida de una declaración o documento extra al despacho.
|
||||||
* `recipients` debe venir deduplicado por el caller. El subject se
|
* `recipients` debe venir deduplicado por el caller. El subject se
|
||||||
* genera a partir del kind y RFC del contribuyente.
|
* genera a partir del kind y RFC del contribuyente.
|
||||||
|
*
|
||||||
|
* Para declaraciones, `attachments` puede contener los PDFs subidos
|
||||||
|
* (acuse + liga de pago) para enviarlos adjuntos al correo.
|
||||||
*/
|
*/
|
||||||
sendDocumentoSubido: async (
|
sendDocumentoSubido: async (
|
||||||
recipients: string[],
|
recipients: string[],
|
||||||
data: import('./templates/documento-subido.js').DocumentoSubidoData,
|
data: import('./templates/documento-subido.js').DocumentoSubidoData,
|
||||||
|
attachments?: EmailAttachment[],
|
||||||
) => {
|
) => {
|
||||||
if (recipients.length === 0) return;
|
if (recipients.length === 0) return;
|
||||||
const { documentoSubidoEmail } = await import('./templates/documento-subido.js');
|
const { documentoSubidoEmail } = await import('./templates/documento-subido.js');
|
||||||
@@ -143,7 +158,7 @@ export const emailService = {
|
|||||||
// destinatario NO debe impedir enviar al siguiente.
|
// destinatario NO debe impedir enviar al siguiente.
|
||||||
for (const to of recipients) {
|
for (const to of recipients) {
|
||||||
try {
|
try {
|
||||||
await sendEmail(to, subject, html);
|
await sendEmail(to, subject, html, attachments);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error(`[Email] Fallo enviando documento-subido a ${to}:`, err?.message || err);
|
console.error(`[Email] Fallo enviando documento-subido a ${to}:`, err?.message || err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { baseTemplate, heading, infoBox, primaryButton, BRAND_COLORS as C } from
|
|||||||
|
|
||||||
export interface DocumentoSubidoData {
|
export interface DocumentoSubidoData {
|
||||||
/** Kind: para el título/subject. */
|
/** Kind: para el título/subject. */
|
||||||
kind: 'declaracion' | 'extra';
|
kind: 'declaracion' | 'extra' | 'obligacion_evidencia';
|
||||||
/** Quién subió el documento (email). */
|
/** Quién subió el documento (email). */
|
||||||
subidoPor: string;
|
subidoPor: string;
|
||||||
/** RFC del contribuyente. */
|
/** RFC del contribuyente. */
|
||||||
@@ -24,25 +24,38 @@ export interface DocumentoSubidoData {
|
|||||||
descripcion?: string | null;
|
descripcion?: string | null;
|
||||||
categoria?: string | null;
|
categoria?: string | null;
|
||||||
};
|
};
|
||||||
|
/** Si es evidencia de obligación fiscal. */
|
||||||
|
evidencia?: {
|
||||||
|
obligacionNombre: string;
|
||||||
|
periodo: string;
|
||||||
|
tipoDocumento: string;
|
||||||
|
filename: string;
|
||||||
|
};
|
||||||
/** URL al sistema (ej. https://despachos.horuxfin.com/documentos). */
|
/** URL al sistema (ej. https://despachos.horuxfin.com/documentos). */
|
||||||
link: string;
|
link: string;
|
||||||
|
/** Solo para declaraciones: los adjuntos se omitieron por exceder el límite de tamaño. */
|
||||||
|
attachmentsOmitted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function documentoSubidoEmail(data: DocumentoSubidoData): string {
|
export function documentoSubidoEmail(data: DocumentoSubidoData): string {
|
||||||
const titulo = data.kind === 'declaracion'
|
const titulo = data.kind === 'declaracion'
|
||||||
? 'Nueva declaración subida'
|
? 'Nueva declaración subida'
|
||||||
: 'Nuevo documento subido';
|
: data.kind === 'obligacion_evidencia'
|
||||||
|
? 'Nueva evidencia de obligación fiscal'
|
||||||
|
: 'Nuevo documento subido';
|
||||||
|
|
||||||
const contenidoEspecifico = data.kind === 'declaracion' && data.declaracion
|
const contenidoEspecifico = data.kind === 'declaracion' && data.declaracion
|
||||||
? declaracionBlock(data.declaracion)
|
? declaracionBlock(data.declaracion)
|
||||||
: data.extra
|
: data.kind === 'obligacion_evidencia' && data.evidencia
|
||||||
? extraBlock(data.extra)
|
? evidenciaBlock(data.evidencia)
|
||||||
: '';
|
: data.extra
|
||||||
|
? extraBlock(data.extra)
|
||||||
|
: '';
|
||||||
|
|
||||||
return baseTemplate(`
|
return baseTemplate(`
|
||||||
${heading(titulo)}
|
${heading(titulo)}
|
||||||
<p style="color:${C.textPrimary};margin:0 0 16px;">
|
<p style="color:${C.textPrimary};margin:0 0 16px;">
|
||||||
<strong>${escapeHtml(data.subidoPor)}</strong> subió un ${data.kind === 'declaracion' ? 'acuse de declaración' : 'documento'}
|
<strong>${escapeHtml(data.subidoPor)}</strong> subió ${data.kind === 'obligacion_evidencia' ? 'una evidencia de obligación fiscal' : data.kind === 'declaracion' ? 'un acuse de declaración' : 'un documento'}
|
||||||
para <strong>${escapeHtml(data.contribuyenteNombre)}</strong>.
|
para <strong>${escapeHtml(data.contribuyenteNombre)}</strong>.
|
||||||
</p>
|
</p>
|
||||||
${infoBox(`
|
${infoBox(`
|
||||||
@@ -57,6 +70,12 @@ export function documentoSubidoEmail(data: DocumentoSubidoData): string {
|
|||||||
<div style="margin-top:24px;">
|
<div style="margin-top:24px;">
|
||||||
${primaryButton('Ver en el sistema', data.link)}
|
${primaryButton('Ver en el sistema', data.link)}
|
||||||
</div>
|
</div>
|
||||||
|
${data.kind === 'declaracion' && data.attachmentsOmitted ? `
|
||||||
|
<p style="color:${C.textMuted};font-size:13px;margin-top:16px;">
|
||||||
|
Los documentos no se adjuntaron porque exceden el tamaño permitido por correo.
|
||||||
|
Puedes descargarlos desde el sistema.
|
||||||
|
</p>
|
||||||
|
` : ''}
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +95,19 @@ function declaracionBlock(d: NonNullable<DocumentoSubidoData['declaracion']>): s
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function evidenciaBlock(e: NonNullable<DocumentoSubidoData['evidencia']>): string {
|
||||||
|
return `
|
||||||
|
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Obligación</p>
|
||||||
|
<p style="margin:0 0 12px;color:${C.textPrimary};font-weight:600;">${escapeHtml(e.obligacionNombre)}</p>
|
||||||
|
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Periodo</p>
|
||||||
|
<p style="margin:0 0 12px;color:${C.textPrimary};">${escapeHtml(e.periodo)}</p>
|
||||||
|
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Tipo de documento</p>
|
||||||
|
<p style="margin:0 0 12px;color:${C.textPrimary};text-transform:capitalize;">${escapeHtml(e.tipoDocumento)}</p>
|
||||||
|
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Archivo</p>
|
||||||
|
<p style="margin:0 0 12px;color:${C.textPrimary};">${escapeHtml(e.filename)}</p>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
function extraBlock(e: NonNullable<DocumentoSubidoData['extra']>): string {
|
function extraBlock(e: NonNullable<DocumentoSubidoData['extra']>): string {
|
||||||
return `
|
return `
|
||||||
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Documento</p>
|
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Documento</p>
|
||||||
|
|||||||
63
apps/api/src/services/email/templates/sat-proxy-report.ts
Normal file
63
apps/api/src/services/email/templates/sat-proxy-report.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { baseTemplate, heading, infoBox, BRAND_COLORS as C } from './base.js';
|
||||||
|
|
||||||
|
export interface ProxyErrorRow {
|
||||||
|
proxy: string;
|
||||||
|
errorCode: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SatProxyReportData {
|
||||||
|
generatedAt: string;
|
||||||
|
recipient: string;
|
||||||
|
dateFrom: string;
|
||||||
|
dateTo: string;
|
||||||
|
totalErrors: number;
|
||||||
|
byProxy: ProxyErrorRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function tableHeader(cells: string[]): string {
|
||||||
|
return `<tr>
|
||||||
|
${cells.map(c => `<th align="left" style="padding:8px 12px;background-color:${C.bgLight};color:${C.textMuted};font-size:12px;font-weight:500;text-transform:uppercase;letter-spacing:0.04em;border-bottom:1px solid ${C.border};">${c}</th>`).join('')}
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tableRow(cells: string[]): string {
|
||||||
|
return `<tr>
|
||||||
|
${cells.map(c => `<td style="padding:10px 12px;border-bottom:1px solid ${C.border};color:${C.textPrimary};font-size:13px;vertical-align:top;">${c}</td>`).join('')}
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function satProxyReportEmail(data: SatProxyReportData): string {
|
||||||
|
const rowsHtml = data.byProxy.length > 0
|
||||||
|
? data.byProxy.map(r => tableRow([
|
||||||
|
r.proxy ? `<code style="font-size:12px;">${r.proxy}</code>` : '<span style="color:#dc2626;">IP directa del servidor</span>',
|
||||||
|
r.errorCode || '—',
|
||||||
|
`<strong>${r.count}</strong>`,
|
||||||
|
])).join('')
|
||||||
|
: tableRow(['—', '—', '<span style="color:#16a34a;">Sin errores de bloqueo</span>']);
|
||||||
|
|
||||||
|
return baseTemplate(`
|
||||||
|
${heading('📊 Reporte diario de errores SAT por proxy')}
|
||||||
|
<p style="color:${C.textPrimary};margin:0 0 16px;">
|
||||||
|
Resumen de errores de bloqueo/devolución del SAT en las últimas 24 horas,
|
||||||
|
agrupados por proxy/IP usada.
|
||||||
|
</p>
|
||||||
|
${infoBox(`
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||||
|
<tr><td style="padding:6px 0;color:${C.textMuted};">Período</td><td style="padding:6px 0;color:${C.textPrimary};font-weight:600;text-align:right;">${data.dateFrom} → ${data.dateTo}</td></tr>
|
||||||
|
<tr><td style="padding:6px 0;color:${C.textMuted};">Errores totales</td><td style="padding:6px 0;color:${data.totalErrors > 0 ? '#dc2626' : '#16a34a'};font-weight:600;text-align:right;">${data.totalErrors}</td></tr>
|
||||||
|
</table>
|
||||||
|
`)}
|
||||||
|
|
||||||
|
<h3 style="font-family:'Inter', sans-serif;font-weight:600;color:${C.textPrimary};margin:28px 0 12px;font-size:16px;">Errores por proxy (${data.byProxy.length})</h3>
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
|
||||||
|
<thead>${tableHeader(['Proxy / IP', 'Código', 'Cantidad'])}</thead>
|
||||||
|
<tbody>${rowsHtml}</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="color:${C.textMuted};margin:24px 0 0;font-size:12px;">
|
||||||
|
Reporte generado el ${data.generatedAt} para ${data.recipient}.<br/>
|
||||||
|
Configura la lista de proxies con SAT_PROXY_LIST y la concurrencia con SAT_CONCURRENT_CONTRIBUYENTES.
|
||||||
|
</p>
|
||||||
|
`);
|
||||||
|
}
|
||||||
194
apps/api/src/services/email/templates/sat-sync-alert.ts
Normal file
194
apps/api/src/services/email/templates/sat-sync-alert.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import { baseTemplate, heading, infoBox, BRAND_COLORS as C } from './base.js';
|
||||||
|
|
||||||
|
export interface SatSyncAlertData {
|
||||||
|
generatedAt: string;
|
||||||
|
recipient: string;
|
||||||
|
summary: {
|
||||||
|
failed: number;
|
||||||
|
stale: number;
|
||||||
|
stuckRunning: number;
|
||||||
|
pendingOld: number;
|
||||||
|
missingInitial: number;
|
||||||
|
};
|
||||||
|
failed: Array<{
|
||||||
|
tenantName: string;
|
||||||
|
tenantRfc: string;
|
||||||
|
contribuyenteName?: string | null;
|
||||||
|
contribuyenteRfc?: string | null;
|
||||||
|
type: string;
|
||||||
|
errorMessage?: string | null;
|
||||||
|
completedAt?: Date | string | null;
|
||||||
|
}>;
|
||||||
|
stale: Array<{
|
||||||
|
id: string;
|
||||||
|
tenantName: string;
|
||||||
|
tenantRfc: string;
|
||||||
|
contribuyenteName?: string | null;
|
||||||
|
contribuyenteRfc?: string | null;
|
||||||
|
type: string;
|
||||||
|
kind: 'pending-stale' | 'running-stale';
|
||||||
|
ageHours: number;
|
||||||
|
}>;
|
||||||
|
stuckRunning: Array<{
|
||||||
|
id: string;
|
||||||
|
tenantName: string;
|
||||||
|
tenantRfc: string;
|
||||||
|
contribuyenteName?: string | null;
|
||||||
|
contribuyenteRfc?: string | null;
|
||||||
|
type: string;
|
||||||
|
progressPercent: number;
|
||||||
|
startedAt?: Date | string | null;
|
||||||
|
hoursRunning: number;
|
||||||
|
}>;
|
||||||
|
pendingOld: Array<{
|
||||||
|
id: string;
|
||||||
|
tenantName: string;
|
||||||
|
tenantRfc: string;
|
||||||
|
contribuyenteName?: string | null;
|
||||||
|
contribuyenteRfc?: string | null;
|
||||||
|
type: string;
|
||||||
|
createdAt?: Date | string | null;
|
||||||
|
nextRetryAt?: Date | string | null;
|
||||||
|
hoursPending: number;
|
||||||
|
}>;
|
||||||
|
missingInitial: Array<{
|
||||||
|
tenantName: string;
|
||||||
|
tenantRfc: string;
|
||||||
|
contribuyenteName: string;
|
||||||
|
contribuyenteRfc: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(value?: Date | string | null): string {
|
||||||
|
if (!value) return 'N/A';
|
||||||
|
const d = typeof value === 'string' ? new Date(value) : value;
|
||||||
|
return d.toLocaleString('es-MX', { timeZone: 'America/Mexico_City' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function tableHeader(cells: string[]): string {
|
||||||
|
return `<tr>
|
||||||
|
${cells.map(c => `<th align="left" style="padding:8px 12px;background-color:${C.bgLight};color:${C.textMuted};font-size:12px;font-weight:500;text-transform:uppercase;letter-spacing:0.04em;border-bottom:1px solid ${C.border};">${c}</th>`).join('')}
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tableRow(cells: string[]): string {
|
||||||
|
return `<tr>
|
||||||
|
${cells.map(c => `<td style="padding:10px 12px;border-bottom:1px solid ${C.border};color:${C.textPrimary};font-size:13px;vertical-align:top;">${c}</td>`).join('')}
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function section(title: string, color: string, rowsHtml: string, headers: string[]): string {
|
||||||
|
return `
|
||||||
|
<h3 style="font-family:'Inter', sans-serif;font-weight:600;color:${color};margin:28px 0 12px;font-size:16px;">${title}</h3>
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
|
||||||
|
<thead>${tableHeader(headers)}</thead>
|
||||||
|
<tbody>${rowsHtml}</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function satSyncAlertEmail(data: SatSyncAlertData): string {
|
||||||
|
const { summary } = data;
|
||||||
|
|
||||||
|
const summaryRows = [
|
||||||
|
{ label: 'Jobs fallidos recientes', value: summary.failed, color: summary.failed > 0 ? '#dc2626' : C.textPrimary },
|
||||||
|
{ label: 'Jobs stale detectados', value: summary.stale, color: summary.stale > 0 ? '#dc2626' : C.textPrimary },
|
||||||
|
{ label: 'Running atorados sin progreso', value: summary.stuckRunning, color: summary.stuckRunning > 0 ? '#f59e0b' : C.textPrimary },
|
||||||
|
{ label: 'Pending sin atender', value: summary.pendingOld, color: summary.pendingOld > 0 ? '#f59e0b' : C.textPrimary },
|
||||||
|
{ label: 'Contribuyentes con FIEL sin sync inicial', value: summary.missingInitial, color: summary.missingInitial > 0 ? '#dc2626' : C.textPrimary },
|
||||||
|
]
|
||||||
|
.map(r => `<tr><td style="padding:6px 0;color:${C.textMuted};">${r.label}</td><td style="padding:6px 0;color:${r.color};font-weight:600;text-align:right;">${r.value}</td></tr>`)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
const failedHtml = data.failed.length > 0
|
||||||
|
? section(
|
||||||
|
`Jobs fallidos (${data.failed.length})`,
|
||||||
|
'#dc2626',
|
||||||
|
data.failed.map(j => tableRow([
|
||||||
|
`<strong>${j.tenantName}</strong><br/><span style="color:${C.textMuted};font-size:11px;">${j.tenantRfc}</span>`,
|
||||||
|
j.contribuyenteName ? `<strong>${j.contribuyenteName}</strong><br/><span style="color:${C.textMuted};font-size:11px;">${j.contribuyenteRfc || ''}</span>` : '—',
|
||||||
|
j.type,
|
||||||
|
`<span style="color:#dc2626;">${j.errorMessage || 'Sin mensaje'}</span>`,
|
||||||
|
fmtDate(j.completedAt),
|
||||||
|
])).join(''),
|
||||||
|
['Tenant', 'Contribuyente', 'Tipo', 'Error', 'Fecha fallo']
|
||||||
|
)
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const staleHtml = data.stale.length > 0
|
||||||
|
? section(
|
||||||
|
`Jobs stale detectados por el watchdog (${data.stale.length})`,
|
||||||
|
'#dc2626',
|
||||||
|
data.stale.map(j => tableRow([
|
||||||
|
`<strong>${j.tenantName}</strong><br/><span style="color:${C.textMuted};font-size:11px;">${j.tenantRfc}</span>`,
|
||||||
|
j.contribuyenteName ? `<strong>${j.contribuyenteName}</strong><br/><span style="color:${C.textMuted};font-size:11px;">${j.contribuyenteRfc || ''}</span>` : '—',
|
||||||
|
j.type,
|
||||||
|
j.kind === 'running-stale' ? 'Running abandonado' : 'Pending abandonado',
|
||||||
|
`${j.ageHours}h`,
|
||||||
|
])).join(''),
|
||||||
|
['Tenant', 'Contribuyente', 'Tipo', 'Problema', 'Antigüedad']
|
||||||
|
)
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const stuckHtml = data.stuckRunning.length > 0
|
||||||
|
? section(
|
||||||
|
`Running atorados sin avance (${data.stuckRunning.length})`,
|
||||||
|
'#f59e0b',
|
||||||
|
data.stuckRunning.map(j => tableRow([
|
||||||
|
`<strong>${j.tenantName}</strong><br/><span style="color:${C.textMuted};font-size:11px;">${j.tenantRfc}</span>`,
|
||||||
|
j.contribuyenteName ? `<strong>${j.contribuyenteName}</strong><br/><span style="color:${C.textMuted};font-size:11px;">${j.contribuyenteRfc || ''}</span>` : '—',
|
||||||
|
j.type,
|
||||||
|
`${j.progressPercent}%`,
|
||||||
|
`${j.hoursRunning}h`,
|
||||||
|
fmtDate(j.startedAt),
|
||||||
|
])).join(''),
|
||||||
|
['Tenant', 'Contribuyente', 'Tipo', 'Progreso', 'Tiempo', 'Inicio']
|
||||||
|
)
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const pendingHtml = data.pendingOld.length > 0
|
||||||
|
? section(
|
||||||
|
`Pending sin atender (${data.pendingOld.length})`,
|
||||||
|
'#f59e0b',
|
||||||
|
data.pendingOld.map(j => tableRow([
|
||||||
|
`<strong>${j.tenantName}</strong><br/><span style="color:${C.textMuted};font-size:11px;">${j.tenantRfc}</span>`,
|
||||||
|
j.contribuyenteName ? `<strong>${j.contribuyenteName}</strong><br/><span style="color:${C.textMuted};font-size:11px;">${j.contribuyenteRfc || ''}</span>` : '—',
|
||||||
|
j.type,
|
||||||
|
`${j.hoursPending}h`,
|
||||||
|
j.nextRetryAt ? fmtDate(j.nextRetryAt) : 'Sin reintento',
|
||||||
|
])).join(''),
|
||||||
|
['Tenant', 'Contribuyente', 'Tipo', 'Tiempo pendiente', 'Próximo reintento']
|
||||||
|
)
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const missingHtml = data.missingInitial.length > 0
|
||||||
|
? section(
|
||||||
|
`Contribuyentes con FIEL sin sync inicial (${data.missingInitial.length})`,
|
||||||
|
'#dc2626',
|
||||||
|
data.missingInitial.map(j => tableRow([
|
||||||
|
`<strong>${j.tenantName}</strong><br/><span style="color:${C.textMuted};font-size:11px;">${j.tenantRfc}</span>`,
|
||||||
|
`<strong>${j.contribuyenteName}</strong><br/><span style="color:${C.textMuted};font-size:11px;">${j.contribuyenteRfc}</span>`,
|
||||||
|
])).join(''),
|
||||||
|
['Tenant', 'Contribuyente']
|
||||||
|
)
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return baseTemplate(`
|
||||||
|
${heading('🚨 Alerta de sincronización SAT')}
|
||||||
|
<p style="color:${C.textPrimary};margin:0 0 16px;">
|
||||||
|
El monitoreo de sincronizaciones SAT detectó anomalías que requieren revisión interna.
|
||||||
|
</p>
|
||||||
|
${infoBox(`<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">${summaryRows}</table>`)}
|
||||||
|
|
||||||
|
${failedHtml}
|
||||||
|
${staleHtml}
|
||||||
|
${stuckHtml}
|
||||||
|
${pendingHtml}
|
||||||
|
${missingHtml}
|
||||||
|
|
||||||
|
<p style="color:${C.textMuted};margin:24px 0 0;font-size:12px;">
|
||||||
|
Reporte generado el ${data.generatedAt} para ${data.recipient}.<br/>
|
||||||
|
Configura umbrales con SAT_STUCK_RUNNING_HOURS y SAT_FAILED_LOOKBACK_HOURS.
|
||||||
|
</p>
|
||||||
|
`);
|
||||||
|
}
|
||||||
@@ -1,30 +1,49 @@
|
|||||||
import type { Pool } from 'pg';
|
import type { Pool } from 'pg';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tipos de correos informativos cuyo envío puede desactivarse por
|
* Tipos de correos informativos cuyo envío puede desactivarse por rol.
|
||||||
* contribuyente. NO incluye correos transaccionales críticos
|
* NO incluye correos transaccionales críticos (welcome, password-reset,
|
||||||
* (welcome, password-reset, payment-*) — esos siempre se envían.
|
* payment-*, invitaciones) — esos siempre se envían.
|
||||||
*
|
*
|
||||||
* Estado de implementación:
|
* Estado de implementación:
|
||||||
* - documento_subido: ✅ implementado (notify-upload.service.ts)
|
* - documento_subido: ✅ implementado (owner + supervisor del contribuyente)
|
||||||
* - weekly_update: ⏳ pendiente (job es tenant-wide hoy)
|
* - weekly_update: ✅ implementado (job tenant-wide, owners)
|
||||||
* - subscription_expiring: ⏳ pendiente (no es per-contribuyente hoy)
|
* - subscription_expiring: ✅ implementado (aviso a owner)
|
||||||
* - recordatorio_fiscal: ⏳ placeholder para futuras alertas
|
* - recordatorio_fiscal: ⏳ placeholder para futuras alertas
|
||||||
|
* - alertas_nuevas: ✅ implementado (supervisor + auxiliares + clientes)
|
||||||
|
* - recordatorio_proximo: ✅ implementado (auxiliar/supervisor/cliente/owner)
|
||||||
*/
|
*/
|
||||||
export const EMAIL_TYPES = [
|
export const EMAIL_TYPES = [
|
||||||
'documento_subido',
|
'documento_subido',
|
||||||
'weekly_update',
|
'weekly_update',
|
||||||
'subscription_expiring',
|
'subscription_expiring',
|
||||||
'recordatorio_fiscal',
|
'recordatorio_fiscal',
|
||||||
|
'alertas_nuevas',
|
||||||
|
'recordatorio_proximo',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type EmailType = (typeof EMAIL_TYPES)[number];
|
export type EmailType = (typeof EMAIL_TYPES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roles que pueden recibir notificaciones informativas. Se excluyen roles
|
||||||
|
* que hoy no son destinatarios de ninguna notificación (cfo, contador, visor).
|
||||||
|
*/
|
||||||
|
export const NOTIFICATION_ROLES = [
|
||||||
|
'owner',
|
||||||
|
'supervisor',
|
||||||
|
'auxiliar',
|
||||||
|
'cliente',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type NotificationRole = (typeof NOTIFICATION_ROLES)[number];
|
||||||
|
|
||||||
export type EmailPreferences = Record<EmailType, boolean>;
|
export type EmailPreferences = Record<EmailType, boolean>;
|
||||||
|
|
||||||
|
export type RoleEmailPreferences = Record<EmailType, Record<NotificationRole, boolean>>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default: todo activado. Si el JSONB en BD viene vacío o falta una
|
* Default legacy (por contribuyente). Se mantiene por compatibilidad con la
|
||||||
* key, asumimos `true` para preservar el comportamiento previo.
|
* columna `contribuyentes.email_preferences`; la UI nueva ya no lo usa.
|
||||||
*/
|
*/
|
||||||
function applyDefaults(raw: Partial<Record<string, unknown>>): EmailPreferences {
|
function applyDefaults(raw: Partial<Record<string, unknown>>): EmailPreferences {
|
||||||
const out = {} as EmailPreferences;
|
const out = {} as EmailPreferences;
|
||||||
@@ -38,10 +57,10 @@ function sanitizeUuid(id: string): string {
|
|||||||
return id.replace(/[^a-f0-9-]/gi, '');
|
return id.replace(/[^a-f0-9-]/gi, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
* Lee las preferencias de un contribuyente. Devuelve defaults (todo
|
// Preferencias por contribuyente (legacy — conservado por compatibilidad)
|
||||||
* activado) si no hay fila o la columna está vacía.
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
*/
|
|
||||||
export async function getContribuyenteEmailPreferences(
|
export async function getContribuyenteEmailPreferences(
|
||||||
pool: Pool,
|
pool: Pool,
|
||||||
contribuyenteId: string,
|
contribuyenteId: string,
|
||||||
@@ -55,11 +74,6 @@ export async function getContribuyenteEmailPreferences(
|
|||||||
return applyDefaults(raw);
|
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(
|
export async function setContribuyenteEmailPreferences(
|
||||||
pool: Pool,
|
pool: Pool,
|
||||||
contribuyenteId: string,
|
contribuyenteId: string,
|
||||||
@@ -81,10 +95,6 @@ export async function setContribuyenteEmailPreferences(
|
|||||||
return getContribuyenteEmailPreferences(pool, contribuyenteId);
|
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(
|
export async function getEmailPreferencesPorContribuyente(
|
||||||
pool: Pool,
|
pool: Pool,
|
||||||
): Promise<Array<{ contribuyenteId: string; rfc: string; nombre: string; preferences: EmailPreferences }>> {
|
): Promise<Array<{ contribuyenteId: string; rfc: string; nombre: string; preferences: EmailPreferences }>> {
|
||||||
@@ -108,3 +118,89 @@ export async function getEmailPreferencesPorContribuyente(
|
|||||||
preferences: applyDefaults(r.email_preferences ?? {}),
|
preferences: applyDefaults(r.email_preferences ?? {}),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Preferencias por rol (nuevo modelo)
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function applyRoleDefaults(raw: Array<{ email_type: string; role: string; enabled: boolean }>): RoleEmailPreferences {
|
||||||
|
const out = {} as RoleEmailPreferences;
|
||||||
|
for (const t of EMAIL_TYPES) {
|
||||||
|
out[t] = {} as Record<NotificationRole, boolean>;
|
||||||
|
for (const r of NOTIFICATION_ROLES) {
|
||||||
|
const row = raw.find(x => x.email_type === t && x.role === r);
|
||||||
|
out[t][r] = row ? row.enabled : true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lee las preferencias de notificación por rol. Si la tabla está vacía para
|
||||||
|
* un (type, role), asume `true` para no romper el comportamiento previo.
|
||||||
|
*/
|
||||||
|
export async function getRoleEmailPreferences(pool: Pool): Promise<RoleEmailPreferences> {
|
||||||
|
const { rows } = await pool.query<{ email_type: string; role: string; enabled: boolean }>(
|
||||||
|
`SELECT email_type, role, enabled FROM notification_role_preferences`
|
||||||
|
);
|
||||||
|
return applyRoleDefaults(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualiza una celda (emailType, role). Ignora valores desconocidos.
|
||||||
|
*/
|
||||||
|
export async function setRoleEmailPreference(
|
||||||
|
pool: Pool,
|
||||||
|
emailType: EmailType,
|
||||||
|
role: NotificationRole,
|
||||||
|
enabled: boolean,
|
||||||
|
): Promise<RoleEmailPreferences> {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO notification_role_preferences (email_type, role, enabled)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (email_type, role) DO UPDATE SET enabled = EXCLUDED.enabled, updated_at = NOW()`,
|
||||||
|
[emailType, role, enabled],
|
||||||
|
);
|
||||||
|
return getRoleEmailPreferences(pool);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Devuelve true si el rol tiene habilitado el tipo de notificación.
|
||||||
|
* Fallback a true si no hay fila (comportamiento seguro).
|
||||||
|
*/
|
||||||
|
export async function isRoleEnabled(
|
||||||
|
pool: Pool,
|
||||||
|
emailType: EmailType,
|
||||||
|
role: NotificationRole,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const { rows } = await pool.query<{ enabled: boolean }>(
|
||||||
|
`SELECT enabled FROM notification_role_preferences WHERE email_type = $1 AND role = $2`,
|
||||||
|
[emailType, role],
|
||||||
|
);
|
||||||
|
return rows[0]?.enabled ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecipientWithRole {
|
||||||
|
email: string;
|
||||||
|
role: NotificationRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filtra una lista de destinatarios con rol según las preferencias guardadas.
|
||||||
|
* Si no hay preferencias para un (type, role), se conserva el destinatario.
|
||||||
|
*/
|
||||||
|
export async function filterRecipientsByRole(
|
||||||
|
pool: Pool,
|
||||||
|
emailType: EmailType,
|
||||||
|
recipients: RecipientWithRole[],
|
||||||
|
): Promise<string[]> {
|
||||||
|
const prefs = await getRoleEmailPreferences(pool);
|
||||||
|
const typePrefs = prefs[emailType];
|
||||||
|
const filtered = recipients.filter(r => {
|
||||||
|
if (!typePrefs) return true;
|
||||||
|
return typePrefs[r.role] !== false;
|
||||||
|
});
|
||||||
|
return [...new Set(filtered.map(r => r.email))];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { RecipientWithRole };
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ import { generarAlertasAutomaticas, type AlertaAuto } from './alertas-auto.servi
|
|||||||
import { emailService } from './email/email.service.js';
|
import { emailService } from './email/email.service.js';
|
||||||
import type { AlertaItem } from './email/templates/alertas-nuevas.js';
|
import type { AlertaItem } from './email/templates/alertas-nuevas.js';
|
||||||
import type { VentanaRecordatorio } from './email/templates/recordatorio-proximo.js';
|
import type { VentanaRecordatorio } from './email/templates/recordatorio-proximo.js';
|
||||||
|
import {
|
||||||
|
filterRecipientsByRole,
|
||||||
|
type RecipientWithRole,
|
||||||
|
type EmailType,
|
||||||
|
type NotificationRole,
|
||||||
|
} from './notification-preferences.service.js';
|
||||||
|
|
||||||
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:3000';
|
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
@@ -100,39 +106,60 @@ async function getUserContacts(userIds: string[]): Promise<UserContact[]> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Destinatarios de una alerta: supervisor + auxiliares + clientes del
|
* Destinatarios de una alerta: supervisor + auxiliares + clientes del
|
||||||
* contribuyente. Si el owner del tenant es supervisor, ya queda incluido
|
* contribuyente. Retorna emails con su rol para poder filtrar por
|
||||||
* (no se duplica).
|
* preferencias de notificación.
|
||||||
*/
|
*/
|
||||||
async function recipientsForAlerta(
|
async function recipientsForAlerta(
|
||||||
pool: Pool,
|
pool: Pool,
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
contribuyenteId: string,
|
contribuyenteId: string,
|
||||||
): Promise<string[]> {
|
): Promise<RecipientWithRole[]> {
|
||||||
const ids = await getUserIdsContribuyente(pool, contribuyenteId);
|
const ids = await getUserIdsContribuyente(pool, contribuyenteId);
|
||||||
const userIds = new Set<string>();
|
const byRole = new Map<string, NotificationRole>();
|
||||||
if (ids.supervisor) userIds.add(ids.supervisor);
|
if (ids.supervisor) byRole.set(ids.supervisor, 'supervisor');
|
||||||
ids.auxiliares.forEach(id => userIds.add(id));
|
ids.auxiliares.forEach(id => byRole.set(id, 'auxiliar'));
|
||||||
ids.clientes.forEach(id => userIds.add(id));
|
ids.clientes.forEach(id => byRole.set(id, 'cliente'));
|
||||||
const contacts = await getUserContacts([...userIds]);
|
|
||||||
return [...new Set(contacts.map(c => c.email))];
|
const contacts = await getUserContacts([...byRole.keys()]);
|
||||||
|
return contacts
|
||||||
|
.filter(c => byRole.has(c.userId))
|
||||||
|
.map(c => ({ email: c.email, role: byRole.get(c.userId)! }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUserRole(
|
||||||
|
tenantId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<NotificationRole | null> {
|
||||||
|
const m = await prisma.tenantMembership.findFirst({
|
||||||
|
where: { userId, tenantId, active: true },
|
||||||
|
include: { rol: { select: { nombre: true } } },
|
||||||
|
});
|
||||||
|
if (!m) return null;
|
||||||
|
const role = m.rol.nombre;
|
||||||
|
if (role === 'owner' || role === 'supervisor' || role === 'auxiliar' || role === 'cliente') {
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Destinatarios de un recordatorio. Los recordatorios del despacho son
|
* Destinatarios de un recordatorio. Los recordatorios del despacho son
|
||||||
* tenant-level (no atados a contribuyente). Para públicos: clientes con
|
* tenant-level (no atados a contribuyente). Retorna emails con rol para
|
||||||
* algún acceso + auxiliares de cualquier cartera; si no hay auxiliares,
|
* filtrado por preferencias.
|
||||||
* supervisores; si owner aparece como supervisor, también recibe.
|
|
||||||
*
|
*
|
||||||
|
* Públicos: clientes + auxiliares + supervisores + owners.
|
||||||
* Privados: solo el creador.
|
* Privados: solo el creador.
|
||||||
*/
|
*/
|
||||||
async function recipientsForRecordatorio(
|
async function recipientsForRecordatorio(
|
||||||
pool: Pool,
|
pool: Pool,
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
recordatorio: { creadoPor: string; privado: boolean },
|
recordatorio: { creadoPor: string; privado: boolean },
|
||||||
): Promise<string[]> {
|
): Promise<RecipientWithRole[]> {
|
||||||
if (recordatorio.privado) {
|
if (recordatorio.privado) {
|
||||||
|
const role = await getUserRole(tenantId, recordatorio.creadoPor);
|
||||||
|
if (!role) return [];
|
||||||
const contacts = await getUserContacts([recordatorio.creadoPor]);
|
const contacts = await getUserContacts([recordatorio.creadoPor]);
|
||||||
return [...new Set(contacts.map(c => c.email))];
|
return contacts.map(c => ({ email: c.email, role }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recordatorio público: lee universos relevantes del tenant.
|
// Recordatorio público: lee universos relevantes del tenant.
|
||||||
@@ -158,27 +185,19 @@ async function recipientsForRecordatorio(
|
|||||||
), ARRAY[]::uuid[]) AS cliente_user_ids
|
), ARRAY[]::uuid[]) AS cliente_user_ids
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const auxiliares = r?.auxiliar_user_ids ?? [];
|
const byRole = new Map<string, NotificationRole>();
|
||||||
const supervisores = r?.supervisor_user_ids ?? [];
|
(r?.auxiliar_user_ids ?? []).forEach(id => byRole.set(id, 'auxiliar'));
|
||||||
const clientes = r?.cliente_user_ids ?? [];
|
(r?.supervisor_user_ids ?? []).forEach(id => byRole.set(id, 'supervisor'));
|
||||||
|
(r?.cliente_user_ids ?? []).forEach(id => byRole.set(id, 'cliente'));
|
||||||
|
|
||||||
|
// Owners siempre se consideran owner aunque también aparezcan como supervisor.
|
||||||
const owners = await getOwnerUserIds(tenantId);
|
const owners = await getOwnerUserIds(tenantId);
|
||||||
|
owners.forEach(id => byRole.set(id, 'owner'));
|
||||||
|
|
||||||
// Regla del owner: clientes y auxiliares siempre. Si no hay auxiliares,
|
const contacts = await getUserContacts([...byRole.keys()]);
|
||||||
// agregar supervisores. Si owner es supervisor y no hay auxiliares,
|
return contacts
|
||||||
// owner queda incluido vía la lista de supervisores.
|
.filter(c => byRole.has(c.userId))
|
||||||
const userIds = new Set<string>();
|
.map(c => ({ email: c.email, role: byRole.get(c.userId)! }));
|
||||||
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))];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
@@ -276,8 +295,10 @@ async function processAlertasContribuyente(
|
|||||||
return { nuevas: 0, resueltas };
|
return { nuevas: 0, resueltas };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Envía email batched a los responsables del contribuyente.
|
// Envía email batched a los responsables del contribuyente, filtrando por
|
||||||
const recipients = await recipientsForAlerta(pool, tenantId, contribuyente.entidadId);
|
// preferencias de rol para alertas_nuevas.
|
||||||
|
const recipientsWithRole = await recipientsForAlerta(pool, tenantId, contribuyente.entidadId);
|
||||||
|
const recipients = await filterRecipientsByRole(pool, 'alertas_nuevas', recipientsWithRole);
|
||||||
if (recipients.length === 0) {
|
if (recipients.length === 0) {
|
||||||
console.warn(`[Notifications] Sin destinatarios para alertas de ${contribuyente.rfc} (tenant ${tenant.rfc})`);
|
console.warn(`[Notifications] Sin destinatarios para alertas de ${contribuyente.rfc} (tenant ${tenant.rfc})`);
|
||||||
return { nuevas: nuevas.length, resueltas };
|
return { nuevas: nuevas.length, resueltas };
|
||||||
@@ -357,14 +378,16 @@ export async function processProximosRecordatorios(
|
|||||||
WHERE completado = false
|
WHERE completado = false
|
||||||
AND fecha_limite = (CURRENT_DATE + ${dias})::date
|
AND fecha_limite = (CURRENT_DATE + ${dias})::date
|
||||||
AND ${col} IS NULL
|
AND ${col} IS NULL
|
||||||
|
AND (serie_id IS NOT NULL OR recurrencia = 'unica')
|
||||||
`);
|
`);
|
||||||
|
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
try {
|
try {
|
||||||
const recipients = await recipientsForRecordatorio(pool, tenantId, {
|
const recipientsWithRole = await recipientsForRecordatorio(pool, tenantId, {
|
||||||
creadoPor: r.creado_por,
|
creadoPor: r.creado_por,
|
||||||
privado: r.privado,
|
privado: r.privado,
|
||||||
});
|
});
|
||||||
|
const recipients = await filterRecipientsByRole(pool, 'recordatorio_proximo', recipientsWithRole);
|
||||||
if (recipients.length === 0) {
|
if (recipients.length === 0) {
|
||||||
console.warn(`[Notifications] Recordatorio ${r.id} (${tenant.rfc}) sin destinatarios — skip ${ventana}`);
|
console.warn(`[Notifications] Recordatorio ${r.id} (${tenant.rfc}) sin destinatarios — skip ${ventana}`);
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -3,8 +3,12 @@ import { prisma } from '../config/database.js';
|
|||||||
import { emailService } from './email/email.service.js';
|
import { emailService } from './email/email.service.js';
|
||||||
import { getTenantOwnerEmails, getUserEmailById } from '../utils/memberships.js';
|
import { getTenantOwnerEmails, getUserEmailById } from '../utils/memberships.js';
|
||||||
import { env } from '../config/env.js';
|
import { env } from '../config/env.js';
|
||||||
import { getContribuyenteEmailPreferences } from './notification-preferences.service.js';
|
import { filterRecipientsByRole, type RecipientWithRole } from './notification-preferences.service.js';
|
||||||
import type { DocumentoSubidoData } from './email/templates/documento-subido.js';
|
import type { DocumentoSubidoData } from './email/templates/documento-subido.js';
|
||||||
|
import type { EmailAttachment } from '@horux/core';
|
||||||
|
|
||||||
|
/** Límite total de adjuntos para evitar rechazos por SMTP (20 MB). */
|
||||||
|
const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Notifica a los destinatarios relevantes cuando se sube una declaración
|
* Notifica a los destinatarios relevantes cuando se sube una declaración
|
||||||
@@ -26,7 +30,11 @@ export async function notifyDocumentoSubido(params: {
|
|||||||
subidoPor: string;
|
subidoPor: string;
|
||||||
kind: DocumentoSubidoData['kind'];
|
kind: DocumentoSubidoData['kind'];
|
||||||
declaracion?: DocumentoSubidoData['declaracion'];
|
declaracion?: DocumentoSubidoData['declaracion'];
|
||||||
|
declaracionId?: number;
|
||||||
extra?: DocumentoSubidoData['extra'];
|
extra?: DocumentoSubidoData['extra'];
|
||||||
|
evidencia?: DocumentoSubidoData['evidencia'];
|
||||||
|
/** PDF en base64 para adjuntar en notificaciones de evidencia de obligación. */
|
||||||
|
pdfBase64?: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const { pool, tenantId, contribuyenteId, subidoPor } = params;
|
const { pool, tenantId, contribuyenteId, subidoPor } = params;
|
||||||
|
|
||||||
@@ -34,10 +42,7 @@ export async function notifyDocumentoSubido(params: {
|
|||||||
// subject informativo ni supervisor — skip.
|
// subject informativo ni supervisor — skip.
|
||||||
if (!contribuyenteId) return;
|
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<{
|
const { rows } = await pool.query<{
|
||||||
rfc: string;
|
rfc: string;
|
||||||
@@ -54,14 +59,17 @@ export async function notifyDocumentoSubido(params: {
|
|||||||
const contrib = rows[0];
|
const contrib = rows[0];
|
||||||
|
|
||||||
// 2. Recipients. Owners primero; luego supervisor si aplica.
|
// 2. Recipients. Owners primero; luego supervisor si aplica.
|
||||||
const owners = await getTenantOwnerEmails(tenantId);
|
const ownerEmails = await getTenantOwnerEmails(tenantId);
|
||||||
const recipients = new Set<string>(owners);
|
const recipientsWithRole: RecipientWithRole[] = ownerEmails.map(email => ({ email, role: 'owner' }));
|
||||||
|
|
||||||
if (contrib.supervisor_user_id) {
|
if (contrib.supervisor_user_id) {
|
||||||
const supervisorEmail = await getUserEmailById(contrib.supervisor_user_id);
|
const supervisorEmail = await getUserEmailById(contrib.supervisor_user_id);
|
||||||
if (supervisorEmail) recipients.add(supervisorEmail);
|
if (supervisorEmail) recipientsWithRole.push({ email: supervisorEmail, role: 'supervisor' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filtra por preferencias de rol para documento_subido.
|
||||||
|
const recipients = new Set(await filterRecipientsByRole(pool, 'documento_subido', recipientsWithRole));
|
||||||
|
|
||||||
// Excluir al uploader: no notificarle su propia acción.
|
// Excluir al uploader: no notificarle su propia acción.
|
||||||
recipients.delete(subidoPor.toLowerCase());
|
recipients.delete(subidoPor.toLowerCase());
|
||||||
recipients.delete(subidoPor);
|
recipients.delete(subidoPor);
|
||||||
@@ -77,6 +85,23 @@ export async function notifyDocumentoSubido(params: {
|
|||||||
// 4. Link al sistema. Usa FRONTEND_URL del env.
|
// 4. Link al sistema. Usa FRONTEND_URL del env.
|
||||||
const link = `${env.FRONTEND_URL}/documentos`;
|
const link = `${env.FRONTEND_URL}/documentos`;
|
||||||
|
|
||||||
|
// Adjuntar los PDFs cuando se trata de una declaración recién creada o de una evidencia de obligación.
|
||||||
|
let attachments: EmailAttachment[] | undefined;
|
||||||
|
let attachmentsOmitted = false;
|
||||||
|
if (params.kind === 'declaracion' && params.declaracionId) {
|
||||||
|
const built = await buildDeclaracionAttachments(pool, params.declaracionId);
|
||||||
|
attachments = built.attachments;
|
||||||
|
attachmentsOmitted = built.omitted;
|
||||||
|
} else if (params.kind === 'obligacion_evidencia' && params.pdfBase64 && params.evidencia) {
|
||||||
|
const content = Buffer.from(params.pdfBase64, 'base64');
|
||||||
|
if (content.length > MAX_ATTACHMENT_BYTES) {
|
||||||
|
attachmentsOmitted = true;
|
||||||
|
console.warn(`[notifyDocumentoSubido] Evidencia de obligación excede ${MAX_ATTACHMENT_BYTES} bytes (${content.length}). Se envía sin adjunto.`);
|
||||||
|
} else {
|
||||||
|
attachments = [{ filename: params.evidencia.filename, content }];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await emailService.sendDocumentoSubido(Array.from(recipients), {
|
await emailService.sendDocumentoSubido(Array.from(recipients), {
|
||||||
kind: params.kind,
|
kind: params.kind,
|
||||||
subidoPor,
|
subidoPor,
|
||||||
@@ -85,6 +110,46 @@ export async function notifyDocumentoSubido(params: {
|
|||||||
despachoNombre: tenant?.nombre,
|
despachoNombre: tenant?.nombre,
|
||||||
declaracion: params.declaracion,
|
declaracion: params.declaracion,
|
||||||
extra: params.extra,
|
extra: params.extra,
|
||||||
|
evidencia: params.evidencia,
|
||||||
link,
|
link,
|
||||||
});
|
attachmentsOmitted,
|
||||||
|
}, attachments);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildDeclaracionAttachments(
|
||||||
|
pool: Pool,
|
||||||
|
declaracionId: number,
|
||||||
|
): Promise<{ attachments?: EmailAttachment[]; omitted: boolean }> {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT pdf_declaracion, pdf_filename,
|
||||||
|
pdf_liga_pago, pdf_liga_pago_filename
|
||||||
|
FROM declaraciones_provisionales
|
||||||
|
WHERE id = $1`,
|
||||||
|
[declaracionId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const row = rows[0];
|
||||||
|
if (!row) return { omitted: false };
|
||||||
|
|
||||||
|
let totalSize = 0;
|
||||||
|
const attachments: EmailAttachment[] = [];
|
||||||
|
|
||||||
|
if (row.pdf_declaracion && row.pdf_filename) {
|
||||||
|
const content = Buffer.from(row.pdf_declaracion);
|
||||||
|
totalSize += content.length;
|
||||||
|
attachments.push({ filename: row.pdf_filename, content });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.pdf_liga_pago && row.pdf_liga_pago_filename) {
|
||||||
|
const content = Buffer.from(row.pdf_liga_pago);
|
||||||
|
totalSize += content.length;
|
||||||
|
attachments.push({ filename: row.pdf_liga_pago_filename, content });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalSize > MAX_ATTACHMENT_BYTES) {
|
||||||
|
console.warn(`[notifyDocumentoSubido] Adjuntos de declaración ${declaracionId} exceden ${MAX_ATTACHMENT_BYTES} bytes (${totalSize}). Se envía sin adjuntos.`);
|
||||||
|
return { omitted: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { attachments, omitted: false };
|
||||||
}
|
}
|
||||||
|
|||||||
272
apps/api/src/services/obligacion-evidencias.service.ts
Normal file
272
apps/api/src/services/obligacion-evidencias.service.ts
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
import type { Pool } from 'pg';
|
||||||
|
import { OBLIGACIONES_CATALOGO } from '../constants/obligaciones-fiscales.js';
|
||||||
|
|
||||||
|
export interface EvidenciaRow {
|
||||||
|
id: number;
|
||||||
|
obligacionId: string;
|
||||||
|
periodo: string;
|
||||||
|
contribuyenteId: string;
|
||||||
|
tipoDocumento: 'declaracion' | 'pago' | 'acuse' | 'complemento';
|
||||||
|
archivo: Buffer;
|
||||||
|
archivoFilename: string;
|
||||||
|
archivoMime: string;
|
||||||
|
notas: string | null;
|
||||||
|
subidoPor: string | null;
|
||||||
|
subidoPorEmail: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateEvidenciaInput {
|
||||||
|
obligacionId: string;
|
||||||
|
periodo: string;
|
||||||
|
contribuyenteId: string;
|
||||||
|
tipoDocumento: 'declaracion' | 'pago' | 'acuse' | 'complemento';
|
||||||
|
pdfBase64: string;
|
||||||
|
pdfFilename: string;
|
||||||
|
notas?: string;
|
||||||
|
subidoPor: string; // userId UUID
|
||||||
|
subidoPorEmail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowToEvidencia(r: any): EvidenciaRow {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
obligacionId: r.obligacion_id,
|
||||||
|
periodo: r.periodo,
|
||||||
|
contribuyenteId: r.contribuyente_id,
|
||||||
|
tipoDocumento: r.tipo_documento,
|
||||||
|
archivo: Buffer.from(r.archivo),
|
||||||
|
archivoFilename: r.archivo_filename,
|
||||||
|
archivoMime: r.archivo_mime,
|
||||||
|
notas: r.notas,
|
||||||
|
subidoPor: r.subido_por,
|
||||||
|
subidoPorEmail: r.subido_por_email,
|
||||||
|
createdAt: r.created_at.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getObligacionContribuyente(pool: Pool, obligacionId: string): Promise<{ contribuyenteId: string; catalogoId: string | null } | null> {
|
||||||
|
const { rows } = await pool.query<{ contribuyente_id: string; catalogo_id: string | null }>(
|
||||||
|
`SELECT contribuyente_id, catalogo_id FROM obligaciones_contribuyente WHERE id = $1`,
|
||||||
|
[obligacionId],
|
||||||
|
);
|
||||||
|
const row = rows[0];
|
||||||
|
if (!row) return null;
|
||||||
|
return { contribuyenteId: row.contribuyente_id, catalogoId: row.catalogo_id };
|
||||||
|
}
|
||||||
|
|
||||||
|
function requierePago(obligacion: { catalogoId: string | null }): boolean {
|
||||||
|
if (!obligacion.catalogoId) return true; // conservador: sin catálogo, requiere pago
|
||||||
|
const catalogo = OBLIGACIONES_CATALOGO.find((o) => o.id === obligacion.catalogoId);
|
||||||
|
return catalogo?.requierePago ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function esDocumentoDeclaracion(tipo: string): boolean {
|
||||||
|
return tipo === 'declaracion' || tipo === 'acuse' || tipo === 'complemento';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updatePeriodoStatus(
|
||||||
|
pool: Pool,
|
||||||
|
obligacionId: string,
|
||||||
|
periodo: string,
|
||||||
|
tipoDocumento: string,
|
||||||
|
reqPago: boolean,
|
||||||
|
completadaPor: string,
|
||||||
|
notas?: string,
|
||||||
|
): Promise<{ completada: boolean; declaracionPresentada: boolean; pagoPresentado: boolean }> {
|
||||||
|
const { rows } = await pool.query<{
|
||||||
|
declaracion_presentada: boolean;
|
||||||
|
pago_presentado: boolean;
|
||||||
|
completada: boolean;
|
||||||
|
}>(
|
||||||
|
`SELECT declaracion_presentada, pago_presentado, completada
|
||||||
|
FROM obligacion_periodos
|
||||||
|
WHERE obligacion_id = $1 AND periodo = $2`,
|
||||||
|
[obligacionId, periodo],
|
||||||
|
);
|
||||||
|
|
||||||
|
const existing = rows[0];
|
||||||
|
let declaracionPresentada = existing?.declaracion_presentada ?? false;
|
||||||
|
let pagoPresentado = existing?.pago_presentado ?? false;
|
||||||
|
|
||||||
|
if (esDocumentoDeclaracion(tipoDocumento)) declaracionPresentada = true;
|
||||||
|
if (tipoDocumento === 'pago') pagoPresentado = true;
|
||||||
|
|
||||||
|
const completada = !reqPago || pagoPresentado;
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE obligacion_periodos
|
||||||
|
SET declaracion_presentada = $3,
|
||||||
|
pago_presentado = $4,
|
||||||
|
completada = $5,
|
||||||
|
completada_at = CASE WHEN $5 THEN COALESCE(completada_at, $6) ELSE completada_at END,
|
||||||
|
completada_por = CASE WHEN $5 THEN COALESCE(completada_por, $7) ELSE completada_por END,
|
||||||
|
notas = COALESCE($8, notas)
|
||||||
|
WHERE obligacion_id = $1 AND periodo = $2`,
|
||||||
|
[obligacionId, periodo, declaracionPresentada, pagoPresentado, completada, now, completadaPor, notas ?? null],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO obligacion_periodos
|
||||||
|
(obligacion_id, periodo, declaracion_presentada, pago_presentado, completada, completada_at, completada_por, notas)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||||
|
[obligacionId, periodo, declaracionPresentada, pagoPresentado, completada, completada ? now : null, completada ? completadaPor : null, notas ?? null],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (completada) {
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE alertas SET resuelta = true WHERE tipo = $1 AND resuelta = false`,
|
||||||
|
[`ob-${obligacionId}-${periodo}`],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { completada, declaracionPresentada, pagoPresentado };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recalcPeriodoStatus(
|
||||||
|
pool: Pool,
|
||||||
|
obligacionId: string,
|
||||||
|
periodo: string,
|
||||||
|
reqPago: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
const { rows } = await pool.query<{ tipo_documento: string }>(
|
||||||
|
`SELECT tipo_documento FROM obligacion_evidencias WHERE obligacion_id = $1 AND periodo = $2`,
|
||||||
|
[obligacionId, periodo],
|
||||||
|
);
|
||||||
|
|
||||||
|
const declaracionPresentada = rows.some((r) => esDocumentoDeclaracion(r.tipo_documento));
|
||||||
|
const pagoPresentado = rows.some((r) => r.tipo_documento === 'pago');
|
||||||
|
const completada = !reqPago || pagoPresentado;
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE obligacion_periodos
|
||||||
|
SET declaracion_presentada = $3,
|
||||||
|
pago_presentado = $4,
|
||||||
|
completada = $5,
|
||||||
|
completada_at = CASE WHEN $5 THEN COALESCE(completada_at, NOW()) ELSE completada_at END
|
||||||
|
WHERE obligacion_id = $1 AND periodo = $2`,
|
||||||
|
[obligacionId, periodo, declaracionPresentada, pagoPresentado, completada],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createEvidencia(
|
||||||
|
pool: Pool,
|
||||||
|
data: CreateEvidenciaInput,
|
||||||
|
): Promise<{ evidencia: EvidenciaRow; completada: boolean; declaracionPresentada: boolean; pagoPresentado: boolean }> {
|
||||||
|
const obligacion = await getObligacionContribuyente(pool, data.obligacionId);
|
||||||
|
if (!obligacion) throw new Error('Obligación no encontrada');
|
||||||
|
if (obligacion.contribuyenteId !== data.contribuyenteId) throw new Error('La obligación no pertenece al contribuyente');
|
||||||
|
|
||||||
|
const reqPago = requierePago(obligacion);
|
||||||
|
const archivo = Buffer.from(data.pdfBase64, 'base64');
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`INSERT INTO obligacion_evidencias
|
||||||
|
(obligacion_id, periodo, contribuyente_id, tipo_documento, archivo, archivo_filename, archivo_mime, notas, subido_por, subido_por_email)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
|
RETURNING id, obligacion_id, periodo, contribuyente_id, tipo_documento, archivo, archivo_filename, archivo_mime,
|
||||||
|
notas, subido_por, subido_por_email, created_at`,
|
||||||
|
[data.obligacionId, data.periodo, data.contribuyenteId, data.tipoDocumento, archivo, data.pdfFilename, 'application/pdf', data.notas ?? null, data.subidoPor, data.subidoPorEmail],
|
||||||
|
);
|
||||||
|
|
||||||
|
const status = await updatePeriodoStatus(
|
||||||
|
pool,
|
||||||
|
data.obligacionId,
|
||||||
|
data.periodo,
|
||||||
|
data.tipoDocumento,
|
||||||
|
reqPago,
|
||||||
|
data.subidoPor,
|
||||||
|
data.notas,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { evidencia: rowToEvidencia(rows[0]), ...status };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listEvidencias(
|
||||||
|
pool: Pool,
|
||||||
|
contribuyenteId: string,
|
||||||
|
filters?: { periodo?: string; obligacionId?: string },
|
||||||
|
): Promise<EvidenciaRow[]> {
|
||||||
|
const conditions: string[] = ['contribuyente_id = $1'];
|
||||||
|
const params: unknown[] = [contribuyenteId];
|
||||||
|
|
||||||
|
if (filters?.periodo) {
|
||||||
|
params.push(filters.periodo);
|
||||||
|
conditions.push(`periodo = $${params.length}`);
|
||||||
|
}
|
||||||
|
if (filters?.obligacionId) {
|
||||||
|
params.push(filters.obligacionId);
|
||||||
|
conditions.push(`obligacion_id = $${params.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT id, obligacion_id, periodo, contribuyente_id, tipo_documento, archivo, archivo_filename, archivo_mime,
|
||||||
|
notas, subido_por, subido_por_email, created_at
|
||||||
|
FROM obligacion_evidencias
|
||||||
|
WHERE ${conditions.join(' AND ')}
|
||||||
|
ORDER BY created_at DESC`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
return rows.map(rowToEvidencia);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEvidenciaPdf(
|
||||||
|
pool: Pool,
|
||||||
|
id: number,
|
||||||
|
): Promise<{ buffer: Buffer; filename: string; mime: string } | null> {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT archivo, archivo_filename, archivo_mime FROM obligacion_evidencias WHERE id = $1`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
if (rows.length === 0 || !rows[0].archivo) return null;
|
||||||
|
return {
|
||||||
|
buffer: Buffer.from(rows[0].archivo),
|
||||||
|
filename: rows[0].archivo_filename || `evidencia-${id}.pdf`,
|
||||||
|
mime: rows[0].archivo_mime || 'application/pdf',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteEvidencia(
|
||||||
|
pool: Pool,
|
||||||
|
id: number,
|
||||||
|
): Promise<{ obligacionId: string; periodo: string } | null> {
|
||||||
|
const { rows } = await pool.query<{ obligacion_id: string; periodo: string }>(
|
||||||
|
`DELETE FROM obligacion_evidencias WHERE id = $1 RETURNING obligacion_id, periodo`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
|
||||||
|
const { obligacion_id: obligacionId, periodo } = rows[0];
|
||||||
|
const obligacion = await getObligacionContribuyente(pool, obligacionId);
|
||||||
|
if (obligacion) {
|
||||||
|
const reqPago = requierePago(obligacion);
|
||||||
|
await recalcPeriodoStatus(pool, obligacionId, periodo, reqPago);
|
||||||
|
}
|
||||||
|
return { obligacionId, periodo };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPeriodoStatus(
|
||||||
|
pool: Pool,
|
||||||
|
obligacionId: string,
|
||||||
|
periodo: string,
|
||||||
|
): Promise<{ completada: boolean; declaracionPresentada: boolean; pagoPresentado: boolean } | null> {
|
||||||
|
const { rows } = await pool.query<{
|
||||||
|
completada: boolean;
|
||||||
|
declaracion_presentada: boolean;
|
||||||
|
pago_presentado: boolean;
|
||||||
|
}>(
|
||||||
|
`SELECT completada, declaracion_presentada, pago_presentado
|
||||||
|
FROM obligacion_periodos
|
||||||
|
WHERE obligacion_id = $1 AND periodo = $2`,
|
||||||
|
[obligacionId, periodo],
|
||||||
|
);
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
return {
|
||||||
|
completada: rows[0].completada,
|
||||||
|
declaracionPresentada: rows[0].declaracion_presentada,
|
||||||
|
pagoPresentado: rows[0].pago_presentado,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import type { Pool } from 'pg';
|
import type { Pool } from 'pg';
|
||||||
import { OBLIGACIONES_CATALOGO, getRecomendaciones, type ObligacionFiscal } from '../constants/obligaciones-fiscales.js';
|
import { OBLIGACIONES_CATALOGO, getRecomendaciones, type ObligacionFiscal } from '../constants/obligaciones-fiscales.js';
|
||||||
|
|
||||||
|
function requierePagoPorCatalogo(catalogoId: string | null): boolean {
|
||||||
|
if (!catalogoId) return true;
|
||||||
|
return OBLIGACIONES_CATALOGO.find((o) => o.id === catalogoId)?.requierePago ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keyword-based matching: each catalog entry has discriminant keywords
|
* Keyword-based matching: each catalog entry has discriminant keywords
|
||||||
* that must ALL appear in the SAT description (normalized, lowercase, no accents).
|
* that must ALL appear in the SAT description (normalized, lowercase, no accents).
|
||||||
@@ -255,6 +260,7 @@ export async function initRecomendaciones(
|
|||||||
function inferirFrecuencia(vencimiento: string): string {
|
function inferirFrecuencia(vencimiento: string): string {
|
||||||
const lower = vencimiento.toLowerCase();
|
const lower = vencimiento.toLowerCase();
|
||||||
if (lower.includes('mensual') || lower.includes('mes')) return 'mensual';
|
if (lower.includes('mensual') || lower.includes('mes')) return 'mensual';
|
||||||
|
if (lower.includes('cuatrimest')) return 'cuatrimestral';
|
||||||
if (lower.includes('bimest')) return 'bimestral';
|
if (lower.includes('bimest')) return 'bimestral';
|
||||||
if (lower.includes('trimest')) return 'trimestral';
|
if (lower.includes('trimest')) return 'trimestral';
|
||||||
if (lower.includes('anual') || lower.includes('ejercicio') || lower.includes('tres meses siguientes')) return 'anual';
|
if (lower.includes('anual') || lower.includes('ejercicio') || lower.includes('tres meses siguientes')) return 'anual';
|
||||||
@@ -351,13 +357,22 @@ export async function getObligacionesPorPeriodo(
|
|||||||
|
|
||||||
const [year, month] = periodo.split('-').map(Number);
|
const [year, month] = periodo.split('-').map(Number);
|
||||||
const currentPeriodo = new Date().toISOString().substring(0, 7);
|
const currentPeriodo = new Date().toISOString().substring(0, 7);
|
||||||
const results: Array<ObligacionContribuyente & { periodStatus: string; periodoAplica: string; declaracion: DeclaracionLink | null }> = [];
|
const results: Array<ObligacionContribuyente & {
|
||||||
|
periodStatus: string;
|
||||||
|
periodoAplica: string;
|
||||||
|
declaracion: DeclaracionLink | null;
|
||||||
|
declaracionPresentada: boolean;
|
||||||
|
pagoPresentado: boolean;
|
||||||
|
requierePago: boolean;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
// Get all completion records + associated declaration info for this contribuyente
|
// Get all completion records + associated declaration info for this contribuyente
|
||||||
const { rows: completions } = await pool.query<{
|
const { rows: completions } = await pool.query<{
|
||||||
obligacion_id: string;
|
obligacion_id: string;
|
||||||
periodo: string;
|
periodo: string;
|
||||||
completada: boolean;
|
completada: boolean;
|
||||||
|
declaracion_presentada: boolean;
|
||||||
|
pago_presentado: boolean;
|
||||||
declaracion_id: number | null;
|
declaracion_id: number | null;
|
||||||
decl_año: number | null;
|
decl_año: number | null;
|
||||||
decl_mes: number | null;
|
decl_mes: number | null;
|
||||||
@@ -365,6 +380,7 @@ export async function getObligacionesPorPeriodo(
|
|||||||
decl_pdf_filename: string | null;
|
decl_pdf_filename: string | null;
|
||||||
}>(`
|
}>(`
|
||||||
SELECT op.obligacion_id, op.periodo, op.completada,
|
SELECT op.obligacion_id, op.periodo, op.completada,
|
||||||
|
op.declaracion_presentada, op.pago_presentado,
|
||||||
op.declaracion_id,
|
op.declaracion_id,
|
||||||
dp.año AS decl_año,
|
dp.año AS decl_año,
|
||||||
dp.mes AS decl_mes,
|
dp.mes AS decl_mes,
|
||||||
@@ -377,10 +393,14 @@ export async function getObligacionesPorPeriodo(
|
|||||||
`, [contribuyenteId]);
|
`, [contribuyenteId]);
|
||||||
|
|
||||||
const completionMap = new Map<string, boolean>();
|
const completionMap = new Map<string, boolean>();
|
||||||
|
const declaracionPresentadaMap = new Map<string, boolean>();
|
||||||
|
const pagoPresentadoMap = new Map<string, boolean>();
|
||||||
const declaracionMap = new Map<string, DeclaracionLink | null>();
|
const declaracionMap = new Map<string, DeclaracionLink | null>();
|
||||||
for (const c of completions) {
|
for (const c of completions) {
|
||||||
const key = `${c.obligacion_id}:${c.periodo}`;
|
const key = `${c.obligacion_id}:${c.periodo}`;
|
||||||
completionMap.set(key, c.completada);
|
completionMap.set(key, c.completada);
|
||||||
|
declaracionPresentadaMap.set(key, c.declaracion_presentada);
|
||||||
|
pagoPresentadoMap.set(key, c.pago_presentado);
|
||||||
if (c.declaracion_id && c.decl_año != null && c.decl_mes != null && c.decl_tipo) {
|
if (c.declaracion_id && c.decl_año != null && c.decl_mes != null && c.decl_tipo) {
|
||||||
declaracionMap.set(key, {
|
declaracionMap.set(key, {
|
||||||
id: c.declaracion_id,
|
id: c.declaracion_id,
|
||||||
@@ -407,6 +427,9 @@ export async function getObligacionesPorPeriodo(
|
|||||||
periodStatus: isCompleted ? 'completada' : 'pendiente',
|
periodStatus: isCompleted ? 'completada' : 'pendiente',
|
||||||
periodoAplica: periodo,
|
periodoAplica: periodo,
|
||||||
declaracion: declaracionMap.get(key) ?? null,
|
declaracion: declaracionMap.get(key) ?? null,
|
||||||
|
declaracionPresentada: declaracionPresentadaMap.get(key) === true,
|
||||||
|
pagoPresentado: pagoPresentadoMap.get(key) === true,
|
||||||
|
requierePago: requierePagoPorCatalogo(ob.catalogoId),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,6 +457,9 @@ export async function getObligacionesPorPeriodo(
|
|||||||
periodStatus: 'atrasada',
|
periodStatus: 'atrasada',
|
||||||
periodoAplica: pastPeriodo,
|
periodoAplica: pastPeriodo,
|
||||||
declaracion: null,
|
declaracion: null,
|
||||||
|
declaracionPresentada: declaracionPresentadaMap.get(pastKey) === true,
|
||||||
|
pagoPresentado: pagoPresentadoMap.get(pastKey) === true,
|
||||||
|
requierePago: requierePagoPorCatalogo(ob.catalogoId),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -448,7 +474,14 @@ export async function getObligacionesPorPeriodo(
|
|||||||
return a.nombre.localeCompare(b.nombre);
|
return a.nombre.localeCompare(b.nombre);
|
||||||
});
|
});
|
||||||
|
|
||||||
return results as Array<ObligacionContribuyente & { periodStatus: 'pendiente' | 'completada' | 'atrasada'; periodoAplica: string; declaracion: DeclaracionLink | null }>;
|
return results as Array<ObligacionContribuyente & {
|
||||||
|
periodStatus: 'pendiente' | 'completada' | 'atrasada';
|
||||||
|
periodoAplica: string;
|
||||||
|
declaracion: DeclaracionLink | null;
|
||||||
|
declaracionPresentada: boolean;
|
||||||
|
pagoPresentado: boolean;
|
||||||
|
requierePago: boolean;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function appliesTo(frecuencia: string | null, periodo: string): boolean {
|
function appliesTo(frecuencia: string | null, periodo: string): boolean {
|
||||||
@@ -457,6 +490,7 @@ function appliesTo(frecuencia: string | null, periodo: string): boolean {
|
|||||||
case 'mensual': return true;
|
case 'mensual': return true;
|
||||||
case 'bimestral': return month % 2 === 1; // Jan, Mar, May...
|
case 'bimestral': return month % 2 === 1; // Jan, Mar, May...
|
||||||
case 'trimestral': return [1, 4, 7, 10].includes(month);
|
case 'trimestral': return [1, 4, 7, 10].includes(month);
|
||||||
|
case 'cuatrimestral': return [1, 5, 9].includes(month);
|
||||||
case 'anual': return month === 3 || month === 4; // March (PM) or April (PF) — show in both
|
case 'anual': return month === 3 || month === 4; // March (PM) or April (PF) — show in both
|
||||||
case 'eventual': return false; // Don't auto-show
|
case 'eventual': return false; // Don't auto-show
|
||||||
default: return true;
|
default: return true;
|
||||||
|
|||||||
@@ -201,18 +201,27 @@ export async function handleAddonPayment(addonId: string, mpPaymentId: string, s
|
|||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────
|
||||||
// Overage automático: Business Control y Enterprise (business_cloud) incluyen
|
// Overage automático: Business Control y Enterprise (business_cloud) incluyen
|
||||||
// 100 contribuyentes; cada uno adicional cuesta $45/mes. Se modela como un
|
// 100 contribuyentes; cada RFC adicional cuesta:
|
||||||
// único `SubscriptionAddon` con `codename = 'contribuyente_extra_business_cloud'`
|
// - Business Control: $25/mes
|
||||||
// (codename heredado por compat con suscripciones existentes; nombre display ya
|
// - Enterprise: $60/mes
|
||||||
// es genérico), `contribuyenteId = null` (tenant-level) y
|
// Se modela como un único `SubscriptionAddon` con
|
||||||
// `quantity = activeCount − 100`. El cobro MP usa un preapproval propio; cuando
|
// `codename = 'contribuyente_extra_business_cloud'` (codename heredado por
|
||||||
// `quantity` cambia, se actualiza vía `updatePreapprovalAmount` (sin
|
// compat con suscripciones existentes; nombre display ya es genérico),
|
||||||
// re-autorización del usuario).
|
// `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 DESPACHO_INCLUDED_RFCS = 100;
|
||||||
const OVERAGE_ADDON_CODENAME = 'contribuyente_extra_business_cloud';
|
const OVERAGE_ADDON_CODENAME = 'contribuyente_extra_business_cloud';
|
||||||
|
|
||||||
|
function getOverageUnitPrice(plan: string): number {
|
||||||
|
if (plan === 'business_control') return 25;
|
||||||
|
if (plan === 'business_cloud') return 60;
|
||||||
|
// Fallback por si en el futuro se agrega otro plan con overage
|
||||||
|
return 45;
|
||||||
|
}
|
||||||
|
|
||||||
export type OverageAction = 'none' | 'created' | 'updated' | 'cancelled' | 'skipped';
|
export type OverageAction = 'none' | 'created' | 'updated' | 'cancelled' | 'skipped';
|
||||||
|
|
||||||
export interface OverageAdjustResult {
|
export interface OverageAdjustResult {
|
||||||
@@ -228,9 +237,9 @@ export interface OverageAdjustResult {
|
|||||||
/**
|
/**
|
||||||
* Ajusta el add-on de overage para el tenant según el número actual de
|
* 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
|
* contribuyentes activos. Aplica a planes Business Control y Enterprise
|
||||||
* (business_cloud) — ambos incluyen 100 contribuyentes y cobran $45/mes por
|
* (business_cloud) — ambos incluyen 100 contribuyentes y cobran $25/mes
|
||||||
* cada adicional. Idempotente: llamar varias veces con el mismo `activeCount`
|
* (Business Control) o $60/mes (Enterprise) por cada adicional. Idempotente:
|
||||||
* no tiene efecto.
|
* llamar varias veces con el mismo `activeCount` no tiene efecto.
|
||||||
*
|
*
|
||||||
* Casos:
|
* Casos:
|
||||||
* - Plan no permite overage (mi_empresa, mi_empresa_plus, trial, etc.) → 'skipped'
|
* - Plan no permite overage (mi_empresa, mi_empresa_plus, trial, etc.) → 'skipped'
|
||||||
@@ -291,7 +300,7 @@ export async function adjustDespachoOverage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Hay overage → crear o actualizar addon
|
// Hay overage → crear o actualizar addon
|
||||||
const price = Number(catalogo.precio);
|
const price = getOverageUnitPrice(sub.plan);
|
||||||
const newAmount = price * overage;
|
const newAmount = price * overage;
|
||||||
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ const preApprovalClient = new PreApproval(config);
|
|||||||
const paymentClient = new MPPayment(config);
|
const paymentClient = new MPPayment(config);
|
||||||
const preferenceClient = new Preference(config);
|
const preferenceClient = new Preference(config);
|
||||||
|
|
||||||
|
/** Límite de la API legacy de preapproval de MercadoPago para MXN. */
|
||||||
|
export const MP_PREAPPROVAL_MAX_AMOUNT = 10000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fallback público para `back_url` cuando `FRONTEND_URL` apunta a localhost.
|
* Fallback público para `back_url` cuando `FRONTEND_URL` apunta a localhost.
|
||||||
* MercadoPago rechaza URLs `http://localhost...` o cualquier dominio no
|
* MercadoPago rechaza URLs `http://localhost...` o cualquier dominio no
|
||||||
@@ -218,6 +221,57 @@ export async function createProrationPreference(params: {
|
|||||||
pending: `${backUrlBase()}/configuracion/suscripcion?upgrade=pending`,
|
pending: `${backUrlBase()}/configuracion/suscripcion?upgrade=pending`,
|
||||||
},
|
},
|
||||||
auto_return: 'approved',
|
auto_return: 'approved',
|
||||||
|
payment_methods: {
|
||||||
|
installments: 12,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
preferenceId: response.id!,
|
||||||
|
checkoutUrl: response.init_point!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea una Preference (checkout de pago único) para el pago anual de una
|
||||||
|
* suscripción. Se usa cuando el monto supera el límite de preapproval ($10k).
|
||||||
|
* external_reference = `subscription:{tenantId}:{subscriptionId}` para que el
|
||||||
|
* webhook active el período anual al aprobarse.
|
||||||
|
*/
|
||||||
|
export async function createSubscriptionPreference(params: {
|
||||||
|
tenantId: string;
|
||||||
|
subscriptionId: string;
|
||||||
|
plan: string;
|
||||||
|
amount: number;
|
||||||
|
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).');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await preferenceClient.create({
|
||||||
|
body: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: `subscription-${params.subscriptionId}`,
|
||||||
|
title: `Horux360 - Plan ${params.plan} - Año completo`,
|
||||||
|
quantity: 1,
|
||||||
|
unit_price: params.amount,
|
||||||
|
currency_id: 'MXN',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
payer: { email: resolvePayerEmail(params.payerEmail) },
|
||||||
|
external_reference: `subscription:${params.tenantId}:${params.subscriptionId}`,
|
||||||
|
back_urls: {
|
||||||
|
success: `${backUrlBase()}/configuracion/suscripcion?subscription=success`,
|
||||||
|
failure: `${backUrlBase()}/configuracion/suscripcion?subscription=failure`,
|
||||||
|
pending: `${backUrlBase()}/configuracion/suscripcion?subscription=pending`,
|
||||||
|
},
|
||||||
|
auto_return: 'approved',
|
||||||
|
payment_methods: {
|
||||||
|
installments: 12,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { prisma } from '../../config/database.js';
|
import { prisma, tenantDb } from '../../config/database.js';
|
||||||
import * as mpService from './mercadopago.service.js';
|
import * as mpService from './mercadopago.service.js';
|
||||||
import { emailService } from '../email/email.service.js';
|
import { emailService } from '../email/email.service.js';
|
||||||
import { auditLog } from '../../utils/audit.js';
|
import { auditLog } from '../../utils/audit.js';
|
||||||
import { getTenantOwnerEmail } from '../../utils/memberships.js';
|
import { getTenantOwnerEmail, getTenantOwnerEmails } from '../../utils/memberships.js';
|
||||||
|
import { filterRecipientsByRole } from '../notification-preferences.service.js';
|
||||||
import { isDespachoPaidPlan, permiteOverage, type DespachoPricePhase } from '@horux/shared';
|
import { isDespachoPaidPlan, permiteOverage, type DespachoPricePhase } from '@horux/shared';
|
||||||
import { despachoPlanTieneDualidadDb, getPrecioDespachoDb } from '../plan-catalogo.service.js';
|
import { despachoPlanTieneDualidadDb, getPrecioDespachoDb } from '../plan-catalogo.service.js';
|
||||||
import {
|
import {
|
||||||
@@ -243,25 +244,76 @@ export async function generatePaymentLink(tenantId: string) {
|
|||||||
const ownerEmail = await getTenantOwnerEmail(tenantId);
|
const ownerEmail = await getTenantOwnerEmail(tenantId);
|
||||||
if (!ownerEmail) throw new Error('No admin user found');
|
if (!ownerEmail) throw new Error('No admin user found');
|
||||||
|
|
||||||
const subscription = await getActiveSubscription(tenantId);
|
let subscription = await getActiveSubscription(tenantId);
|
||||||
const plan = subscription?.plan || tenant.plan;
|
const plan = (subscription?.plan || tenant.plan) as Plan;
|
||||||
const amount = subscription?.amount || 0;
|
if (plan === 'custom' || plan === 'trial') {
|
||||||
|
throw new Error('No se puede generar link de pago para el plan actual');
|
||||||
|
}
|
||||||
|
|
||||||
if (!amount) throw new Error('No se encontró monto de suscripción');
|
const frequency = (subscription?.frequency as Frequency) || 'annual';
|
||||||
|
let amount = subscription?.amount ? Number(subscription.amount) : 0;
|
||||||
|
if (!amount) {
|
||||||
|
amount = await getPlanPrice(plan, frequency, 'firstYear');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Los planes Business Control / Enterprise exceden el límite de cobro recurrente
|
||||||
|
// de MercadoPago ($10k). Para esos montos usamos una Preference de pago único
|
||||||
|
// anual; el webhook activa el período de 1 año al aprobarse.
|
||||||
|
if (amount > mpService.MP_PREAPPROVAL_MAX_AMOUNT) {
|
||||||
|
if (!subscription) {
|
||||||
|
subscription = await prisma.subscription.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
plan: plan as any,
|
||||||
|
status: 'pending',
|
||||||
|
amount,
|
||||||
|
frequency,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
invalidateSubscriptionCache(tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mp = await mpService.createSubscriptionPreference({
|
||||||
|
tenantId,
|
||||||
|
subscriptionId: subscription.id,
|
||||||
|
plan,
|
||||||
|
amount,
|
||||||
|
payerEmail: ownerEmail,
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.subscription.update({
|
||||||
|
where: { id: subscription.id },
|
||||||
|
data: { mpPreferenceId: mp.preferenceId, status: 'pending', amount },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { paymentUrl: mp.checkoutUrl };
|
||||||
|
}
|
||||||
|
|
||||||
const mp = await mpService.createPreapproval({
|
const mp = await mpService.createPreapproval({
|
||||||
tenantId,
|
tenantId,
|
||||||
reason: `Horux360 - Plan ${plan} - ${tenant.nombre}`,
|
reason: `Horux360 - Plan ${plan} - ${tenant.nombre}`,
|
||||||
amount,
|
amount,
|
||||||
payerEmail: ownerEmail,
|
payerEmail: ownerEmail,
|
||||||
|
frequency,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update subscription with new MP preapproval ID
|
|
||||||
if (subscription) {
|
if (subscription) {
|
||||||
await prisma.subscription.update({
|
await prisma.subscription.update({
|
||||||
where: { id: subscription.id },
|
where: { id: subscription.id },
|
||||||
data: { mpPreapprovalId: mp.preapprovalId },
|
data: { mpPreapprovalId: mp.preapprovalId, status: mp.status || 'pending' },
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
await prisma.subscription.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
plan: plan as any,
|
||||||
|
status: mp.status || 'pending',
|
||||||
|
amount,
|
||||||
|
frequency,
|
||||||
|
mpPreapprovalId: mp.preapprovalId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
invalidateSubscriptionCache(tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { paymentUrl: mp.initPoint };
|
return { paymentUrl: mp.initPoint };
|
||||||
@@ -462,6 +514,54 @@ export async function subscribe(params: {
|
|||||||
? `${tenant.nombre} - Plan ${params.plan} - $${amount.toLocaleString('es-MX')} primer año, $${renewalAmount.toLocaleString('es-MX')} renovaciones`
|
? `${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}`;
|
: `Horux360 - Plan ${params.plan} (${params.frequency}) - ${tenant.nombre}`;
|
||||||
|
|
||||||
|
// Planes Business Control / Enterprise superan el límite de cobro recurrente
|
||||||
|
// de MercadoPago ($10k). Se cobra el año completo vía Preference one-off; el
|
||||||
|
// webhook activa el período anual tras el primer pago aprobado.
|
||||||
|
if (amount > mpService.MP_PREAPPROVAL_MAX_AMOUNT) {
|
||||||
|
const subscription = await prisma.subscription.create({
|
||||||
|
data: {
|
||||||
|
tenantId: params.tenantId,
|
||||||
|
plan: params.plan,
|
||||||
|
status: 'pending',
|
||||||
|
amount,
|
||||||
|
frequency: params.frequency,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mp = await mpService.createSubscriptionPreference({
|
||||||
|
tenantId: params.tenantId,
|
||||||
|
subscriptionId: subscription.id,
|
||||||
|
plan: params.plan,
|
||||||
|
amount,
|
||||||
|
payerEmail: params.payerEmail,
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.subscription.update({
|
||||||
|
where: { id: subscription.id },
|
||||||
|
data: { mpPreferenceId: mp.preferenceId },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.subscription.updateMany({
|
||||||
|
where: { tenantId: params.tenantId, status: 'trial' },
|
||||||
|
data: { status: 'trial_converted' },
|
||||||
|
});
|
||||||
|
|
||||||
|
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, paymentMethod: 'preference' },
|
||||||
|
});
|
||||||
|
return { subscription, paymentUrl: mp.checkoutUrl };
|
||||||
|
}
|
||||||
|
|
||||||
const mp = await mpService.createPreapproval({
|
const mp = await mpService.createPreapproval({
|
||||||
tenantId: params.tenantId,
|
tenantId: params.tenantId,
|
||||||
reason,
|
reason,
|
||||||
@@ -637,13 +737,20 @@ export async function applyApprovedUpgrade(subscriptionId: string): Promise<void
|
|||||||
const newPlan = sub.upgradeTargetPlan as Plan;
|
const newPlan = sub.upgradeTargetPlan as Plan;
|
||||||
const newAmount = Number(sub.upgradeTargetAmount);
|
const newAmount = Number(sub.upgradeTargetAmount);
|
||||||
|
|
||||||
// Actualiza el monto del preapproval en MP (si existe)
|
// Actualiza el monto del preapproval en MP (si existe). Si el nuevo monto
|
||||||
|
// supera el límite de cobro recurrente de MP ($10k), cancelamos el preapproval
|
||||||
|
// anterior: el plan alto se cobrará anualmente vía Preference one-off.
|
||||||
if (sub.mpPreapprovalId) {
|
if (sub.mpPreapprovalId) {
|
||||||
try {
|
if (newAmount > mpService.MP_PREAPPROVAL_MAX_AMOUNT) {
|
||||||
await mpService.updatePreapprovalAmount(sub.mpPreapprovalId, newAmount);
|
await mpService.cancelPreapproval(sub.mpPreapprovalId);
|
||||||
} catch (error: any) {
|
console.log(`[Upgrade] Preapproval ${sub.mpPreapprovalId} cancelado porque el nuevo monto $${newAmount} supera el límite de MP`);
|
||||||
console.error(`[Upgrade] Error actualizando preapproval ${sub.mpPreapprovalId}:`, error.message);
|
} else {
|
||||||
throw error; // Re-lanza para que MP reintente el webhook
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1085,7 +1192,7 @@ export async function sendExpiryReminders(): Promise<{ sent: number; resetOnly:
|
|||||||
{ status: 'trial_expired', currentPeriodEnd: { gte: oneDayAgo } },
|
{ status: 'trial_expired', currentPeriodEnd: { gte: oneDayAgo } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
include: { tenant: { select: { nombre: true, rfc: true } } },
|
include: { tenant: { select: { nombre: true, rfc: true, databaseName: true } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
let sent = 0;
|
let sent = 0;
|
||||||
@@ -1129,33 +1236,48 @@ export async function sendExpiryReminders(): Promise<{ sent: number; resetOnly:
|
|||||||
|
|
||||||
// Hay algo que avisar.
|
// Hay algo que avisar.
|
||||||
try {
|
try {
|
||||||
const ownerEmail = await getTenantOwnerEmail(sub.tenantId);
|
// Para suscripciones de pago, respeta preferencia 'subscription_expiring' del rol owner.
|
||||||
if (!ownerEmail) {
|
// Para trials siempre avisa al owner (no depende de preferencias de notificación informativa).
|
||||||
|
const isTrialFlow = sub.status === 'trial' || sub.status === 'trial_expired';
|
||||||
|
let emailsToNotify: string[] = [];
|
||||||
|
|
||||||
|
if (isTrialFlow) {
|
||||||
|
const ownerEmail = await getTenantOwnerEmail(sub.tenantId);
|
||||||
|
if (ownerEmail) emailsToNotify = [ownerEmail];
|
||||||
|
} else {
|
||||||
|
const pool = await tenantDb.getPool(sub.tenantId, sub.tenant.databaseName);
|
||||||
|
const ownerEmails = await getTenantOwnerEmails(sub.tenantId);
|
||||||
|
const recipientsWithRole = ownerEmails.map(email => ({ email, role: 'owner' as const }));
|
||||||
|
emailsToNotify = await filterRecipientsByRole(pool, 'subscription_expiring', recipientsWithRole);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emailsToNotify.length === 0) {
|
||||||
skipped++;
|
skipped++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isTrialFlow = sub.status === 'trial' || sub.status === 'trial_expired';
|
for (const ownerEmail of emailsToNotify) {
|
||||||
if (isTrialFlow) {
|
if (isTrialFlow) {
|
||||||
if (bucket === 0) {
|
if (bucket === 0) {
|
||||||
await emailService.sendTrialExpired(ownerEmail, {
|
await emailService.sendTrialExpired(ownerEmail, {
|
||||||
nombre: sub.tenant.nombre,
|
nombre: sub.tenant.nombre,
|
||||||
despachoNombre: 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 {
|
} else {
|
||||||
await emailService.sendTrialReminder(ownerEmail, {
|
await emailService.sendSubscriptionExpiring(ownerEmail, {
|
||||||
nombre: sub.tenant.nombre,
|
nombre: sub.tenant.nombre,
|
||||||
despachoNombre: sub.tenant.nombre,
|
plan: sub.plan,
|
||||||
diasRestantes: Math.max(0, daysUntil),
|
expiresAt: sub.currentPeriodEnd.toLocaleDateString('es-MX', { dateStyle: 'long' }),
|
||||||
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({
|
await prisma.subscription.update({
|
||||||
|
|||||||
@@ -1,77 +1,221 @@
|
|||||||
import type { Pool } from 'pg';
|
import type { Pool } from 'pg';
|
||||||
import type { EventoFiscal, EventoCreate, EventoUpdate } from '@horux/shared';
|
import type { EventoFiscal, EventoCreate, EventoUpdate } from '@horux/shared';
|
||||||
|
|
||||||
|
export type RecurrenciaRecordatorio = 'unica' | 'mensual' | 'bimestral' | 'trimestral' | 'anual';
|
||||||
|
|
||||||
|
const RECURRENCIA_DELTA_MESES: Record<RecurrenciaRecordatorio, number> = {
|
||||||
|
unica: 0,
|
||||||
|
mensual: 1,
|
||||||
|
bimestral: 2,
|
||||||
|
trimestral: 3,
|
||||||
|
anual: 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface RecordatorioRow {
|
||||||
|
id: number;
|
||||||
|
titulo: string;
|
||||||
|
descripcion: string | null;
|
||||||
|
fecha_limite: Date;
|
||||||
|
notas: string | null;
|
||||||
|
completado: boolean;
|
||||||
|
privado: boolean;
|
||||||
|
creado_por: string;
|
||||||
|
created_at: Date;
|
||||||
|
recurrencia: RecurrenciaRecordatorio;
|
||||||
|
serie_id: number | null;
|
||||||
|
activo: boolean;
|
||||||
|
fecha_inicio: Date | null;
|
||||||
|
fecha_fin: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toISODate(d: Date): string {
|
||||||
|
return d.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOfDay(d: Date): Date {
|
||||||
|
const r = new Date(d);
|
||||||
|
r.setHours(0, 0, 0, 0);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMonthsLocal(date: Date, months: number): Date {
|
||||||
|
const result = new Date(date);
|
||||||
|
const day = result.getDate();
|
||||||
|
result.setMonth(result.getMonth() + months);
|
||||||
|
// Si el mes resultante no tiene el mismo día, volver al último día del mes anterior
|
||||||
|
if (result.getDate() !== day) {
|
||||||
|
result.setDate(0);
|
||||||
|
}
|
||||||
|
return startOfDay(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
function calcularSiguienteFecha(fecha: Date, recurrencia: RecurrenciaRecordatorio): Date {
|
||||||
|
return addMonthsLocal(fecha, RECURRENCIA_DELTA_MESES[recurrencia]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generarFechasInstancias(
|
||||||
|
fechaInicio: Date,
|
||||||
|
recurrencia: RecurrenciaRecordatorio,
|
||||||
|
fechaFin: Date | null | undefined,
|
||||||
|
maxMesesHorizonte = 24,
|
||||||
|
): Date[] {
|
||||||
|
const delta = RECURRENCIA_DELTA_MESES[recurrencia];
|
||||||
|
if (delta === 0) return [];
|
||||||
|
|
||||||
|
const inicio = startOfDay(fechaInicio);
|
||||||
|
const limiteHorizonte = addMonthsLocal(new Date(), maxMesesHorizonte);
|
||||||
|
const limite = fechaFin ? startOfDay(fechaFin) : limiteHorizonte;
|
||||||
|
|
||||||
|
const fechas: Date[] = [];
|
||||||
|
let current = inicio;
|
||||||
|
while (current <= limite) {
|
||||||
|
fechas.push(new Date(current));
|
||||||
|
current = calcularSiguienteFecha(current, recurrencia);
|
||||||
|
}
|
||||||
|
return fechas;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowToEventoFiscal(r: RecordatorioRow): EventoFiscal {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
titulo: r.titulo,
|
||||||
|
descripcion: r.descripcion || '',
|
||||||
|
tipo: 'custom' as const,
|
||||||
|
fechaLimite: toISODate(r.fecha_limite),
|
||||||
|
recurrencia: r.recurrencia,
|
||||||
|
completado: r.completado,
|
||||||
|
notas: r.notas,
|
||||||
|
privado: r.privado,
|
||||||
|
creadoPor: r.creado_por,
|
||||||
|
createdAt: r.created_at?.toISOString(),
|
||||||
|
// metadata extra para el frontend
|
||||||
|
serieId: r.serie_id ?? undefined,
|
||||||
|
esPeriodico: r.recurrencia !== 'unica',
|
||||||
|
} as EventoFiscal;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findById(pool: Pool, id: number): Promise<RecordatorioRow | null> {
|
||||||
|
const { rows } = await pool.query<RecordatorioRow>(
|
||||||
|
`SELECT id, titulo, descripcion, fecha_limite, notas, completado, privado,
|
||||||
|
creado_por, created_at, recurrencia, serie_id, activo, fecha_inicio, fecha_fin
|
||||||
|
FROM recordatorios WHERE id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMaestroDesdeId(pool: Pool, id: number): Promise<RecordatorioRow | null> {
|
||||||
|
const row = await findById(pool, id);
|
||||||
|
if (!row) return null;
|
||||||
|
if (row.serie_id === null) return row;
|
||||||
|
return findById(pool, row.serie_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generarInstancias(
|
||||||
|
pool: Pool,
|
||||||
|
maestro: RecordatorioRow,
|
||||||
|
maxMesesHorizonte = 24,
|
||||||
|
): Promise<number> {
|
||||||
|
if (!maestro.fecha_inicio) return 0;
|
||||||
|
if (maestro.recurrencia === 'unica') return 0;
|
||||||
|
|
||||||
|
const fechas = generarFechasInstancias(
|
||||||
|
maestro.fecha_inicio,
|
||||||
|
maestro.recurrencia,
|
||||||
|
maestro.fecha_fin ?? undefined,
|
||||||
|
maxMesesHorizonte,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (fechas.length === 0) return 0;
|
||||||
|
|
||||||
|
let insertadas = 0;
|
||||||
|
for (const fecha of fechas) {
|
||||||
|
const { rowCount } = await pool.query(
|
||||||
|
`INSERT INTO recordatorios (
|
||||||
|
titulo, descripcion, fecha_limite, notas, completado, privado,
|
||||||
|
creado_por, recurrencia, serie_id, activo, fecha_inicio, fecha_fin
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, false, $5, $6, 'unica', $7, true, $8, $9)
|
||||||
|
ON CONFLICT DO NOTHING`,
|
||||||
|
[
|
||||||
|
maestro.titulo,
|
||||||
|
maestro.descripcion,
|
||||||
|
toISODate(fecha),
|
||||||
|
maestro.notas,
|
||||||
|
maestro.privado,
|
||||||
|
maestro.creado_por,
|
||||||
|
maestro.id,
|
||||||
|
toISODate(maestro.fecha_inicio),
|
||||||
|
maestro.fecha_fin ? toISODate(maestro.fecha_fin) : null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
insertadas += rowCount ?? 0;
|
||||||
|
}
|
||||||
|
return insertadas;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Obtiene recordatorios visibles para el usuario.
|
* Obtiene recordatorios visibles para el usuario.
|
||||||
* - Públicos: todos los del tenant
|
* - Públicos: todos los del tenant
|
||||||
* - Privados: solo los creados por el usuario
|
* - Privados: solo los creados por el usuario
|
||||||
|
* Excluye los maestros periódicos (serie_id IS NULL AND recurrencia != 'unica')
|
||||||
|
* para no duplicar eventos en el calendario.
|
||||||
*/
|
*/
|
||||||
export async function getRecordatorios(
|
export async function getRecordatorios(
|
||||||
pool: Pool,
|
pool: Pool,
|
||||||
userId: string,
|
userId: string,
|
||||||
año: number
|
año: number
|
||||||
): Promise<EventoFiscal[]> {
|
): Promise<EventoFiscal[]> {
|
||||||
const { rows } = await pool.query(`
|
const { rows } = await pool.query<RecordatorioRow>(`
|
||||||
SELECT id, titulo, descripcion, fecha_limite as "fechaLimite",
|
SELECT id, titulo, descripcion, fecha_limite, notas, completado, privado,
|
||||||
notas, completado, privado, creado_por as "creadoPor",
|
creado_por, created_at, recurrencia, serie_id, activo, fecha_inicio, fecha_fin
|
||||||
created_at as "createdAt"
|
|
||||||
FROM recordatorios
|
FROM recordatorios
|
||||||
WHERE EXTRACT(YEAR FROM fecha_limite) = $1
|
WHERE EXTRACT(YEAR FROM fecha_limite) = $1
|
||||||
|
AND activo = true
|
||||||
AND (privado = false OR creado_por = $2)
|
AND (privado = false OR creado_por = $2)
|
||||||
|
AND NOT (serie_id IS NULL AND recurrencia <> 'unica')
|
||||||
ORDER BY fecha_limite
|
ORDER BY fecha_limite
|
||||||
`, [año, userId]);
|
`, [año, userId]);
|
||||||
|
|
||||||
return rows.map(r => ({
|
return rows.map(rowToEventoFiscal);
|
||||||
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(
|
export async function createRecordatorio(
|
||||||
pool: Pool,
|
pool: Pool,
|
||||||
userId: string,
|
userId: string,
|
||||||
data: EventoCreate & { privado?: boolean }
|
data: EventoCreate & { privado?: boolean; fechaFin?: string | null }
|
||||||
): Promise<EventoFiscal> {
|
): Promise<EventoFiscal> {
|
||||||
const { rows } = await pool.query(`
|
const recurrencia = (data.recurrencia as RecurrenciaRecordatorio) || 'unica';
|
||||||
INSERT INTO recordatorios (titulo, descripcion, fecha_limite, notas, privado, creado_por)
|
const fechaFin = data.fechaFin ? new Date(data.fechaFin + 'T00:00:00') : null;
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
const fechaLimite = new Date(data.fechaLimite + 'T00:00:00');
|
||||||
RETURNING id, titulo, descripcion, fecha_limite as "fechaLimite",
|
|
||||||
notas, completado, privado, creado_por as "creadoPor",
|
const { rows } = await pool.query<RecordatorioRow>(`
|
||||||
created_at as "createdAt"
|
INSERT INTO recordatorios (
|
||||||
|
titulo, descripcion, fecha_limite, notas, completado, privado,
|
||||||
|
creado_por, recurrencia, serie_id, activo, fecha_inicio, fecha_fin
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, false, $5, $6, $7, NULL, true, $8, $9)
|
||||||
|
RETURNING id, titulo, descripcion, fecha_limite, notas, completado, privado,
|
||||||
|
creado_por, created_at, recurrencia, serie_id, activo, fecha_inicio, fecha_fin
|
||||||
`, [
|
`, [
|
||||||
data.titulo,
|
data.titulo,
|
||||||
data.descripcion || null,
|
data.descripcion || null,
|
||||||
data.fechaLimite,
|
toISODate(fechaLimite),
|
||||||
data.notas || null,
|
data.notas || null,
|
||||||
data.privado ?? false,
|
data.privado ?? false,
|
||||||
userId,
|
userId,
|
||||||
|
recurrencia,
|
||||||
|
toISODate(fechaLimite),
|
||||||
|
fechaFin ? toISODate(fechaFin) : null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const r = rows[0];
|
const maestro = rows[0];
|
||||||
return {
|
|
||||||
id: r.id,
|
if (recurrencia !== 'unica') {
|
||||||
titulo: r.titulo,
|
await generarInstancias(pool, maestro);
|
||||||
descripcion: r.descripcion || '',
|
}
|
||||||
tipo: 'custom',
|
|
||||||
fechaLimite: r.fechaLimite instanceof Date
|
return rowToEventoFiscal(maestro);
|
||||||
? 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(
|
export async function updateRecordatorio(
|
||||||
@@ -80,54 +224,123 @@ export async function updateRecordatorio(
|
|||||||
id: number,
|
id: number,
|
||||||
data: EventoUpdate & { privado?: boolean }
|
data: EventoUpdate & { privado?: boolean }
|
||||||
): Promise<EventoFiscal | null> {
|
): Promise<EventoFiscal | null> {
|
||||||
// Verify ownership or public
|
const row = await findById(pool, id);
|
||||||
const { rows: existing } = await pool.query(
|
if (!row) return null;
|
||||||
`SELECT id, creado_por FROM recordatorios WHERE id = $1`,
|
|
||||||
[id]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existing.length === 0) return null;
|
// Completar una instancia individual de una serie periódica
|
||||||
|
const soloCompletado =
|
||||||
|
data.completado !== undefined &&
|
||||||
|
data.titulo === undefined &&
|
||||||
|
data.descripcion === undefined &&
|
||||||
|
data.fechaLimite === undefined &&
|
||||||
|
data.notas === undefined &&
|
||||||
|
data.privado === undefined;
|
||||||
|
|
||||||
const sets: string[] = [];
|
if (soloCompletado && row.serie_id !== null) {
|
||||||
const params: any[] = [];
|
await pool.query(`UPDATE recordatorios SET completado = $1, updated_at = NOW() WHERE id = $2`, [
|
||||||
|
data.completado,
|
||||||
|
id,
|
||||||
|
]);
|
||||||
|
const updated = await findById(pool, id);
|
||||||
|
return updated ? rowToEventoFiscal(updated) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maestro = row.serie_id === null ? row : await findById(pool, row.serie_id);
|
||||||
|
if (!maestro) return null;
|
||||||
|
|
||||||
|
// Recordatorio único: editar directamente
|
||||||
|
if (maestro.recurrencia === 'unica') {
|
||||||
|
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.notas !== undefined) { sets.push(`notas = $${idx++}`); params.push(data.notas); }
|
||||||
|
if (data.privado !== undefined) { sets.push(`privado = $${idx++}`); params.push(data.privado); }
|
||||||
|
if (data.completado !== undefined) { sets.push(`completado = $${idx++}`); params.push(data.completado); }
|
||||||
|
|
||||||
|
if (sets.length === 0) return rowToEventoFiscal(maestro);
|
||||||
|
sets.push(`updated_at = NOW()`);
|
||||||
|
params.push(maestro.id);
|
||||||
|
|
||||||
|
const { rows } = await pool.query<RecordatorioRow>(`
|
||||||
|
UPDATE recordatorios SET ${sets.join(', ')}
|
||||||
|
WHERE id = $${idx}
|
||||||
|
RETURNING id, titulo, descripcion, fecha_limite, notas, completado, privado,
|
||||||
|
creado_por, created_at, recurrencia, serie_id, activo, fecha_inicio, fecha_fin
|
||||||
|
`, params);
|
||||||
|
|
||||||
|
return rows[0] ? rowToEventoFiscal(rows[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serie periódica: editar maestro y propagar a instancias futuras no completadas
|
||||||
|
const updatesMaestro: string[] = [];
|
||||||
|
const paramsMaestro: any[] = [];
|
||||||
let idx = 1;
|
let idx = 1;
|
||||||
|
|
||||||
if (data.titulo !== undefined) { sets.push(`titulo = $${idx++}`); params.push(data.titulo); }
|
if (data.titulo !== undefined) { updatesMaestro.push(`titulo = $${idx++}`); paramsMaestro.push(data.titulo); }
|
||||||
if (data.descripcion !== undefined) { sets.push(`descripcion = $${idx++}`); params.push(data.descripcion); }
|
if (data.descripcion !== undefined) { updatesMaestro.push(`descripcion = $${idx++}`); paramsMaestro.push(data.descripcion); }
|
||||||
if (data.fechaLimite !== undefined) { sets.push(`fecha_limite = $${idx++}`); params.push(data.fechaLimite); }
|
if (data.notas !== undefined) { updatesMaestro.push(`notas = $${idx++}`); paramsMaestro.push(data.notas); }
|
||||||
if (data.completado !== undefined) { sets.push(`completado = $${idx++}`); params.push(data.completado); }
|
if (data.privado !== undefined) { updatesMaestro.push(`privado = $${idx++}`); paramsMaestro.push(data.privado); }
|
||||||
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;
|
const nuevaFechaInicio = data.fechaLimite ? new Date(data.fechaLimite + 'T00:00:00') : null;
|
||||||
|
if (nuevaFechaInicio) {
|
||||||
|
updatesMaestro.push(`fecha_limite = $${idx++}`);
|
||||||
|
paramsMaestro.push(toISODate(nuevaFechaInicio));
|
||||||
|
updatesMaestro.push(`fecha_inicio = $${idx++}`);
|
||||||
|
paramsMaestro.push(toISODate(nuevaFechaInicio));
|
||||||
|
}
|
||||||
|
|
||||||
sets.push(`updated_at = NOW()`);
|
if (updatesMaestro.length > 0) {
|
||||||
params.push(id);
|
updatesMaestro.push(`updated_at = NOW()`);
|
||||||
|
paramsMaestro.push(maestro.id);
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE recordatorios SET ${updatesMaestro.join(', ')} WHERE id = $${idx}`,
|
||||||
|
paramsMaestro
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const { rows } = await pool.query(`
|
// Propagar campos de contenido a instancias futuras no completadas
|
||||||
UPDATE recordatorios SET ${sets.join(', ')}
|
const updatesInstancias: string[] = [];
|
||||||
WHERE id = $${idx}
|
const paramsInstancias: any[] = [];
|
||||||
RETURNING id, titulo, descripcion, fecha_limite as "fechaLimite",
|
let iIdx = 1;
|
||||||
notas, completado, privado, creado_por as "creadoPor",
|
|
||||||
created_at as "createdAt"
|
|
||||||
`, params);
|
|
||||||
|
|
||||||
if (rows.length === 0) return null;
|
if (data.titulo !== undefined) { updatesInstancias.push(`titulo = $${iIdx++}`); paramsInstancias.push(data.titulo); }
|
||||||
|
if (data.descripcion !== undefined) { updatesInstancias.push(`descripcion = $${iIdx++}`); paramsInstancias.push(data.descripcion); }
|
||||||
|
if (data.notas !== undefined) { updatesInstancias.push(`notas = $${iIdx++}`); paramsInstancias.push(data.notas); }
|
||||||
|
if (data.privado !== undefined) { updatesInstancias.push(`privado = $${iIdx++}`); paramsInstancias.push(data.privado); }
|
||||||
|
|
||||||
const r = rows[0];
|
if (updatesInstancias.length > 0) {
|
||||||
return {
|
paramsInstancias.push(maestro.id);
|
||||||
id: r.id,
|
await pool.query(
|
||||||
titulo: r.titulo,
|
`UPDATE recordatorios
|
||||||
descripcion: r.descripcion || '',
|
SET ${updatesInstancias.join(', ')}
|
||||||
tipo: 'custom',
|
WHERE serie_id = $${iIdx}
|
||||||
fechaLimite: r.fechaLimite instanceof Date
|
AND fecha_limite >= CURRENT_DATE
|
||||||
? r.fechaLimite.toISOString().split('T')[0]
|
AND completado = false`,
|
||||||
: String(r.fechaLimite).split('T')[0],
|
paramsInstancias
|
||||||
recurrencia: 'unica',
|
);
|
||||||
completado: r.completado,
|
}
|
||||||
notas: r.notas,
|
|
||||||
createdAt: r.createdAt?.toISOString(),
|
// Si cambió la fecha de inicio, regenerar instancias futuras
|
||||||
};
|
if (nuevaFechaInicio) {
|
||||||
|
await pool.query(
|
||||||
|
`DELETE FROM recordatorios
|
||||||
|
WHERE serie_id = $1
|
||||||
|
AND fecha_limite >= CURRENT_DATE
|
||||||
|
AND completado = false`,
|
||||||
|
[maestro.id]
|
||||||
|
);
|
||||||
|
const maestroActualizado = await findById(pool, maestro.id);
|
||||||
|
if (maestroActualizado) {
|
||||||
|
await generarInstancias(pool, maestroActualizado);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const maestroFinal = await findById(pool, maestro.id);
|
||||||
|
return maestroFinal ? rowToEventoFiscal(maestroFinal) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteRecordatorio(
|
export async function deleteRecordatorio(
|
||||||
@@ -135,9 +348,70 @@ export async function deleteRecordatorio(
|
|||||||
userId: string,
|
userId: string,
|
||||||
id: number
|
id: number
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const { rowCount } = await pool.query(
|
const row = await findById(pool, id);
|
||||||
`DELETE FROM recordatorios WHERE id = $1`,
|
if (!row) return false;
|
||||||
[id]
|
|
||||||
);
|
// Serie periódica: cancelar (desactivar maestro y borrar instancias futuras no completadas)
|
||||||
|
if (row.recurrencia !== 'unica' || row.serie_id !== null) {
|
||||||
|
const maestro = row.serie_id === null ? row : await findById(pool, row.serie_id);
|
||||||
|
if (!maestro) return false;
|
||||||
|
|
||||||
|
await pool.query(`UPDATE recordatorios SET activo = false, updated_at = NOW() WHERE id = $1`, [maestro.id]);
|
||||||
|
await pool.query(
|
||||||
|
`DELETE FROM recordatorios
|
||||||
|
WHERE serie_id = $1
|
||||||
|
AND fecha_limite >= CURRENT_DATE
|
||||||
|
AND completado = false`,
|
||||||
|
[maestro.id]
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Único: borrar directamente
|
||||||
|
const { rowCount } = await pool.query(`DELETE FROM recordatorios WHERE id = $1`, [id]);
|
||||||
return (rowCount ?? 0) > 0;
|
return (rowCount ?? 0) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extiende las series activas para mantener un horizonte futuro de instancias.
|
||||||
|
* Útil para llamar desde un cron periódico.
|
||||||
|
*/
|
||||||
|
export async function extenderSeriesActivas(
|
||||||
|
pool: Pool,
|
||||||
|
mesesHorizonte = 24,
|
||||||
|
): Promise<{ series: number; instancias: number }> {
|
||||||
|
const { rows: maestros } = await pool.query<RecordatorioRow>(`
|
||||||
|
SELECT id, titulo, descripcion, fecha_limite, notas, completado, privado,
|
||||||
|
creado_por, created_at, recurrencia, serie_id, activo, fecha_inicio, fecha_fin
|
||||||
|
FROM recordatorios
|
||||||
|
WHERE serie_id IS NULL
|
||||||
|
AND recurrencia <> 'unica'
|
||||||
|
AND activo = true
|
||||||
|
`);
|
||||||
|
|
||||||
|
let seriesProcesadas = 0;
|
||||||
|
let instanciasCreadas = 0;
|
||||||
|
|
||||||
|
const horizonte = addMonthsLocal(new Date(), mesesHorizonte);
|
||||||
|
|
||||||
|
for (const maestro of maestros) {
|
||||||
|
const { rows: ultimas } = await pool.query<{ fecha_limite: Date }>(`
|
||||||
|
SELECT fecha_limite
|
||||||
|
FROM recordatorios
|
||||||
|
WHERE serie_id = $1
|
||||||
|
ORDER BY fecha_limite DESC
|
||||||
|
LIMIT 1
|
||||||
|
`, [maestro.id]);
|
||||||
|
|
||||||
|
const ultima = ultimas[0]?.fecha_limite;
|
||||||
|
if (!ultima || startOfDay(ultima) < addMonthsLocal(new Date(), mesesHorizonte - 6)) {
|
||||||
|
const creadas = await generarInstancias(pool, maestro, mesesHorizonte);
|
||||||
|
if (creadas > 0) {
|
||||||
|
seriesProcesadas++;
|
||||||
|
instanciasCreadas += creadas;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { series: seriesProcesadas, instancias: instanciasCreadas };
|
||||||
|
}
|
||||||
|
|||||||
100
apps/api/src/services/sat/proxy.service.ts
Normal file
100
apps/api/src/services/sat/proxy.service.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||||
|
|
||||||
|
export interface ProxyConfig {
|
||||||
|
url: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProxyStrategy = 'round-robin' | 'random';
|
||||||
|
|
||||||
|
function parseProxyList(raw: string): ProxyConfig[] {
|
||||||
|
if (!raw.trim()) return [];
|
||||||
|
|
||||||
|
const configs: ProxyConfig[] = [];
|
||||||
|
const items = raw.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
try {
|
||||||
|
const url = new URL(item);
|
||||||
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||||
|
console.warn(`[ProxyManager] Protocolo no soportado, se omite: ${item}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
configs.push({
|
||||||
|
url: item,
|
||||||
|
host: url.hostname,
|
||||||
|
port: Number(url.port) || (url.protocol === 'https:' ? 443 : 80),
|
||||||
|
username: url.username || undefined,
|
||||||
|
password: url.password || undefined,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[ProxyManager] URL de proxy inválida, se omite: ${item}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return configs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProxyManager {
|
||||||
|
private proxies: ProxyConfig[];
|
||||||
|
private strategy: ProxyStrategy;
|
||||||
|
private currentIndex = 0;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
proxyList = process.env.SAT_PROXY_LIST || '',
|
||||||
|
strategy: ProxyStrategy = (process.env.SAT_PROXY_STRATEGY as ProxyStrategy) || 'round-robin',
|
||||||
|
) {
|
||||||
|
this.proxies = parseProxyList(proxyList);
|
||||||
|
this.strategy = ['round-robin', 'random'].includes(strategy) ? strategy : 'round-robin';
|
||||||
|
|
||||||
|
if (this.proxies.length > 0) {
|
||||||
|
console.log(`[ProxyManager] ${this.proxies.length} proxy(s) configurados (estrategia: ${this.strategy})`);
|
||||||
|
} else {
|
||||||
|
console.log('[ProxyManager] No hay proxies configurados; se usará la IP directa del servidor');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hasProxies(): boolean {
|
||||||
|
return this.proxies.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
getProxyCount(): number {
|
||||||
|
return this.proxies.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
getNextProxy(): ProxyConfig | null {
|
||||||
|
if (this.proxies.length === 0) return null;
|
||||||
|
|
||||||
|
if (this.strategy === 'random') {
|
||||||
|
return this.proxies[Math.floor(Math.random() * this.proxies.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxy = this.proxies[this.currentIndex];
|
||||||
|
this.currentIndex = (this.currentIndex + 1) % this.proxies.length;
|
||||||
|
return proxy;
|
||||||
|
}
|
||||||
|
|
||||||
|
createAgent(proxy: ProxyConfig): HttpsProxyAgent<string> {
|
||||||
|
return new HttpsProxyAgent(proxy.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea un agente con el siguiente proxy del pool.
|
||||||
|
* Útil cuando se quiere una nueva conexión por solicitud.
|
||||||
|
*/
|
||||||
|
createNextAgent(): HttpsProxyAgent<string> | null {
|
||||||
|
const proxy = this.getNextProxy();
|
||||||
|
if (!proxy) return null;
|
||||||
|
console.log(`[ProxyManager] Usando proxy: ${proxy.host}:${proxy.port}`);
|
||||||
|
return this.createAgent(proxy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instancia global del proxy manager.
|
||||||
|
* Lee SAT_PROXY_LIST y SAT_PROXY_STRATEGY del entorno.
|
||||||
|
*/
|
||||||
|
export const proxyManager = new ProxyManager();
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
DocumentStatus,
|
DocumentStatus,
|
||||||
ServiceEndpoints,
|
ServiceEndpoints,
|
||||||
} from '@nodecfdi/sat-ws-descarga-masiva';
|
} from '@nodecfdi/sat-ws-descarga-masiva';
|
||||||
|
import { proxyManager } from './proxy.service.js';
|
||||||
|
|
||||||
export interface FielData {
|
export interface FielData {
|
||||||
cerContent: string;
|
cerContent: string;
|
||||||
@@ -17,10 +18,33 @@ export interface FielData {
|
|||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProxyInfo {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timeout explícito para el cliente HTTP del SAT (ms).
|
||||||
|
*
|
||||||
|
* IMPORTANTE: la librería @nodecfdi/sat-ws-descarga-masiva@2.0.0 tiene un bug
|
||||||
|
* en HttpsWebClient: si no se pasa un timeout explícito y ocurre un timeout
|
||||||
|
* de red, rechaza con un `Error` nativo en vez de `WebClientException`.
|
||||||
|
* Eso rompe el manejo de errores posterior y produce
|
||||||
|
* `webError.getResponse is not a function`.
|
||||||
|
*
|
||||||
|
* Al pasar un timeout explícito, `_timeout` queda definido y la librería
|
||||||
|
* envuelve el timeout como `WebClientException`, permitiendo reintentos sanos.
|
||||||
|
*
|
||||||
|
* El endpoint de verificación del SAT suele tardar >30s en responder; 5 minutos
|
||||||
|
* da margen sin dejar la conexión colgada indefinidamente.
|
||||||
|
*/
|
||||||
|
const SAT_WEB_CLIENT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutos
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Crea el servicio de descarga masiva del SAT usando los datos de la FIEL
|
* Crea el servicio de descarga masiva del SAT usando los datos de la FIEL
|
||||||
*/
|
*/
|
||||||
export function createSatService(fielData: FielData): Service {
|
export function createSatService(fielData: FielData): { service: Service; proxyInfo: ProxyInfo | null } {
|
||||||
// Crear FIEL usando el método estático create
|
// Crear FIEL usando el método estático create
|
||||||
const fiel = Fiel.create(fielData.cerContent, fielData.keyContent, fielData.password);
|
const fiel = Fiel.create(fielData.cerContent, fielData.keyContent, fielData.password);
|
||||||
|
|
||||||
@@ -29,14 +53,33 @@ export function createSatService(fielData: FielData): Service {
|
|||||||
throw new Error('La FIEL no es válida o está vencida');
|
throw new Error('La FIEL no es válida o está vencida');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Crear cliente HTTP
|
// Crear cliente HTTP con timeout explícito para evitar el bug de la librería
|
||||||
const webClient = new HttpsWebClient();
|
// cuando ocurre un timeout de red. Si hay proxies configurados, se usa uno
|
||||||
|
// del pool para reducir el riesgo de bloqueo por IP del SAT.
|
||||||
|
const proxy = proxyManager.getNextProxy();
|
||||||
|
const proxyAgent = proxy ? proxyManager.createAgent(proxy) : null;
|
||||||
|
if (proxyAgent) {
|
||||||
|
console.log('[SAT] Usando proxy para la conexión con el SAT');
|
||||||
|
} else {
|
||||||
|
console.log('[SAT] Sin proxy configurado; usando IP directa del servidor');
|
||||||
|
}
|
||||||
|
|
||||||
|
const webClient = new (HttpsWebClient as any)(
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
SAT_WEB_CLIENT_TIMEOUT_MS,
|
||||||
|
proxyAgent,
|
||||||
|
);
|
||||||
|
|
||||||
// Crear request builder con la FIEL
|
// Crear request builder con la FIEL
|
||||||
const requestBuilder = new FielRequestBuilder(fiel);
|
const requestBuilder = new FielRequestBuilder(fiel);
|
||||||
|
|
||||||
// Crear y retornar el servicio
|
// Crear y retornar el servicio
|
||||||
return new Service(requestBuilder, webClient, undefined, ServiceEndpoints.cfdi());
|
const service = new Service(requestBuilder, webClient, undefined, ServiceEndpoints.cfdi());
|
||||||
|
return {
|
||||||
|
service,
|
||||||
|
proxyInfo: proxy ? { host: proxy.host, port: proxy.port, url: proxy.url } : null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QueryResult {
|
export interface QueryResult {
|
||||||
@@ -73,10 +116,13 @@ export async function querySat(
|
|||||||
): Promise<QueryResult> {
|
): Promise<QueryResult> {
|
||||||
try {
|
try {
|
||||||
// El SAT rechaza fechaInicial >= fechaFinal. Como formatDateForSat trunca
|
// El SAT rechaza fechaInicial >= fechaFinal. Como formatDateForSat trunca
|
||||||
// a medianoche, dos fechas dentro del mismo día calendario resultan iguales.
|
// a medianoche en zona horaria de México, dos fechas dentro del mismo día
|
||||||
// Ajustamos fechaFin al día siguiente para evitar el error.
|
// calendario mexicano resultan iguales. Ajustamos fechaFin al día siguiente
|
||||||
|
// en hora México para evitar el error.
|
||||||
let adjustedFechaFin = fechaFin;
|
let adjustedFechaFin = fechaFin;
|
||||||
if (formatDateForSat(fechaInicio) === formatDateForSat(fechaFin)) {
|
if (isSameMexicoDay(fechaInicio, fechaFin)) {
|
||||||
|
// Sumar 24h en ms es suficiente porque formatDateForSat solo usa la fecha
|
||||||
|
// calendaria de México, no la hora.
|
||||||
adjustedFechaFin = new Date(fechaFin.getTime() + 24 * 60 * 60 * 1000);
|
adjustedFechaFin = new Date(fechaFin.getTime() + 24 * 60 * 60 * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +156,30 @@ export async function querySat(
|
|||||||
statusCode: result.getStatus().getCode().toString(),
|
statusCode: result.getStatus().getCode().toString(),
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[SAT Query Error]', error);
|
// Errores tipo "EmptyResult (5004)" o "Se han agotado las solicitudes de por vida"
|
||||||
|
// a veces vienen como excepción en vez de resultado aceptado. Los traducimos para
|
||||||
|
// que el llamador los trate como "sin datos / no hay nada más que hacer" en lugar
|
||||||
|
// de error fatal.
|
||||||
|
const raw = error?.message || String(error);
|
||||||
|
const emptyMatch = raw.match(/EmptyResult\s*\(?\s*(5004)\s*\)?/i) || raw.includes('5004');
|
||||||
|
if (emptyMatch) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'No se encontró la información',
|
||||||
|
statusCode: '5004',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const exhaustedMatch = raw.includes('Se han agotado las solicitudes de por vida');
|
||||||
|
if (exhaustedMatch) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Se han agotado las solicitudes de por vida para este rango',
|
||||||
|
statusCode: 'exhausted',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('[SAT Query Error]', error?.message, error?.stack || error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: error.message || 'Error al realizar consulta',
|
message: error.message || 'Error al realizar consulta',
|
||||||
@@ -174,6 +243,7 @@ export async function verifySatRequest(
|
|||||||
if (entryId === 'Finished') status = 'ready';
|
if (entryId === 'Finished') status = 'ready';
|
||||||
else if (entryId === 'InProgress') status = 'processing';
|
else if (entryId === 'InProgress') status = 'processing';
|
||||||
else if (entryId === 'Accepted') status = 'pending';
|
else if (entryId === 'Accepted') status = 'pending';
|
||||||
|
else if (entryId === 'Unknown' && result.getStatus().getCode().toString() === '404') status = 'failed';
|
||||||
else status = 'pending';
|
else status = 'pending';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,6 +253,38 @@ export async function verifySatRequest(
|
|||||||
const statusMsg = result.getStatus().getMessage();
|
const statusMsg = result.getStatus().getMessage();
|
||||||
const reqValue = statusRequest.getValue();
|
const reqValue = statusRequest.getValue();
|
||||||
const reqEntry = statusRequest.getEntryId();
|
const reqEntry = statusRequest.getEntryId();
|
||||||
|
|
||||||
|
// EmptyResult (5004) o Exhausted (5002, "solicitudes de por vida"): el SAT
|
||||||
|
// aceptó la solicitud pero no generó paquetes (rango sin info) o ya agotamos
|
||||||
|
// las solicitudes de ese rango. Tratarlos como "ready" con 0 paquetes para
|
||||||
|
// NO fallar la etapa ni quemar reintentos — es un resultado benigno.
|
||||||
|
// Se comparan value/entry/mensaje de forma defensiva porque getValue() puede
|
||||||
|
// venir como number o string según la versión de la librería.
|
||||||
|
const codeValueStr = codeRequestValue != null ? String(codeRequestValue) : '';
|
||||||
|
const codeEntryStr = codeRequestEntry != null ? String(codeRequestEntry) : '';
|
||||||
|
const codeMsgStr = codeRequestMessage != null ? String(codeRequestMessage) : '';
|
||||||
|
const isEmptyResult =
|
||||||
|
codeValueStr === '5004' ||
|
||||||
|
codeEntryStr === '5004' ||
|
||||||
|
/EmptyResult/i.test(codeEntryStr) ||
|
||||||
|
/\b5004\b/.test(codeMsgStr);
|
||||||
|
const isExhausted =
|
||||||
|
codeValueStr === '5002' ||
|
||||||
|
/Exhausted/i.test(codeEntryStr) ||
|
||||||
|
/solicitudes de por vida/i.test(codeMsgStr);
|
||||||
|
if (isEmptyResult || isExhausted) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
status: 'ready',
|
||||||
|
packageIds: [],
|
||||||
|
totalCfdis: 0,
|
||||||
|
message: isExhausted
|
||||||
|
? 'Se han agotado las solicitudes de por vida para este rango'
|
||||||
|
: 'No se encontró información para el rango solicitado',
|
||||||
|
statusCode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
let message = statusMsg;
|
let message = statusMsg;
|
||||||
if (status === 'rejected' || status === 'failed') {
|
if (status === 'rejected' || status === 'failed') {
|
||||||
const codeReqStr = codeRequestValue
|
const codeReqStr = codeRequestValue
|
||||||
@@ -200,7 +302,7 @@ export async function verifySatRequest(
|
|||||||
statusCode,
|
statusCode,
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[SAT Verify Error]', error.message || error);
|
console.error('[SAT Verify Error]', error?.message, error?.stack || error);
|
||||||
// Errores de la librería (ej. webError.getResponse is not a function)
|
// Errores de la librería (ej. webError.getResponse is not a function)
|
||||||
// no son fallos del SAT — devolver 'pending' para reintentar polling
|
// no son fallos del SAT — devolver 'pending' para reintentar polling
|
||||||
return {
|
return {
|
||||||
@@ -250,8 +352,34 @@ export async function downloadSatPackage(
|
|||||||
* Formatea una fecha para el SAT (YYYY-MM-DD HH:mm:ss).
|
* Formatea una fecha para el SAT (YYYY-MM-DD HH:mm:ss).
|
||||||
* El SAT requiere hora 00:00:00; cualquier otra hora causa
|
* El SAT requiere hora 00:00:00; cualquier otra hora causa
|
||||||
* "Fecha final invalida" / "Fecha inicial invalida".
|
* "Fecha final invalida" / "Fecha inicial invalida".
|
||||||
|
*
|
||||||
|
* IMPORTANTE: las fechas deben interpretarse en la zona horaria de México
|
||||||
|
* (America/Mexico_City) porque el SAT opera en esa zona. El servidor corre
|
||||||
|
* en UTC, así que usamos Intl.DateTimeFormat para obtener los componentes
|
||||||
|
* locales a México.
|
||||||
*/
|
*/
|
||||||
function formatDateForSat(date: Date): string {
|
function formatDateForSat(date: Date): string {
|
||||||
const pad = (n: number) => n.toString().padStart(2, '0');
|
const fmt = new Intl.DateTimeFormat('es-MX', {
|
||||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} 00:00:00`;
|
timeZone: 'America/Mexico_City',
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
});
|
||||||
|
const parts = fmt.formatToParts(date);
|
||||||
|
const get = (type: string) => parts.find(p => p.type === type)?.value || '00';
|
||||||
|
return `${get('year')}-${get('month')}-${get('day')} 00:00:00`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Devuelve true si dos fechas (interpretadas en zona horaria de México)
|
||||||
|
* caen en el mismo día calendario.
|
||||||
|
*/
|
||||||
|
function isSameMexicoDay(a: Date, b: Date): boolean {
|
||||||
|
const fmt = new Intl.DateTimeFormat('es-MX', {
|
||||||
|
timeZone: 'America/Mexico_City',
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
});
|
||||||
|
return fmt.format(a) === fmt.format(b);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +1,113 @@
|
|||||||
import type { Page, Locator, Frame, Response } from 'playwright';
|
import type { Page, Locator, Frame, Response, BrowserContext } from 'playwright';
|
||||||
import type { CsfLoginSession } from './sat-csf-login.js';
|
import type { CsfLoginSession } from './sat-csf-login.js';
|
||||||
|
|
||||||
async function tryFetchPdfFromUrl(page: Page, url: string): Promise<Buffer | null> {
|
async function tryFetchPdfFromUrl(frame: Frame, url: string): Promise<Buffer | null> {
|
||||||
|
if (!url || url === 'about:blank') return null;
|
||||||
|
|
||||||
|
// Blob / data URI → fetchear dentro del navegador para respetar cookies/sesión
|
||||||
if (url.startsWith('blob:') || url.startsWith('data:')) {
|
if (url.startsWith('blob:') || url.startsWith('data:')) {
|
||||||
const arr = await page.evaluate(async (u) => {
|
try {
|
||||||
const r = await fetch(u);
|
const page = frame.page();
|
||||||
const buf = await r.arrayBuffer();
|
const arr = await page.evaluate(async (u) => {
|
||||||
return Array.from(new Uint8Array(buf));
|
const r = await fetch(u);
|
||||||
}, url);
|
const buf = await r.arrayBuffer();
|
||||||
return Buffer.from(arr);
|
return Array.from(new Uint8Array(buf));
|
||||||
|
}, url);
|
||||||
|
const buf = Buffer.from(arr);
|
||||||
|
return buf.subarray(0, 5).toString().startsWith('%PDF-') ? buf : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// URL http(s) relativa o absoluta → fetchear desde el frame para mantener sesión
|
||||||
if (url.startsWith('http')) {
|
if (url.startsWith('http')) {
|
||||||
const response = await page.context().request.get(url);
|
try {
|
||||||
if (!response.ok()) return null;
|
const response = await frame.page().context().request.get(url);
|
||||||
return Buffer.from(await response.body());
|
if (!response.ok()) return null;
|
||||||
|
const ct = response.headers()['content-type'] ?? '';
|
||||||
|
if (!ct.includes('application/pdf') && !url.toLowerCase().includes('.pdf')) return null;
|
||||||
|
const buf = Buffer.from(await response.body());
|
||||||
|
return buf.subarray(0, 5).toString().startsWith('%PDF-') ? buf : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async function findPdfInFrames(appPage: Page, deadlineMs: number): Promise<Buffer | null> {
|
||||||
* Busca "Generar Constancia" en cualquiera de los frames del appPage (vive
|
const deadline = Date.now() + deadlineMs;
|
||||||
* 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<Buffer> {
|
|
||||||
const { context, appPage } = session;
|
|
||||||
|
|
||||||
let interceptedPdf: Buffer | null = null;
|
while (Date.now() < deadline) {
|
||||||
const responseListener = async (response: Response) => {
|
const frames = appPage.frames();
|
||||||
const ct = response.headers()['content-type'] ?? '';
|
|
||||||
if (ct.includes('application/pdf')) {
|
for (const frame of frames) {
|
||||||
try { interceptedPdf = Buffer.from(await response.body()); } catch { /* ok */ }
|
try {
|
||||||
|
const frameUrl = frame.url();
|
||||||
|
|
||||||
|
// 1. Frame cuya URL sea directamente un PDF
|
||||||
|
if (
|
||||||
|
frameUrl.toLowerCase().includes('.pdf') ||
|
||||||
|
frameUrl.includes('application/pdf')
|
||||||
|
) {
|
||||||
|
const body = await frame.content().catch(() => null);
|
||||||
|
if (!body) continue;
|
||||||
|
// content() de un PDF no es util; intentar fetch por URL
|
||||||
|
const pdf = await tryFetchPdfFromUrl(frame, frameUrl);
|
||||||
|
if (pdf) return pdf;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. <embed type="application/pdf">
|
||||||
|
const embed = frame.locator('embed[type="application/pdf"]').first();
|
||||||
|
if ((await embed.count()) > 0) {
|
||||||
|
const src = await embed.getAttribute('src');
|
||||||
|
if (src) {
|
||||||
|
const pdf = await tryFetchPdfFromUrl(frame, src);
|
||||||
|
if (pdf) return pdf;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. <iframe> cuyo src apunte al visor/constancia (IdcSiat, .pdf, etc.)
|
||||||
|
const iframes = await frame.locator('iframe').all();
|
||||||
|
for (const iframe of iframes) {
|
||||||
|
const src = await iframe.getAttribute('src');
|
||||||
|
if (src) {
|
||||||
|
const lower = src.toLowerCase();
|
||||||
|
if (
|
||||||
|
lower.includes('.pdf') ||
|
||||||
|
lower.includes('idcsiat') ||
|
||||||
|
lower.includes('reimpresion') ||
|
||||||
|
lower.includes('consultatramite')
|
||||||
|
) {
|
||||||
|
const pdf = await tryFetchPdfFromUrl(frame, src);
|
||||||
|
if (pdf) return pdf;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. <a download> o link con data/blob generado por jsPDF
|
||||||
|
const downloadLinks = await frame.locator('a[download], a[href*="data:"], a[href*="blob:"]').all();
|
||||||
|
for (const link of downloadLinks) {
|
||||||
|
const href = await link.getAttribute('href');
|
||||||
|
if (href) {
|
||||||
|
const pdf = await tryFetchPdfFromUrl(frame, href);
|
||||||
|
if (pdf) return pdf;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Frame puede estar navegando; ignorar y continuar
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
context.on('response', responseListener);
|
|
||||||
|
|
||||||
|
await appPage.waitForTimeout(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findGenerarButton(appPage: Page, timeoutMs: number): Promise<Locator | null> {
|
||||||
const GENERAR_SELECTORS = [
|
const GENERAR_SELECTORS = [
|
||||||
'button:has-text("Generar Constancia")',
|
'button:has-text("Generar Constancia")',
|
||||||
'button:has-text("Generar constancia")',
|
'button:has-text("Generar constancia")',
|
||||||
@@ -44,77 +117,109 @@ export async function extractCsfPdf(session: CsfLoginSession): Promise<Buffer> {
|
|||||||
'a:has-text("Generar constancia")',
|
'a:has-text("Generar constancia")',
|
||||||
].join(', ');
|
].join(', ');
|
||||||
|
|
||||||
let generarLocator: Locator | null = null;
|
const deadline = Date.now() + timeoutMs;
|
||||||
let foundFrame: Frame | null = null;
|
|
||||||
const deadline = Date.now() + 90_000;
|
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
for (const frame of appPage.frames()) {
|
for (const frame of appPage.frames()) {
|
||||||
const loc = frame.locator(GENERAR_SELECTORS).first();
|
const loc = frame.locator(GENERAR_SELECTORS).first();
|
||||||
const count = await loc.count().catch(() => 0);
|
const count = await loc.count().catch(() => 0);
|
||||||
if (count > 0 && await loc.isVisible().catch(() => false)) {
|
if (count > 0 && (await loc.isVisible().catch(() => false))) {
|
||||||
generarLocator = loc;
|
return loc;
|
||||||
foundFrame = frame;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (generarLocator) break;
|
|
||||||
await appPage.waitForTimeout(1000);
|
await appPage.waitForTimeout(1000);
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (!generarLocator || !foundFrame) {
|
/**
|
||||||
context.off('response', responseListener);
|
* Busca "Generar Constancia" en cualquiera de los frames del appPage,
|
||||||
throw new Error('Botón "Generar Constancia" no encontrado en ningún frame del portal SAT (tras 90s)');
|
* hace click, y extrae el PDF desde el visor/iframe que genera el SAT.
|
||||||
}
|
*
|
||||||
|
* El portal SAT actual (2025-2026) genera la CSF dentro de un iframe JSF
|
||||||
|
* legacy (rfcampc.siat.sat.gob.mx/PTSC/.../ConsultaTramite.jsf). El PDF
|
||||||
|
* no siempre se descarga como evento de download ni abre popup; a veces
|
||||||
|
* se renderiza en un <embed> o en un iframe cuyo src devuelve el PDF.
|
||||||
|
*/
|
||||||
|
export async function extractCsfPdf(session: CsfLoginSession): Promise<Buffer> {
|
||||||
|
const { context, appPage } = session;
|
||||||
|
|
||||||
await generarLocator.scrollIntoViewIfNeeded();
|
let interceptedPdf: Buffer | null = null;
|
||||||
await appPage.waitForTimeout(500);
|
const responseListener = async (response: Response) => {
|
||||||
|
const ct = response.headers()['content-type'] ?? '';
|
||||||
const popupPromise = context.waitForEvent('page', { timeout: 15_000 }).catch(() => null);
|
if (ct.includes('application/pdf')) {
|
||||||
const downloadPromise = appPage.waitForEvent('download', { timeout: 15_000 }).catch(() => null);
|
try {
|
||||||
await generarLocator.click();
|
interceptedPdf = Buffer.from(await response.body());
|
||||||
|
} catch {
|
||||||
const [popup, download] = await Promise.all([popupPromise, downloadPromise]);
|
/* ok */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
context.on('response', responseListener);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Path 1: download event
|
const generarLocator = await findGenerarButton(appPage, 90_000);
|
||||||
|
if (!generarLocator) {
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Algunos botones del SAT usan JSF/ajax; un click simple a veces no basta.
|
||||||
|
// Hacemos click normal y, como fallback, dispatchEvent si no hay reacción.
|
||||||
|
await generarLocator.click();
|
||||||
|
await appPage.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Intentar extraer el PDF del iframe/visor
|
||||||
|
let pdf = await findPdfInFrames(appPage, 60_000);
|
||||||
|
if (pdf) return pdf;
|
||||||
|
|
||||||
|
// Si aún no hay PDF, algunos flujos abren popup clásico
|
||||||
|
const popupPromise = context.waitForEvent('page', { timeout: 10_000 }).catch(() => null);
|
||||||
|
const downloadPromise = appPage.waitForEvent('download', { timeout: 10_000 }).catch(() => null);
|
||||||
|
|
||||||
|
// Reintentar click por si el primero no disparó el handler
|
||||||
|
const stillVisible = await generarLocator.isVisible().catch(() => false);
|
||||||
|
if (stillVisible) {
|
||||||
|
await generarLocator.dispatchEvent('click');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [popup, download] = await Promise.all([popupPromise, downloadPromise]);
|
||||||
|
|
||||||
if (download) {
|
if (download) {
|
||||||
const stream = await download.createReadStream();
|
const stream = await download.createReadStream();
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
for await (const chunk of stream) chunks.push(chunk as Buffer);
|
for await (const chunk of stream) chunks.push(chunk as Buffer);
|
||||||
const pdf = Buffer.concat(chunks);
|
const downloaded = Buffer.concat(chunks);
|
||||||
if (!pdf.subarray(0, 5).toString().startsWith('%PDF-')) {
|
if (downloaded.subarray(0, 5).toString().startsWith('%PDF-')) return downloaded;
|
||||||
throw new Error('El archivo descargado no es un PDF válido');
|
|
||||||
}
|
|
||||||
return pdf;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path 2: viewer popup
|
|
||||||
if (popup) {
|
if (popup) {
|
||||||
await popup.waitForLoadState('domcontentloaded').catch(() => undefined);
|
await popup.waitForLoadState('domcontentloaded').catch(() => undefined);
|
||||||
await popup.waitForTimeout(2000);
|
await popup.waitForTimeout(2000);
|
||||||
|
|
||||||
let pdf = await tryFetchPdfFromUrl(popup, popup.url()).catch(() => null);
|
pdf = await findPdfInFrames(popup, 20_000);
|
||||||
if (!pdf) {
|
if (pdf) return pdf;
|
||||||
const embedSrc = await popup.locator('embed[type="application/pdf"], iframe').first().getAttribute('src').catch(() => null);
|
|
||||||
if (embedSrc) {
|
const embedSrc = await popup.locator('embed[type="application/pdf"], iframe').first().getAttribute('src').catch(() => null);
|
||||||
const absolute = new URL(embedSrc, popup.url()).toString();
|
if (embedSrc) {
|
||||||
pdf = await tryFetchPdfFromUrl(popup, absolute).catch(() => null);
|
const absolute = new URL(embedSrc, popup.url()).toString();
|
||||||
}
|
pdf = await tryFetchPdfFromUrl(popup.mainFrame(), absolute).catch(() => null);
|
||||||
|
if (pdf) return pdf;
|
||||||
}
|
}
|
||||||
if (!pdf && interceptedPdf) pdf = interceptedPdf;
|
|
||||||
if (!pdf || pdf.length === 0) throw new Error('El visor abrió pero no se pudo extraer el PDF');
|
if (interceptedPdf) return interceptedPdf;
|
||||||
if (!pdf.subarray(0, 5).toString().startsWith('%PDF-')) throw new Error('Buffer extraído no es un PDF válido');
|
throw new Error('El visor abrió pero no se pudo extraer el PDF');
|
||||||
return pdf;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path 3: inline response (no popup, no download)
|
// Último intento: esperar un poco más a que el iframe termine de cargar
|
||||||
await appPage.waitForTimeout(3000);
|
await appPage.waitForTimeout(5000);
|
||||||
if (interceptedPdf) {
|
pdf = await findPdfInFrames(appPage, 20_000);
|
||||||
const pdf = interceptedPdf as Buffer;
|
if (pdf) return pdf;
|
||||||
if (!pdf.subarray(0, 5).toString().startsWith('%PDF-')) throw new Error('Buffer interceptado no es un PDF válido');
|
|
||||||
return pdf;
|
if (interceptedPdf) return interceptedPdf;
|
||||||
}
|
|
||||||
throw new Error('Click en "Generar Constancia" no produjo descarga, popup ni respuesta PDF');
|
throw new Error('Click en "Generar Constancia" no produjo un PDF descargable ni visible');
|
||||||
} finally {
|
} finally {
|
||||||
context.off('response', responseListener);
|
context.off('response', responseListener);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,7 @@ export interface SweepResult {
|
|||||||
|
|
||||||
const DEFAULT_RUNNING_HOURS_BY_TYPE: Record<string, number> = {
|
const DEFAULT_RUNNING_HOURS_BY_TYPE: Record<string, number> = {
|
||||||
initial: 24,
|
initial: 24,
|
||||||
daily: 4,
|
daily: 8,
|
||||||
incremental: 2,
|
incremental: 2,
|
||||||
custom: 24,
|
custom: 24,
|
||||||
};
|
};
|
||||||
@@ -38,8 +38,8 @@ const DEFAULT_RUNNING_HOURS_BY_TYPE: Record<string, number> = {
|
|||||||
* (volver a correrlo no reabre los ya-marcados-failed).
|
* (volver a correrlo no reabre los ya-marcados-failed).
|
||||||
*
|
*
|
||||||
* - `apply=false` (default): dry-run, no toca BD.
|
* - `apply=false` (default): dry-run, no toca BD.
|
||||||
* - `pendingHours`: threshold pending (default 12h).
|
* - `pendingHours`: threshold pending (default 24h).
|
||||||
* - `runningHours`: fallback threshold running si no se usa por-tipo (default 4h).
|
* - `runningHours`: fallback threshold running si no se usa por-tipo (default 8h).
|
||||||
* - `runningHoursByType`: override por tipo de sync.
|
* - `runningHoursByType`: override por tipo de sync.
|
||||||
*/
|
*/
|
||||||
export async function sweepStaleSatJobs(params: {
|
export async function sweepStaleSatJobs(params: {
|
||||||
@@ -48,7 +48,7 @@ export async function sweepStaleSatJobs(params: {
|
|||||||
runningHours?: number;
|
runningHours?: number;
|
||||||
runningHoursByType?: Record<string, number>;
|
runningHoursByType?: Record<string, number>;
|
||||||
} = { apply: false }): Promise<SweepResult> {
|
} = { apply: false }): Promise<SweepResult> {
|
||||||
const pendingHours = params.pendingHours ?? 12;
|
const pendingHours = params.pendingHours ?? 24;
|
||||||
const runningHoursByType = { ...DEFAULT_RUNNING_HOURS_BY_TYPE, ...(params.runningHoursByType || {}) };
|
const runningHoursByType = { ...DEFAULT_RUNNING_HOURS_BY_TYPE, ...(params.runningHoursByType || {}) };
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const pendingCutoff = new Date(now.getTime() - pendingHours * 3600 * 1000);
|
const pendingCutoff = new Date(now.getTime() - pendingHours * 3600 * 1000);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const ROLE_META: Record<PlatformRole, { label: string; desc: string; icon: any;
|
|||||||
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_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_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_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_sales: { label: 'Vendedor', desc: 'Enviar invitaciones de trial', 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' },
|
platform_finance: { label: 'Finance', desc: 'Pagos, facturas manuales, editar precios', icon: DollarSign, color: 'bg-amber-100 text-amber-700 border-amber-200' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { DashboardShell } from '@/components/layouts/dashboard-shell';
|
import { DashboardShell } from '@/components/layouts/dashboard-shell';
|
||||||
import { Card, CardContent, CardHeader, CardTitle, Button, Input, Label } from '@horux/shared-ui';
|
import { Card, CardContent, CardHeader, CardTitle, Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@horux/shared-ui';
|
||||||
import { useEventos, useCreateEvento, useUpdateEvento, useDeleteEvento } from '@/lib/hooks/use-calendario';
|
import { useEventos, useCreateEvento, useUpdateEvento, useDeleteEvento } from '@/lib/hooks/use-calendario';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import {
|
import {
|
||||||
@@ -42,6 +42,8 @@ interface RecordatorioForm {
|
|||||||
fechaLimite: string;
|
fechaLimite: string;
|
||||||
notas: string;
|
notas: string;
|
||||||
privado: boolean;
|
privado: boolean;
|
||||||
|
recurrencia: 'unica' | 'mensual' | 'bimestral' | 'trimestral' | 'anual';
|
||||||
|
fechaFin: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyForm: RecordatorioForm = {
|
const emptyForm: RecordatorioForm = {
|
||||||
@@ -50,6 +52,8 @@ const emptyForm: RecordatorioForm = {
|
|||||||
fechaLimite: '',
|
fechaLimite: '',
|
||||||
notas: '',
|
notas: '',
|
||||||
privado: false,
|
privado: false,
|
||||||
|
recurrencia: 'unica',
|
||||||
|
fechaFin: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CalendarioPage() {
|
export default function CalendarioPage() {
|
||||||
@@ -100,6 +104,8 @@ export default function CalendarioPage() {
|
|||||||
fechaLimite: evento.fechaLimite,
|
fechaLimite: evento.fechaLimite,
|
||||||
notas: evento.notas || '',
|
notas: evento.notas || '',
|
||||||
privado: (evento as any).privado ?? false,
|
privado: (evento as any).privado ?? false,
|
||||||
|
recurrencia: (evento.recurrencia as RecordatorioForm['recurrencia']) || 'unica',
|
||||||
|
fechaFin: '', // La fecha fin no se edita desde el calendario; se mantiene la original
|
||||||
});
|
});
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
};
|
};
|
||||||
@@ -113,15 +119,19 @@ export default function CalendarioPage() {
|
|||||||
data: { titulo: form.titulo, descripcion: form.descripcion, fechaLimite: form.fechaLimite, notas: form.notas, privado: form.privado } as any,
|
data: { titulo: form.titulo, descripcion: form.descripcion, fechaLimite: form.fechaLimite, notas: form.notas, privado: form.privado } as any,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await createEvento.mutateAsync({
|
const payload: any = {
|
||||||
titulo: form.titulo,
|
titulo: form.titulo,
|
||||||
descripcion: form.descripcion,
|
descripcion: form.descripcion,
|
||||||
tipo: 'custom',
|
tipo: 'custom',
|
||||||
fechaLimite: form.fechaLimite,
|
fechaLimite: form.fechaLimite,
|
||||||
recurrencia: 'unica',
|
recurrencia: form.recurrencia,
|
||||||
notas: form.notas,
|
notas: form.notas,
|
||||||
privado: form.privado,
|
privado: form.privado,
|
||||||
} as any);
|
};
|
||||||
|
if (form.recurrencia !== 'unica' && form.fechaFin) {
|
||||||
|
payload.fechaFin = form.fechaFin;
|
||||||
|
}
|
||||||
|
await createEvento.mutateAsync(payload);
|
||||||
}
|
}
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
setForm(emptyForm);
|
setForm(emptyForm);
|
||||||
@@ -131,10 +141,15 @@ export default function CalendarioPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
const handleDelete = async (evento: EventoFiscal) => {
|
||||||
if (!confirm('¿Eliminar este recordatorio?')) return;
|
if (!evento.id) return;
|
||||||
|
const esPeriodico = evento.recurrencia && evento.recurrencia !== 'unica';
|
||||||
|
const mensaje = esPeriodico
|
||||||
|
? 'Esto cancelará todas las ocurrencias futuras de esta serie. ¿Continuar?'
|
||||||
|
: '¿Eliminar este recordatorio?';
|
||||||
|
if (!confirm(mensaje)) return;
|
||||||
try {
|
try {
|
||||||
await deleteEvento.mutateAsync(id);
|
await deleteEvento.mutateAsync(evento.id);
|
||||||
} catch {
|
} catch {
|
||||||
alert('Error al eliminar');
|
alert('Error al eliminar');
|
||||||
}
|
}
|
||||||
@@ -206,6 +221,44 @@ export default function CalendarioPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{!editingId && (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="recurrencia">Recurrencia</Label>
|
||||||
|
<Select
|
||||||
|
value={form.recurrencia}
|
||||||
|
onValueChange={(v) => setForm({ ...form, recurrencia: v as RecordatorioForm['recurrencia'] })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="unica">Única</SelectItem>
|
||||||
|
<SelectItem value="mensual">Mensual</SelectItem>
|
||||||
|
<SelectItem value="bimestral">Bimestral</SelectItem>
|
||||||
|
<SelectItem value="trimestral">Trimestral</SelectItem>
|
||||||
|
<SelectItem value="anual">Anual</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{form.recurrencia !== 'unica' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="fechaFin">Fecha fin (opcional)</Label>
|
||||||
|
<Input
|
||||||
|
id="fechaFin"
|
||||||
|
type="date"
|
||||||
|
value={form.fechaFin}
|
||||||
|
onChange={e => setForm({ ...form, fechaFin: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{editingId && form.recurrencia !== 'unica' && (
|
||||||
|
<div className="text-sm text-amber-700 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
|
||||||
|
Este es un recordatorio periódico. Los cambios se aplicarán a todas las ocurrencias futuras.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="descripcion">Descripción (opcional)</Label>
|
<Label htmlFor="descripcion">Descripción (opcional)</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -417,7 +470,7 @@ export default function CalendarioPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost" size="icon" className="h-7 w-7 text-destructive"
|
variant="ghost" size="icon" className="h-7 w-7 text-destructive"
|
||||||
onClick={() => evento.id && handleDelete(evento.id)}
|
onClick={() => handleDelete(evento)}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -322,41 +322,65 @@ export default function CfdiPage() {
|
|||||||
const [activeTab, setActiveTab] = useState<'cfdis' | 'conceptos'>('cfdis');
|
const [activeTab, setActiveTab] = useState<'cfdis' | 'conceptos'>('cfdis');
|
||||||
// Filtros locales de la pestaña Conceptos (no compartidos con CFDIs).
|
// Filtros locales de la pestaña Conceptos (no compartidos con CFDIs).
|
||||||
// Popovers en headers UUID, Clave, Descripción + ordenamiento por importe.
|
// Popovers en headers UUID, Clave, Descripción + ordenamiento por importe.
|
||||||
const [conceptosFilters, setConceptosFilters] = useState<{
|
// Valores que el usuario escribe en los inputs de los popovers
|
||||||
|
const [conceptosDraftFilters, setConceptosDraftFilters] = useState<{
|
||||||
uuidLike: string;
|
uuidLike: string;
|
||||||
claveProdServ: string;
|
claveProdServ: string;
|
||||||
descripcionConcepto: string;
|
descripcionConcepto: string;
|
||||||
noIdentificacion: string;
|
noIdentificacion: string;
|
||||||
|
}>({ uuidLike: '', claveProdServ: '', descripcionConcepto: '', noIdentificacion: '' });
|
||||||
|
// Filtros realmente aplicados a la query (solo cambian al dar Aplicar o Limpiar)
|
||||||
|
const [appliedConceptosFilters, setAppliedConceptosFilters] = useState<{
|
||||||
|
uuidLike: string;
|
||||||
|
claveProdServ: string;
|
||||||
|
descripcionConcepto: string;
|
||||||
|
noIdentificacion: string;
|
||||||
|
}>({ uuidLike: '', claveProdServ: '', descripcionConcepto: '', noIdentificacion: '' });
|
||||||
|
const [conceptosSort, setConceptosSort] = useState<{
|
||||||
orderBy?: 'fecha' | 'importe';
|
orderBy?: 'fecha' | 'importe';
|
||||||
orderDir?: 'asc' | 'desc';
|
orderDir?: 'asc' | 'desc';
|
||||||
}>({ uuidLike: '', claveProdServ: '', descripcionConcepto: '', noIdentificacion: '' });
|
}>({});
|
||||||
const [conceptosOpenFilter, setConceptosOpenFilter] = useState<'uuid' | 'clave' | 'descripcion' | 'noIdentificacion' | null>(null);
|
const [conceptosOpenFilter, setConceptosOpenFilter] = useState<'uuid' | 'clave' | 'descripcion' | 'noIdentificacion' | null>(null);
|
||||||
|
|
||||||
const conceptosQuery = useQuery({
|
const conceptosQuery = useQuery({
|
||||||
queryKey: ['cfdi-conceptos', filters, selectedContribuyenteId, conceptosFilters],
|
queryKey: ['cfdi-conceptos', filters, selectedContribuyenteId, appliedConceptosFilters, conceptosSort],
|
||||||
queryFn: () => getConceptosList({
|
queryFn: () => getConceptosList({
|
||||||
...filters,
|
...filters,
|
||||||
contribuyenteId: selectedContribuyenteId || undefined,
|
contribuyenteId: selectedContribuyenteId || undefined,
|
||||||
uuidLike: conceptosFilters.uuidLike || undefined,
|
uuidLike: appliedConceptosFilters.uuidLike || undefined,
|
||||||
claveProdServ: conceptosFilters.claveProdServ || undefined,
|
claveProdServ: appliedConceptosFilters.claveProdServ || undefined,
|
||||||
descripcionConcepto: conceptosFilters.descripcionConcepto || undefined,
|
descripcionConcepto: appliedConceptosFilters.descripcionConcepto || undefined,
|
||||||
noIdentificacion: conceptosFilters.noIdentificacion || undefined,
|
noIdentificacion: appliedConceptosFilters.noIdentificacion || undefined,
|
||||||
orderBy: conceptosFilters.orderBy,
|
orderBy: conceptosSort.orderBy,
|
||||||
orderDir: conceptosFilters.orderDir,
|
orderDir: conceptosSort.orderDir,
|
||||||
}),
|
}),
|
||||||
enabled: activeTab === 'conceptos',
|
enabled: activeTab === 'conceptos',
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggleImporteSort = () => {
|
const toggleImporteSort = () => {
|
||||||
setConceptosFilters(prev => {
|
setConceptosSort(prev => {
|
||||||
// null → asc → desc → null (o ciclo simple asc ↔ desc si prefieres)
|
|
||||||
const isImporte = prev.orderBy === 'importe';
|
const isImporte = prev.orderBy === 'importe';
|
||||||
if (!isImporte) return { ...prev, orderBy: 'importe', orderDir: 'desc' };
|
if (!isImporte) return { orderBy: 'importe', orderDir: 'desc' };
|
||||||
if (prev.orderDir === 'desc') return { ...prev, orderBy: 'importe', orderDir: 'asc' };
|
if (prev.orderDir === 'desc') return { orderBy: 'importe', orderDir: 'asc' };
|
||||||
return { ...prev, orderBy: undefined, orderDir: undefined };
|
return {};
|
||||||
});
|
});
|
||||||
setFilters(f => ({ ...f, page: 1 }));
|
setFilters(f => ({ ...f, page: 1 }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const applyConceptosFilters = () => {
|
||||||
|
setAppliedConceptosFilters({ ...conceptosDraftFilters });
|
||||||
|
setFilters(f => ({ ...f, page: 1 }));
|
||||||
|
setConceptosOpenFilter(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearConceptosFilter = (field: keyof typeof conceptosDraftFilters) => {
|
||||||
|
const newDraft = { ...conceptosDraftFilters, [field]: '' };
|
||||||
|
setConceptosDraftFilters(newDraft);
|
||||||
|
setAppliedConceptosFilters(newDraft);
|
||||||
|
setFilters(f => ({ ...f, page: 1 }));
|
||||||
|
setConceptosOpenFilter(null);
|
||||||
|
};
|
||||||
|
|
||||||
const createCfdi = useCreateCfdi();
|
const createCfdi = useCreateCfdi();
|
||||||
const deleteCfdi = useDeleteCfdi();
|
const deleteCfdi = useDeleteCfdi();
|
||||||
|
|
||||||
@@ -480,12 +504,12 @@ export default function CfdiPage() {
|
|||||||
const fullResponse = await getConceptosList({
|
const fullResponse = await getConceptosList({
|
||||||
...filters,
|
...filters,
|
||||||
contribuyenteId: selectedContribuyenteId || undefined,
|
contribuyenteId: selectedContribuyenteId || undefined,
|
||||||
uuidLike: conceptosFilters.uuidLike || undefined,
|
uuidLike: appliedConceptosFilters.uuidLike || undefined,
|
||||||
claveProdServ: conceptosFilters.claveProdServ || undefined,
|
claveProdServ: appliedConceptosFilters.claveProdServ || undefined,
|
||||||
descripcionConcepto: conceptosFilters.descripcionConcepto || undefined,
|
descripcionConcepto: appliedConceptosFilters.descripcionConcepto || undefined,
|
||||||
noIdentificacion: conceptosFilters.noIdentificacion || undefined,
|
noIdentificacion: appliedConceptosFilters.noIdentificacion || undefined,
|
||||||
orderBy: conceptosFilters.orderBy,
|
orderBy: conceptosSort.orderBy,
|
||||||
orderDir: conceptosFilters.orderDir,
|
orderDir: conceptosSort.orderDir,
|
||||||
page: 1,
|
page: 1,
|
||||||
limit: EXPORT_MAX,
|
limit: EXPORT_MAX,
|
||||||
});
|
});
|
||||||
@@ -1598,17 +1622,17 @@ export default function CfdiPage() {
|
|||||||
UUID
|
UUID
|
||||||
<Popover open={conceptosOpenFilter === 'uuid'} onOpenChange={(open) => setConceptosOpenFilter(open ? 'uuid' : null)}>
|
<Popover open={conceptosOpenFilter === 'uuid'} onOpenChange={(open) => setConceptosOpenFilter(open ? 'uuid' : null)}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<button className={`p-1 rounded hover:bg-muted ${conceptosFilters.uuidLike ? 'text-primary' : ''}`}>
|
<button className={`p-1 rounded hover:bg-muted ${appliedConceptosFilters.uuidLike ? 'text-primary' : ''}`}>
|
||||||
<Filter className="h-3.5 w-3.5" />
|
<Filter className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-64" align="start">
|
<PopoverContent className="w-64" align="start">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h4 className="font-medium text-sm">Filtrar por UUID</h4>
|
<h4 className="font-medium text-sm">Filtrar por UUID</h4>
|
||||||
<Input className="h-8 text-sm font-mono" placeholder="Fragmento del UUID..." value={conceptosFilters.uuidLike} onChange={(e) => setConceptosFilters({ ...conceptosFilters, uuidLike: e.target.value })} />
|
<Input className="h-8 text-sm font-mono" placeholder="Fragmento del UUID..." value={conceptosDraftFilters.uuidLike} onChange={(e) => setConceptosDraftFilters({ ...conceptosDraftFilters, uuidLike: e.target.value })} />
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button size="sm" className="flex-1" onClick={() => { setFilters({ ...filters, page: 1 }); setConceptosOpenFilter(null); }}>Aplicar</Button>
|
<Button size="sm" className="flex-1" onClick={applyConceptosFilters}>Aplicar</Button>
|
||||||
{conceptosFilters.uuidLike && <Button size="sm" variant="outline" onClick={() => { setConceptosFilters({ ...conceptosFilters, uuidLike: '' }); setFilters({ ...filters, page: 1 }); }}>Limpiar</Button>}
|
{appliedConceptosFilters.uuidLike && <Button size="sm" variant="outline" onClick={() => clearConceptosFilter('uuidLike')}>Limpiar</Button>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
@@ -1620,17 +1644,17 @@ export default function CfdiPage() {
|
|||||||
Clave
|
Clave
|
||||||
<Popover open={conceptosOpenFilter === 'clave'} onOpenChange={(open) => setConceptosOpenFilter(open ? 'clave' : null)}>
|
<Popover open={conceptosOpenFilter === 'clave'} onOpenChange={(open) => setConceptosOpenFilter(open ? 'clave' : null)}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<button className={`p-1 rounded hover:bg-muted ${conceptosFilters.claveProdServ ? 'text-primary' : ''}`}>
|
<button className={`p-1 rounded hover:bg-muted ${appliedConceptosFilters.claveProdServ ? 'text-primary' : ''}`}>
|
||||||
<Filter className="h-3.5 w-3.5" />
|
<Filter className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-64" align="start">
|
<PopoverContent className="w-64" align="start">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h4 className="font-medium text-sm">Filtrar por Clave SAT</h4>
|
<h4 className="font-medium text-sm">Filtrar por Clave SAT</h4>
|
||||||
<Input className="h-8 text-sm font-mono" placeholder="Ej: 81112502" value={conceptosFilters.claveProdServ} onChange={(e) => setConceptosFilters({ ...conceptosFilters, claveProdServ: e.target.value })} />
|
<Input className="h-8 text-sm font-mono" placeholder="Ej: 81112502" value={conceptosDraftFilters.claveProdServ} onChange={(e) => setConceptosDraftFilters({ ...conceptosDraftFilters, claveProdServ: e.target.value })} />
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button size="sm" className="flex-1" onClick={() => { setFilters({ ...filters, page: 1 }); setConceptosOpenFilter(null); }}>Aplicar</Button>
|
<Button size="sm" className="flex-1" onClick={applyConceptosFilters}>Aplicar</Button>
|
||||||
{conceptosFilters.claveProdServ && <Button size="sm" variant="outline" onClick={() => { setConceptosFilters({ ...conceptosFilters, claveProdServ: '' }); setFilters({ ...filters, page: 1 }); }}>Limpiar</Button>}
|
{appliedConceptosFilters.claveProdServ && <Button size="sm" variant="outline" onClick={() => clearConceptosFilter('claveProdServ')}>Limpiar</Button>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
@@ -1642,17 +1666,17 @@ export default function CfdiPage() {
|
|||||||
Descripción
|
Descripción
|
||||||
<Popover open={conceptosOpenFilter === 'descripcion'} onOpenChange={(open) => setConceptosOpenFilter(open ? 'descripcion' : null)}>
|
<Popover open={conceptosOpenFilter === 'descripcion'} onOpenChange={(open) => setConceptosOpenFilter(open ? 'descripcion' : null)}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<button className={`p-1 rounded hover:bg-muted ${conceptosFilters.descripcionConcepto ? 'text-primary' : ''}`}>
|
<button className={`p-1 rounded hover:bg-muted ${appliedConceptosFilters.descripcionConcepto ? 'text-primary' : ''}`}>
|
||||||
<Filter className="h-3.5 w-3.5" />
|
<Filter className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-72" align="start">
|
<PopoverContent className="w-72" align="start">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h4 className="font-medium text-sm">Filtrar por descripción</h4>
|
<h4 className="font-medium text-sm">Filtrar por descripción</h4>
|
||||||
<Input className="h-8 text-sm" placeholder="Texto contenido en la descripción..." value={conceptosFilters.descripcionConcepto} onChange={(e) => setConceptosFilters({ ...conceptosFilters, descripcionConcepto: e.target.value })} />
|
<Input className="h-8 text-sm" placeholder="Texto contenido en la descripción..." value={conceptosDraftFilters.descripcionConcepto} onChange={(e) => setConceptosDraftFilters({ ...conceptosDraftFilters, descripcionConcepto: e.target.value })} />
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button size="sm" className="flex-1" onClick={() => { setFilters({ ...filters, page: 1 }); setConceptosOpenFilter(null); }}>Aplicar</Button>
|
<Button size="sm" className="flex-1" onClick={applyConceptosFilters}>Aplicar</Button>
|
||||||
{conceptosFilters.descripcionConcepto && <Button size="sm" variant="outline" onClick={() => { setConceptosFilters({ ...conceptosFilters, descripcionConcepto: '' }); setFilters({ ...filters, page: 1 }); }}>Limpiar</Button>}
|
{appliedConceptosFilters.descripcionConcepto && <Button size="sm" variant="outline" onClick={() => clearConceptosFilter('descripcionConcepto')}>Limpiar</Button>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
@@ -1664,17 +1688,17 @@ export default function CfdiPage() {
|
|||||||
No. Identificación
|
No. Identificación
|
||||||
<Popover open={conceptosOpenFilter === 'noIdentificacion'} onOpenChange={(open) => setConceptosOpenFilter(open ? 'noIdentificacion' : null)}>
|
<Popover open={conceptosOpenFilter === 'noIdentificacion'} onOpenChange={(open) => setConceptosOpenFilter(open ? 'noIdentificacion' : null)}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<button className={`p-1 rounded hover:bg-muted ${conceptosFilters.noIdentificacion ? 'text-primary' : ''}`}>
|
<button className={`p-1 rounded hover:bg-muted ${appliedConceptosFilters.noIdentificacion ? 'text-primary' : ''}`}>
|
||||||
<Filter className="h-3.5 w-3.5" />
|
<Filter className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-64" align="start">
|
<PopoverContent className="w-64" align="start">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h4 className="font-medium text-sm">Filtrar por No. Identificación</h4>
|
<h4 className="font-medium text-sm">Filtrar por No. Identificación</h4>
|
||||||
<Input className="h-8 text-sm font-mono" placeholder="Ej: PROD-001" value={conceptosFilters.noIdentificacion} onChange={(e) => setConceptosFilters({ ...conceptosFilters, noIdentificacion: e.target.value })} />
|
<Input className="h-8 text-sm font-mono" placeholder="Ej: PROD-001" value={conceptosDraftFilters.noIdentificacion} onChange={(e) => setConceptosDraftFilters({ ...conceptosDraftFilters, noIdentificacion: e.target.value })} />
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button size="sm" className="flex-1" onClick={() => { setFilters({ ...filters, page: 1 }); setConceptosOpenFilter(null); }}>Aplicar</Button>
|
<Button size="sm" className="flex-1" onClick={applyConceptosFilters}>Aplicar</Button>
|
||||||
{conceptosFilters.noIdentificacion && <Button size="sm" variant="outline" onClick={() => { setConceptosFilters({ ...conceptosFilters, noIdentificacion: '' }); setFilters({ ...filters, page: 1 }); }}>Limpiar</Button>}
|
{appliedConceptosFilters.noIdentificacion && <Button size="sm" variant="outline" onClick={() => clearConceptosFilter('noIdentificacion')}>Limpiar</Button>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
@@ -1694,8 +1718,8 @@ export default function CfdiPage() {
|
|||||||
title="Ordenar por importe"
|
title="Ordenar por importe"
|
||||||
>
|
>
|
||||||
Importe
|
Importe
|
||||||
{conceptosFilters.orderBy === 'importe' ? (
|
{conceptosSort.orderBy === 'importe' ? (
|
||||||
<span className="text-primary">{conceptosFilters.orderDir === 'asc' ? '▲' : '▼'}</span>
|
<span className="text-primary">{conceptosSort.orderDir === 'asc' ? '▲' : '▼'}</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground/40">⇅</span>
|
<span className="text-muted-foreground/40">⇅</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ export default function CsdConfigPage() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [creatingOrg, setCreatingOrg] = useState(false);
|
||||||
const [cerFile, setCerFile] = useState<string>('');
|
const [cerFile, setCerFile] = useState<string>('');
|
||||||
const [keyFile, setKeyFile] = useState<string>('');
|
const [keyFile, setKeyFile] = useState<string>('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
@@ -178,16 +179,28 @@ export default function CsdConfigPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateOrg = async () => {
|
const handleCreateOrg = async () => {
|
||||||
|
if (creatingOrg) return;
|
||||||
|
setCreatingOrg(true);
|
||||||
|
setMessage(null);
|
||||||
try {
|
try {
|
||||||
|
const cfg = { timeout: 60000 };
|
||||||
if (selectedContribuyenteId) {
|
if (selectedContribuyenteId) {
|
||||||
await apiClient.post(`/contribuyentes/${selectedContribuyenteId}/facturapi/org`);
|
await apiClient.post(`/contribuyentes/${selectedContribuyenteId}/facturapi/org`, undefined, cfg);
|
||||||
} else {
|
} else {
|
||||||
await apiClient.post('/facturacion/org');
|
await apiClient.post('/facturacion/org', undefined, cfg);
|
||||||
}
|
}
|
||||||
queryClient.invalidateQueries({ queryKey: ['facturapi-org-contrib'] });
|
queryClient.invalidateQueries({ queryKey: ['facturapi-org-contrib'] });
|
||||||
setMessage({ type: 'success', text: 'Organización creada en Facturapi' });
|
setMessage({ type: 'success', text: 'Organización creada en Facturapi' });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setMessage({ type: 'error', text: err.response?.data?.message || 'Error al crear organización' });
|
const isTimeout = err?.code === 'ECONNABORTED';
|
||||||
|
setMessage({
|
||||||
|
type: 'error',
|
||||||
|
text: isTimeout
|
||||||
|
? 'La creación está tardando más de lo esperado. Refresca la página en unos segundos; si no aparece, intenta de nuevo.'
|
||||||
|
: (err.response?.data?.message || 'Error al crear organización'),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setCreatingOrg(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -268,7 +281,9 @@ export default function CsdConfigPage() {
|
|||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
No hay organización configurada para este tenant.
|
No hay organización configurada para este tenant.
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={handleCreateOrg}>Crear Organización</Button>
|
<Button onClick={handleCreateOrg} disabled={creatingOrg}>
|
||||||
|
{creatingOrg ? 'Creando… (puede tardar unos segundos)' : 'Crear Organización'}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-2 text-sm">
|
||||||
|
|||||||
@@ -1,51 +1,59 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Header } from '@/components/layouts/header';
|
import { Header } from '@/components/layouts/header';
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@horux/shared-ui';
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@horux/shared-ui';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import { useContribuyenteStore } from '@/stores/contribuyente-store';
|
|
||||||
import { Bell, Loader2 } from 'lucide-react';
|
import { Bell, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
|
owner: 'Owner',
|
||||||
|
supervisor: 'Supervisor',
|
||||||
|
auxiliar: 'Auxiliar',
|
||||||
|
cliente: 'Cliente',
|
||||||
|
};
|
||||||
|
|
||||||
const EMAIL_LABELS: Record<string, { label: string; description: string; status: 'active' | 'pending' }> = {
|
const EMAIL_LABELS: Record<string, { label: string; description: string; status: 'active' | 'pending' }> = {
|
||||||
documento_subido: {
|
documento_subido: {
|
||||||
label: 'Documento subido',
|
label: 'Documento subido',
|
||||||
description: 'Notificación cuando se sube una declaración o documento extra del contribuyente.',
|
description: 'Cuando se sube una declaración o documento extra del contribuyente.',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
},
|
},
|
||||||
weekly_update: {
|
weekly_update: {
|
||||||
label: 'Reporte semanal',
|
label: 'Reporte semanal',
|
||||||
description: 'Resumen de KPIs, alertas y discrepancias enviado los lunes 8:00 AM.',
|
description: 'Resumen de KPIs, alertas y discrepancias enviado los lunes 8:00 AM.',
|
||||||
status: 'pending',
|
status: 'active',
|
||||||
},
|
},
|
||||||
subscription_expiring: {
|
subscription_expiring: {
|
||||||
label: 'Vencimiento de suscripción',
|
label: 'Vencimiento de suscripción',
|
||||||
description: 'Aviso cuando la suscripción del despacho está por vencer.',
|
description: 'Aviso cuando la suscripción del despacho está por vencer.',
|
||||||
status: 'pending',
|
status: 'active',
|
||||||
},
|
},
|
||||||
recordatorio_fiscal: {
|
recordatorio_fiscal: {
|
||||||
label: 'Recordatorios fiscales',
|
label: 'Recordatorios fiscales',
|
||||||
description: 'Avisos de obligaciones próximas a vencer (declaraciones, pagos provisionales).',
|
description: 'Avisos de obligaciones próximas a vencer (declaraciones, pagos provisionales).',
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
},
|
},
|
||||||
|
alertas_nuevas: {
|
||||||
|
label: 'Alertas nuevas',
|
||||||
|
description: 'Notificación diaria cuando aparecen alertas fiscales nuevas para un contribuyente.',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
recordatorio_proximo: {
|
||||||
|
label: 'Recordatorios próximos',
|
||||||
|
description: 'Avisos de recordatorios del calendario a 3, 1 y 0 días de su fecha límite.',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ContribuyentePrefs {
|
|
||||||
contribuyenteId: string;
|
|
||||||
rfc: string;
|
|
||||||
nombre: string;
|
|
||||||
preferences: Record<string, boolean>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ListResponse {
|
interface ListResponse {
|
||||||
emailTypes: string[];
|
emailTypes: string[];
|
||||||
data: ContribuyentePrefs[];
|
roles: string[];
|
||||||
|
preferences: Record<string, Record<string, boolean>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NotificacionesPage() {
|
export default function NotificacionesPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { selectedContribuyenteId } = useContribuyenteStore();
|
|
||||||
|
|
||||||
const { data, isLoading } = useQuery<ListResponse>({
|
const { data, isLoading } = useQuery<ListResponse>({
|
||||||
queryKey: ['notification-preferences'],
|
queryKey: ['notification-preferences'],
|
||||||
@@ -55,32 +63,23 @@ export default function NotificacionesPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Aplica el filtro del selector global de contribuyente. Si hay uno
|
|
||||||
// seleccionado, solo se muestra esa fila. "Todos" muestra todos.
|
|
||||||
const visibles = useMemo(() => {
|
|
||||||
if (!data) return [];
|
|
||||||
if (!selectedContribuyenteId) return data.data;
|
|
||||||
return data.data.filter(c => c.contribuyenteId === selectedContribuyenteId);
|
|
||||||
}, [data, selectedContribuyenteId]);
|
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: async ({ contribuyenteId, emailType, enabled }: { contribuyenteId: string; emailType: string; enabled: boolean }) => {
|
mutationFn: async ({ emailType, role, enabled }: { emailType: string; role: string; enabled: boolean }) => {
|
||||||
await apiClient.put('/notificaciones', {
|
await apiClient.put('/notificaciones', { emailType, role, enabled });
|
||||||
contribuyenteId,
|
|
||||||
preferences: { [emailType]: enabled },
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
onMutate: async ({ contribuyenteId, emailType, enabled }) => {
|
onMutate: async ({ emailType, role, enabled }) => {
|
||||||
await queryClient.cancelQueries({ queryKey: ['notification-preferences'] });
|
await queryClient.cancelQueries({ queryKey: ['notification-preferences'] });
|
||||||
const previous = queryClient.getQueryData<ListResponse>(['notification-preferences']);
|
const previous = queryClient.getQueryData<ListResponse>(['notification-preferences']);
|
||||||
if (previous) {
|
if (previous) {
|
||||||
queryClient.setQueryData<ListResponse>(['notification-preferences'], {
|
queryClient.setQueryData<ListResponse>(['notification-preferences'], {
|
||||||
...previous,
|
...previous,
|
||||||
data: previous.data.map(c =>
|
preferences: {
|
||||||
c.contribuyenteId === contribuyenteId
|
...previous.preferences,
|
||||||
? { ...c, preferences: { ...c.preferences, [emailType]: enabled } }
|
[emailType]: {
|
||||||
: c,
|
...previous.preferences[emailType],
|
||||||
),
|
[role]: enabled,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return { previous };
|
return { previous };
|
||||||
@@ -93,6 +92,9 @@ export default function NotificacionesPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const roles = data?.roles ?? [];
|
||||||
|
const emailTypes = data?.emailTypes ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header title="Notificaciones" />
|
<Header title="Notificaciones" />
|
||||||
@@ -101,10 +103,10 @@ export default function NotificacionesPage() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
<Bell className="h-4 w-4" />
|
<Bell className="h-4 w-4" />
|
||||||
Correos informativos por contribuyente
|
Correos informativos por rol
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Por default todos los correos están activados. Desactiva los que no quieras recibir para cada cliente. Los correos críticos (welcome, recuperación de contraseña, confirmación de pago) siempre se envían independientemente de esta configuración.
|
Activa o desactiva cada notificación según el rol del usuario en el despacho. Por default todos están activados. Los correos críticos (welcome, recuperación de contraseña, confirmación de pago) siempre se envían.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -114,35 +116,30 @@ export default function NotificacionesPage() {
|
|||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
Cargando...
|
Cargando...
|
||||||
</div>
|
</div>
|
||||||
) : visibles.length === 0 ? (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="py-8 text-center text-muted-foreground">
|
|
||||||
{selectedContribuyenteId
|
|
||||||
? 'El contribuyente seleccionado no tiene preferencias configuradas todavía.'
|
|
||||||
: 'No hay contribuyentes en este despacho.'}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
) : (
|
||||||
visibles.map(contrib => (
|
<Card>
|
||||||
<Card key={contrib.contribuyenteId}>
|
<CardContent className="p-0 overflow-x-auto">
|
||||||
<CardHeader>
|
<table className="w-full text-sm">
|
||||||
<CardTitle className="text-sm font-medium">
|
<thead>
|
||||||
{contrib.nombre}
|
<tr className="border-b bg-muted/50">
|
||||||
</CardTitle>
|
<th className="text-left font-medium px-4 py-3 w-1/3">Notificación</th>
|
||||||
<CardDescription className="font-mono text-xs">{contrib.rfc}</CardDescription>
|
{roles.map(role => (
|
||||||
</CardHeader>
|
<th key={role} className="text-center font-medium px-4 py-3 min-w-[100px]">
|
||||||
<CardContent>
|
{ROLE_LABELS[role] ?? role}
|
||||||
<div className="space-y-3">
|
</th>
|
||||||
{(data?.emailTypes ?? []).map(type => {
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{emailTypes.map(type => {
|
||||||
const meta = EMAIL_LABELS[type];
|
const meta = EMAIL_LABELS[type];
|
||||||
if (!meta) return null;
|
if (!meta) return null;
|
||||||
const checked = contrib.preferences[type] !== false;
|
|
||||||
const isPending = meta.status === 'pending';
|
const isPending = meta.status === 'pending';
|
||||||
return (
|
return (
|
||||||
<div key={type} className="flex items-start justify-between gap-4 py-2 border-b last:border-0">
|
<tr key={type} className="border-b last:border-0">
|
||||||
<div className="flex-1 min-w-0">
|
<td className="px-4 py-3 align-top">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className="text-sm font-medium">{meta.label}</span>
|
<span className="font-medium">{meta.label}</span>
|
||||||
{isPending && (
|
{isPending && (
|
||||||
<span className="text-[10px] uppercase tracking-wide bg-muted text-muted-foreground rounded px-1.5 py-0.5">
|
<span className="text-[10px] uppercase tracking-wide bg-muted text-muted-foreground rounded px-1.5 py-0.5">
|
||||||
Próximamente
|
Próximamente
|
||||||
@@ -150,29 +147,37 @@ export default function NotificacionesPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">{meta.description}</p>
|
<p className="text-xs text-muted-foreground mt-0.5">{meta.description}</p>
|
||||||
</div>
|
</td>
|
||||||
<label className="inline-flex items-center cursor-pointer flex-shrink-0">
|
{roles.map(role => {
|
||||||
<input
|
const checked = data?.preferences?.[type]?.[role] !== false;
|
||||||
type="checkbox"
|
return (
|
||||||
className="sr-only peer"
|
<td key={role} className="px-4 py-3 text-center align-middle">
|
||||||
checked={checked}
|
<label className={`inline-flex items-center ${isPending ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}>
|
||||||
onChange={e =>
|
<input
|
||||||
mutation.mutate({
|
type="checkbox"
|
||||||
contribuyenteId: contrib.contribuyenteId,
|
className="sr-only peer"
|
||||||
emailType: type,
|
checked={checked}
|
||||||
enabled: e.target.checked,
|
disabled={isPending}
|
||||||
})
|
onChange={e =>
|
||||||
}
|
mutation.mutate({
|
||||||
/>
|
emailType: type,
|
||||||
<div className="relative w-10 h-6 bg-muted peer-checked:bg-primary rounded-full peer-focus:ring-2 peer-focus:ring-primary/30 transition-colors after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-transform peer-checked:after:translate-x-4" />
|
role,
|
||||||
</label>
|
enabled: e.target.checked,
|
||||||
</div>
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="relative w-10 h-6 bg-muted peer-checked:bg-primary rounded-full peer-focus:ring-2 peer-focus:ring-primary/30 transition-colors after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-transform peer-checked:after:translate-x-4" />
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</tbody>
|
||||||
</CardContent>
|
</table>
|
||||||
</Card>
|
</CardContent>
|
||||||
))
|
</Card>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -189,6 +189,7 @@ export default function ObligacionesPage() {
|
|||||||
mensual: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
|
mensual: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
|
||||||
bimestral: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300',
|
bimestral: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300',
|
||||||
trimestral: 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300',
|
trimestral: 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300',
|
||||||
|
cuatrimestral: 'bg-pink-100 text-pink-700 dark:bg-pink-900 dark:text-pink-300',
|
||||||
anual: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
|
anual: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
|
||||||
eventual: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
|
eventual: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { apiClient } from '@/lib/api/client';
|
|||||||
import { subscribeMe, changeMyPlan, cancelMySubscription, upgradeMe, generatePaymentLink } from '@/lib/api/subscription';
|
import { subscribeMe, changeMyPlan, cancelMySubscription, upgradeMe, generatePaymentLink } from '@/lib/api/subscription';
|
||||||
import { getPendingInvitation, acceptInvitation } from '@/lib/api/trial-invitations';
|
import { getPendingInvitation, acceptInvitation } from '@/lib/api/trial-invitations';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
import { getSubscriptionState } from '@horux/shared';
|
||||||
|
|
||||||
type Despachoplan = 'trial' | 'business_control' | 'business_cloud' | 'mi_empresa' | 'mi_empresa_plus' | 'custom';
|
type Despachoplan = 'trial' | 'business_control' | 'business_cloud' | 'mi_empresa' | 'mi_empresa_plus' | 'custom';
|
||||||
type PaidPlan = 'business_control' | 'business_cloud' | 'mi_empresa' | 'mi_empresa_plus';
|
type PaidPlan = 'business_control' | 'business_cloud' | 'mi_empresa' | 'mi_empresa_plus';
|
||||||
@@ -24,6 +25,7 @@ interface PlanInfo {
|
|||||||
dbMode: string;
|
dbMode: string;
|
||||||
trialEndsAt: string | null;
|
trialEndsAt: string | null;
|
||||||
isTrialActive: boolean;
|
isTrialActive: boolean;
|
||||||
|
planPrice: number | null;
|
||||||
subscription: SubscriptionInfo | null;
|
subscription: SubscriptionInfo | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,9 +90,14 @@ export default function PlanesDespachoPage() {
|
|||||||
// El usuario puede cancelar si tiene una suscripción que aún corre (paid, trial,
|
// El usuario puede cancelar si tiene una suscripción que aún corre (paid, trial,
|
||||||
// custom). Si ya está cancelada o expirada, no hay nada que cancelar.
|
// custom). Si ya está cancelada o expirada, no hay nada que cancelar.
|
||||||
const subStatus = planInfo?.subscription?.status ?? null;
|
const subStatus = planInfo?.subscription?.status ?? null;
|
||||||
const hasActiveSub = subStatus != null
|
const subState = planInfo?.subscription ? getSubscriptionState(planInfo.subscription) : null;
|
||||||
&& subStatus !== 'cancelled'
|
const hasActiveSub = subState?.isActive || subState?.isTrial || subState?.isCancelledInPeriod || false;
|
||||||
&& subStatus !== 'trial_expired';
|
// Estados en los que se puede generar un link de pago (incluye trial, vencido y pending).
|
||||||
|
const isPayableStatus = subStatus === 'trial'
|
||||||
|
|| subStatus === 'trial_expired'
|
||||||
|
|| subStatus === 'pending'
|
||||||
|
|| hasActiveSub;
|
||||||
|
const isCurrentPlanPaid = currentPlan === planInfo?.subscription?.plan && subState?.isActive === true;
|
||||||
|
|
||||||
/** Resuelve la frecuencia para un plan. Mi Empresa y Mi Empresa+ leen su
|
/** Resuelve la frecuencia para un plan. Mi Empresa y Mi Empresa+ leen su
|
||||||
* propio toggle; el resto (business_*) siempre annual. */
|
* propio toggle; el resto (business_*) siempre annual. */
|
||||||
@@ -105,6 +112,15 @@ export default function PlanesDespachoPage() {
|
|||||||
setBusy(plan);
|
setBusy(plan);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
try {
|
try {
|
||||||
|
// Si el plan actual está pendiente de pago, solo regeneramos el link de pago.
|
||||||
|
if (currentPlan === plan && subState?.isPending) {
|
||||||
|
return await handlePagarAhora();
|
||||||
|
}
|
||||||
|
// Si tiene una sub pendiente en otro plan, no permitir cambiar hasta pagar.
|
||||||
|
if (subState?.isPending) {
|
||||||
|
setMessage({ kind: 'err', text: 'Completa el pago del plan actual antes de cambiar de plan.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Sin sub activa: subscribe directo → MP (preapproval del plan completo).
|
// Sin sub activa: subscribe directo → MP (preapproval del plan completo).
|
||||||
const result = await subscribeMe({ plan, frequency });
|
const result = await subscribeMe({ plan, frequency });
|
||||||
window.open(result.paymentUrl, '_blank');
|
window.open(result.paymentUrl, '_blank');
|
||||||
@@ -190,10 +206,10 @@ export default function PlanesDespachoPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ActiveBadge() {
|
function CurrentPlanBadge({ pending }: { pending?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-green-600 text-white text-xs px-3 py-1 rounded-full font-medium whitespace-nowrap">
|
<div className={`absolute -top-3 left-1/2 -translate-x-1/2 text-white text-xs px-3 py-1 rounded-full font-medium whitespace-nowrap ${pending ? 'bg-yellow-600' : 'bg-green-600'}`}>
|
||||||
Plan actual
|
{pending ? 'Plan actual — pendiente' : 'Plan actual'}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -225,9 +241,25 @@ export default function PlanesDespachoPage() {
|
|||||||
|
|
||||||
function PlanActionButton({ plan }: { plan: PaidPlan }) {
|
function PlanActionButton({ plan }: { plan: PaidPlan }) {
|
||||||
const isCurrent = currentPlan === plan;
|
const isCurrent = currentPlan === plan;
|
||||||
if (isCurrent) {
|
if (isCurrent && isCurrentPlanPaid) {
|
||||||
return <Button disabled className="w-full">Plan actual</Button>;
|
return <Button disabled className="w-full">Plan actual</Button>;
|
||||||
}
|
}
|
||||||
|
if (isCurrent) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => handleContratar(plan)}
|
||||||
|
disabled={busy === plan}
|
||||||
|
>
|
||||||
|
{busy === plan ? 'Procesando...' : (
|
||||||
|
<>
|
||||||
|
<ExternalLink className="h-4 w-4 mr-2" />
|
||||||
|
Pagar este plan
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
const label = hasActiveSub ? 'Cambiar a este plan' : 'Contratar';
|
const label = hasActiveSub ? 'Cambiar a este plan' : 'Contratar';
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@@ -302,7 +334,7 @@ export default function PlanesDespachoPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Banner de suscripción activa */}
|
{/* Banner de suscripción activa */}
|
||||||
{!loading && planInfo?.subscription && hasPaidPlan && (() => {
|
{!loading && planInfo?.subscription && hasPaidPlan && subState?.isActive && (() => {
|
||||||
const sub = planInfo.subscription;
|
const sub = planInfo.subscription;
|
||||||
const periodEndDate = sub.currentPeriodEnd ? new Date(sub.currentPeriodEnd) : null;
|
const periodEndDate = sub.currentPeriodEnd ? new Date(sub.currentPeriodEnd) : null;
|
||||||
const fechaFormato = periodEndDate
|
const fechaFormato = periodEndDate
|
||||||
@@ -329,18 +361,45 @@ export default function PlanesDespachoPage() {
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* Botón "Pagar mi período actual" — visible cuando la sub corre y hay
|
{/* Banner de suscripción pendiente */}
|
||||||
un monto > 0 que cobrar. Crea una MP Preference one-off por el monto
|
{!loading && planInfo?.subscription && hasPaidPlan && subState?.isPending && (
|
||||||
actual (custom $10, paid plan, lo que sea). Útil para pre-pagar antes
|
<div className="flex items-start gap-3 bg-yellow-50 dark:bg-yellow-950 border border-yellow-200 dark:border-yellow-800 rounded-lg px-4 py-3 max-w-3xl mx-auto">
|
||||||
del cobro automático o cuando no hay preapproval recurrente activo. */}
|
<Clock className="h-5 w-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" />
|
||||||
{!loading && hasActiveSub && planInfo?.subscription && Number(planInfo.subscription.amount) > 0 && (() => {
|
<div className="text-sm space-y-0.5">
|
||||||
|
<div className="font-semibold text-yellow-800 dark:text-yellow-300">
|
||||||
|
Suscripción pendiente de pago
|
||||||
|
</div>
|
||||||
|
<div className="text-yellow-700 dark:text-yellow-400">
|
||||||
|
Tu suscripción aún no está activa. Completa el pago para evitar la suspensión del servicio.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Banner de trial vencido */}
|
||||||
|
{!loading && subStatus === 'trial_expired' && hasPaidPlan && (
|
||||||
|
<div className="flex items-start gap-3 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-lg px-4 py-3 max-w-3xl mx-auto">
|
||||||
|
<Clock className="h-5 w-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="font-semibold text-red-800 dark:text-red-300">Tu período de prueba terminó</span>
|
||||||
|
<span className="text-red-700 dark:text-red-400"> — elige un plan o paga el plan actual para recuperar el acceso.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Botón "Pagar mi período actual" — visible cuando se puede pagar y hay
|
||||||
|
un monto definido (subscription.amount > 0 o precio de catálogo).
|
||||||
|
Crea una MP Preference one-off por el monto actual. */}
|
||||||
|
{!loading && isPayableStatus && planInfo?.subscription && (() => {
|
||||||
const sub = planInfo.subscription!;
|
const sub = planInfo.subscription!;
|
||||||
|
const effectiveAmount = Number(sub.amount) > 0 ? Number(sub.amount) : (planInfo.planPrice ?? 0);
|
||||||
|
if (!effectiveAmount) return null;
|
||||||
const periodEnd = sub.currentPeriodEnd ? new Date(sub.currentPeriodEnd) : null;
|
const periodEnd = sub.currentPeriodEnd ? new Date(sub.currentPeriodEnd) : null;
|
||||||
const fechaFmt = periodEnd
|
const fechaFmt = periodEnd
|
||||||
? periodEnd.toLocaleDateString('es-MX', { year: 'numeric', month: 'long', day: 'numeric' })
|
? periodEnd.toLocaleDateString('es-MX', { year: 'numeric', month: 'long', day: 'numeric' })
|
||||||
: null;
|
: null;
|
||||||
const dias = periodEnd ? Math.max(0, Math.ceil((periodEnd.getTime() - Date.now()) / (1000 * 60 * 60 * 24))) : null;
|
const dias = periodEnd ? Math.max(0, Math.ceil((periodEnd.getTime() - Date.now()) / (1000 * 60 * 60 * 24))) : null;
|
||||||
const montoFmt = Number(sub.amount).toLocaleString('es-MX', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
const montoFmt = effectiveAmount.toLocaleString('es-MX', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg px-5 py-4 max-w-3xl mx-auto">
|
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg px-5 py-4 max-w-3xl mx-auto">
|
||||||
<CreditCard className="h-6 w-6 text-blue-600 dark:text-blue-400 flex-shrink-0" />
|
<CreditCard className="h-6 w-6 text-blue-600 dark:text-blue-400 flex-shrink-0" />
|
||||||
@@ -388,7 +447,7 @@ export default function PlanesDespachoPage() {
|
|||||||
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6 max-w-7xl mx-auto">
|
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6 max-w-7xl mx-auto">
|
||||||
{/* Mi Empresa */}
|
{/* Mi Empresa */}
|
||||||
<Card className={`relative flex flex-col${currentPlan === 'mi_empresa' ? ' ring-2 ring-green-500' : ''}`}>
|
<Card className={`relative flex flex-col${currentPlan === 'mi_empresa' ? ' ring-2 ring-green-500' : ''}`}>
|
||||||
{currentPlan === 'mi_empresa' && <ActiveBadge />}
|
{currentPlan === 'mi_empresa' && <CurrentPlanBadge pending={subState?.isPending} />}
|
||||||
<CardHeader className="text-center pb-2">
|
<CardHeader className="text-center pb-2">
|
||||||
<div className="mx-auto bg-emerald-100 dark:bg-emerald-900 rounded-full p-3 w-fit mb-2">
|
<div className="mx-auto bg-emerald-100 dark:bg-emerald-900 rounded-full p-3 w-fit mb-2">
|
||||||
<Cloud className="h-6 w-6 text-emerald-600 dark:text-emerald-400" />
|
<Cloud className="h-6 w-6 text-emerald-600 dark:text-emerald-400" />
|
||||||
@@ -422,7 +481,7 @@ export default function PlanesDespachoPage() {
|
|||||||
|
|
||||||
{/* Mi Empresa + */}
|
{/* Mi Empresa + */}
|
||||||
<Card className={`relative flex flex-col${currentPlan === 'mi_empresa_plus' ? ' ring-2 ring-green-500' : ''}`}>
|
<Card className={`relative flex flex-col${currentPlan === 'mi_empresa_plus' ? ' ring-2 ring-green-500' : ''}`}>
|
||||||
{currentPlan === 'mi_empresa_plus' && <ActiveBadge />}
|
{currentPlan === 'mi_empresa_plus' && <CurrentPlanBadge pending={subState?.isPending} />}
|
||||||
<CardHeader className="text-center pb-2">
|
<CardHeader className="text-center pb-2">
|
||||||
<div className="mx-auto bg-teal-100 dark:bg-teal-900 rounded-full p-3 w-fit mb-2">
|
<div className="mx-auto bg-teal-100 dark:bg-teal-900 rounded-full p-3 w-fit mb-2">
|
||||||
<Cloud className="h-6 w-6 text-teal-600 dark:text-teal-400" />
|
<Cloud className="h-6 w-6 text-teal-600 dark:text-teal-400" />
|
||||||
@@ -459,7 +518,7 @@ export default function PlanesDespachoPage() {
|
|||||||
{/* Business Control */}
|
{/* Business Control */}
|
||||||
<Card className={`relative flex flex-col${currentPlan === 'business_control' ? ' ring-2 ring-green-500' : ' border-primary ring-2 ring-primary/20'}`}>
|
<Card className={`relative flex flex-col${currentPlan === 'business_control' ? ' ring-2 ring-green-500' : ' border-primary ring-2 ring-primary/20'}`}>
|
||||||
{currentPlan === 'business_control'
|
{currentPlan === 'business_control'
|
||||||
? <ActiveBadge />
|
? <CurrentPlanBadge pending={subState?.isPending} />
|
||||||
: (
|
: (
|
||||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-primary text-primary-foreground text-xs px-3 py-1 rounded-full">
|
<div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-primary text-primary-foreground text-xs px-3 py-1 rounded-full">
|
||||||
Más popular
|
Más popular
|
||||||
@@ -475,9 +534,9 @@ export default function PlanesDespachoPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col flex-1 gap-4">
|
<CardContent className="flex flex-col flex-1 gap-4">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-3xl font-bold">$25,850</div>
|
<div className="text-3xl font-bold">$30,850</div>
|
||||||
<p className="text-sm text-muted-foreground">por año (IVA incluido)</p>
|
<p className="text-sm text-muted-foreground">por año (IVA incluido)</p>
|
||||||
<p className="text-xs text-muted-foreground mt-1">+ $45/mes por cada RFC adicional sobre 100</p>
|
<p className="text-xs text-muted-foreground mt-1">+ $25/mes por cada RFC adicional sobre 100</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-2 text-sm">
|
||||||
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Hasta 100 RFCs</span></div>
|
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Hasta 100 RFCs</span></div>
|
||||||
@@ -494,7 +553,7 @@ export default function PlanesDespachoPage() {
|
|||||||
|
|
||||||
{/* Enterprise (key interna: business_cloud) */}
|
{/* Enterprise (key interna: business_cloud) */}
|
||||||
<Card className={`relative flex flex-col${currentPlan === 'business_cloud' ? ' ring-2 ring-green-500' : ''}`}>
|
<Card className={`relative flex flex-col${currentPlan === 'business_cloud' ? ' ring-2 ring-green-500' : ''}`}>
|
||||||
{currentPlan === 'business_cloud' && <ActiveBadge />}
|
{currentPlan === 'business_cloud' && <CurrentPlanBadge pending={subState?.isPending} />}
|
||||||
<CardHeader className="text-center pb-2">
|
<CardHeader className="text-center pb-2">
|
||||||
<div className="mx-auto bg-purple-100 dark:bg-purple-900 rounded-full p-3 w-fit mb-2">
|
<div className="mx-auto bg-purple-100 dark:bg-purple-900 rounded-full p-3 w-fit mb-2">
|
||||||
<Cloud className="h-6 w-6 text-purple-600 dark:text-purple-400" />
|
<Cloud className="h-6 w-6 text-purple-600 dark:text-purple-400" />
|
||||||
@@ -504,9 +563,9 @@ export default function PlanesDespachoPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col flex-1 gap-4">
|
<CardContent className="flex flex-col flex-1 gap-4">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-3xl font-bold">$43,000</div>
|
<div className="text-3xl font-bold">$68,850</div>
|
||||||
<p className="text-sm text-muted-foreground">por año (IVA incluido)</p>
|
<p className="text-sm text-muted-foreground">por año (IVA incluido)</p>
|
||||||
<p className="text-xs text-muted-foreground mt-1">+ $45/mes por cada RFC adicional sobre 100</p>
|
<p className="text-xs text-muted-foreground mt-1">+ $60/mes por cada RFC adicional sobre 100</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-2 text-sm">
|
||||||
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Hasta 100 RFCs</span></div>
|
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Hasta 100 RFCs</span></div>
|
||||||
|
|||||||
@@ -305,6 +305,7 @@ export default function PreciosSuscripcionPage() {
|
|||||||
</p>
|
</p>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
<strong>SAT Inc</strong> habilita 3 syncs SAT extra al día (11:00, 15:00, 19:00) además del daily de las 03:00.
|
<strong>SAT Inc</strong> habilita 3 syncs SAT extra al día (11:00, 15:00, 19:00) además del daily de las 03:00.
|
||||||
|
Disponible en <strong>Business Cloud</strong> y <strong>Mi Empresa Plus</strong>; no incluido en <strong>Business Control</strong>.
|
||||||
Ventana de 8h por sync, deduplicado por UUID. Latencia típica de un CFDI ~1-2h en horario laboral
|
Ventana de 8h por sync, deduplicado por UUID. Latencia típica de un CFDI ~1-2h en horario laboral
|
||||||
vs ~24h con solo el daily.
|
vs ~24h con solo el daily.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ export default function ContribuyentesPage() {
|
|||||||
...form,
|
...form,
|
||||||
supervisorUserId: assignSelf ? user?.id : undefined,
|
supervisorUserId: assignSelf ? user?.id : undefined,
|
||||||
});
|
});
|
||||||
|
if (created.reactivated) {
|
||||||
|
alert(`El contribuyente ${form.rfc.toUpperCase()} fue reactivado. Se recuperó su historial (CFDIs, FIEL, tareas, etc.).`);
|
||||||
|
}
|
||||||
// Overage Business Cloud: si el 4º+ RFC disparó un nuevo addon, abre
|
// Overage Business Cloud: si el 4º+ RFC disparó un nuevo addon, abre
|
||||||
// MercadoPago para autorizar el cobro recurrente mensual de $45/RFC.
|
// MercadoPago para autorizar el cobro recurrente mensual de $45/RFC.
|
||||||
if (created.overage?.action === 'created' && created.overage.paymentUrl) {
|
if (created.overage?.action === 'created' && created.overage.paymentUrl) {
|
||||||
@@ -71,7 +74,7 @@ export default function ContribuyentesPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeactivate = async (id: string, rfc: string) => {
|
const handleDeactivate = async (id: string, rfc: string) => {
|
||||||
if (!confirm(`¿Desactivar contribuyente ${rfc}?`)) return;
|
if (!confirm(`¿Desactivar contribuyente ${rfc}?\n\nPuedes volver a activarlo más adelante agregando el mismo RFC. Se conservarán sus CFDIs y historial.`)) return;
|
||||||
try {
|
try {
|
||||||
const result = await deactivateMut.mutateAsync(id);
|
const result = await deactivateMut.mutateAsync(id);
|
||||||
if (result.overage?.action === 'cancelled') {
|
if (result.overage?.action === 'cancelled') {
|
||||||
|
|||||||
@@ -232,6 +232,8 @@ export default function DashboardPage() {
|
|||||||
: 'Sin datos del periodo anterior'
|
: 'Sin datos del periodo anterior'
|
||||||
}
|
}
|
||||||
href={drillUrl('Ingresos del Mes - CFDIs', { bucket: 'ingresos' })}
|
href={drillUrl('Ingresos del Mes - CFDIs', { bucket: 'ingresos' })}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
title={regimenSeleccionado ? `NCs Emitidas (${regimenSeleccionado})` : 'NCs Emitidas'}
|
title={regimenSeleccionado ? `NCs Emitidas (${regimenSeleccionado})` : 'NCs Emitidas'}
|
||||||
@@ -251,6 +253,8 @@ export default function DashboardPage() {
|
|||||||
: 'Sin datos del periodo anterior'
|
: 'Sin datos del periodo anterior'
|
||||||
}
|
}
|
||||||
href={drillUrl('Gastos del Mes - CFDIs', { bucket: 'gastos' })}
|
href={drillUrl('Gastos del Mes - CFDIs', { bucket: 'gastos' })}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
title={regimenSeleccionado ? `NCs Recibidas (${regimenSeleccionado})` : 'NCs Recibidas'}
|
title={regimenSeleccionado ? `NCs Recibidas (${regimenSeleccionado})` : 'NCs Recibidas'}
|
||||||
@@ -278,6 +282,8 @@ export default function DashboardPage() {
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
href={drillUrl('Balance IVA - CFDIs', {})}
|
href={drillUrl('Balance IVA - CFDIs', {})}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Card, CardContent } from '@horux/shared-ui';
|
import { Card, CardContent } from '@horux/shared-ui';
|
||||||
@@ -10,7 +11,7 @@ import { apiClient } from '@/lib/api/client';
|
|||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { useContribuyenteStore } from '@/stores/contribuyente-store';
|
import { useContribuyenteStore } from '@/stores/contribuyente-store';
|
||||||
import { usePeriodoStore, añoMesFromFechaInicio } from '@/stores/periodo-store';
|
import { usePeriodoStore, añoMesFromFechaInicio } from '@/stores/periodo-store';
|
||||||
import { Building2, Clock, AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react';
|
import { Building2, Clock, AlertTriangle, CheckCircle2, Loader2, Search, FolderOpen, ChevronDown } from 'lucide-react';
|
||||||
|
|
||||||
interface Asignado {
|
interface Asignado {
|
||||||
contribuyenteId: string;
|
contribuyenteId: string;
|
||||||
@@ -29,6 +30,9 @@ const ROLES_ASIGNADOS = new Set(['owner', 'cfo', 'supervisor', 'auxiliar', 'cont
|
|||||||
const PLATFORM_SUPERSET = new Set(['platform_admin', 'platform_ti']);
|
const PLATFORM_SUPERSET = new Set(['platform_admin', 'platform_ti']);
|
||||||
|
|
||||||
export default function MisAsignadosPage() {
|
export default function MisAsignadosPage() {
|
||||||
|
const [filtroCliente, setFiltroCliente] = useState('');
|
||||||
|
const [filtroCartera, setFiltroCartera] = useState('');
|
||||||
|
|
||||||
const role = useAuthStore(s => s.user?.role);
|
const role = useAuthStore(s => s.user?.role);
|
||||||
const platformRoles = useAuthStore(s => s.user?.platformRoles);
|
const platformRoles = useAuthStore(s => s.user?.platformRoles);
|
||||||
const isPlatformStaff = platformRoles?.some(r => PLATFORM_SUPERSET.has(r)) ?? false;
|
const isPlatformStaff = platformRoles?.some(r => PLATFORM_SUPERSET.has(r)) ?? false;
|
||||||
@@ -64,6 +68,19 @@ export default function MisAsignadosPage() {
|
|||||||
|
|
||||||
const items = data ?? [];
|
const items = data ?? [];
|
||||||
|
|
||||||
|
const carterasUnicas = Array.from(new Set(items.map(it => it.carteraNombre || 'Sin cartera'))).sort((a, b) =>
|
||||||
|
a.localeCompare(b, 'es', { sensitivity: 'base' })
|
||||||
|
);
|
||||||
|
|
||||||
|
const itemsFiltrados = items.filter((it) => {
|
||||||
|
const coincideCliente = [it.nombre, it.rfc].some(v =>
|
||||||
|
v.toLowerCase().includes(filtroCliente.trim().toLowerCase())
|
||||||
|
);
|
||||||
|
const coincideCartera =
|
||||||
|
filtroCartera === '' || (filtroCartera === '__sin_cartera__' ? !it.carteraNombre : it.carteraNombre === filtroCartera);
|
||||||
|
return coincideCliente && coincideCartera;
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header title="Despacho — Mis asignados"><PeriodoSelector /></Header>
|
<Header title="Despacho — Mis asignados"><PeriodoSelector /></Header>
|
||||||
@@ -84,6 +101,39 @@ export default function MisAsignadosPage() {
|
|||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3 p-4 border-b bg-muted/30">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={filtroCliente}
|
||||||
|
onChange={(e) => setFiltroCliente(e.target.value)}
|
||||||
|
placeholder="Buscar por cliente o RFC..."
|
||||||
|
className="w-full rounded-md border border-input bg-background pl-9 pr-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="relative sm:w-64">
|
||||||
|
<FolderOpen className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<select
|
||||||
|
value={filtroCartera}
|
||||||
|
onChange={(e) => setFiltroCartera(e.target.value)}
|
||||||
|
className="w-full appearance-none rounded-md border border-input bg-background pl-9 pr-8 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
>
|
||||||
|
<option value="">Todas las carteras</option>
|
||||||
|
<option value="__sin_cartera__">Sin cartera</option>
|
||||||
|
{carterasUnicas.filter(c => c !== 'Sin cartera').map((c) => (
|
||||||
|
<option key={c} value={c}>{c}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{itemsFiltrados.length === 0 ? (
|
||||||
|
<div className="py-8 text-center text-muted-foreground text-sm">
|
||||||
|
No hay resultados para los filtros seleccionados.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="border-b bg-muted/50">
|
<thead className="border-b bg-muted/50">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -102,7 +152,7 @@ export default function MisAsignadosPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{items.map(it => {
|
{itemsFiltrados.map(it => {
|
||||||
const total =
|
const total =
|
||||||
it.obligacionesPendientes + it.obligacionesAtrasadas + it.obligacionesCompletadas +
|
it.obligacionesPendientes + it.obligacionesAtrasadas + it.obligacionesCompletadas +
|
||||||
it.tareasPendientes + it.tareasAtrasadas + it.tareasCompletadas;
|
it.tareasPendientes + it.tareasAtrasadas + it.tareasCompletadas;
|
||||||
@@ -193,6 +243,7 @@ export default function MisAsignadosPage() {
|
|||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -23,9 +23,11 @@ import {
|
|||||||
import { PapeleriaTab } from '@/components/documentos/papeleria-tab';
|
import { PapeleriaTab } from '@/components/documentos/papeleria-tab';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import * as docsApi from '@/lib/api/documentos';
|
import * as docsApi from '@/lib/api/documentos';
|
||||||
|
import { getObligacionesPorPeriodo, type ObligacionPeriodo } from '@/lib/api/obligaciones';
|
||||||
|
|
||||||
const MESES = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
|
const MESES = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
|
||||||
const IMPUESTOS: Impuesto[] = ['IVA', 'ISR', 'IEPS', 'ISN', 'DIOT', 'OTRO', 'ISH'];
|
const IMPUESTOS: Impuesto[] = ['IVA', 'ISR', 'IEPS', 'ISN', 'DIOT', 'OTRO', 'ISH'];
|
||||||
|
const OBLIGACIONES_ROLES_UPLOAD = ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'];
|
||||||
const PERIODICIDADES: { value: Periodicidad; label: string }[] = [
|
const PERIODICIDADES: { value: Periodicidad; label: string }[] = [
|
||||||
{ value: 'mensual', label: 'Mensual' },
|
{ value: 'mensual', label: 'Mensual' },
|
||||||
{ value: 'bimestral', label: 'Bimestral' },
|
{ value: 'bimestral', label: 'Bimestral' },
|
||||||
@@ -504,7 +506,7 @@ function UploadDialog({ onClose }: { onClose: () => void }) {
|
|||||||
const [tipo, setTipo] = useState<'normal' | 'complementaria'>('normal');
|
const [tipo, setTipo] = useState<'normal' | 'complementaria'>('normal');
|
||||||
const [periodicidad, setPeriodicidad] = useState<Periodicidad>('mensual');
|
const [periodicidad, setPeriodicidad] = useState<Periodicidad>('mensual');
|
||||||
const yearsOptions = Array.from({ length: 6 }, (_, i) => currentYear - i);
|
const yearsOptions = Array.from({ length: 6 }, (_, i) => currentYear - i);
|
||||||
const [impuestos, setImpuestos] = useState<Impuesto[]>([]);
|
const [obligacionesIds, setObligacionesIds] = useState<string[]>([]);
|
||||||
const [montoPago, setMontoPago] = useState('');
|
const [montoPago, setMontoPago] = useState('');
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const [ligaFile, setLigaFile] = useState<File | null>(null);
|
const [ligaFile, setLigaFile] = useState<File | null>(null);
|
||||||
@@ -512,6 +514,15 @@ function UploadDialog({ onClose }: { onClose: () => void }) {
|
|||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
const periodOptions = getPeriodOptions(periodicidad);
|
const periodOptions = getPeriodOptions(periodicidad);
|
||||||
|
const periodo = `${año}-${String(mes).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
const obligacionesQ = useQuery({
|
||||||
|
queryKey: ['obligaciones-periodo-declaracion', selectedContribuyenteId, periodo],
|
||||||
|
queryFn: () => selectedContribuyenteId
|
||||||
|
? getObligacionesPorPeriodo(selectedContribuyenteId, periodo, false)
|
||||||
|
: Promise.resolve({ data: [], periodo }),
|
||||||
|
enabled: !!selectedContribuyenteId,
|
||||||
|
});
|
||||||
|
|
||||||
const handlePeriodicidadChange = (p: Periodicidad) => {
|
const handlePeriodicidadChange = (p: Periodicidad) => {
|
||||||
setPeriodicidad(p);
|
setPeriodicidad(p);
|
||||||
@@ -522,21 +533,21 @@ function UploadDialog({ onClose }: { onClose: () => void }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleImpuesto = (i: Impuesto) => {
|
const toggleObligacion = (id: string) => {
|
||||||
setImpuestos(prev => prev.includes(i) ? prev.filter(x => x !== i) : [...prev, i]);
|
setObligacionesIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const submit = async (e: React.FormEvent) => {
|
const submit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setErr(null);
|
setErr(null);
|
||||||
if (!file) return setErr('Selecciona el PDF de la declaración');
|
if (!file) return setErr('Selecciona el PDF de la declaración');
|
||||||
if (impuestos.length === 0) return setErr('Selecciona al menos un impuesto');
|
if (obligacionesIds.length === 0) return setErr('Selecciona al menos una obligación fiscal');
|
||||||
try {
|
try {
|
||||||
const pdfBase64 = await fileToBase64(file);
|
const pdfBase64 = await fileToBase64(file);
|
||||||
const ligaPagoBase64 = ligaFile ? await fileToBase64(ligaFile) : undefined;
|
const ligaPagoBase64 = ligaFile ? await fileToBase64(ligaFile) : undefined;
|
||||||
const montoNum = montoPago.trim() !== '' ? parseFloat(montoPago) : undefined;
|
const montoNum = montoPago.trim() !== '' ? parseFloat(montoPago) : undefined;
|
||||||
await create.mutateAsync({
|
await create.mutateAsync({
|
||||||
año, mes, tipo, periodicidad, impuestos,
|
año, mes, tipo, periodicidad, obligacionesIds,
|
||||||
montoPago: montoNum,
|
montoPago: montoNum,
|
||||||
pdfBase64, pdfFilename: file.name,
|
pdfBase64, pdfFilename: file.name,
|
||||||
ligaPagoBase64,
|
ligaPagoBase64,
|
||||||
@@ -606,16 +617,51 @@ function UploadDialog({ onClose }: { onClose: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label>Impuestos cubiertos</Label>
|
<Label>Obligaciones fiscales cubiertas</Label>
|
||||||
<div className="grid grid-cols-3 gap-2 mt-1">
|
{!selectedContribuyenteId ? (
|
||||||
{IMPUESTOS.map(i => (
|
<p className="text-sm text-muted-foreground mt-1">Selecciona un contribuyente para ver sus obligaciones.</p>
|
||||||
<label key={i} className={`flex items-center gap-2 px-3 py-2 rounded-md border cursor-pointer text-sm ${impuestos.includes(i) ? 'bg-primary/10 border-primary' : 'hover:bg-muted'}`}>
|
) : obligacionesQ.isLoading ? (
|
||||||
<input type="checkbox" checked={impuestos.includes(i)} onChange={() => toggleImpuesto(i)} className="accent-primary" />
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-2">
|
||||||
{i}
|
<Loader2 className="h-4 w-4 animate-spin" /> Cargando obligaciones...
|
||||||
</label>
|
</div>
|
||||||
))}
|
) : obligacionesQ.error ? (
|
||||||
</div>
|
<p className="text-sm text-red-600 mt-1">Error al cargar obligaciones.</p>
|
||||||
<p className="text-xs text-muted-foreground mt-1">Selecciona todos los impuestos que incluye esta declaración — definen qué recordatorios se desactivan.</p>
|
) : obligacionesQ.data?.data.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">No hay obligaciones fiscales configuradas para este periodo.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3 mt-2 max-h-60 overflow-y-auto rounded-md border p-3">
|
||||||
|
{Array.from(new Set((obligacionesQ.data?.data || []).map(o => o.categoria || 'Sin categoría'))).map((categoria) => (
|
||||||
|
<div key={categoria}>
|
||||||
|
<p className="text-xs font-semibold uppercase text-muted-foreground mb-1.5">{categoria}</p>
|
||||||
|
<div className="grid grid-cols-1 gap-2">
|
||||||
|
{(obligacionesQ.data?.data || [])
|
||||||
|
.filter(o => (o.categoria || 'Sin categoría') === categoria)
|
||||||
|
.map((o) => (
|
||||||
|
<label
|
||||||
|
key={o.id}
|
||||||
|
className={`flex items-start gap-2 px-3 py-2 rounded-md border cursor-pointer text-sm ${obligacionesIds.includes(o.id) ? 'bg-primary/10 border-primary' : 'hover:bg-muted'}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={obligacionesIds.includes(o.id)}
|
||||||
|
onChange={() => toggleObligacion(o.id)}
|
||||||
|
className="accent-primary mt-0.5"
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<span className="font-medium">{o.nombre}</span>
|
||||||
|
<span className="text-xs text-muted-foreground ml-2 capitalize">({o.frecuencia || '—'})</span>
|
||||||
|
{o.requierePago && (
|
||||||
|
<span className="block text-[10px] text-muted-foreground">Requiere comprobante de pago</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Selecciona las obligaciones fiscales que cubre esta declaración. Al guardar se marcarán como presentadas y, si aplica, quedarán a la espera de su comprobante de pago.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { formatCurrency, toCfdiDate } from '@/lib/utils';
|
|||||||
import { exportToExcel } from '@/lib/export-excel';
|
import { exportToExcel } from '@/lib/export-excel';
|
||||||
import { useTableSort } from '@horux/shared-ui';
|
import { useTableSort } from '@horux/shared-ui';
|
||||||
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
|
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
|
||||||
|
import { getCfdiById } from '@/lib/api/cfdi';
|
||||||
import { Eye, Download } from 'lucide-react';
|
import { Eye, Download } from 'lucide-react';
|
||||||
import type { Cfdi } from '@horux/shared';
|
import type { Cfdi } from '@horux/shared';
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ export default function DrillDownPage() {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const titulo = searchParams.get('titulo') || 'Detalle de CFDIs';
|
const titulo = searchParams.get('titulo') || 'Detalle de CFDIs';
|
||||||
const [selectedCfdi, setSelectedCfdi] = useState<Cfdi | null>(null);
|
const [selectedCfdi, setSelectedCfdi] = useState<Cfdi | null>(null);
|
||||||
|
const [loadingCfdiId, setLoadingCfdiId] = useState<number | null>(null);
|
||||||
const { selectedContribuyenteId } = useContribuyenteStore();
|
const { selectedContribuyenteId } = useContribuyenteStore();
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -154,7 +156,23 @@ export default function DrillDownPage() {
|
|||||||
<td className="py-2 text-xs font-mono">{cfdi.regimenEmisor || '-'}</td>
|
<td className="py-2 text-xs font-mono">{cfdi.regimenEmisor || '-'}</td>
|
||||||
<td className="py-2 text-xs font-mono">{cfdi.regimenReceptor || '-'}</td>
|
<td className="py-2 text-xs font-mono">{cfdi.regimenReceptor || '-'}</td>
|
||||||
<td className="py-2">
|
<td className="py-2">
|
||||||
<Button variant="ghost" size="sm" onClick={() => setSelectedCfdi(cfdi)} title="Ver factura">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={loadingCfdiId === cfdi.id}
|
||||||
|
onClick={async () => {
|
||||||
|
setLoadingCfdiId(cfdi.id);
|
||||||
|
try {
|
||||||
|
const fullCfdi = await getCfdiById(String(cfdi.id));
|
||||||
|
setSelectedCfdi(fullCfdi);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error cargando CFDI completo:', err);
|
||||||
|
} finally {
|
||||||
|
setLoadingCfdiId(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="Ver factura"
|
||||||
|
>
|
||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -554,12 +554,26 @@ export default function FacturacionPage() {
|
|||||||
? clavesUnidad?.filter(u => !SERVICE_UNITS.includes(u.clave))
|
? clavesUnidad?.filter(u => !SERVICE_UNITS.includes(u.clave))
|
||||||
: clavesUnidad;
|
: clavesUnidad;
|
||||||
|
|
||||||
|
const prodSearchAbort = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
const handleSearchProduct = async (q: string, idx: number) => {
|
const handleSearchProduct = async (q: string, idx: number) => {
|
||||||
setProdSearch(q);
|
setProdSearch(q);
|
||||||
setSearchingIdx(idx);
|
setSearchingIdx(idx);
|
||||||
if (q.length < 2) { setProdResults([]); return; }
|
setProdResults([]);
|
||||||
const results = await searchClaveProdServ(q);
|
if (q.length < 2) return;
|
||||||
setProdResults(results);
|
|
||||||
|
prodSearchAbort.current?.abort();
|
||||||
|
prodSearchAbort.current = new AbortController();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await searchClaveProdServ(q, prodSearchAbort.current.signal);
|
||||||
|
setProdResults(results ?? []);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.name !== 'AbortError' && err.code !== 'ERR_CANCELED') {
|
||||||
|
console.error('Error buscando clave SAT:', err);
|
||||||
|
}
|
||||||
|
setProdResults([]);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectProduct = (idx: number, clave: string, descripcion: string) => {
|
const selectProduct = (idx: number, clave: string, descripcion: string) => {
|
||||||
@@ -1418,6 +1432,7 @@ export default function FacturacionPage() {
|
|||||||
onChange={e => handleSearchProduct(e.target.value, idx)}
|
onChange={e => handleSearchProduct(e.target.value, idx)}
|
||||||
onFocus={() => { setSearchingIdx(idx); setProdSearch(c.productKey); }}
|
onFocus={() => { setSearchingIdx(idx); setProdSearch(c.productKey); }}
|
||||||
placeholder="Buscar clave SAT..."
|
placeholder="Buscar clave SAT..."
|
||||||
|
autoComplete="off"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<Search className="absolute right-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute right-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
|||||||
@@ -186,6 +186,8 @@ export default function ImpuestosPage() {
|
|||||||
icon={<TrendingUp className="h-4 w-4" />}
|
icon={<TrendingUp className="h-4 w-4" />}
|
||||||
subtitle="Cobrado a clientes"
|
subtitle="Cobrado a clientes"
|
||||||
href={drillUrl('IVA Trasladado - CFDIs Emitidos', { bucket: 'causado' })}
|
href={drillUrl('IVA Trasladado - CFDIs Emitidos', { bucket: 'causado' })}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
title={regimenSeleccionado ? `IVA Acreditable (${regimenSeleccionado})` : 'IVA Acreditable'}
|
title={regimenSeleccionado ? `IVA Acreditable (${regimenSeleccionado})` : 'IVA Acreditable'}
|
||||||
@@ -197,6 +199,8 @@ export default function ImpuestosPage() {
|
|||||||
icon={<TrendingDown className="h-4 w-4" />}
|
icon={<TrendingDown className="h-4 w-4" />}
|
||||||
subtitle="Pagado a proveedores"
|
subtitle="Pagado a proveedores"
|
||||||
href={drillUrl('IVA Acreditable - CFDIs Recibidos', { bucket: 'acreditable' })}
|
href={drillUrl('IVA Acreditable - CFDIs Recibidos', { bucket: 'acreditable' })}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
/>
|
/>
|
||||||
{(() => {
|
{(() => {
|
||||||
const val = regimenSeleccionado
|
const val = regimenSeleccionado
|
||||||
@@ -405,24 +409,32 @@ export default function ImpuestosPage() {
|
|||||||
value={ingSel}
|
value={ingSel}
|
||||||
icon={<TrendingUp className="h-4 w-4" />}
|
icon={<TrendingUp className="h-4 w-4" />}
|
||||||
href={drillUrl('Ingresos ISR - CFDIs Emitidos', { bucket: 'ingresos' })}
|
href={drillUrl('Ingresos ISR - CFDIs Emitidos', { bucket: 'ingresos' })}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
title={regimenSeleccionado ? `NCs Emitidas (${regimenSeleccionado})` : 'NCs Emitidas'}
|
title={regimenSeleccionado ? `NCs Emitidas (${regimenSeleccionado})` : 'NCs Emitidas'}
|
||||||
value={ncsEmSel}
|
value={ncsEmSel}
|
||||||
icon={<TrendingDown className="h-4 w-4" />}
|
icon={<TrendingDown className="h-4 w-4" />}
|
||||||
href={drillUrl('NCs Emitidas - CFDIs', { bucket: 'ncs_emitidas' })}
|
href={drillUrl('NCs Emitidas - CFDIs', { bucket: 'ncs_emitidas' })}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
title={regimenSeleccionado ? `Deducciones (${regimenSeleccionado})` : 'Deducciones'}
|
title={regimenSeleccionado ? `Deducciones (${regimenSeleccionado})` : 'Deducciones'}
|
||||||
value={dedSel}
|
value={dedSel}
|
||||||
icon={<TrendingDown className="h-4 w-4" />}
|
icon={<TrendingDown className="h-4 w-4" />}
|
||||||
href={drillUrl('Deducciones - CFDIs Recibidos', { bucket: 'gastos' })}
|
href={drillUrl('Deducciones - CFDIs Recibidos', { bucket: 'gastos' })}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
title={regimenSeleccionado ? `NCs Recibidas (${regimenSeleccionado})` : 'NCs Recibidas'}
|
title={regimenSeleccionado ? `NCs Recibidas (${regimenSeleccionado})` : 'NCs Recibidas'}
|
||||||
value={ncsRecSel}
|
value={ncsRecSel}
|
||||||
icon={<TrendingUp className="h-4 w-4" />}
|
icon={<TrendingUp className="h-4 w-4" />}
|
||||||
href={drillUrl('NCs Recibidas - CFDIs', { bucket: 'ncs_recibidas' })}
|
href={drillUrl('NCs Recibidas - CFDIs', { bucket: 'ncs_recibidas' })}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
title={regimenSeleccionado ? `Base Gravable (${regimenSeleccionado})` : 'Base Gravable'}
|
title={regimenSeleccionado ? `Base Gravable (${regimenSeleccionado})` : 'Base Gravable'}
|
||||||
@@ -459,6 +471,8 @@ export default function ImpuestosPage() {
|
|||||||
icon={<TrendingDown className="h-4 w-4" />}
|
icon={<TrendingDown className="h-4 w-4" />}
|
||||||
subtitle="Efectivo > $2,000"
|
subtitle="Efectivo > $2,000"
|
||||||
href={drillUrl('No Deducibles - Efectivo > $2,000', { bucket: 'no_deducibles_efectivo' })}
|
href={drillUrl('No Deducibles - Efectivo > $2,000', { bucket: 'no_deducibles_efectivo' })}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Header } from '@/components/layouts/header';
|
|||||||
import { Card, CardContent, CardHeader, CardTitle, Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@horux/shared-ui';
|
import { Card, CardContent, CardHeader, CardTitle, Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@horux/shared-ui';
|
||||||
import { getMyTenants, addMyTenant, type MyTenantDetailed } from '@/lib/api/tenants';
|
import { getMyTenants, addMyTenant, type MyTenantDetailed } from '@/lib/api/tenants';
|
||||||
import { switchTenant } from '@/lib/api/auth';
|
import { switchTenant } from '@/lib/api/auth';
|
||||||
|
import { cancelAllApiRequests } from '@/lib/api/client';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { formatCurrency } from '@/lib/utils';
|
import { formatCurrency } from '@/lib/utils';
|
||||||
import { Building2, Plus, Crown, ArrowRight, Loader2, AlertCircle, CheckCircle2 } from 'lucide-react';
|
import { Building2, Plus, Crown, ArrowRight, Loader2, AlertCircle, CheckCircle2 } from 'lucide-react';
|
||||||
@@ -61,6 +62,9 @@ export default function MisEmpresasPage() {
|
|||||||
router.push('/dashboard');
|
router.push('/dashboard');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Cancela requests pendientes para evitar que intenten refrescar con el
|
||||||
|
// token que switchTenant va a invalidar.
|
||||||
|
cancelAllApiRequests();
|
||||||
try {
|
try {
|
||||||
const res = await switchTenant(tenantId);
|
const res = await switchTenant(tenantId);
|
||||||
setTokens(res.accessToken, res.refreshToken);
|
setTokens(res.accessToken, res.refreshToken);
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ export default function PendientesPage() {
|
|||||||
mensual: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
|
mensual: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
|
||||||
bimestral: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300',
|
bimestral: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300',
|
||||||
trimestral: 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300',
|
trimestral: 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300',
|
||||||
|
cuatrimestral: 'bg-pink-100 text-pink-700 dark:bg-pink-900 dark:text-pink-300',
|
||||||
anual: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
|
anual: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
|
||||||
};
|
};
|
||||||
return f ? (
|
return f ? (
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { addClienteAcceso } from '@/lib/api/contribuyentes';
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { Users, UserPlus, Trash2, Shield, Eye, Calculator, UserCheck, UserCog, Building2, FolderOpen, KeyRound } from 'lucide-react';
|
import { Users, UserPlus, Trash2, Shield, Eye, Calculator, UserCheck, UserCog, Building2, FolderOpen, KeyRound, Pencil } from 'lucide-react';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@horux/shared-ui';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@horux/shared-ui';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { cn } from '@horux/shared-ui';
|
import { cn } from '@horux/shared-ui';
|
||||||
@@ -79,7 +79,7 @@ export default function UsuariosPage() {
|
|||||||
const isDespacho = isDespachoTenant(currentUser?.tenantRfc);
|
const isDespacho = isDespachoTenant(currentUser?.tenantRfc);
|
||||||
const inviteRoles = isDespacho
|
const inviteRoles = isDespacho
|
||||||
? (currentUser?.role === 'supervisor'
|
? (currentUser?.role === 'supervisor'
|
||||||
? despachoInviteRoles.filter(r => r.value === 'cliente')
|
? despachoInviteRoles.filter(r => r.value === 'cliente' || r.value === 'auxiliar')
|
||||||
: despachoInviteRoles)
|
: despachoInviteRoles)
|
||||||
: legacyInviteRoles;
|
: legacyInviteRoles;
|
||||||
const defaultInviteRole = isDespacho ? 'auxiliar' : 'visor';
|
const defaultInviteRole = isDespacho ? 'auxiliar' : 'visor';
|
||||||
@@ -106,6 +106,13 @@ export default function UsuariosPage() {
|
|||||||
|
|
||||||
const [currentSupervisorNombre, setCurrentSupervisorNombre] = useState<string>('');
|
const [currentSupervisorNombre, setCurrentSupervisorNombre] = useState<string>('');
|
||||||
|
|
||||||
|
// Edit user modal (owner only)
|
||||||
|
const [editingUser, setEditingUser] = useState<{ id: string; nombre: string; role: Role; email: string } | null>(null);
|
||||||
|
const [editForm, setEditForm] = useState<{ nombre: string; role: Role }>({ nombre: '', role: 'auxiliar' });
|
||||||
|
const [savingUser, setSavingUser] = useState(false);
|
||||||
|
|
||||||
|
const isOwner = currentUser?.role === 'owner';
|
||||||
|
|
||||||
const openEditSupervisor = async (userId: string, nombre: string) => {
|
const openEditSupervisor = async (userId: string, nombre: string) => {
|
||||||
try {
|
try {
|
||||||
const res = await apiClient.get<{ supervisorUserId: string | null; supervisorNombre: string | null }>(`/usuarios/${userId}/supervisor`);
|
const res = await apiClient.get<{ supervisorUserId: string | null; supervisorNombre: string | null }>(`/usuarios/${userId}/supervisor`);
|
||||||
@@ -132,6 +139,24 @@ export default function UsuariosPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openEditUser = (usuario: { id: string; nombre: string; role: Role; email: string }) => {
|
||||||
|
setEditingUser(usuario);
|
||||||
|
setEditForm({ nombre: usuario.nombre, role: usuario.role });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveUser = async () => {
|
||||||
|
if (!editingUser) return;
|
||||||
|
setSavingUser(true);
|
||||||
|
try {
|
||||||
|
await updateUsuario.mutateAsync({ id: editingUser.id, data: editForm });
|
||||||
|
setEditingUser(null);
|
||||||
|
} catch (error: any) {
|
||||||
|
alert(error.response?.data?.message || 'Error al guardar usuario');
|
||||||
|
} finally {
|
||||||
|
setSavingUser(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const openEditAccesos = async (userId: string, nombre: string) => {
|
const openEditAccesos = async (userId: string, nombre: string) => {
|
||||||
try {
|
try {
|
||||||
const res = await apiClient.get<{ data: string[] }>(`/usuarios/${userId}/accesos`);
|
const res = await apiClient.get<{ data: string[] }>(`/usuarios/${userId}/accesos`);
|
||||||
@@ -270,7 +295,16 @@ export default function UsuariosPage() {
|
|||||||
<Label htmlFor="role">Rol</Label>
|
<Label htmlFor="role">Rol</Label>
|
||||||
<Select
|
<Select
|
||||||
value={inviteForm.role}
|
value={inviteForm.role}
|
||||||
onValueChange={(v) => { setInviteForm({ ...inviteForm, role: v as UserInvite['role'], supervisorUserId: undefined }); if (v !== 'cliente') setSelectedRfcIds([]); }}
|
onValueChange={(v) => {
|
||||||
|
const isAuxiliar = v === 'auxiliar';
|
||||||
|
const isSupervisor = currentUser?.role === 'supervisor';
|
||||||
|
setInviteForm({
|
||||||
|
...inviteForm,
|
||||||
|
role: v as UserInvite['role'],
|
||||||
|
supervisorUserId: isAuxiliar && isSupervisor ? currentUser?.id : undefined,
|
||||||
|
});
|
||||||
|
if (v !== 'cliente') setSelectedRfcIds([]);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
@@ -393,6 +427,18 @@ export default function UsuariosPage() {
|
|||||||
<RoleIcon className="h-4 w-4" />
|
<RoleIcon className="h-4 w-4" />
|
||||||
<span className="text-sm">{roleInfo.label}</span>
|
<span className="text-sm">{roleInfo.label}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{isOwner && (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => openEditUser(usuario)}
|
||||||
|
title="Editar nombre y rol"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4 mr-1" /> Editar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{isAdmin && !isCurrentUser && (
|
{isAdmin && !isCurrentUser && (
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{isDespacho && usuario.role === 'cliente' && (
|
{isDespacho && usuario.role === 'cliente' && (
|
||||||
@@ -526,6 +572,67 @@ export default function UsuariosPage() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Edit User Modal */}
|
||||||
|
{editingUser && (
|
||||||
|
<Dialog open onOpenChange={(open) => { if (!open) setEditingUser(null); }}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Editar usuario</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="edit-email">Email</Label>
|
||||||
|
<Input id="edit-email" value={editingUser.email} disabled />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="edit-nombre">Nombre</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-nombre"
|
||||||
|
value={editForm.nombre}
|
||||||
|
onChange={e => setEditForm({ ...editForm, nombre: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="edit-role">Rol</Label>
|
||||||
|
{editingUser.id === currentUser?.id ? (
|
||||||
|
<div className="text-sm border rounded-md px-3 py-2 bg-muted text-muted-foreground">
|
||||||
|
{getRoleInfo(editForm.role, isDespacho).label}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Select
|
||||||
|
value={editForm.role}
|
||||||
|
onValueChange={(v) => setEditForm({ ...editForm, role: v as Role })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(isDespacho
|
||||||
|
? (['owner', 'supervisor', 'auxiliar', 'cliente'] as Role[])
|
||||||
|
: (['owner', 'cfo', 'contador', 'visor', 'auxiliar'] as Role[])
|
||||||
|
).map((r) => (
|
||||||
|
<SelectItem key={r} value={r}>
|
||||||
|
{getRoleInfo(r, isDespacho).label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
{editingUser.id === currentUser?.id && (
|
||||||
|
<p className="text-xs text-muted-foreground">No puedes cambiar tu propio rol.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setEditingUser(null)}>Cancelar</Button>
|
||||||
|
<Button onClick={handleSaveUser} disabled={savingUser}>
|
||||||
|
{savingUser ? 'Guardando...' : 'Guardar'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
</DashboardShell>
|
</DashboardShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ export function ContribuyenteSelector() {
|
|||||||
|
|
||||||
const selected = contribuyentes.find((c) => c.id === selectedContribuyenteId);
|
const selected = contribuyentes.find((c) => c.id === selectedContribuyenteId);
|
||||||
|
|
||||||
|
// Orden alfabético por nombre (locale español, sin distinguir acentos/mayúsculas)
|
||||||
|
const contribuyentesOrdenados = [...contribuyentes].sort((a, b) =>
|
||||||
|
a.nombre.localeCompare(b.nombre, 'es', { sensitivity: 'base', numeric: true })
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="contribuyente-selector relative">
|
<div className="contribuyente-selector relative">
|
||||||
<button
|
<button
|
||||||
@@ -91,7 +96,7 @@ export function ContribuyenteSelector() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Lista de contribuyentes */}
|
{/* Lista de contribuyentes */}
|
||||||
{contribuyentes.map((c) => (
|
{contribuyentesOrdenados.map((c) => (
|
||||||
<button
|
<button
|
||||||
key={c.id}
|
key={c.id}
|
||||||
onClick={() => { setSelectedContribuyente(c.id, c.rfc, c.nombre); setOpen(false); }}
|
onClick={() => { setSelectedContribuyente(c.id, c.rfc, c.nombre); setOpen(false); }}
|
||||||
|
|||||||
@@ -72,6 +72,11 @@ const adminNavigation: NavItem[] = [
|
|||||||
{ name: 'Audit Log', href: '/admin/audit-log', icon: FileWarning },
|
{ name: 'Audit Log', href: '/admin/audit-log', icon: FileWarning },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const vendedorNavigation: NavItem[] = [
|
||||||
|
{ name: 'Invitaciones trial', href: '/admin/invitaciones-trial', icon: Gift },
|
||||||
|
{ name: 'Configuracion', href: '/configuracion', icon: Settings },
|
||||||
|
];
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -103,12 +108,15 @@ export function Sidebar() {
|
|||||||
|
|
||||||
const { data: contribuyentes } = useContribuyentes();
|
const { data: contribuyentes } = useContribuyentes();
|
||||||
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, role, user?.platformRoles);
|
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, role, user?.platformRoles);
|
||||||
|
const isVendedor = user?.platformRoles?.includes('platform_sales') && !isGlobalAdmin;
|
||||||
// El admin global NO necesita "Configuración inicial" — su tenant raíz
|
// El admin global NO necesita "Configuración inicial" — su tenant raíz
|
||||||
// (Horux 360) no tiene contribuyentes propios y nunca los tendrá.
|
// (Horux 360) no tiene contribuyentes propios y nunca los tendrá.
|
||||||
const showOnboarding = (!contribuyentes || contribuyentes.length === 0) && role !== 'cliente' && !isGlobalAdmin;
|
const showOnboarding = (!contribuyentes || contribuyentes.length === 0) && role !== 'cliente' && !isGlobalAdmin && !isVendedor;
|
||||||
const allNavigation = isGlobalAdmin
|
const allNavigation = isGlobalAdmin
|
||||||
? [...filteredNav.slice(0, -1), ...adminNavigation, filteredNav[filteredNav.length - 1]]
|
? [...filteredNav.slice(0, -1), ...adminNavigation, filteredNav[filteredNav.length - 1]]
|
||||||
: filteredNav;
|
: isVendedor
|
||||||
|
? vendedorNavigation
|
||||||
|
: filteredNav;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="fixed left-0 top-0 z-40 h-screen w-64 border-r bg-card">
|
<aside className="fixed left-0 top-0 z-40 h-screen w-64 border-r bg-card">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { switchTenant } from '@/lib/api/auth';
|
import { switchTenant } from '@/lib/api/auth';
|
||||||
|
import { cancelAllApiRequests } from '@/lib/api/client';
|
||||||
import { Building2, ChevronDown, Check, Loader2, Crown } from 'lucide-react';
|
import { Building2, ChevronDown, Check, Loader2, Crown } from 'lucide-react';
|
||||||
import { cn } from '@horux/shared-ui';
|
import { cn } from '@horux/shared-ui';
|
||||||
import { isGlobalAdminRfc } from '@horux/shared';
|
import { isGlobalAdminRfc } from '@horux/shared';
|
||||||
@@ -44,6 +45,9 @@ export function MembershipSwitcher() {
|
|||||||
const handleSwitch = async (tenantId: string) => {
|
const handleSwitch = async (tenantId: string) => {
|
||||||
if (tenantId === user?.tenantId) { setOpen(false); return; }
|
if (tenantId === user?.tenantId) { setOpen(false); return; }
|
||||||
setSwitching(true);
|
setSwitching(true);
|
||||||
|
// Cancela requests pendientes para evitar que intenten refrescar con el
|
||||||
|
// token que switchTenant va a invalidar.
|
||||||
|
cancelAllApiRequests();
|
||||||
try {
|
try {
|
||||||
const res = await switchTenant(tenantId);
|
const res = await switchTenant(tenantId);
|
||||||
setTokens(res.accessToken, res.refreshToken);
|
setTokens(res.accessToken, res.refreshToken);
|
||||||
|
|||||||
@@ -123,20 +123,6 @@ export function TareasTab({ contribuyenteId }: { contribuyenteId: string | null
|
|||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
});
|
});
|
||||||
|
|
||||||
const completarMutation = useMutation({
|
|
||||||
mutationFn: async (periodoId: string) => apiClient.post(`/tareas/periodo/${periodoId}/completar`),
|
|
||||||
onSuccess: invalidate,
|
|
||||||
onError: (err: unknown) => {
|
|
||||||
const e = err as { response?: { data?: { message?: string } } };
|
|
||||||
alert(e.response?.data?.message || 'No se pudo marcar como completada');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const descompletarMutation = useMutation({
|
|
||||||
mutationFn: async (periodoId: string) => apiClient.delete(`/tareas/periodo/${periodoId}/completar`),
|
|
||||||
onSuccess: invalidate,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleEdit = (t: Tarea) => {
|
const handleEdit = (t: Tarea) => {
|
||||||
setEditingId(t.id);
|
setEditingId(t.id);
|
||||||
setForm({
|
setForm({
|
||||||
@@ -206,16 +192,11 @@ export function TareasTab({ contribuyenteId }: { contribuyenteId: string | null
|
|||||||
return (
|
return (
|
||||||
<Card key={t.id}>
|
<Card key={t.id}>
|
||||||
<CardContent className="py-3 flex items-center gap-3">
|
<CardContent className="py-3 flex items-center gap-3">
|
||||||
<button
|
<div className="flex-shrink-0" title={p?.completada ? 'Completada' : atrasada ? 'Atrasada' : 'Pendiente'}>
|
||||||
onClick={() => p && (p.completada ? descompletarMutation.mutate(p.id) : completarMutation.mutate(p.id))}
|
|
||||||
disabled={!p || completarMutation.isPending}
|
|
||||||
title={p?.completada ? 'Marcar pendiente' : 'Marcar completada'}
|
|
||||||
className="flex-shrink-0"
|
|
||||||
>
|
|
||||||
{p?.completada
|
{p?.completada
|
||||||
? <CheckCircle2 className="h-5 w-5 text-success" />
|
? <CheckCircle2 className="h-5 w-5 text-success" />
|
||||||
: <Circle className={`h-5 w-5 ${atrasada ? 'text-destructive' : 'text-muted-foreground'}`} />}
|
: <Circle className={`h-5 w-5 ${atrasada ? 'text-destructive' : 'text-muted-foreground'}`} />}
|
||||||
</button>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className={`text-sm font-medium ${p?.completada ? 'line-through text-muted-foreground' : ''}`}>
|
<span className={`text-sm font-medium ${p?.completada ? 'line-through text-muted-foreground' : ''}`}>
|
||||||
|
|||||||
@@ -20,5 +20,6 @@ export const getMetodosPago = () => apiClient.get<CatalogoItem[]>('/catalogos/me
|
|||||||
export const getUsosCfdi = () => apiClient.get<UsoCfdiItem[]>('/catalogos/uso-cfdi').then(r => r.data);
|
export const getUsosCfdi = () => apiClient.get<UsoCfdiItem[]>('/catalogos/uso-cfdi').then(r => r.data);
|
||||||
export const getMonedas = () => apiClient.get<MonedaItem[]>('/catalogos/moneda').then(r => r.data);
|
export const getMonedas = () => apiClient.get<MonedaItem[]>('/catalogos/moneda').then(r => r.data);
|
||||||
export const getClavesUnidad = () => apiClient.get<CatalogoItem[]>('/catalogos/clave-unidad').then(r => r.data);
|
export const getClavesUnidad = () => apiClient.get<CatalogoItem[]>('/catalogos/clave-unidad').then(r => r.data);
|
||||||
export const searchClaveProdServ = (q: string) => apiClient.get<CatalogoItem[]>(`/catalogos/clave-prod-serv?q=${encodeURIComponent(q)}`).then(r => r.data);
|
export const searchClaveProdServ = (q: string, signal?: AbortSignal) =>
|
||||||
|
apiClient.get<CatalogoItem[]>(`/catalogos/clave-prod-serv?q=${encodeURIComponent(q)}`, { signal }).then(r => r.data);
|
||||||
export const getObjetosImp = () => apiClient.get<CatalogoItem[]>('/catalogos/objeto-imp').then(r => r.data);
|
export const getObjetosImp = () => apiClient.get<CatalogoItem[]>('/catalogos/objeto-imp').then(r => r.data);
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ export const apiClient = axios.create({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Lock para refrescos: solo un /auth/refresh puede estar en vuelo a la vez.
|
||||||
|
// Cualquier otra peticion 401 espera el resultado del refresh en curso.
|
||||||
|
let refreshPromise: Promise<{ accessToken: string; refreshToken: string }> | null = null;
|
||||||
|
|
||||||
|
// Controllers de peticiones activas, para poder cancelarlas en operaciones
|
||||||
|
// que invalidan el refresh token (ej. cambio de tenant real).
|
||||||
|
const activeControllers = new Set<AbortController>();
|
||||||
|
|
||||||
apiClient.interceptors.request.use((config) => {
|
apiClient.interceptors.request.use((config) => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
const token = localStorage.getItem('accessToken');
|
const token = localStorage.getItem('accessToken');
|
||||||
@@ -26,13 +34,30 @@ apiClient.interceptors.request.use((config) => {
|
|||||||
// Ignore parse errors
|
// Ignore parse errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rastrear controller para cancelacion masiva
|
||||||
|
const controller = new AbortController();
|
||||||
|
config.signal = controller.signal;
|
||||||
|
(config as any)._horuxController = controller;
|
||||||
|
activeControllers.add(controller);
|
||||||
}
|
}
|
||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function releaseController(config: any) {
|
||||||
|
const controller = config?._horuxController as AbortController | undefined;
|
||||||
|
if (controller) {
|
||||||
|
activeControllers.delete(controller);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
apiClient.interceptors.response.use(
|
apiClient.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => {
|
||||||
|
releaseController(response.config);
|
||||||
|
return response;
|
||||||
|
},
|
||||||
async (error) => {
|
async (error) => {
|
||||||
|
releaseController(error.config);
|
||||||
const originalRequest = error.config;
|
const originalRequest = error.config;
|
||||||
|
|
||||||
// Rate limit hit. El backend envía { message } — lo preservamos para que los
|
// Rate limit hit. El backend envía { message } — lo preservamos para que los
|
||||||
@@ -67,9 +92,11 @@ apiClient.interceptors.response.use(
|
|||||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
originalRequest._retry = true;
|
originalRequest._retry = true;
|
||||||
|
|
||||||
try {
|
if (!refreshPromise) {
|
||||||
const refreshToken = localStorage.getItem('refreshToken');
|
refreshPromise = (async () => {
|
||||||
if (refreshToken) {
|
const refreshToken = localStorage.getItem('refreshToken');
|
||||||
|
if (!refreshToken) throw new Error('No refresh token');
|
||||||
|
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api'}/auth/refresh`,
|
`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api'}/auth/refresh`,
|
||||||
{ refreshToken }
|
{ refreshToken }
|
||||||
@@ -78,17 +105,35 @@ apiClient.interceptors.response.use(
|
|||||||
const { accessToken, refreshToken: newRefreshToken } = response.data;
|
const { accessToken, refreshToken: newRefreshToken } = response.data;
|
||||||
localStorage.setItem('accessToken', accessToken);
|
localStorage.setItem('accessToken', accessToken);
|
||||||
localStorage.setItem('refreshToken', newRefreshToken);
|
localStorage.setItem('refreshToken', newRefreshToken);
|
||||||
|
return { accessToken, refreshToken: newRefreshToken };
|
||||||
|
})().finally(() => {
|
||||||
|
refreshPromise = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
|
try {
|
||||||
return apiClient(originalRequest);
|
const { accessToken } = await refreshPromise;
|
||||||
}
|
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
|
||||||
|
return apiClient(originalRequest);
|
||||||
} catch {
|
} catch {
|
||||||
localStorage.removeItem('accessToken');
|
localStorage.removeItem('accessToken');
|
||||||
localStorage.removeItem('refreshToken');
|
localStorage.removeItem('refreshToken');
|
||||||
|
localStorage.removeItem('horux-tenant-view');
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancela todas las peticiones activas del apiClient.
|
||||||
|
* Util antes de operaciones que invalidan el refresh token (ej. switch-tenant)
|
||||||
|
* para evitar race conditions entre requests viejas y el nuevo par de tokens.
|
||||||
|
*/
|
||||||
|
export function cancelAllApiRequests() {
|
||||||
|
activeControllers.forEach((controller) => controller.abort());
|
||||||
|
activeControllers.clear();
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user